Repository navigation
v0.25.0
The full notes are in the CHANGELOG at v0.25.0; upgrading from 0.24 is covered in the upgrade guide.
After upgrading, run rebase db stop once, so the managed development database restarts with 0.25.
Breaking
Server & REST
-
?count=on a REST list is now the count switch, so a filter on a column
named "count" goes inside?where=.?count=noneskips the count behind
meta.total,?count=exactis the default (see Added), and any other value
is a 400INVALID_COUNT. Before,?count=5filtered on acountcolumn.
Write that filter as?where={"count":["==",5]}. The SDK already sends it
that way. -
A plain OPTIONS to a route with no OPTIONS handler is now a 404, not an
empty 204. The CORS middleware answered every OPTIONS itself; it now
answers only CORS preflights, and any other OPTIONS reaches its route.
OPTIONS /api/storage/tusanswers with the TUS capabilities (Tus-Version,
Tus-Extension,Tus-Max-Size), as documented. A client or health check
that probed a route with OPTIONS and read the 204 as "exists" must use the
route's real method. -
A collection set whose tables collide after Postgres's 63-byte name
truncation is refused at boot, with the reason. A table, junction table or
enum type whose truncated names are equal used to fail atdb pushwith
"already exists". Two properties that resolve to the same column (myTitle
andmy_title) and a relationtargetwritten as a string
(target: "authors") are refused at config time too, each saying what to
write. Rename one of the colliding names. -
An upsert on the users (auth) collection is refused with a 400
INVALID_CONFLICT_TARGET. It skipped both rule sets: the create rules
(password hashing, email normalization,onCreateUser, the invitation) and
the update rules (the last administrator cannot be demoted). A/bulkupsert
of{ id: <the last admin>, roles: ["viewer"] }demoted them, and an inserted
row had no password and the email as typed. The refusal covers?on_conflict=,
/bulkwithupsert: true, a_batchupsertoperation, the SDK's
upsert()andcreateMany(…, { upsert: true }), and the realtime socket.
Create users withPOST(orcreate) and change them withPATCH(or
update). -
A read naming both page and offset is refused with
PAGE_WITH_OFFSET.
The server answers 400, and the client SDK, the in-processrebase.dataand
the offline client refuse it before any request. Before,pagewon silently
and theoffsetwas never applied, though the docs already said no window
parameter is ignored. Send one of them; see the upgrade guide. -
An empty string on a required string field is refused like a missing
value. Every write door (REST create, update,/bulk, upsert and batch,
the realtime socket, MCP) answers400 VALIDATION_CONSTRAINTwith the
requiredviolation, on creates and on updates, even where the field has a
defaultValue: a default fills an absent key, not an empty one. A
collection with abeforeSavekeeps its exemption, since the hook may fill
the value. The admin panel already writesnullfor a cleared field. See
the upgrade guide.
Postgres
- On a table whose name is 42 bytes or more, the three default admin-write
policies get new names. They are now<head>_<hash8>_<op>, at most 63
bytes. The old names never existed in the database: Postgres had kept one
truncated policy, which the next boot orrebase db pushreplaces (see
Fixed). Only a policy name that collided changes.
MCP
- The remote MCP's
list_collectionsreply carries its listing once, in the
text block, with nostructuredContent. A client that read the listing
fromstructuredContentreads the text instead: the same content, as
compact JSON. The other tools keep theirstructuredContent.
Admin (CMS & app)
-
@rebasepro/appno longer exports the Spanish translation table. The
entry re-exportedesfrom./locales/es, the only language besides
English it exported, and that kept the Spanish strings in every bundle built
from the package.enis still exported. To change the admin's strings,
pass overrides through<Rebase translations={…}>. -
ClientStorageSourceRegistry.getOrDefault(key)throws for a key that is
not registered. It used to return the default source, so a property naming
a source the panel had not loaded yet uploaded to the default bucket. Call
get(key)where a missing source is expected. -
useBackendStorageSource is removed from @rebasepro/app. Use
client.storage, orclient.createStorageSource(key)for a named source;
both carrystorageId,publicandcontext, which the hook dropped.
Client SDK
-
client.cron calls
/api/admin/cronby default, not the deprecated
/api/cronalias. A server that serves only the old path (or a proxy that
forwards only it) now answers 404 to the SDK's cron calls. Pass
cron.cronPathto point the client elsewhere. -
Offline, findAll() and iterate() fail with OFFLINE on a page this
device never fetched, and count() with no count cached from the server
fails with OFFLINE. They used to return part of the collection as if it
were all of it, and a count of the cached rows. Catch OFFLINE where the
app showed those partial results. -
QueryBuilder, createBackups and CreateBackupsOptions are no longer
exported from @rebasepro/client. They were internal engines: use
client.data.<collection>.where(…)(withor,and,not,cond, still
exported) andclient.backups.client.wsis unchanged.
UI kit & plugins
- TextField no longer declares maxRows, and MultiSelect no longer
declares onChange, name, position, endAdornment, multiple or
padding. None was ever read, so passing one was a silent no-op and is now
a type error. Delete the prop.
Auth
-
A session lasts 30 days of inactivity by default, whichever way the server
starts. The runtime (JWT_REFRESH_EXPIRES_IN) defaulted to 400 days while
initializeRebaseBackendand the scaffold's.env.exampleused 30, so how
long a session lasted depended on the entry point. The refresh token is
still sliding: an active user is never signed out. Set
JWT_REFRESH_EXPIRES_IN=400d(orauth.refreshExpiresIn) to keep the
longer window. -
An administrator's chosen password is held to the password policy
(400 WEAK_PASSWORD). OnPOST /api/admin/usersand on every create on
the users collection through the data API (REST,/bulk, batch, MCP, the
socket), as registration, resets andPUT /api/admin/users/:uidalready
were. On a/bulkthe message names the row (Row <n>:), like the bulk's
other row errors, so the admin panel's import can say which line failed. An
import may sendskipPasswordPolicy: trueon a create; the server honours
it only for an administrator or the service key, and never stores it. The
admin panel's import sends it for rows that carry apassword.
AuthAdapter.prepareUserCreationtakes an optional third argument,
UserCreationOptions { mayWaivePasswordPolicy }.
Storage & email
-
signedUrlExpiration is removed from S3StorageConfig and
GCSStorageConfig. It reached no client: routes hand out metadata and a
download token, whose lifetime isstorageDownloadTokenTtl.
controller.getSignedUrl()called from your own server code signs for an
hour, the old default. -
A named bucket no longer becomes the default storage source by declaration
order. A project that declared only named buckets had the first one promoted
to serve uploads whose property names nostorageSource. The default source
now always reads its own configuration:STORAGE_TYPE,STORAGE_PATH(or
./uploads), and the unsuffixedS3_*/GCS_*. If your unqualified uploads
relied on the promotion, mark that bucket
bucket("media", { default: true }); in production these are the files the
default wrote into the named bucket. Without the mark, those uploads answer
501 STORAGE_NOT_CONFIGUREDin production, and boot says so once.
Security
Storage & email
-
A private file's signed URL is no longer handed to the next user in the
same tab. The SDK'screateStorage, the admin'suseBackendStorageSource
and the CMS thumbnail cache kept download URLs for the token's life (about
5 minutes) with no owner. After a sign-out, or another user signing in, the
next user's preview of the same path got the previous user's URL, even for
a file they may not read. Each cache now belongs to the credential that
asked (the signed-in user, for thumbnails); a change empties it, and a
request in flight at the switch is not reused. A token refresh also starts
the cache over. -
The
storageAuthorizehook is skipped only for the two grants storage
makes itself (a public object's read, a download token's read). An identity
that merely has the uidpublicordownload-token, such as a custom auth
adapter's guest, was never asked; it is now asked on every operation, like
any user. -
On S3 and GCS the hook's
bucketis always"default". Naming the
provider's bucket on an upload, listing, folder or resumable upload passed a
hook that judged the bucket. -
Local storage refuses keys ending in
.metadata.json(400
INVALID_STORAGE_KEY). Such an upload overwrote another file's stored
content type and metadata. -
The metadata of a key ending in / answers 404 on S3 and GCS, as it already
did on local storage. That isGET /api/storage/metadata/<folder>/and
the same key inPOST /api/storage/metadata. Afolder/marker object used
to be described and given a download token that read every object under the
prefix, while the authorize hook had been asked only about the marker.
Server & REST
-
A one-to-many membership write no longer takes a child that the target's
beforeQueryscope or the trash hides. Setting the foreign key on the
listed children ignored the scope that the clear step beside it applied, so
a caller could name a hidden child and take it from its current parent
wherever RLS allowed the UPDATE. A hidden child is now left alone, as an
unknown id is, so the answer does not reveal that it exists. -
A resumable upload can no longer be continued or cancelled by a non-admin
user whose uid isservice. -
A query naming an excludeFromApi field or relation answers exactly as one
naming an unknown field, so the error no longer confirms the field exists.
A filter, sort,fields,include, aggregateselectorgroupBy, or
vector_searchnaming one gets the misspelt name's code
(UNKNOWN_FILTER_FIELD,UNKNOWN_ORDER_BY_FIELD,UNKNOWN_FIELD,
UNKNOWN_RELATION,UNKNOWN_AGGREGATE_FIELD,UNKNOWN_VECTOR_PROPERTY),
message and details. No unknown-field answer lists such a field among the
valid ones. -
A filter through a relation (
?author.salary=gt.1) and an aggregate sort
over a relation's field (orderBy=max(staff.salary)) are judged by the
target's read rule. Both used to be answered for a caller who may not read
the field, which made them a one-predicate-at-a-time oracle. -
The functions 404 names a file that failed to load only to a signed-in
caller. An anonymous caller gets the plain "No function named …" answer. -
History entries never expose a related row's values. Relations are
stored and served as{ __type, id, path }references. Delete entries
recorded before 0.24, which embedded the related row, are served with the
embedded values removed, and the recorder strips an embedded row before it
stores an entry. NewwithRelationReferencesOnlyin@rebasepro/common. -
verifyCredentialreaches the verdict the HTTP routes reach.
backend.verifyCredential(token)and the rootverifyCredential(token)
checked only the token's signature, so a custom socket or tunnel built on them
admitted a session its owner had signed out of, a token minted for a user that
does not exist, and a demoted administrator with the roles in their old token.
They now share the built-in adapter's judgement with the data plane:
revocation, the account's existence and status, and the roles it holds now.
The realtime socket already used it. They throw when the database cannot be
reached. -
A backend built from the scaffold's
collectionsarray keeps its read
policies.config/collections/index.tsdeclaresdefaultSecurityRulesfor
the collections that declare none, and only the directory loader applied them.
createRebaseBackend({ collections, provision: true }), as two
custom-server.md examples have it, provisioned posts, authors and tags
admin-only and dropped all 12 generated policies from the database, so
non-admins got empty lists on every server sharing it,rebase devincluded.
The scaffold's index now applies the defaults to the array it exports, and
provisioning warns, naming each policy, when a collection loses every rule it
had. A project scaffolded before this keeps its old index: if it hands that
array to a backend of its own, copy the closing loop from the new template's
config/collections/index.ts.
CLI
-
Backup and restore never print the database password.
pg_dump,
pg_dumpallandpg_restorereceive it inPGPASSWORDinstead of on their
command line, so it is in neither an error message, the scheduled backup's run
history nor the process list, and connection strings in the driver CLI's
errors are shown asuser:***@. -
The CLI sends the service key only to this project's backend. With
rebase devstopped,rebase api-keys,rebase storageandrebase auth reset-passwordfell back to.env'sPORT(3001 in every scaffold) or to a
stopped backend's leftover address, and sent the admin service key to whatever
listened there. They now useREBASE_BASE_URLwhen you set it, or the address
the running backend recorded while its process is alive. Otherwiseapi-keys
andstoragerefuse before sending anything, andreset-passwordresets
through the database. -
rebase dev --dockernever logs into another project's database. Projects
scaffolded side by side could get the same compose port, and--dockertook
whatever listened there for its owndbservice, then suggestedALTER USER
on it.dev --dockerand everyrebase db … --dockernow ask Docker who
holds the port, and refuse with its name and how to give the project its own
when it is not this project's container. -
A misspelt anonymize flag on a database pull no longer copies personal
data.rebase db pullread its flags by hand, so--anonymise,
--anonymizand--anonwere ignored and the copy arrived with real names
and emails, and--schem publicpulled every schema.db pull,db url,db stopanddb resetnow refuse an option they do not take before anything is
read or copied, and name the one it was probably meant to be. -
The database pull refusal no longer prints a password.
rebase db pull --database-url <url>suggested the--fromline to type with the URL echoed,
password and all, and unquoted. It is now masked (user:***@) and quoted.
Deprecated
Client SDK
-
The client.ws admin methods (executeSql, createBranch, deleteBranch) are
deprecated, with no removal date. Run SQL server-side. The Studio SQL
console still usesclient.ws, so those methods stay until it has another
path. -
client.ws.setAuthTokenGetter()is deprecated: use
client.setAuthTokenGetter(), which authenticates HTTP and the socket
together. It keeps its old meaning, the socket alone, and warns once.
Server & REST
-
loadFunctionsFromDirectoryandloadFunctionsWithDiagnosticsare
deprecated: useloadFunctions(dir), withonSkipfor the skipped
files. Both keep their old shape and behaviour, and warn once per
process. -
loadCronJobsFromDirectoryandloadCronJobsWithDiagnosticsare
deprecated: useloadCrons(dir), anddeclareCrons(crons)where the old
pair declared them. Both keep their old shape and behaviour, still declare
what they load, and warn once per process. -
The
prefixoption ofbucket()is deprecated; nothing ever applied
it. A source declared withprefix: "tenant-a/"stored its objects at the
root of the bucket, so two sources sharing one bucket shared one namespace,
and nothing said so. Give each source a bucket of its own (S3_BUCKET__<KEY>,
GCS_BUCKET__<KEY>, orSTORAGE_PATH__<KEY>for a local one), or put the
folder in each storage property'sstoragePath. A declaration that sets it
still compiles and deploys, and the backend warns once per bucket at boot. -
DatabaseAdapter.mountRoutesandBackendBootstrapper.mountRoutesare
deprecated. The server never called them, and its adapter wrappers no
longer forward them. The types still accept them. A driver that needs HTTP
routes exports a route factory for the application to mount.
UI kit & plugins
- The plugin hooks
hooks.modifyCollectionAsyncand
lifecycle.onCollectionChangeare deprecated; neither was ever called.
Fetch the data before rendering<Rebase>and build the plugin from it, so
itshooks.modifyCollectionreads data it already has; subscribe with
context.data.collection(slug).listen(…)fromlifecycle.onMount,
unsubscribing inonUnmount. A plugin that sets either still compiles, and
<Rebase>warns once per plugin, naming the replacement.
Added
Storage & email
-
Storage controllers can stream, through two new optional methods,
statObjectandgetObjectStream. The file route uses them to serve a
file, aRangerequest, a revalidation and aHEADwithout holding the
object in memory, and backups use them to stream a dump. The built-in local,
S3 and GCS controllers implement both; a custom controller without them keeps
the buffered path.putObject(props, { uploadLimits: false })writes without
the controller's upload limits; only the server's own backup upload passes it
(see Fixed), and it is not part of the client's upload props. -
rebase storage orphanslists the stored files no row names, and
--deletedeletes them. Every row of every collection is read,
soft-deleted rows included; files newer than--older-thanhours (24) are
left out; nothing is deleted while a collection cannot be read
(409 STORAGE_SCAN_INCOMPLETE). BehindGET|DELETE /api/admin/storage/orphans(database:read/database:write). -
rebase storage movecopies a folder of files to another storage source
under the same keys, resumable, with--from,--to,--prefix, and
--delete-sourceto delete each source copy once the target holds it.
BehindPOST /api/admin/storage/move(database:write). -
storageTargetsInText(text)lists the objects a text names
(references and pre-reference download URLs), from@rebasepro/typesand
@rebasepro/client. -
Local storage warns at boot when its folder is on a case-insensitive file
system (macOS, Windows), whereAvatar.pngandavatar.pngare one file.
Server & REST
-
isDatabaseUnreachable(),ApiError.databaseUnavailable()and
DATABASE_UNAVAILABLE_MESSAGEin@rebasepro/server, for a custom route or
function that wants to answer an unreachable database the way the built-in
routes do:503 DATABASE_UNAVAILABLEwithRetry-After: 5. -
A list can skip its count, with
?count=noneover REST or
count: "none"onfind(); a value the server does not know is a 400
INVALID_COUNT.meta.totalis aCOUNT(*)over every matching row,
most of what a list request costs on a large table. Withnonenothing is counted and
meta.hasMorestays exact, because the read asks for one row past the page;
over HTTPmeta.totalis then absent.exactis the default. The SDK's
iterate()andfindAll()send it on every page, and the admin's table
on every read past its live first page (see Changed). A new client talking
to a server that does not know the parameter repeats that read once without
it and stops sending it. -
Custom data drivers gain
readTogether()and row preconditions.
DataDriver.readTogether(work)runs several reads in one read-only
REPEATABLE READ transaction under the driver's identity; the REST list reads
its page and count through it, and a get-by-id its ETag row and body.
SaveProps.preconditionandDeleteProps.preconditioncarry the 404 and
If-Matchchecks into the write's own transaction, on a driver that
declareschecksRowPreconditions. All three are optional: a driver without
them keeps the separate reads it had. Both Postgres drivers implement them. -
jobs.timeoutMsbounds one job attempt, and every job, queue and topic
handler gets a signal aborted at that deadline. A handler that hangs on a
fetchcan pass the signal on and stop with the attempt. -
getSignal(c)in@rebasepro/server/functionsis aborted when a custom
function's request times out (504). Pass it tofetchso the work stops
with the request instead of running on. -
CORS_EXPOSED_HEADERS is exported from
@rebasepro/server. A backend
that wires its owncors()exposes the same headers the runtime does
(ETag,Retry-After, the resumable-upload headers, …) instead of copying
the list by hand. -
Server-side data can be typed from your generated Database. Register it
once (declare module "@rebasepro/types" { interface Register { database: Database } }, orrebase generate-sdk --register, see CLI) and
context.datain collection callbacks,rebase.dataAsAdmin, and the
rebasea function or cron receives are typed from it: rows,
where/orderBy,include, andcreate/updateinputs.
defineFunction<Database>()anddefineCron<Database>()type a single file
without registering. Projects that register nothing are unchanged. See
"Typed server-side data". -
With a Database registered, context.data.collection("my-notes") is typed
like context.data.myNotes. A slug computed at runtime, or one the schema
does not declare, stays untyped. -
context.data.<collection>.trashin collection callbacks, and
rebase.data.<collection>.trashin functions and crons:find,count,
restore,restoreMany,purgeandpurgeMany, the same trash the
browser client has. The reads return only soft-deleted rows; a restore sets
the soft-delete field back tonull; a purge deletes the row for good. -
databaseDefaultmarks a column the database fills on create
(NOT NULL DEFAULT gen_random_uuid()and the like). A required field with
it may be left out of a create, by the admin form and the API; anullsent
on create is left to the database; the generatedInserttype makes it
optional. Given the expression, in the template formisIdtakes
(databaseDefault: "sql`gen_random_uuid()`", new typeSqlExpression),
the schema planner writes it as the column'sDEFAULT, sorebase db push
keeps it;databaseDefault: truedeclares aDEFAULTwritten elsewhere and
writes none. "Import from table" andrebase schema introspectset the
expression they read on such columns. -
A built bundle can edit its collections from the admin panel. Set
REBASE_LIVE_SCHEMA_REPOSITORY=owner/nameand
REBASE_LIVE_SCHEMA_GITHUB_TOKEN(orREBASE_LIVE_SCHEMA_GITHUB_APP_ID,
_INSTALLATION_IDand_PRIVATE_KEY). Each change is planned, committed to
that repository, and its DDL applied on the deployment's database. It is off
unless set, admin-only, and applied only by a person. A repository named
without a usable credential refuses to boot.REBASE_LIVE_SCHEMA_BRANCHand
REBASE_LIVE_SCHEMA_COLLECTIONS_PATHdefault tomainand
config/collections. -
createRebaseBackendbuilds a backend and mounts, starts and listens to
nothing: each of those is a call you make.routes.auth(),data(),
storage(),functions(),admin(),cron()andmeta()are the route
groups as Hono sub-apps. Each carries its own auth context, rate limits and
admin gate, so one can be mounted alone at any path, a parameterised one such
as/:tenant/dataincluded, andgetUser(c)andgetDriver(c)work inside
it.mount(app, { basePath })is the whole layoutinitializeRebaseBackend
serves;attachRealtime(server),start()(cron, job workers, sweeps) and
shutdown()are the rest.backend.rebaseis the in-process client, and
backend.fetch(request)serves the whole backend as one fetch handler, for a
script or a test with no server. See "Custom Server Integration". -
Boot-time DDL is a decision:
createRebaseBackendrequires a provision
option, which istrue,falseor{ collections, realtime }.truecreates what
the backend serves: the collection tables and their RLS policies, change
capture, the request role and the framework's own tables.falseopens the
database without changing it, for a script, a test or a second process.
{ collections: false, realtime: false }keeps the framework's tables and
the role, for a process that does not own the schema in a split deployment.
initializeRebaseBackendpassestrue, andREBASE_MIGRATE_ON_BOOT=none
still turns the collection schema off for any backend. -
Functions and cron jobs are values, and
loadFunctionsandloadCrons
read a directory into them.functions: { "send-invoice": sendInvoice }
andcrons: { nightly: defineCron({ … }) }sit besidecollections; the
record key is the URL segment or the job id.functions: await loadFunctions(dir)serves whatfunctionsDir: dirserves, and the same holds
for crons;onSkipreceives each file a loader skips. Loading declares
nothing:declareCrons(crons)records crons in the resource graph, for a
build step that wants them there. A directory and values for the same kind
are refused at boot, naming both. -
composeCallbackscombines several sets of collection callbacks into
one, the way the backend combines its own global, collection and property
tiers.beforeSaveandafterReadchain,beforeQuerynarrowings are
AND-ed, anyfalsefrombeforeDeletevetoes, and theafter*hooks run
in order. From@rebasepro/cms-types,@rebasepro/serverand
@rebasepro/common.setCollectionCallbacksstill replaces a collection's
declared callbacks, and its warning now namescomposeCallbacks(declared, mine)as the way to keep them. -
createCallContextbuilds thecontexta collection callback receives,
so a hook can be unit-tested by calling it. Fields you pass are used as
they are; a missingclient,dataorstorageSourcethrows on first use,
naming the field;userstays absent unless passed. From
@rebasepro/serverand@rebasepro/common. -
Each backend keeps its own state in
backend.services, and
backend.verifyCredential(token)checks a credential against it.
services.jwt(aJwtService: sessions, JWKS, download and purpose
tokens),access(the access model and the personal-key owner resolver),
apiKeyStore,rateLimitStore,authLimiters,collections(),topicsand
queues.createJwtService(config)builds a JWT service on its own, and
JwtService,AccessControl,AuthLimiters,BackendServicesand
JwtConfigare exported types. Every route group setsc.var.services; set
it yourself,c.set("services", backend.services), to make a route of your
own answer for a backend. -
setDefaultBackendmakes a backend built by hand the process default:
the one the module-level API answers for, namelyrebaseoutside a request,
verifyAccessTokenand the other JWT helpers,getAccessModel,
verifyCredential, and topics and queues published outside a request.
initializeRebaseBackenddoes this for its backend. -
installUnhandledRejectionHandler()is exported, so a backend built by
hand can repeatinitializeRebaseBackend's first step. It returns a function
that uninstalls the handler, orundefinedwhen one was already installed or
REBASE_EXIT_ON_UNHANDLED_REJECTIONis set. -
setLogOutput("stderr")sends every log line to stderr. From
@rebasepro/server, whatever the level and in both the development and the
production (JSON) format;setLogOutput()restores the default. For a process
whose stdout is its result, asrebase console -eis. -
backend.services.authAdapter: the auth adapter that judges this
backend's sessions, the oneverifyCredentialuses. -
The rule for reading a content hash from a static file name is exported
from@rebasepro/typesasisContentHashedFileName(name): what the build
precompresses by, and what the runtime caches for a year by.
Client SDK
-
findById(id, { include })andlistenById(id, onUpdate, onError, { include })load relations into the row. A row subscription carries its
include to the server, which reads it on every frame, so a live record
keeps its relations. -
@rebasepro/clientexports the types its calls are written in:
WriteOptions,WhereFilterOp,WhereFor, LogicalCondition,
AggregateParams, AggregateResult, IncludeSpec, RowOf/InsertOf/
UpdateOf,StorageSourceand others. A helper that wrapsfind()or
update()no longer has to rebuild them fromParameters<…>. -
client.offline.status().persistedsays whether the browser agreed to keep
the offline database. It is asked withnavigator.storage.persist()when
the first write is queued. Queued writes are also stamped with a layout
version: a write saved by a newer SDK is kept, not replayed by an older one. -
observeById(id, onResult, onError, { include }) loads relations into every
emission, online and offline. The options are typed as
ObserveByIdOptions, and the condition types behind a typedor()/
and()group,FilterConditionForandLogicalConditionFor, are exported
too (see Changed). -
aggregate() takes a typed orderBy, by group key or alias
(AggregateOrderKey). For exampleorderBy: ["sum_total", "desc"]. The
HTTP client sends it, and the in-process accessor now applies it instead of
dropping it. -
client.data.<slug>.trashlists, counts, restores and purges the rows a
soft delete stamped. It maps to?deleted=only, the update setting the
stamp tonull({ field }for a custom soft-delete field), and
?hard=true. With offline enabled, the trash is never cached or queued, and
a call fails withOFFLINEwithout the server. -
auth.restoreOutcome()says how restoring the session on load ended:
restored,none,refusedorunreachable. -
client.data.<collection>.listenCount(params, onCount)keeps a count
current: the total matching the filter, again whenever a write changes
it. The server reads no rows for it. -
runSqlScript takes an optional signal. On
SQLAdmin.runSqlScriptand
RebaseWebSocketClient.runSqlScript, aborting it rejects the run and
cancels the statement where the server can. -
offline.keepLastUser(off by default): in cookie mode, an offline reload
opens the last signed-in user's local data instead of the signed-out
user's. Their queued writes replay only once the session is confirmed.
offline.status().unverifiedUseris true meanwhile. -
offline.replayBatchSize(default 1): a run of creates queued offline
replays as one createMany instead of one request each. -
offline.onConflict: a queued edit replays with If-Match, and a row
changed on the server since is yours to "overwrite" or "discard". Without
it, the last writer still wins.
UI kit & plugins
-
VirtualTable,ListViewandCardViewacceptundefinedentries in
data(a row not read yet, drawn as a placeholder) and an
onRowsRendered(range)callback; EntityTableController hassparseData
andonRowsRendered. A table can hold a sparse window of a large
collection (see Changed). -
The kit has a
HeartIcon, and gains the accessibility props Alert
role, AvataronClick, ResizablePanelsdividerAriaLabeland
ToggleButtonGrouparia-label/aria-labelledby.DateTimeField's
localeis now applied, as the input'slang. -
ToggleButtonGroup takes fullWidth.
-
UiLabelsProvideranduseUiLabels()give the kit's own words in another
language: the list and card notices, the table's error title, the search
bar's names and the collection-view checkboxes. English is the default, and
nothing changes without a provider. -
formatRelativeTime takes labels, to say "5m ago" in another language
(RelativeTimeLabels,defaultRelativeTimeLabels). -
TableBody and TableRow take variant="list": a list of rows on the sheet
with no fill of its own, only the hovered row highlighted. Rows inherit the
body's variant. Studio's storage list view uses it. -
DateTimeField takes mode="time": a time of day alone, kept on
1970-01-01 in the field's zone. NewDateTimeFieldModetype and
UiLabels.openTimePicker. -
Markdown fields get the Autocomplete slash command from the AI plugin,
behind the same gate as Autofill (the service available,
getConfigForPathallowing the collection). A field's own
editorProps.aiControllerstill wins. -
warnDeprecated(key, message)from@rebasepro/commonprints a
deprecation message once per process per key. Rebase's own deprecated
names warn through it, and a plugin or library can use it for its own.
Admin (CMS & app)
-
Four translation keys name the split-view dividers and the drawer's
CMS / Studio switch:resize_sidebar,resize_editor,resize_listand
panel_mode. All four are optional onRebaseTranslations, so a custom
translation table without them falls back to English. -
useCreateFormex takes
beforeSubmit, awaited at the start of a submit,
before the values are read. The record form uses it to wait for a file
that is still uploading (see Fixed). -
EntityAction.nameKeyis a translation key for an action's name. The
built-in actions set it. -
The record form saves with If-Match. A save made against a version
someone has since replaced is refused, and the form says "This record
changed since you opened it…". The edit stays in the form, rebased onto the
new version, with the conflicting fields marked. -
The collection toolbar's row count, and the "select all N" offer, follow
rows other people add and remove, wherever the data source can subscribe. -
A time-column date property is picked and shown as a time of day
alone, in UTC, in the form, the table, the filter and previews — no more
01/01/1970 date part (columnType: "time"). -
Creating a collection from a file infers a date for a column of epoch
timestamps (seconds or milliseconds, 2001–2103) whose name ends in a word
likeat,date,timeortimestamp(created_at,updatedAt,
signup_date). -
EditorAIControllerProvider(from @rebasepro/cms) gives every markdown
field below it an AI controller for the editor's Autocomplete slash
command. -
EntityFormPanelandCollectionsProviderput one record's form, or one
collection's table, in a page of your own app.<CollectionPanel collection={postsCollection}/>now takes the collection as a value, and
<EntityFormPanel collection={postsCollection} entityId="123"/>loads one
record (or starts a new one withoutentityId) and saves it through
<Rebase>'s data layer, withonSavedandonClose. Both work under
<Rebase>and any React Router router, without<RebaseCMS>,
<RebaseNavigation>or<RebaseShell>, and bring their own
CollectionsProviderwhen none is above them. Records open over the page,
in a side panel or a dialog, never by URL. -
The
modeControllerprop of<Rebase>hands Rebase your app's light and
dark mode.
Rebase then leaves<html>,prefers-color-schemeand localStorage alone.
useBuildModeController({ enabled: false })builds an inert controller. -
Several layers can register collections with
<Rebase>at once. A
CollectionResolverRegistrationContextregistration takes an optional
owneras its third argument, andUrlController.routesMountedisfalse
where no CMS route serves the page.
CLI
-
Schema introspection writes each table's row-level security policies into
its collection's securityRules, with their names, commands, modes,TO
lists and clauses, so therebase db pushafter it recreates them as they
were. The<table>_default_*policies Rebase adds itself are left out. -
rebase doctor names a junction table no collection declares
(orphaned_junction, info). It shows theDROP TABLEfix and never runs
it. -
rebase db generate names the destructive statements in the migration it
writes, andrebase db migrate --refuse-destructiverefuses one. The flag
dry-runs the pending files and exits 1, applying nothing, when one destroys
data. Without it,db migrateis unchanged. -
rebase generate-sdk --registertypes server-side data: it writes the
generated types and theRegisterdeclaration toconfig/generated/, where
both the backend's and theconfigpackage's compiles see them, and
remembers the choice inrebase.json(registerDatabaseon the backend
app). Later runs,rebase dev's and a fresh clone's included, keep them
current. -
rebase db push, db generate and db migrate work on the managed development
database (PGlite). Atlas needs a second, empty database to plan against,
which PGlite cannot provide, so the CLI starts a throwaway in-memory PGlite
for the command and stops it afterwards. A column can now be dropped,
renamed or narrowed there; before,rebase devcould only add. The
destructive gate is unchanged. -
REBASE_ATLAS_DEV_URLnames the empty database Atlas plans against,
instead of the derived _dev_diff. Rebase neither creates nor drops a
database named this way. -
rebase consoleopens a Node REPL with the project's data in scope.
rebaseis whatopenRebasereturns andcollectionsthe project's
collections, with top-level await. The project,.env, database and
--collectionsare resolved the wayrebase db pushresolves them, and a
managed database is started if the project uses one.rebase console -e "<code>"runs one snippet instead: an expression's value, or a function
body'sreturn, is printed as JSON on stdout, with notices on stderr, and a
throw exits 1. History is kept in.rebase/console_history. -
rebase upgrade --allow-downgradelets a dist-tag move pins to an older
release. Without it,rebase upgraderefuses such a move, with
downgrade_refusedunder--json. -
normalizeEsmSpecifiers(outDir, { write: false })computes the import
rewrite without writing anything. From@rebasepro/cli;
rebase normalize-imports --checkuses it. -
A new project ships a root TypeScript config that typechecks
scripts/.
The root also declares@types/node, sotsc --noEmitat the project root
checks something instead of printing its help and exiting 0. Each workspace
still typechecks on its own. -
rebase statusreports the branch. It includesbranch: { name, database }when the checkout is switched to one, and the human output says
so. -
The SDK generator takes the address its README connects to.
generateSDK(collections, { baseUrl })in@rebasepro/codegen.rebase generate-sdkpasses the port the project's backend last bound, as its printed
snippet already did, sogenerated/sdk/README.mdno longer says 3001.
CI & tooling
-
Three new gates:
pnpm check:test-types,pnpm check:castsand
pnpm check:test-resolution. Type errors in tests are counted per package
and may not grow; the count ofas any,as neverandas unknown asin
package source may not grow; and every test runner must import workspace
packages from source, notdist. -
pnpm check:auth-sql-tests: every auth module that issues SQL is run
against a real database by a test. A module whose queries only a mock ever
saw fails the gate. -
pnpm check:icon-button-namesfails on anyIconButtonin package source
without an accessible name. -
A package build fails when its
distcontains Vite's browser stub for a
Node built-in.assert-build-output.mjslooks for__vite-browser-external
in every emitted file. The published Postgres driver carried one for
readline, which crashed every confirmation prompt.
Auth
GET /api/admin/users/pick, the user picker's read. It answers id,
display name, email and avatar per user, nothing else, tousers:read
holders and to anyone who may write a collection with auserSelectfield,
so an editor's user picker is no longer empty and a picked user shows by
name instead of a bare id. A collection whose write rules are raw SQL cannot
be judged ahead and does not count: there onlyusers:readholders can
pick. New typePickedUser.
rls-check
-
An unconditional policy that nothing can reach yet is reported as latent
info. For exampleUSING (true) TO anonon a table anon holds no
privilege on, or in a schema it has no USAGE on. Postgres answers
"permission denied" today, so it is not an exposure, but oneGRANTwould
make it critical with no change to the policy. It is reported under
policy-always-trueatinfo, titled "latent", with what is missing and
the fix. Info is below every--fail-onexceptinfo, so default runs are
unaffected. -
anonymous-write-allowed reports Rebase databases. Every request there
runs asrebase_user, a signed-out one too whenAUTH_REQUIRE=falseor
guest sign-in is on, so a write rule that accepts any row is reported as
heuristic, naming the switch. A policy for a role thatanoninherits is
reported too.
Studio
-
The property editor warns when a smaller max narrows a varchar or char
column. On those column typesvalidation.maxis the column's width, so
lowering it is a DDL change:rebase db pushasks before it narrows a
column, and the change fails while a stored value is longer. -
SQL console: Cancel run while a run is in flight. A server that
supports it stops the statement withpg_cancel_backend; the console is
given back at once either way. -
SQL console: Export typed JSON writes each value read by its column's
type (numbers a JSON number holds exactly, booleans, json); Export JSON
is unchanged.
Realtime
-
Live row frames carry the row's version.
etagOf(row)on a
listenById()row is theETagaGETof it answers, so an edit made
against what a frame showed can be saved withifMatchand is refused with
412 if the row has moved on. A successfulPATCHanswers with theETagof
the version it wrote, andupdate()returns a row that carries it, so
conditional edits chain without a read in between. -
The SDK notices a half-open socket and reconnects. A browser cannot see
pings, so a socket whose network silently died used to look connected
forever. A 0.25 server now sends a heartbeat every 30 s to clients that ask,
and the SDK reconnects after two missed. Older servers and older clients
behave as before. -
Retained channels fill holes. A message that arrives past a gap in the
sequence (a frame lost between instances, a bus that does not order) is held
back while the missed ones are fetched from history, and all are delivered
in order. Senders are told the number their own message took
(broadcast_ack), so their own messages are never mistaken for a gap. This
is active only between a 0.25 SDK and a 0.25 server. -
REALTIME_MAX_SOCKETS_PER_USER(or realtime.maxSocketsPerUser) limits how
many realtime sockets one user holds on a server. A sign-in past it is
refused withTOO_MANY_SOCKETSand the socket is closed (4002). Unset, as by
default, there is no limit. -
REALTIME_REFETCH_MAX_WAIT_MSsets the longest a change waits for its live
frame (default 1000 ms);REALTIME_RECOUNT_INTERVAL_MSand
REALTIME_RECOUNT_MIN_ROWSlet a big list reuse its total for a while.
Instead of counting it on every write; such frames carrymeta.totalAsOf.
Unset, every frame counts, as before. Each has arealtime.*option. -
realtime.pathon the Postgres adapter, andattachRealtime(server, { path }), set the URL path the realtime socket answers. The default is
the base path given tomount()with a trailing/apiremoved (/api→
/,/x/api→/x), which is where the SDK dials.RealtimeSocketOptions
in@rebasepro/typescarries it to a driver aspath.
Jobs & cron
- POST /api/admin/cron/:id/trigger?async=1 (and
client.cron.triggerJob(id, { async: true })) answers 202 { accepted,
startedAt, job } as soon as the run has started, instead of holding the
request for the whole run, which a proxy may cut at 60 s. The outcome is in
the run history as the entry withmanual: trueand thatstartedAt. A run
that cannot start still answers409. The synchronous call is unchanged and
stays the default.
Postgres
-
openRebaseopens a project's data from a script, a REPL or a test, with
no server. It returnsdataAsAdminandsql, the accessors a function
gets,withAuth(user)for that user's row-level-security-scopeddata, and
close(). Opening changes nothing in the database and reads no environment
variable. Against a database no server has booted on,withAuthfails rather
than run unfiltered. From@rebasepro/server-postgres; see "Scripts and the
Console". -
Writes through
openRebaseandrebase consolerecord entity history.
A write to ahistory: truecollection gets its entry the way a server
write does: in the write's transaction, so a write that rolls back leaves no
entry, and as the identity it ran as (servicefordataAsAdmin, the
user'suidunderwithAuth). Nothing is created for it. If the history
table or itsrebase.record_historyfunction is missing, writes to those
collections are refused withHISTORY_UNAVAILABLEbefore any callback runs,
and reads still work. A script prunes no entries, because retention is the
server's setting. -
openPostgresDriverandpostgresDriverConfigFromEnvare the driver open
and the boot's environment reading, as two public pieces.
createPostgresAdapteris one step above them, andopenPostgresDriver
reads no environment variable. The adapter'sinitializeDriverconfig takes
provision: false, which opens the driver with no DDL of any kind, and
realtime: false, which takes no part in realtime. -
realtime.cdcanddirectConnectionStringon the Postgres adapter
config are the explicit forms ofREALTIME_CDCandDATABASE_DIRECT_URL.
Both variables keep working, and still win or fill in as before. -
A script's openRebase() is typed the way a function's rebase is. Its
accessors follow theDatabasethatrebase generate-sdk --register
registers, so a misspelled collection, field or enum value in a script is a
type error.openRebase<Database>()andOpenedRebase<Database>name the
type per call, or for a helper. -
The backup service resets the managed database's shared session.
resetSharedSession(url)from@rebasepro/server-postgresrunsRESET ALL
andDEALLOCATE ALLon a PGlite database and does nothing on any other. The
service also exportsrestoreDumpReplacingandfindCleanExtrasfor a
one-transaction clean restore. -
The driver can reopen databases left closed.
reopenClosedDatabases(url, others?)andreopenedDatabasesNotice(result)from
@rebasepro/server-postgresreopen any of the named databases that refuse new
connections, from the server'spostgresdatabase, and say which.
Changed
Server & REST
-
A database that cannot be reached is answered with 503
DATABASE_UNAVAILABLE, and Retry-After: 5, on every door: the data API,
sign-in and the other auth routes, the admin routes, a service-key or API-key
request, the realtime socket (anERRORframe, also onAUTHENTICATE) and a
subscription. They answered503 ROLE_LOOKUP_FAILED"Could not verify your
permissions",500 INTERNAL_ERROR, a 500 with the codeECONNREFUSED, or,
on the socket,AUTH_ERROR INVALID_TOKEN, which sent the SDK to refresh a
valid session.ROLE_LOOKUP_FAILEDremains for a role lookup the database
answered and that still failed. -
A service a request calls that cannot be reached is answered with 503
SERVICE_UNAVAILABLE, such as an S3 or MinIO bucket that is down, or a
fetch()to a host that refuses the connection or does not resolve. It
answered500with the socket error as the code (ECONNREFUSED), or
500 INTERNAL_ERROR. The server log names the host and the error; the
response does not. -
A list page and its count are read in one transaction. A
GET /posts?limit=25was 8 statements in 2 transactions and is 5 in 1 (4
with?count=none); withinclude=*, 13 → 10. The count now comes from the
same snapshot as the page. A?distinct=trueread is no longer counted,
since its answer never carriedmeta.total. -
A get-by-id with an include or a field list reads in one transaction
instead of two.?include=author: 2 transactions and 9 statements → 1 and
6;?fields=id,title: 8 → 5 statements. The ETag is unchanged. -
An include's per-relation limit is applied by Postgres.
{ comments: { limit: 2 } }on a page of 100 posts read every comment of
every post and kept two of each. At 2,000 comments a post that was 200,000
rows, 4.5 s and about 100 MB of heap; it is now 200 rows and 0.2 s. Ties, and
alimitwith noorderBy, go by the related row's key, ascending. It is
still one statement per relation. -
A to-one include takes the foreign keys from the rows it already has. One
statement fewer perbelongsToper level:include=author4 → 3,
include=author.company6 → 4. -
PATCH and DELETE no longer load every relation of the row before writing
it. The 404 and theIf-Matchcheck run on the read the write already
makes, inside its transaction. A PATCH of a post with 20,000 comments and 200
tags took 123 ms, 20.6 MB of heap and 2 transactions; it takes 5 ms, 0.8 MB
and 1. An item PATCH is 13 → 9 statements, withIf-Match17 → 10, a DELETE
12 → 8. Bulk update,_batchupdate and the history routes do the same. -
A write with no hooks skips its savepoint and its pre-commit check. A
create is 8 → 5 statements, an update 9 → 6, a delete 8 → 5. A write a hook
started, and a write outside a request's transaction, keep their savepoint. -
Bulk writes send half the statements. For 1,000 rows: create 4,007 →
2,005, update 6,007 → 3,005, delete 4,007 → 2,005, upsert of existing rows
6,007 → 3,005. An upsert that meets a stored row reads it once, not twice,
and history pruning after a write is one statement, not two. -
Re-sending a parent's unchanged children rewrites none of them, and a
many-to-many write updates only the links whose pivot values changed.
Saving a post with its 2,000 unchanged comment ids (the admin form sends them
on every save) rewrote all 2,000 rows, sent 2,000 change notifications and
bumped every child'supdated_at; it now touches none. 20 unchanged junction
rows were 20 UPDATEs and are 0. -
A bulk create of users hashes their passwords several at a time. 200
users: 5.7 s → 1.5 s. It applies when the user adapter declares itself
independent(UserCreationWriteContract), as the built-in one does when
there is noonCreateUser,onAdminCreateUseror customhashPassword;
otherwise users are still created one at a time. -
API responses under 1 KB are sent uncompressed, with a Content-Length. A
single-row answer costs 22 µs instead of 68. Larger and streamed bodies are
compressed as before; a JSON body of 1 KB or more costs about 10 µs more,
for the measurement. -
Static apps answer revalidations with 304 and gzip each hashed chunk
once. Files and SPA deep links carry a weak ETag and honour
If-None-MatchandIf-Modified-Since, so/, a deep link or
/logo.svgrevalidates with no body. Content-hashed assets from 1 KB to
8 MB are gzipped once into a 32 MB in-memory cache: an 862 KB chunk costs
0.4 ms of CPU per request instead of 16.7. Precompressed.br,.gzand
.zstsiblings are served when a scan at mount finds them, and a release
path behind a symlink (current -> releases/N) is still resolved on every
request. -
/api/docsbuilds the OpenAPI document once per mount, not on every
request. At 200 collections a request after the first takes 1.7 ms
instead of 39. -
Webhooks each drain on their own, and an entity change is delivered to
every matching webhook at once. WithenqueueEntityChangeand no
jobQueue, a receiver that was down delayed every other webhook by 6–36 s
per event; a healthy receiver now gets its deliveries in milliseconds, and
each webhook keeps its own order.flush(timeoutMs)takes a time budget and
resolvesfalseif it runs out (it resolvedundefined).onEntityChange
with three matching webhooks that each answer in T took 3T and now takes T;
the results stay in webhook order. The in-memory queue is still unbounded. -
The in-memory rate limiter's cost no longer grows with the limit. A hit
near a limit of 100,000 took about 1 ms and takes 0.1 µs; at 1,000, 11 µs →
0.2 µs. It makes the same decisions. -
An unknown relation in include is a 400 UNKNOWN_RELATION even when no
row matched. A typo against an empty table, under a filter that matched
nothing, on a read of a missing row, or nested below a relation that loaded
nothing answered 200, and became a 400 the first time a row matched. -
With liveSchema.repository configured, live schema editing works under
NODE_ENV=production and with schemaEditor: false: it writes only to that
repository, never to files beside the process. -
A server committing to a remote repository makes one schema change per
deploy. After a commit lands, the next apply is refused with
SCHEMA_CHANGE_FAILED, naming the commit, until the commit is deployed. -
initializeRebaseBackend is now the public steps it stands for:
installUnhandledRejectionHandler, createRebaseBackend, setDefaultBackend,
mount, attachRealtime and start. Its options, routes
and behaviour are unchanged, and itsshutdown()still closes the server it
was given; a backend fromcreateRebaseBackendcloses one only when passed
{ server }.RebaseBackendInstanceis nowRebaseBackend, a superset of
its old members. To change one step, copy those lines and change it. -
The server client's control-plane calls (rebase.auth, rebase.admin,
rebase.functions, rebase.cron) reach the backend's own routes in-process,
not the application's Hono app. They work wherever, or whether, the routes
are mounted. Middleware the application added to its own app no longer runs
on them. -
Two backends in one process no longer share state. Each signs and
verifies sessions with its own JWT keys, judges scopes by its own access
model, checks API keys in its own store and counts rate limits in its own
store.createRebaseBackendwrites no process global; the module-level API
answers for the process default, whichconfigureJwt,configureAccess,
setDefaultBackendandinitializeRebaseBackendset. A backend given no
auth.jwtSecretfollows the process default's JWT configuration, as
before. -
Inside a request, cron run or job a backend serves, rebase (imported or
ctx.rebase), topic publish and queue enqueue reach that backend. Anywhere
else they reach the process default. -
createRebaseBackend sets the log level only when logging.level is
given; the logger is process-wide, and building a backend no longer resets
it toLOG_LEVEL. -
A refused session says why. The data API, storage and custom functions
answer401 SESSION_REVOKED(signed out, or the account deleted) or401 ACCOUNT_DISABLED, as the admin routes already did, instead ofUNAUTHORIZED
"Invalid or expired token".createBuiltinAuthAdapter().verifyRequestrejects
with that error instead of resolvingnull. The service key now verifies
throughverifyCredentialaskind: "service-key", as it does on the HTTP
routes, where it used to benull. -
A route's scope guard refuses a scope nobody could hold where the route is
registered. InrequireScope, a name that is not a scope, a target written into the scope, or
a misspelt built-in such asdata:raedthrows at registration. An app scope
the backend does not declare underauth.scopesanswers500 SCOPE_NOT_DECLARED, instead of403 SCOPE_MISSINGto everyone, admin
included, with a message saying admin holds every scope. -
loadEnv()names what is wrong. It throws anErrorreading "The
environment is not valid: DATABASE_URL: is required", with zod's issues on
.issues, instead of a rawZodErrorthat printed asZodError { name: 'ZodError', message: [Getter/Setter] }. -
A token this backend did not sign is one log line. A bad signature is one
WARN line and anything else one DEBUG line, instead of an ERROR with a stack
on every such request and every correctnullfromverifyCredential. A
routine 401 the server marks expected, such asNO_SESSIONfromPOST /auth/refreshfor a browser with no session, has its request line at info
rather than warn, so an anonymous admin-panel load no longer puts a WARN in
the dev log. -
A built bundle run from a project keeps local uploads in the project.
Underrebase start, local uploads lived indist-bundle/uploads, which the
nextrebase builddeleted while the rows naming them stayed, and a file
uploaded underrebase devanswered 404. A bundle withrebase.jsonabove it
now stores them in<project>/uploads, asrebase devand an ejected server
do, sorebase-serverrun from a project checkout withoutSTORAGE_PATHdoes
too. A container's storage does not move:STORAGE_PATHstill decides, and a
bundle with no project around it stores where it did. Uploads an earlier
version left inside the bundle are named at boot, with where to move them.
Postgres
-
A server booting against an unchanged database runs about a tenth of the
statements, and no longer re-applies what is already there. It re-created
the helper functions and four indexes (the example app logged "Applied 7
additive schema change(s)" on every boot, now none), re-attached the
change-capture trigger on every table, ran ENABLE ROW LEVEL SECURITY on
tables that had it, and re-issued the auth tables' columns, indexes and
back-fills. A function altered or an index dropped by hand is still put back.
Each table's RLS policies are now replaced in one transaction instead of one
commit per statement. At 200 collections on a disk that really flushes, a
warm boot took 8.8–13 s and 5,080 statements; it takes 1.3 s and 467. -
A first deploy builds its tables in a few dozen transactions instead of one
commit per column. At 200 collections the table step makes 49 round trips
instead of 3,769, and the cold boot takes 2.2 s and 762 statements instead of
15.9 s and 8,839. A batch that fails is replayed one statement at a time. -
Boot waits for a busy table in one-second slices, so live queries on it
wait at most a second behind a deploy. Schema and policy changes take the
table's lock with a 1 slock_timeoutand retry, where a live read used to
queue behind the boot for up to the 30 s statement timeout: with a reader
holding a table for 5 s, live reads on it waited 4,981 ms and now wait
991 ms. Policies retry 3 times within a 10 s budget; a table still locked
keeps exactly the policies it had, and the boot logs it, at error when the
policy names differ. A table change retries for about 30 s, as before, then
fails the boot with a hint naming the lock. The change-capture trigger
retries 3 times, then is skipped with a warning, as before. -
RLS policies check the caller once per query instead of once per row.
Every part of a compiled policy that does not read the row is wrapped in
(SELECT …), which Postgres evaluates once per statement and plans around.
A deep page of a large table no longer turns into a full scan and an
on-disk sort: 1M rows,ORDER BY id DESC LIMIT 50 OFFSET 20000as an admin,
7.3 s → 4 ms, andcount(*)300 → 45 ms. Owner- and tenant-scoped reads are
2–8× faster. Policy names, and who may read or write what, are unchanged; the
next boot orrebase db pushrewrites each policy in place. A deployment that
applies only generated migrations keeps the old, equivalent text until its
nextrebase db generatewrites a migration. -
Each authenticated transaction sets the caller's identity and role in one
statement instead of two.set_config('role', …, true)joins the claims
query in place of a separateSET LOCAL ROLE, which saves a round trip on
every read, write and realtime refetch: a get-by-id is 6 → 5 statements, a
list 11 → 9. -
Schema planning scales linearly with the number of collections. At 1,000
collections it takes 55 ms instead of 164. Boot,rebase db pushand
rebase db generateplan the schema. -
The Postgres adapter reads its environment variables on the boot path,
through postgresDriverConfigFromEnv, not while the driver opens. Every
variable and its precedence is unchanged. A mistyped
REALTIME_MAX_SOCKETS_PER_USERnow failsinitializeDriverinstead of
initializeWebsockets, with the same message.
createPostgresDatabaseConnectionandresolveChannelBusSettingtake an
optional trailingenv. -
collectionRegistry is optional on the adapter's initializeDriver config;
no bundled driver reads it, so a script no longer passes
collectionRegistry: undefined as never. -
openPostgresDriverno longer logs how authenticated requests are
isolated. It returns the sentence asinternals.rlsEnforcement, and the
server's boot logs it, so a script that opens the project prints nothing of
its own on stdout at the default log level.openRebaselogs it at debug, and
rejects with one readableCannot connect to PostgreSQL at <host>:<port>: <reason>.when the database is down.
Realtime
-
Live views of a collection written several times a second update at least
once a second. The refetch debounce had no maximum wait, so a steady stream
of writes held every live view of the collection frozen until the writes
stopped. With a write every 100 ms for 3 s, the first update came at 3.3 s;
it now comes at 1.1 s. -
One write's realtime fan-out uses at most a quarter of the connection
pool. Refetches queue, at most max(2, pool size / 4) at a time (5 on the
default pool of 20), so REST requests no longer wait behind them. An
unrelated query during a fan-out to 200 principals waited 1,001 ms for a
connection; it now waits 13 ms. -
A live list's refetch reads and counts in one transaction, and skips the
count when the first page holds the whole set. Statements per write: 9 → 6
for one principal watching, 801 → 501 for 100. -
A bulk UPDATE on a list many users watch no longer stalls the event loop.
Every changed row visited every group watching the list. A 100k-row UPDATE
with 1,000 principals watching stalled the server for 940 ms and delivered
its last frame 4.4 s after the commit; now 92 ms and 2.5 s. -
Change capture reads a changed row's id without reading the rest of the
row. A row with a 200 KB document cost the trigger 150 µs and costs 9 µs; a
20-column row, 32 → 21 µs. The trigger function keeps its name and arguments,
its payload is unchanged, and the next boot gives existing triggers the new
body. -
A presence heartbeat that repeats its state sends nothing. Every
heartbeat sent each member of the room apresence_diff: one round at 1,000
members was 1,000,000 frames (108 MB); it is now none. -
A closing socket finds its presence rows by index, and a socket that never
tracked presence runs no query. 1,000 closes on a 20,000-member roster took
615 ms and take 3 ms. The index is created at boot. -
Without change capture, a bulk save is announced to other instances in a
few notifications, and they read nothing on receipt. A 1,000-row save
across two instances sent 1,000pg_notifyand made the other instance read
1,000 rows it never used; it now sends 3 and reads none, and the save takes
~235 ms instead of 447. A single change keeps the previous payload; a batch
uses a new shape, which an instance still on the previous version ignores
during a rolling deploy. -
Channel names that clients invent no longer accumulate in server memory.
History retention is resolved per rule rather than cached per channel name,
and the prune throttle forgets expired entries: 20,000 invented names at
3,600 a minute left 40,000 entries, now fewer than 6,000. -
REALTIME_CDC=offremoves the change-capture triggers an earlier boot
installed. The process that owns the schema drops both capture functions,
and the triggers with them, and logs how many. Give every process the same
REALTIME_CDC: a schema owner set tooffbeside anapileft onauto
leaves theapilistening for nothing. -
The realtime socket reads a filter the way REST, MCP and the SDK do. A
two-element value array is an implicitin, and an unknown operator is an
UNKNOWN_FILTER_OPERATORrefusal, on list, count and subscription frames. -
Frames arriving on one socket while its identity check is in flight share
that check. Each used to query the auth store, which a client
re-subscribing after a reconnect did once per subscription. Nothing is
cached past the flight and nothing is shared across sockets; a demotion
during a shared lookup is seen one frame later. -
The realtime socket URL gains ?rebase_caps=heartbeat,channel_seq, and
an older server ignores it. -
The SQL console's cancel is negotiated like the other socket opt-ins.
The SDK asks forcancel_sqlon the socket URL (?rebase_caps=…,cancel_sql)
and sendsCANCEL_SQL { requestId }(an admin-plane frame,
database:write) only to a server whosehellolists it. -
The realtime socket answers WebSocket upgrades on its own path only,
/for an API at/api, which is where the SDK dials. An upgrade to any
other path is left to the host app's ownupgradelisteners, or answered
404 when there are none. Before, the socket took every upgrade on the
server (see Fixed). -
The realtime socket verifies sessions and reads roles through what the
server passes it,verifyAccessTokenandaccessModelon
RealtimeSocketOptions, so it uses its own backend's keys and access model.
A socket built by hand without them uses the process default.
Admin (CMS & app)
-
The admin's login screen preloads about 2 MB less JavaScript. The example
app's eager set was 3.3 MB (966 kB gzipped) and is 1.3 MB (415 kB). Only
English ships up front: another language is fetched the first time it is
picked or configured, and the panel waits for it instead of flashing
English. The collection table, record views, entity form and side panel
load when a collection opens, prefetched while idle after sign-in. The
lucide icon map and markdown-it load on first use (see UI kit & plugins),
and the/debug/uiview is out of the preload set.@rebasepro/appand
@rebasepro/cmsdeclare"sideEffects": ["**/*.css"], so a bundler keeps
only the modules an app uses from their barrels. -
A scaffolded admin no longer preloads the syntax highlighter to get
clsx.rebaseManualChunks, and the template's copy of it, put React and
clsx in chunks of their own (vendor-react,vendor-clsx), so
vendor-prism(85 kB) leaves the preload set. -
A collection view loads only the relations its properties declare. List
reads (table, list, cards, board, relation picker,useCollection) name the
relations a property declares instead ofinclude: ["*"]. A relation
declared only for a record's tabs is no longer loaded for every row of every
page and every realtime re-read: the demo's orders went from 2 relation
queries and about 125 embedded line items per page to 1 and none. Custom code
that read a tab-only relation off a list row (tableController.data, an
additional field, an entity action) no longer finds it there. -
A scrolled table re-reads the rows it holds at most once every 2 s under a
stream of writes, and never counts them. A write after a quiet spell is
still picked up at once. Writes every 300 ms for 10 s with 1,000 rows held
made 34 reads of 32,300 rows; now 6 of 5,700. Every read past the live
first page sends?count=none: scrolling to 10,000 rows ran 199COUNT(*)s
nobody read, now none. -
The collection table redraws only what changed. A view render that
changes nothing no longer redraws the rows; ticking a checkbox redraws the
row actions instead of every cell (460 cell renders → 0); a table holding
10,000 rows compares only the rows on screen on each realtime update (27 ms
→ 1–2 ms). Cells share one ResizeObserver (784 per mount → 3) and one
validation schema per column (276 → 12). Asking whether a row is selected
takes constant time, so "select all loaded rows" at 10,000 rows takes
0.3 ms instead of 172. A date column in a fixed timezone builds its
formatters once (60 cells: 240 → 2), and a table with pinned rows (the
relation picker's dialog) no longer gets a new row array on every render. -
Loading the next page of cards draws the new cards only, and the list view
no longer redraws every row on each update. With 2,000 cards loaded, the
next page rendered 4,050 cards (798 ms); now 50. -
The kanban board no longer keeps the table view's first page subscribed
beside its columns. A board of N columns held N + 1 subscriptions; now N.
The toolbar's "select loaded" no longer selects the hidden table's rows;
"select all matching" is unchanged.DataTableControllerProps.enabled
(defaulttrue) turns a table controller's reads off. -
The relation picker reads the rows already linked in one request per
1,000. 40 linked rows took 40 requests and take 1; 2,500 take 3. This
covers the dialog and the inline picker; a target with a composite key keeps
one read per id. -
A keystroke in a record form re-renders the field typed into, not every
field. In a 40-field form with a 500-item array, one keystroke re-rendered
540 field bindings; now 1. A customadmin.Field,dynamicProps,
conditions, a functionstoragePathorfileName, and every field when a
plugin has afieldBuilder, still re-render on each change. A form opened
with a draft no longer rebuilds its touched map on every keystroke (1,000
keys: 62 ms per render → 0). -
Array fields no longer re-hash every item on every render or keep a
document listener per item. 2,000 items made 2,000 hash calls per render,
now none; re-rendering a 500-item sortable list took 192 ms and takes 23; and
a 500-item list kept 500 document listeners with no menu open, now none. -
Form undo keeps the last 200 steps instead of every keystroke. 10,000
keystrokes in a 500-item array retained about 43 MB of history and retain
about 0.9 MB. The limit isFORMEX_MAX_UNDO_STEPS, exported from
@rebasepro/forms. -
CSV and JSON import writes up to 500 rows per request instead of 25. A
batch is capped at about 1 MB, and a 413 halves it for the rest of the
import instead of failing. 10,000 rows took 400 requests and take 20. A
failed batch names the row of the file that failed (failedRow), and Retry
resumes after the last committed batch. Reading the file no longer throws an
exception per text cell (10,000 × 20 cells: 600 → 90 ms). -
Export and "select all" read 1,000 rows per page by cursor instead of 500
by offset, and process each row in time linear in its columns. A
100,000-row export makes 100 requests instead of 200, with no growing
OFFSET; turning 100,000 × 20 into CSV takes 0.8 s instead of 1.35. -
Thumbnails of stored images download a small webp rendition instead of
the original. Tables and cards ask the file route for a 96, 200, 400 or
800 px webp: a 2.47 MB photo comes down as 3.5 KB at 96 px, and a 100-row
page as 0.34 MB instead of 247 MB. A server withoutsharpis detected with
one probe per page load and keeps serving originals, and a failed rendition
falls back to the original. -
The user picker fetches its list when opened, not when it mounts. A form
with three user fields made three list requests on open; it makes none until
a picker opens. -
A collection scrolled deep re-reads only the rows near the screen on a
realtime update, and coming back to it shows the rows that were on screen at
once. With 10,000 rows held, a write cost 10 reads of 9,950 rows before and
costs 1 read of 120 rows. The scroll cache no longer keeps every row ever
shown; the rows above load when scrolled to. -
The cards view mounts only the rows of cards on screen, and a CSV export
is written a page at a time. With 2,000 loaded cards the view held 42,000
DOM nodes and holds about 300. Exporting 100,000 rows × 20 columns held about
196 MB and holds about 51 MB; the file is unchanged. -
buildEntityPropertiesFromData no longer drops keys starting with _,
so a collection created from a Mongo export keeps_id. Pass
{ ignoreKey: k => k.startsWith("_") }to keep the old behaviour. -
CollectionViewHeaderAction and PropertyAddColumnComponent are removed.
They were not exported and mounted nowhere. -
Component overrides compose across scopes: a collection's wrap override
receives the app's override of the same component as OriginalComponent.
Before, it always received the built-in component. A collection override
that replaces the component still replaces both. -
When Rebase owns the theme, it removes the dark class it added to the
html element when it unmounts. Adarkclass the page already had is
left. -
@rebasepro/cms-types depends on @rebasepro/common, for the
composeCallbacksit re-exports. -
A collection table shows the row id once. When a collection declares a
single key property (isId), the table and the relation picker no longer give
that property a column next to the ID column, which showed the same value. Set
hideIdFromCollection: trueto show the key in its own column. -
@rebasepro/forms: a pendingonValuesChangeDeferredcall runs when the
form submits (beforeonSubmit) or unmounts, and is dropped when the form
resets. It is otherwise still debounced by 300 ms. -
A relation tab takes its target's singular name.
getEntityChildViews
sets the child view'ssingularNamefrom the target collection; the
declaring property'snamestill names the tab, so dialogs say "Remove
this Tag from this record?" and "Add Tag", not "this Tags".
UI kit & plugins
-
Importing one component from
@rebasepro/uibundles that component, not
the whole kit. The package ships one file per module: an app importing
{ Button }bundles 31 kB instead of 925 kB, minified. Import paths and the
package's exports are unchanged, and the entry is still one"use client"
boundary. -
The lucide icon map behind icons chosen by name loads the first time one
renders, not before the login screen. That is about 480 kB less preloaded
in the example admin. -
Markdown loads its parser the first time it renders. markdown-it (44 kB
gzipped) leaves the preload set. The first markdown of a session shows its
source text for one chunk fetch; server rendering, and the hydration after
it, show plain text. -
Chips follow the theme with one observer, multiline fields re-measure only
when their content or styling changes, and date fields format once per
change. 500 chips created 500 MutationObservers, now 1; five identical
re-renders of a multiline field measured it five times, now never; date
fields reuse their formatters for named time zones and share one stylesheet
(ten fields added 11 copies, now 1). -
Typing in a form no longer rebuilds the AI plugin's autofill property map,
and building it is linear. It was rebuilt on every keystroke; it is built
once per run. At 1,000 blocks a build took 371 ms and takes about 5. -
The editor's slash autocomplete sends at most 8,000 characters on each
side of the cursor (MAX_AUTOCOMPLETE_CONTEXT). That is exactly what the
hosted service reads. It used to upload the whole document on every request. -
Plugin field wrappers compose: every plugin's
fieldBuilder.wrapgets
the field as the plugins before it left it, and all the wrappers render.
params.Fieldis the previous plugin's wrapper, so the first plugin's is
innermost; a plugin that returnsnullorenabled: falsepasses the field
on unchanged. Before, only the last plugin that returned a wrapper
rendered. -
Every neutral in the theme sits on the brand hue.
@rebasepro/ui's
theme.cssre-hues the surface ladder, the six text tokens and the numbered
and slate scales to the brand blue's hue (oklch, hue 258, chroma 0.002–0.014),
each at the lightness it had, so contrast and the ladder's steps do not move.
It adds a--color-graphiteramp and points Tailwind'szinc,neutral,
grayandstonepalettes at it, so abg-zinc-*in your own code lands on
the same hue. Light secondary text rises to 4.85:1 on the frame (it was 4.17,
under AA), and dark disabled text clears 4.6:1 on a card.
Studio
-
The SQL console keeps at most 10,000 rows, or 32 MB of values, of a
result, and only the last statement's. A million-rowSELECTno longer
takes the server down: its heap went from 432 MB to 7 MB, peak memory from
2.1 GB to 240 MB, and the browser reads it in 31 ms instead of 4 s. A result
cut short says how many rows there were ("Showing the first N of M rows");
SqlScriptResultcarriestruncated: trueand the truerowCount. Copy and
export include the rows shown. A result too large for one string is no
longer reported as an SQL error.client.executeSqlis unchanged. -
The storage browser shows a folder as soon as it is listed, and reads
metadata only for the files on screen. A 500-file folder with 12 on screen
made 501 requests before it rendered and 500 metadata requests; it makes 1,
then 12, six at a time. Deleting a folder deletes up to eight files at once. -
Picking a table in the RLS editor no longer re-reads the catalogue.
Opening it makes 3 catalogue reads instead of 6, and a table click none
instead of 3. -
The SQL console's schema browser groups 5,000 tables in 10 ms instead of
1.8 s. -
The schema visualizer lays a graph out once. It laid it out again when
RLS status arrived and when the view was reopened, 5 s each at 200 tables;
both now take 0.1 ms. The first layout still blocks. -
The admin no longer asks the database for unmapped tables on every
load. The new-collection dialog asks when it opens, its only reader. -
POST /api/admin/schema/planlists every file/applycommits in
files: the collection's source first, when the backend can write it, then
the generated schema. It used to list only the generated files, so Studio's
review under-reported the commit.
CLI
-
The check that stops a destructive schema change also stops the drop of a
CHECK or EXCLUDE constraint the database holds, and names it. It is the
check behindrebase db push,rebase db migrate --refuse-destructiveand
the reportrebase db generateprints. A push that would still drop one asks
on a terminal and refuses elsewhere;--allow-destructiveapplies it. -
Schema introspection classifies a table whose policies give each user
their own rows as an entity, not as a code list or as a child hidden from
the navigation, however few rows it has. -
rebase devno longer reads through the node_modules folder under config
before it starts the backend. tsx's default ignores apply only under the
project root, so with aconfig/node_modules, as in the monorepo's example
app, the backend started after 16–17 s of crawling; it now starts in 0.1 s.
Installing a package there, or a.DS_Storeor editor swap file appearing
inconfig/, no longer restarts the backend. -
rebase devreaches "Server running at" about a second sooner. It no
longer re-checks a schema it has just generated, and the driver it runs no
longer loads the server package: 11 collections took 3.7–4.7 s and take
2.6–2.8 s. -
Driver commands start without loading the server package.
db push,
db generate,schema …anddoctorresolve 189 modules instead of 426;
rebase schema generateat 11 collections takes 1.2 s instead of 1.6–2.0. -
rebase db pushon a database that already matches runs Atlas once
instead of twice. That is one Atlas plan fewer (about 0.9 s at 111 tables,
5.5 s at 411) and 7 database connections fewer per push. The post-steps (RLS,
auth tables, vector, search, triggers, policies, role) all still run. -
The CLI loads a command's code only when that command runs.
rebase --versionand--helpload about a fifth of the modules they did:
from source, 254 → 103 ms. Their output is unchanged. -
A project pinned to a prerelease stays on its channel when upgraded.
rebase upgradewith no--to, on a canary for example, takes the newest
canary unlesslatestis newer. An exact--toolder than the project is
still applied, and the summary says it is a downgrade. -
The telemetry question at the end of an interactive init defaults to no.
-
A failed import check reports on stderr with exit code 1.
rebase normalize-imports --checkused to throw, with a stack-trace hint. -
The other commands follow the database the last dev server ran on.
rebase devrecords whether it ran with--docker. The choice is kept in
.rebase/dev-database.json(gitignored) and ranks where--dockerdoes,
below everyDATABASE_URL, the branch pointer and--database-url; commands
say so on stderr. The nextrebase devwithout--dockerswitches them back
to the managed database, and warns that it is leaving the Docker one. -
Anonymized emails are random.
rebase db pull --anonymizewrites
user-<random hex>@example.invalid(no longeruser<id>@…), and gives unique
columns<label>-<random hex>values. -
The resource graph in JSON says what the check or the write found.
rebase resources --check --jsonand--write --jsonprint the graph with
check: { file, status }orwritten: { file, changed }besideversionand
resources. -
Starting a bundle with no database URL stops before it boots.
rebase startexits 1 when noDATABASE_URLis set, and prints
DATABASE_URL="$(rebase db url)" rebase startfor a local run. It already
failed then, inside the runtime, with a less useful message. -
Backups default to ./backups where you run the CLI.
rebase db backup
without--out, andrebase db backups list, use./backupsin the
directory you runrebasefrom, normally the project root: the same directory
--out ./backupsnames. Before, they usedbackend/backups. A relative
--out, and the dump pathrebase db restoretakes, resolve against that
directory; a relativeBACKUP_DESTINATIONstill resolves against the backend
directory, where the server reads it. If the new location is empty and
backend/backupsholds dumps,backups listsays where they are. -
A backup takes its destination only from --out. Use
--out ./backups
instead ofrebase db backup ./backups.rebase db restorerefuses more than
one backup. -
The manifest the CLI infers for a project leaves out what equals a default,
and spells the build for the project's package manager.synthesizeManifest
(@rebasepro/cli) writesspa,functionsandcronsonly when they differ
from their defaults, and builds the static app with pnpm in a pnpm project.
The inferred static app is still namedweb. -
A pull replaces the local database's contents.
rebase db pullempties
every schema it copies, in one transaction, before restoring. For a whole-database
pull that is every local schema, so tables that exist only locally, migration
history included, no longer survive a pull. -
The service-key commands need an address for a server dev did not start.
Forrebase api-keysandrebase storageagainstrebase startin production mode, or a server in a
container, setREBASE_BASE_URL; see the Security entry. -
The branch subcommands refuse a flag they do not take. Every
rebase db branchaction,switchincluded, names the closest real flag
("did you mean --older-than?"), asdb backupanddb restoredo. They used
to ignore it, so--froceran without--force. -
Each new project gets its own compose port.
rebase inittakes the host
port from a per-project range (5433–5632, derived from the project path), never
5432, and skips ports any Docker container is bound to, running or stopped. -
After
schema introspecton tables with row-level security off,rebase db push --dry-runlists what the push does to them: the RLS it switches on and
the admin baseline it adds, instead of "No changes". The push itself is
unchanged. -
The CLI refuses an unknown option in one sentence.
console,storage,
api-keys,status,init,devand the other commands on the shared
parser saidunknown or unexpected option: --x (did you mean --y?). They now
say whatdb backupsays: "rebase storage movehas no option --form — did
you mean --from? Nothing was run." A script matching the old text needs
updating. -
The status view no longer flags a resource graph that has not been written
yet.rebase buildwritesrebase.resources.jsonon every build, so a
fresh scaffold'srebase statusno longer ends with "is missing". A stale
file is still flagged, andrebase resources --checkstill fails on a missing
one. -
New scaffolds take the admin's chunk rules from the app package.
frontend/vite.config.tsusesmanualChunks: rebaseManualChunksfrom
@rebasepro/app/vitePlugininstead of a copy, so an upgrade brings the
package's revisions with it. -
A backup refuses to leave out the rebase schema.
rebase db backup --exclude-schema rebase, or any pattern that matches it, is refused before
anything runs: a dump without that schema cannot be restored. -
A clean restore needs psql, and drops what the backup's schemas hold beyond
it.rebase db restore --cleanruns the dump throughpsql(shipped with
pg_restore, orPSQL_PATH), and drops each schema the backup creates whole,
tables the backup lacks included, naming them first.--continue-on-error
keeps pg_restore's own--clean. -
A forced branch creation closes the source to new connections for the
copy.rebase db branch create --forcerunsALTER DATABASE … ALLOW_CONNECTIONS falsefrom the server'spostgresdatabase for the length
of the copy, then opens the source again: on failure, and on every signal it
can catch, closing the terminal and Ctrl-C included. A source a SIGKILL leaves
closed is reopened by the nextrebase db branchcommand, any of them, which
says so first, and a forced create also reopens a source it found closed. -
The driver's commands refuse an unknown option in the shared sentence.
rebase db migrate,schema introspect,schema staleand the other
driver-checked commands say "rebase db migratehas no option --x — did you
mean --y? Nothing was run.", as the CLI's own commands now do. A script
matchingunknown or unexpected optionneeds updating.
Client SDK
-
Walking a collection seeks by cursor and skips the count on every page.
iterate()andfindAll()hand the server'smeta.nextCursorback as
afterinstead of a growingoffset, and sendcount: "none", since a walk
reports no total. A 100,000-row walk spent about 4.5 s in the database and
spends about 26 ms. Listings the server issues no cursor for (relevance,
vector, distinct) still page by offset;cursor:now means "require
seeking". -
Response bodies and realtime frames parse at plain JSON.parse speed. The
typed-value revive runs only on text that names a__type: a 1,000-row page
took 10 ms of main thread and takes 1.7; a 1,000-row socket frame, 36–41 ms
→ 4–6. The parsed values are identical. -
A live query sends no HTTP request for rows the socket already replayed to
it. Tenobserve()calls on a query the socket held sent ten requests whose
answers were discarded; they send none. -
A request made with a token the client knows has expired waits for one
shared refresh. It went out, failed with a 401 and was sent again: ten
requests after a device slept made 21 round trips, now 11. A custom
Transportcan take part through the optionalsetTokenRefresher. -
The typed object-form where checks each value against its column, as the
fluent.where()does, and.fields(),.vectorSearch(), sum/avg and
field operations ($inc, $push, …) accept only what the server
accepts. For example["array-contains", ["a"]]on astring[]column is
now a compile error, where the server refused it at run time. -
On a client with generated types, or(), and(), not() and cond() groups
are checked against the row, as the object-form where is.
posts.where(or(cond("titel", "==", 1)))andcond("views", "==", "many")
are now compile errors. Relation and JSON paths are not checked. -
etagOf and withETag now live in @rebasepro/common.
@rebasepro/client
still exportsetagOf. -
The realtime socket gets its token the way HTTP requests do: the token
getter first, then the held token (the session, setToken() or the token
option). After a sign-out it no longer falls back to thetokenoption,
which HTTP never did. Installing a token getter or callingsetToken()
rebuilds an open socket, so it authenticates as the new credential. -
The token client option is read each time a token is needed, so it may
be declared as a getter, on HTTP as well as on the socket.
Storage & email
-
Storage files are streamed instead of read whole, and a Range request
reads only that range from S3 and GCS. Serving a 100 MB object from S3 held
401 MB of buffers and holds 33 MB. A 1 MiBRange, what a<video>sends on
load and on every seek, read the whole object; it reads 1 MiB. A
revalidation that ends in 304, aHEAD, a 416 and a cached image rendition
read no body at all. GCS reads are pinned to the generation the route
described. -
Uploads are written a chunk at a time. S3 files over 8 MiB go up as
multipart uploads, which also lifts the 5 GB single-upload cap; GCS writes
from a stream; local disk writes a temporary file and renames it into place.
A 100 MB upload to S3 held 201 MB and holds 73 MB. A resumable (TUS)
upload's finalize no longer reads the whole file into memory: it looked for
openAsBlobin the wrong module and always fell back to reading the file.
POST /uploadstill parses its multipart body in memory. -
Backups to and from S3 and GCS stream, and dumps over 2 GiB upload. A
100 MB dump held 402 MB of the server's memory and holds 73 MB; a dump over
2 GiB failed to upload and now uploads. The backup download route and
rebase db restore s3://…stream the dump. -
Paging through a local storage folder reads the directory once, not once
per page. 20,000 files in pages of 100 took 5 s and take 40 ms. -
Listing a local backup folder no longer blocks the server while it stats
every dump. At 8,760 dumps the longest stall went from about 50 ms to about
20 ms. -
An image transform with the rendition cache on reads one stored rendition,
not four. It probed four file extensions; on S3 or GCS that is one round
trip instead of up to four. -
S3's metadata route no longer signs a URL it throws away.
-
Previews of one file share one signed-URL request, at most 8 run at once,
and the URL caches are bounded. 50 previews of one private file made 50
/storage/metadatarequests and make 1; 100 different files go 8 at a time;
at most 2,000 URLs are kept. This covers the SDK'sclient.storage,
useBackendStorageSourceand the admin's thumbnails, through
createDownloadUrlCacheandcreateConcurrencyLimitin@rebasepro/common. -
SMTP mail reuses its connections and holds at most 5 open at once. 50
sends one after another opened 50 connections and open 1, at 112 ms a
message instead of 163. A burst now queues behind 5 connections: 50 at once
against a server with no connection cap took 175 ms and take 1.15 s. Set
smtp.maxConnectionsto change the cap (3 for Office 365); the pool closes
5 s after the last send. -
In production, a project whose named buckets are bound but whose default
source is not logs one warning at boot naming both fixes. Before, it logged
an INFO "File storage is off" and an ERROR "No default storage source". -
A transform without its image library throws a named error.
transformImagethrowsImageTransformUnavailableErrorwhensharpis not
installed, with a message namingpnpm add sharp.
Jobs & cron
-
A cron job with a time zone no longer blocks the server while it finds its
next run. The search read the zone's wall clock once per minute: a yearly
schedule in Madrid took 319 ms and takes 0.4 ms, 29 February in New York 1.9 s
and 2 ms. Studio's Cron panel on a split deployment'sapirole spent 450 ms
per poll on four zoned jobs; it now spends 1 ms, then nothing, since each
job's next run is kept until it passes. -
Finished jobs and old cron claims are pruned every hour, in batches,
instead of once at boot. A boot with 2M finished jobs ran a 6 s DELETE
before serving; the backlog now drains in the background in batches of at
most about 220 ms, and a long-lived pod stays near its retention size. A
year of claims took 7.8 s at boot and now goes in batches of at most 170 ms,
then about 8 ms an hour. The new partial indexrebase.idx_jobs_finishedis
built at the first boot after upgrading (4.7 s on a 2M-row table). A custom
store takes part through the optionalJobStore.sweepExpired()and
CronStore.sweepClaims(). -
A job attempt times out after 15 minutes when
jobs.timeoutMsis not
set. The worker names the default in one log line at start. Set
jobs.timeoutMsfor longer jobs, or0for no limit (it used to be
refused).
MCP
-
Every MCP tool answers in compact JSON, and the collection listing is sent
once. Indentation was about half of every answer. A project of 200
collections with 20 fields each got a 1,983 KB listing reply, about 317,000
tokens of it read by the model; it is 697 KB and about 150,000 tokens. At
20 collections, about 32,000 tokens → 15,000. This covers the remote and the
local MCP server. -
The connected-apps list and disconnecting an app read refresh tokens
through an index, and expired ones are swept while the server runs. At
210,000 refresh tokens the list took 547 ms and takes 9. Expired codes and
refresh tokens are swept at most once an hour, when a credential is issued,
instead of only at boot.
CI & tooling
-
The bundle budget fails when a module that should load lazily reaches the
login screen.check-bundle-budget.mjsreads the eager chunks' sourcemaps
and refuses non-English translations,packages/app/src/debug/, the CMS
record views and entity form, prism-react-renderer, markdown-it and more
than 400 lucide icon modules; a chunk without a sourcemap fails.--dist
runs the same check and exits 1 on a violation.@rebasepro/ui's build
checks that{ cls },{ Button }and{ Typography }each bundle under
60 kB. -
pnpm check:hookscounts an exhaustive-deps disable that has no
-- reason;pnpm check:gates-docchecks the "Tests" rows against the
workflow and every package test script against a register in
docs/gates.md;pnpm rls:check --strict-baselinefails on a stale
baseline entry, and CI passes it. The admin e2e job log lists every
Playwright test, flaky ones included. -
Canary builds are numbered as the minor they preview:
0.25.0-canary.g<commit>, not0.24.1-canary.g<commit>, so a canary's
version matches the Since badges in the docs it ships with. It still sorts
above every release on the current minor and below the release it previews.
Auth
-
/auth/configand sign-up learn whether any user exists without counting
and sorting the users table. At 100,000 users that was 2 statements and
26–43 ms; it is 1 statement and 0.3 ms, and in production/auth/config
runs no query at all. A custom auth repository can provide the optional
firstUserIds. -
Verifying an access token no longer re-imports the JWT secret. A
verification takes 5.8 µs instead of 20.6, an HS256 signature 3.2 µs instead
of 15. A rotated secret is imported once, on first use. -
A storage request verifies the caller's token once in the rate limiter, not
twice, and a personal API key reads its owner in one statement, not two. A
disabled owner's key is still refused.
Fixed and Documentation
264 fixes and 17 documentation entries did not fit in a GitHub release; they are in the CHANGELOG.