Pular para o conteúdo

Upgrading 0.23 to 0.24

Este conteúdo não está disponível em sua língua ainda.

These are the ### Breaking entries 0.24 carries for an app on 0.23.

Sections 41 to 46 are about the CLI: generated SQL leaves the repository, rebase dev regenerates the SDK types without being asked, and paths, failures and agent setup are made consistent. Sections 47 to 50 change what the server answers, and 51 to 55 are about access. Permissions are now scopes, one vocabulary for sessions, API keys, roles and MCP tokens; if every person who uses your admin surfaces holds the admin role, most of them ask nothing of you. Sections 56 to 58 cover the remote MCP endpoint, in-process writes and the UI kit, 59 to 64 the session and account changes, 65 the backup routes, 66 the insights plugin, and 67 images in markdown fields.


41. db push and db generate write their SQL to .rebase/sql/

Section titled “41. db push and db generate write their SQL to .rebase/sql/”

rebase db push and rebase db generate render your collections to five SQL files: schema.sql, policies.sql, search.sql, vector.sql and triggers.sql. They were written into drizzle/, beside the migrations, and committed. Both commands regenerate all five before reading any of them, so the committed copy was never read, and it showed up in the diff of every collection change. They are now written to .rebase/sql/, relative to the directory the command runs in — backend/.rebase/sql/ in a scaffolded project. The directory writes its own .gitignore, so it is never committed.

The first run after upgrading deletes the old copies in drizzle/ and names each one. It deletes only files that open with the generator’s header, so a file you wrote there stays, and so do the migrations in drizzle/migrations/.

What to change: commit the deletion the first run reports. If a script or a CI step reads one of these files, point it at .rebase/sql/ and run rebase db push or rebase db generate before it: a fresh checkout no longer has the file.

grep -rnE "drizzle/(schema|policies|search|vector|triggers)\.sql" --exclude-dir=node_modules .

What the flag switched on is what rebase dev always does now. On start and on every save under config/collections/, it regenerates the SDK types in generated/sdk/, as it already did for the Drizzle schema. Before, the SDK was regenerated only with the flag, so a fresh clone had no SDK types until someone ran rebase generate-sdk. Passing --generate or -g stops with a message telling you to drop it, and REBASE_AUTO_GENERATE and REBASE_GENERATE are no longer read.

What to change: remove --generate and -g from every script that starts rebase dev, and delete REBASE_AUTO_GENERATE and REBASE_GENERATE from your .env files. rebase generate-sdk is unchanged: keep it in CI, in a frontend repository that has no collections (--from link), and anywhere rebase dev is not running.

grep -rnE "rebase dev.*(--generate|-g)|REBASE_(AUTO_)?GENERATE" --exclude-dir=node_modules .

43. A relative --collections path is read from where you run the command

Section titled “43. A relative --collections path is read from where you run the command”

schema generate, db push, db generate and doctor resolved a relative --collections from backend/, while generate-sdk and db backup --out resolved theirs from the directory you typed the command in. Every command now uses the second rule, and so do schema --output and doctor --schema/--sdk. A script written for the old rule, --collections ../config/collections run from the project root, now names a directory that is not there. The CLI refuses it before anything runs, because a schema generated from no collections is empty and pushing it would drop every table, and the message says what to write instead.

What to change: drop the flag where it names config/collections, which is the default, or write the path from where the command runs. generate-sdk now takes --collections too; --collections-dir still works.

"db:push": "rebase db push --collections ../config/collections"
"db:push": "rebase db push"
grep -rnE -- "(--collections|-c) \.\./" --exclude-dir=node_modules .

44. rebase generate-sdk stops on a collection file that does not load

Section titled “44. rebase generate-sdk stops on a collection file that does not load”

generate-sdk read the index.ts barrel in config/collections/ and skipped, with a warning, a file that failed to load. It now loads every top-level collection file in the directory, the same files the backend serves and the admin panel lists, whether or not the barrel names them. A file that does not load fails the command, as it already failed the boot. The barrel orders the navigation and holds defaultSecurityRules; it is not a registry.

What to change: nothing, if your backend boots. If generate-sdk (or rebase dev, which runs it) now stops, fix the file it names: it is one the backend would refuse too. A collection left out of the barrel now appears in the generated Database type, so a typed client gains its accessor.

45. Agents start the project’s own @rebasepro/mcp

Section titled “45. Agents start the project’s own @rebasepro/mcp”

The .mcp.json that rebase init scaffolded ran npx -y @rebasepro/mcp: the newest version on the registry, whatever version the project’s CLI was. rebase init, and the new rebase skills install --mcp, now add @rebasepro/mcp as a devDependency pinned with the CLI and start that copy: pnpm exec rebase-mcp, or npx --no rebase-mcp in an npm project. A project made before this gets the devDependency, with a message to install it, the next time skills install --mcp runs.

What to change: add the devDependency, with rebase skills install --mcp or pnpm add -D @rebasepro/mcp, and point the rebase server in .mcp.json (and any other agent configuration you wrote by hand) at the local binary:

"command": "npx", "args": ["-y", "@rebasepro/mcp"]
"command": "pnpm", "args": ["exec", "rebase-mcp"]

Upgrade @rebasepro/mcp with the CLI from then on, since the two are pinned together. See Connecting a client.

--scopes names what the key may do, comma-separated or repeated: --scopes data:read:posts,logs:read. --roles gives the RLS roles it runs as beside service, and --expires-in <days> or --expires-at <date> says when it stops. --full-access is every scope the service key holds, except keys:read and keys:write, which no key may hold. rebase api-keys scopes lists every scope the backend knows.

--permissions, --admin and --expires are removed. Each one stops the command with a message naming its replacement, so a script carrying one fails rather than minting a different key than it meant.

What to change: in every script that creates keys, replace --permissions with --scopes, --admin with --roles admin plus the admin-plane scopes the key needs, and --expires 90d with --expires-in 90.

Before After
--permissions '[{"collection":"orders","operations":["read","write"]}]' --scopes data:read:orders,data:write:orders
--full-access --full-access (now also the admin-plane scopes the service key holds)
--admin --full-access --full-access --roles admin
--expires 30d --expires-in 30
grep -rnE "api-keys create.*(--permissions|--admin|--expires )" --exclude-dir=node_modules .

A change applied from the admin panel’s live schema editor commits the collection and backend/src/schema.generated.ts, and nothing else. It also used to commit the five SQL files, but it wrote them at the project root while the CLI writes them beside the backend, so a project could hold two copies that disagreed. SchemaCommitPaths and DEFAULT_COMMIT_PATHS now hold only schemaFile.

What to change: nothing, unless your code reads DEFAULT_COMMIT_PATHS or builds a SchemaCommitPaths — then drop ddlFile, policiesFile, searchFile, vectorFile and triggersFile, which TypeScript now reports. If a live edit committed those files to a drizzle/ directory at your project root, nothing reads them: delete them. A project that keeps versioned migrations still runs rebase db generate after a live edit, and it writes the migration from the committed collection.

48. An upsert into a row in the trash is refused with 409 ROW_IN_TRASH

Section titled “48. An upsert into a row in the trash is refused with 409 ROW_IN_TRASH”

On a collection with soft delete, a deleted row keeps its key. An upsert whose key belonged to such a row wrote its values into the hidden row and answered 201 Created, so the row stayed deleted and the caller saw a success. It is now refused with 409 ROW_IN_TRASH and nothing is written, on every upsert door: ?on_conflict=, /bulk, /_batch, the SDK, the socket, and rebase.data or context.data on the server.

What to change: nothing, unless something upserts into a soft-deleted collection and expects to bring a row back. Restore the row first, with an update setting its soft-delete field to null, then upsert. Or purge it with ?hard=true and upsert a new one.

await client.data.invoices.upsert({ id, ...values });
await client.data.invoices.update(id, { deletedAt: null });
await client.data.invoices.upsert({ id, ...values });

49. An update that changes a row’s key is refused with 400 KEY_IMMUTABLE

Section titled “49. An update that changes a row’s key is refused with 400 KEY_IMMUTABLE”

An update whose body named the row’s key with another value, or a beforeSave that changed it, answered 500. In-process, through rebase.data, the row had already moved to the new key when the write threw. Every door now refuses it with 400 KEY_IMMUTABLE before any hook runs, naming the field, and nothing is written. Sending the key the row already has is still accepted.

What to change: where code moved a row by updating its key, create the row under the new key and delete the old one. A beforeSave that rewrote the key on update has to stop. See Writing.

50. Strict-Transport-Security no longer sends includeSubDomains

Section titled “50. Strict-Transport-Security no longer sends includeSubDomains”

The header told browsers to refuse plain HTTP on every subdomain of the server’s domain, including ones the server has nothing to do with, for as long as the header said. It now covers the server’s own host only.

What to change: nothing, unless your parent domain relies on this server’s header to force HTTPS on its subdomains. Then set the variable to keep the old header:

REBASE_HSTS_INCLUDE_SUBDOMAINS=true

See Security headers.

Every “may this caller do this?” now asks for a scope, named resource:action: data:read, users:write, cron:write. A scope may name one target: data:read:posts is data:read on posts alone. A person’s session, an API key, a role and an MCP token all hold scopes, and they all use the same strings.

Every signed-in person holds the data plane: data:*, storage:* and functions:invoke. Their rows are still decided by the collection’s securityRules, so nothing changes there. The admin plane is users, schema, database, backups, cron, logs and keys. The admin role holds all of it, and any role you declare under auth.roles on the users collection holds what you list. Every admin surface now asks for its own scope instead of “is admin”: the user routes need users:read or users:write, cron needs cron:read or cron:write, and so on. The endpoint index names the scope on every route. A refusal is 403 SCOPE_MISSING, with the scope in details.requiredScope.

What to change: nothing, if everyone who uses the admin surfaces holds the admin role, because an admin holds every scope. If a narrower role should reach one — a support team that manages accounts, a developer who reads logs — declare it with the scopes it needs. See Roles and scopes. A client that branched on API_KEY_FORBIDDEN, or on FORBIDDEN from an admin route, branches on SCOPE_MISSING.

grep -rnE "API_KEY_FORBIDDEN|Admin privileges required" --exclude-dir=node_modules .

52. API keys hold scopes, not a permission list and an admin flag

Section titled “52. API keys hold scopes, not a permission list and an admin flag”

A key now has scopes and, for a service key, roles: the RLS roles it runs as beside service. permissions: [{ collection, operations }] and admin: true are gone from CreateApiKeyRequest, UpdateApiKeyRequest and ApiKeyMasked, and so are ApiKeyPermission, ApiKeyOperation and isOperationAllowed. A request that sends permissions or admin instead of scopes is refused with 400 INVALID_INPUT.

Keys already stored are converted at boot to the scopes they held, and never more: { collection: "posts", operations: ["read"] } becomes data:read:posts, and admin: true becomes the admin role plus the users, schema, backups, cron and logs scopes. The secret does not change. The full table is in API keys.

One grant narrows. A function grant without write becomes nothing, because calling a function is not a read. A key that called a GET function with read alone is refused after the upgrade.

What to change:

  • Give any key that calls a custom function functions:invoke, or functions:invoke:<name>, with PUT /api/admin/api-keys/:id or client.apiKeys.updateKey.
  • Code that creates keys sends scopes, and roles: ["admin"] where it sent admin: true.
grep -rnE "ApiKeyPermission|ApiKeyOperation|isOperationAllowed|api-keys create" --exclude-dir=node_modules .

53. schema-admin is no longer an administrator, and roles are declared

Section titled “53. schema-admin is no longer an administrator, and roles are declared”

admin is the one built-in role. A role named schema-admin used to count as a second admin; it now means nothing on its own. Roles are no longer stored objects: createRole, updateRole, deleteRole, getRoleById, getUserRoles, RoleRepository, RoleData and CreateRoleData are removed, and so is BootstrappedAuth.roleService. getUserWithRoles returns role ids. GET /api/admin/roles lists admin and the roles you declare, with their scopes. The MongoDB driver no longer seeds a rebase_roles collection.

What to change: if any account holds schema-admin, declare the role with the scopes it should have:

import { defineCollection } from "@rebasepro/cms-types";
export const usersCollection = defineCollection({
slug: "users",
name: "Users",
table: "users",
auth: {
enabled: true,
roles: {
"schema-admin": {
name: "Schema admin",
scopes: ["schema:read", "schema:write", "database:read", "database:write"]
}
}
},
properties: {
email: { name: "Email", type: "string" }
}
});

Code that called the role functions reads the declared roles instead, with client.admin.listRoles(). A defaultRole that holds any admin-plane scope now fails the boot.

grep -rnE "schema-admin|roleService|RoleData|createRole|getUserRoles|collectionPermissions" --exclude-dir=node_modules .

54. The hosted MCP server’s scopes are data:read, data:write and data:delete

Section titled “54. The hosted MCP server’s scopes are data:read, data:write and data:delete”

The remote MCP endpoint used mcp:read and mcp:write. It now uses the data scopes every other credential uses, and each can be narrowed to a collection: data:read:posts. delete_document needs data:delete, which data:write no longer covers.

What to change: nothing for clients already connected. A stored grant of mcp:read is read as data:read, and mcp:write as data:write data:delete, so they keep their reach, and a client that still asks for the old names gets the same. Code that compares a granted scope string with mcp:read or mcp:write compares it with the new names.

55. RealtimeSocketLimits is now RealtimeSocketOptions

Section titled “55. RealtimeSocketLimits is now RealtimeSocketOptions”

It carries the API-key resolver beside the limits, as the sixth argument of initializeWebsockets. That is what lets an API key authenticate the realtime socket.

What to change: nothing, unless you call initializeWebsockets from your own bootstrapper. Then rename the type.

grep -rn "RealtimeSocketLimits" --exclude-dir=node_modules .

56. The remote MCP endpoint takes the SDK’s arguments

Section titled “56. The remote MCP endpoint takes the SDK’s arguments”

The tools on /mcp used their own argument names. They now take what the SDK takes: where (was filter), orderBy as ["field", "desc"] or "field:desc" (was orderBy plus order), and data for a write (was values). query_collection answers REST’s shape, { data, meta }, instead of { rows, count, truncated }. An argument a tool does not declare is refused instead of ignored, and a limit above 1000 is refused, as on REST, instead of being clamped to 200.

What to change: nothing for an assistant, which reads the tool schemas each time it connects. Code or saved prompts that call the tools with fixed arguments use the new names and read data and meta.total from a query:

{ "collection": "orders", "filter": { "status": ["==", "paid"] }, "orderBy": "total", "order": "desc" }
{ "collection": "orders", "where": { "status": ["==", "paid"] }, "orderBy": ["total", "desc"] }

See The remote endpoint.

57. In-process writes refuse ifMatch and idempotencyKey

Section titled “57. In-process writes refuse ifMatch and idempotencyKey”

A write through context.data in a callback, or rebase.data in a function, cron job or seed, never leaves the process: there is no If-Match to check and no idempotency store. Both options were silently ignored, so a conditional write there was unconditional. They now throw UNSUPPORTED_OPTION before anything is written.

What to change: drop the option from in-process writes. Where the condition matters, check it in the code that writes, or make the write through the HTTP client, which honours both.

await context.data.orders.update(id, { status: "paid" }, { ifMatch: etag });
await context.data.orders.update(id, { status: "paid" });
grep -rnE "(context|rebase)\.data\..*(ifMatch|idempotencyKey)" --exclude-dir=node_modules .

58. Button and IconButton are typed against the element they render

Section titled “58. Button and IconButton are typed against the element they render”

Both accepted any prop, which landed on the DOM and did nothing. They are now typed against the element they render, a button or the component you pass, so a prop they do not have is a compile error. That includes IconButton’s color, the obsolete HTML attribute, which never coloured anything, and endIcon or loading on Button, which never rendered.

What to change: drop each prop TypeScript reports. What renders does not change; an icon that was meant to show goes in as a child.

<IconButton color="primary" onClick={close}><CloseIcon /></IconButton>
<IconButton onClick={close}><CloseIcon /></IconButton>

59. An open realtime socket is checked again, and closed when its identity ends

Section titled “59. An open realtime socket is checked again, and closed when its identity ends”

A socket checked its token once, when it authenticated. A token that expired, a session that was revoked or a role that was taken away changed nothing until the socket reconnected. Now, before each frame, at least every 30 seconds for a socket that only listens, and at the instant its token expires, the server asks whether the account still exists, is still signed in and still holds its roles. An ended identity gets an AUTH_ERROR frame (SESSION_ENDED or TOKEN_EXPIRED) and close code 4001. A changed role re-scopes the socket’s subscriptions from the next frame.

What to change: nothing with the SDK, which re-authenticates on every token refresh and reconnects after a 4001. A client that speaks the socket protocol directly must send a fresh token in AUTHENTICATE before the old one expires, and treat close code 4001 as a reason to reconnect with a new session.

Section titled “60. An email-verification link proves the address, and nothing else”

Following a verification link marked the address verified and kept everything else on the account. Someone could register a stranger’s address with a password of their own, and when the owner clicked the link, that password became a way into a verified account. Without a live session of the account or its password, the link now removes the password and any identity nobody proved, and ends every session, as magic links, email codes and password resets already did.

client.auth.verifyEmail(token, options?) now POSTs, sends the session when there is one, and may throw PROOF_REQUIRED (409): the account holds a password or an identity the request does not prove, and the token is not spent.

What to change: in a custom verification page, catch PROOF_REQUIRED and ask for the password, or let the person verify without it. A page that calls GET /api/auth/verify-email?token= without the account’s session now removes the password. The CMS’s own page already asks.

await client.auth.verifyEmail(token);
try {
await client.auth.verifyEmail(token);
} catch (error) {
if (error instanceof RebaseApiError && error.code === "PROOF_REQUIRED") {
await client.auth.verifyEmail(token, { password: await askForPassword() });
} else throw error;
}

See Email verification.

61. client.auth.signUp resolves confirmationRequired, and user may be null

Section titled “61. client.auth.signUp resolves confirmationRequired, and user may be null”

With requireEmailVerification on, registering signs nobody in until the address is confirmed. signUp now resolves { confirmationRequired, user, accessToken, refreshToken }: with confirmationRequired: true, the other three are null. With it off, the default, confirmationRequired is false and the rest is as before.

What to change: TypeScript now reports code that reads user without checking. Narrow on confirmationRequired:

const result = await client.auth.signUp(email, password);
showWelcome(result.user.uid);
if (result.confirmationRequired) showCheckYourInbox();
else showWelcome(result.user.uid);

62. mfa.enroll().recoveryCodes is null when adding a second factor

Section titled “62. mfa.enroll().recoveryCodes is null when adding a second factor”

Every enrolment made a fresh set of recovery codes and replaced the old one, so adding a second authenticator, or starting an enrolment and abandoning it, silently invalidated the printout. The codes are now made once, with the first factor. Enrolling another returns recoveryCodes: null and keeps the codes the account has; mfa.regenerateRecoveryCodes() replaces them when asked.

What to change: show the codes only when they are there, and offer regeneration where you offered re-enrolment. See Enrolling a factor.

const { totp, recoveryCodes } = await client.auth.mfa.enroll({ friendlyName: "Phone" });
showRecoveryCodes(recoveryCodes);
if (recoveryCodes) showRecoveryCodes(recoveryCodes);

63. Turning a guest into an account needs registration open

Section titled “63. Turning a guest into an account needs registration open”

POST /api/auth/anonymous/link, and linking an OAuth provider to a guest session, turn a guest into an account. Both now answer to allowRegistration like any other sign-up, and refuse with 403 REGISTRATION_DISABLED when it is off.

What to change: nothing, if allowRegistration is on. If your app keeps registration closed and upgrades guests, it has to open it, in the users collection’s auth block. See Anonymous sessions.

64. A deleted account’s token answers 401 SESSION_REVOKED

Section titled “64. A deleted account’s token answers 401 SESSION_REVOKED”

An access token whose account had been deleted answered 404 from /me. Every door now answers 401 SESSION_REVOKED, as for a session that was signed out.

What to change: nothing with the SDK, which signs out on it. A client that treated a 404 from /me as “account gone” checks for SESSION_REVOKED instead. See Errors.

65. createBackupRoutes takes storageFor, not storage

Section titled “65. createBackupRoutes takes storageFor, not storage”

The backup routes listed and downloaded an s3:// or gs:// backup through the app’s own file-storage controller, which addresses another bucket, a local directory, or nothing. They now build a controller for the backup destination’s bucket from BACKUP_DESTINATION and the S3_* variables, the way the backup cron does. The storage option of createBackupRoutes is gone; storageFor(destination) replaces it. listBackupObjects, readBackupBytes and openBackupStream throw for an object destination they have no controller for, where they used to return nothing.

What to change: nothing, unless you mount createBackupRoutes yourself. If you do, drop storage, or pass storageFor when the destination needs a controller other than the one the environment describes. See Backups.

66. The insights plugin reads named sources

Section titled “66. The insights plugin reads named sources”

An insight carried its own data() callback and a scorecard block, so the same figure on the home page and on a collection’s card was fetched twice, and the change beside it was a delta the callback had to compute. Now the config declares sources — each returns one record of figures for the period it is handed — and an insight names the source, the value field, and the field holding the previous figure. The plugin computes the change itself.

What to change: in your useInsightsPlugin config, move each data() callback into sources under a name, have it return one record instead of { rows }, and replace scorecard with source, value and comparison. ScorecardFormat is InsightFormat, without showSign. Drop description and dateRange: the row above the figures names the period. Wrap the config in useMemo, because a new sources object starts a new cache.

const insightsPlugin = useInsightsPlugin(useMemo(() => ({
period: { days: 30 },
sources: {
orders: ({ period }) => fetchOrderStats(period.previousFrom, period.from, period.to)
},
insights: {
home: [{
id: "revenue",
title: "Revenue",
source: "orders",
value: { field: "revenue", format: { style: "currency", currency: "USD" } },
comparison: { previous: "previousRevenue", intent: "increase_is_good" }
}]
}
}), []));

The source returns both windows’ figures, here { revenue, previousRevenue }. A count that moves by a few reads better as a difference than a percentage: set show: "absolute" on its comparison.

67. A markdown field stores an uploaded image as a storage reference

Section titled “67. A markdown field stores an uploaded image as a storage reference”

The markdown editor wrote a private file’s signed URL into the text. Its download token expires, five minutes after the upload by default, so the image broke in the admin panel and on every site that published the text. An image uploaded in the editor is now stored as rebase-storage:<key> (with ?storageId= for a named source), and the panel exchanges each reference for a fresh URL when it renders.

What to change: nothing in the panel. A site or app that renders a markdown field itself resolves the references before it renders, or the images point at rebase-storage: URLs a browser cannot open:

import { resolveStorageReferences } from "@rebasepro/client";
const body = await resolveStorageReferences(String(post.body ?? ""), client);

Text saved before keeps the URLs it was saved with. See Files inside text.