0.25 also changes what a few existing calls do, without breaking them. Check
these if your app relies on the old behaviour:
Plugin field wrappers compose. Every plugin’s fieldBuilder.wrap renders,
each around the field the plugins before it left, instead of only the last.
Component overrides compose. A collection’s wrap: true override receives
the app’s override of the same component as OriginalComponent, not the
built-in one.
rebase.auth, rebase.admin, rebase.functions and rebase.cron on the
server reach the backend’s routes in-process, so middleware you added to
your own Hono app no longer runs on them.
The realtime socket answers its own path only (/ for an API at /api).
A client that dialled another path on the same server gets a 404, or your own
upgrade listener.
The socket gets its token as HTTP does, so client.setAuthTokenGetter()
now authenticates it, and after a sign-out it no longer falls back to the
token option.
A named local bucket’s directory sits beside STORAGE_PATH, as
<STORAGE_PATH>__<key>, not beside the runtime’s own base path. If you set
STORAGE_PATH and a named local bucket already holds files, boot names the
old directory, and STORAGE_PATH__<KEY> keeps serving it. A compose file from
rebase init or rebase eject mounts its volume at /uploads. That volume
holds only the default bucket, and the image’s user cannot write to it. Mount
it at /storage with STORAGE_PATH: /storage/uploads, as the new compose
files do.
A service a request calls that cannot be reached is answered with
503 SERVICE_UNAVAILABLE, such as an S3 bucket that is down or a host a
function fetch()es. It was a 500 whose code was the socket error
(ECONNREFUSED), or INTERNAL_ERROR.
rebase db push keeps the CHECK and EXCLUDE constraints on a collection’s
table, where the first push after rebase schema introspect dropped them.
The destructive check now counts the drop of one too, so
rebase db migrate --refuse-destructive stops on a migration that drops one.
Drop a constraint you no longer want by hand.
These names still work, warn once at runtime, and have a replacement:
Deprecated
Use
loadFunctionsFromDirectory, loadFunctionsWithDiagnostics
loadFunctions(dir, { onSkip })
loadCronJobsFromDirectory, loadCronJobsWithDiagnostics
loadCrons(dir, { onSkip }), then declareCrons(crons) where the old pair declared them
client.ws.setAuthTokenGetter()
client.setAuthTokenGetter()
plugin hooks.modifyCollectionAsync (never called)
hooks.modifyCollection, with the data fetched before <Rebase> renders
plugin lifecycle.onCollectionChange (never called)
context.data.collection(slug).listen(…) from lifecycle.onMount
DatabaseAdapter.mountRoutes, BackendBootstrapper.mountRoutes (never called)
a route factory your app mounts
:::
?count= on a REST list is now the switch for the count behind meta.total:
?count=none skips it and ?count=exact is the default. Any other value is
refused with 400 INVALID_COUNT. Before, ?count=5 filtered the rows whose
count column was 5.
What to change: nothing with the SDK, which already sends a filter on a
column named count inside ?where=. A hand-written REST call that filters on
such a column moves the filter there:
GET /api/data/products?count=5
GET /api/data/products?where={"count":["==",5]}
A new SDK against an old server: iterate() and findAll() send
?count=none on every page, and so does find({ count: "none" }). A 0.24
server reads it as a filter on a column named count. Where the collection has
no such column it answers 400 UNKNOWN_FILTER_FIELD, and the SDK sends the read
again without it. Where the collection has a text column named count, the
old server filters to the rows whose count is 'none' instead, with no error:
the page comes back short or empty and a walk ends early. Upgrade the server
before the SDK when a collection has such a column.
See Skipping the count .
list_collections sent its listing twice: pretty-printed in the text block, and
again as structuredContent. On a large project the two together were more than
a model’s context. It now sends the listing once, in the text block, as compact
JSON. The other tools keep their structuredContent.
What to change: nothing for an assistant, which reads the text. A program
that read the listing from structuredContent parses the text block instead;
its content is the same. See The remote endpoint .
The package’s entry re-exported the Spanish translation table, es, which put
the Spanish strings into every bundle built from it. Translations other than
English are now fetched when a user picks their language, and es is no longer
exported. en still is.
What to change: nothing, unless you imported es. To change the admin’s
strings, pass overrides to <Rebase translations={…}>; to pin the language,
pass locale.
grep -rnE "import \{[^}]*\bes\b[^}]*\} from \" @rebasepro/app \" " --exclude-dir=node_modules .
The CORS middleware used to answer every OPTIONS request itself, with an empty
204. It now answers only CORS preflights (an OPTIONS carrying
Access-Control-Request-Method); any other OPTIONS reaches its route. A route
with no OPTIONS handler answers 404. OPTIONS /api/storage/tus now returns
the TUS capabilities (Tus-Version, Tus-Extension, Tus-Max-Size), as the
docs always said.
Who is affected: a client, proxy or health check that probed a route with
OPTIONS and read the 204 as “it exists”. Browsers’ preflights are
unchanged.
What to change: probe with the route’s real method (HEAD or GET).
A table, junction table or enum type whose names are equal after Postgres’s
63-byte truncation used to fail at db push with “already exists”. Two
properties that resolve to the same column (myTitle and my_title), and a
relation target written as a string (target: "authors"), failed later or
confusingly. Each is now refused when the configuration loads, and the message
says what to rename or write.
Who is affected: a project that declares such a pair. It could not complete
db push before either, so this moves the failure earlier.
What to change: rename one of the colliding names, and write a relation
target as the collection, not its slug:
relation: { kind: "belongsTo", target: "authors" },
relation: { kind: "belongsTo", target: () => authors },
The SDK’s cron client defaulted to /api/cron, the deprecated alias. It now
calls /api/admin/cron, where every admin surface lives.
Who is affected: a server (or a proxy rule) that serves only the old path.
The SDK’s cron calls answer 404 against it.
What to change: upgrade the server with the SDK, or keep the old path
explicitly:
const client = createRebaseClient ({ baseUrl, cron: { cronPath: "/api/cron" } });
While offline, findAll() and iterate() returned the part of the collection
this device had cached as if it were all of it, and count() counted the cached
rows. A page this device never fetched now fails with OFFLINE, and so does a
count() with no count cached from the server.
Who is affected: an app that shows those reads offline.
What to change: catch the error where the app drew partial results, and
show what it has with a note, or ask for a page the device already holds.
rows = await client.data.posts. findAll ();
if (e instanceof RebaseApiError && e.code === "OFFLINE" ) rows = cached;
ClientStorageSourceRegistry.getOrDefault(key) returned the default source for
a key it did not know, so a property naming a source the panel had not loaded
yet uploaded to the default bucket. It now throws.
Who is affected: code that calls getOrDefault with a key it did not
register. The admin itself no longer does.
What to change: register the source first, or use get(key), which returns
undefined for an unknown key, where a missing source is expected.
TextField declared maxRows, and MultiSelect declared onChange, name,
position, endAdornment, multiple and padding. None was ever read, so
passing one did nothing. They are removed from the types, and passing one is now
a type error.
Who is affected: code that passed one of them. Nothing changes at run time.
What to change: delete the prop. For a multi-select’s changes use
onValueChange.
The users collection (the one with auth enabled) now answers a 400
INVALID_CONFLICT_TARGET to an upsert, on every door: ?on_conflict=, /bulk
with upsert: true, a _batch upsert operation, the SDK’s upsert() and
createMany(…, { upsert: true }), and the realtime socket. An upsert only
learns inside the write whether it creates or updates, so it applied neither
set of rules: no password hashing, email normalization, onCreateUser or
invitation on a create, and no last-administrator check on an update.
Who is affected: code that upserts users, for example a sync that ran
client.data.users.upsert(user, { onConflict: ["email"] }).
What to change: look the user up, then create or update.
const user = { email: "ada@example.com" , displayName: "Ada" };
const { data : [ found ] } = await client.data.users. find ({ where: { email: [ "==" , user.email] }, limit: 1 });
if ( ! found) await client.data.users. create (user);
else await client.data.users. update ( String (found.id), user);
A read naming both page and offset is now refused. The server answers
400 PAGE_WITH_OFFSET, and the SDK refuses the pair before any request, with a
RebaseClientError carrying the same code: over HTTP, in-process
(rebase.data) and offline. Before, page won and the offset was dropped
without a word.
Who is affected: code that sends both, for example a find({ page, offset })
or a REST call with ?page=3&offset=40. Such a read was already only reading
page.
What to change: send one of them. To keep what the read returned before,
drop offset.
await client.data.products. find ({ page: 3 , offset: 40 , limit: 20 });
await client.data.products. find ({ page: 3 , limit: 20 });
On a table whose name is 42 bytes or more, the three default admin-write
policies (…_default_admin_write_insert, _update and _delete) are now named
<head>_<hash8>_<op>, at most 63 bytes. Postgres keeps 63 bytes of a name, so
the three old names were cut to the same name and only one policy, the DELETE
one, existed: admins could not insert or update.
Who is affected: a project with a table name of 42 bytes or more, and
anything that refers to those policies by name, such as a migration or a script
that drops them. The old names never existed in the database.
What to change: nothing for the database. The next boot or rebase db push
drops the truncated policy and creates one policy per operation. Update any
script that names the old policies.
The refresh token’s default lifetime is now 30 days on every entry point. A
server started with the rebase-server runtime (or rebase start) used 400
days whenever JWT_REFRESH_EXPIRES_IN was unset; a hand-written
initializeRebaseBackend already used 30.
The lifetime is sliding: every refresh issues a token good for another 30
days, so a user who opens the app at least once a month stays signed in. A
user idle for longer signs in again. Tokens issued before the upgrade keep the
expiry they were issued with until their next refresh.
Who is affected: a deployment on the runtime that never set
JWT_REFRESH_EXPIRES_IN, whose users return less than once a month.
What to change: nothing, to take the new default. To keep the previous
behaviour, set it explicitly:
JWT_REFRESH_EXPIRES_IN = 400d
or auth: { refreshExpiresIn: "400d" } in an ejected backend. Browsers cap
the refresh cookie at 400 days, so a longer value buys nothing in cookie mode.
POST /api/admin/users and a create on the users collection through the data
API (POST /api/data/users, /bulk, a batch, MCP create_document, a socket
save) now refuse a password that fails the policy with 400 WEAK_PASSWORD,
as sign-up, resets and PUT /api/admin/users/:uid already did. Nothing is
written and no create hook runs. On a /bulk, the message starts
Row <n>: , like the bulk’s other row errors. A create without a password is
unchanged: a password is generated, or an invitation sent.
Who is affected: code that creates users with a password chosen elsewhere,
such as an import or a sync from another system.
What to change: to import accounts whose passwords were set elsewhere, send
skipPasswordPolicy: true in each create body (on each row of a /bulk), as
an administrator or with the service key:
await client.admin. createUser ({ email, password: legacyPassword, skipPasswordPolicy: true });
From any other caller the flag waives nothing. It is never stored and never
reaches onCreateUser or onAdminCreateUser. The admin panel’s import sends
it for you on rows that carry a password. A custom AuthAdapter receives
the decision as the new third argument of prepareUserCreation
({ mayWaivePasswordPolicy }); an adapter that ignores it keeps its current
behaviour. To change the policy itself, pass auth.hooks.validatePasswordStrength.
A write that sends "" for a string property with validation.required now
answers 400 VALIDATION_CONSTRAINT, with a details.violations entry
{ field, code: "required" }: the same answer as a missing value. This applies
to creates and updates on every door (REST, bulk, batch, upsert, the realtime
socket, MCP), and even when the property has a defaultValue, since a default
fills an absent key, not an empty one. The admin panel already writes null
for a cleared field, so it is unaffected.
Who is affected: code that writes "" to a required string field, for
example a form or an import that sends every column whether filled or not.
What to change: send a value, omit the key on create when a defaultValue
should apply, or drop required from fields that may legitimately be empty.
Collections that declare a beforeSave are not checked at the boundary (the
hook may fill the value), as before. Server code writing in-process
(rebase.data) is not affected.
Both were the engines behind client calls.
new QueryBuilder(collection).where(…) → client.data.<collection>.where(…);
the or, and, not and cond helpers are still exported.
createBackups(transport) → client.backups (same list, download,
downloadUrl, stream). CreateBackupsOptions is gone with it;
BackupStream is still exported.
client.ws is unchanged.
const storageSource = useBackendStorageSource ({ apiUrl, getAuthToken });
// after — the client you already pass to the admin
const storageSource = client.storage; // default source
const media = client. createStorageSource ( "media" ); // a named source
The client’s source also sends public and context on uploads (property
limits checked server-side) and storageId for named sources, which the hook
did not.
Delete the key from storage: { type: "s3" | "gcs", … }; TypeScript reports it
as an unknown property. It never set the lifetime of a URL a browser received.
To change how long a private file’s URL works, set storageDownloadTokenTtl
(seconds; STORAGE_DOWNLOAD_TOKEN_TTL on a bundle deployment). Server code that
calls controller.getSignedUrl() directly gets a URL good for an hour.
A project that declared only named buckets in config/resources.ts, such as
bucket("media", { engine: "s3" }), had the first one promoted to the default:
uploads whose property names no storageSource went to it. The default now
always reads its own configuration, whatever is declared: STORAGE_TYPE,
STORAGE_PATH (or ./uploads), and the unsuffixed S3_* / GCS_*.
If those uploads should keep going to the named bucket, say so:
export const media = bucket ( "media" , { engine: "s3" , default: true });
Without the mark, in production those uploads answer
501 STORAGE_NOT_CONFIGURED until the default is bound (STORAGE_TYPE=s3 and
S3_BUCKET, or STORAGE_TYPE=gcs and GCS_BUCKET), and boot logs one warning
naming both fixes. The files the promoted default wrote are in the named bucket;
with the mark they are served through the default as before. In development, the
files already in ./uploads are served again.
Boot also refuses two sources that resolve to one directory (or one inside
another), or to one bucket on one endpoint. Give the named source a location of
its own (STORAGE_PATH__MEDIA, S3_BUCKET__MEDIA), or mark it the default.