Skip to content

v0.25.0

Choose a tag to compare

@fgatti675 fgatti675 released this 14 Oct 12:31
· 562 commits to main since this release

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=none skips the count behind
    meta.total, ?count=exact is the default (see Added), and any other value
    is a 400 INVALID_COUNT. Before, ?count=5 filtered on a count column.
    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/tus answers 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 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") 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 /bulk upsert
    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=,
    /bulk with upsert: true, a _batch upsert operation, the SDK's
    upsert() and createMany(…, { upsert: true }), and the realtime socket.
    Create users with POST (or create) and change them with PATCH (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-process rebase.data and
    the offline client refuse it before any request. Before, page won silently
    and the offset was 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) answers 400 VALIDATION_CONSTRAINT with the
    required violation, 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 a beforeSave keeps its exemption, since the hook may fill
    the value. The admin panel already writes null for 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 or rebase db push replaces (see
    Fixed). Only a policy name that collided changes.

MCP

  • The remote MCP's list_collections reply carries its listing once, in the
    text block, with no structuredContent.
    A client that read the listing
    from structuredContent reads the text instead: the same content, as
    compact JSON. The other tools keep their structuredContent.

Admin (CMS & app)

  • @rebasepro/app no longer exports the Spanish translation table. The
    entry re-exported es from ./locales/es, the only language besides
    English it exported, and that kept the Spanish strings in every bundle built
    from the package. en is 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, or client.createStorageSource(key) for a named source;
    both carry storageId, public and context, which the hook dropped.

Client SDK

  • client.cron calls /api/admin/cron by default, not the deprecated
    /api/cron alias.
    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.cronPath to 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(…) (with or, and, not, cond, still
    exported) and client.backups. client.ws is 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
    initializeRebaseBackend and the scaffold's .env.example used 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 (or auth.refreshExpiresIn) to keep the
    longer window.

  • An administrator's chosen password is held to the password policy
    (400 WEAK_PASSWORD).
    On POST /api/admin/users and on every create on
    the users collection through the data API (REST, /bulk, batch, MCP, the
    socket), as registration, resets and PUT /api/admin/users/:uid already
    were. On a /bulk the 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 send skipPasswordPolicy: true on 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 a password.
    AuthAdapter.prepareUserCreation takes 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 is storageDownloadTokenTtl.
    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 no storageSource. The default source
    now always reads its own configuration: STORAGE_TYPE, STORAGE_PATH (or
    ./uploads), and the unsuffixed S3_* / 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_CONFIGURED in 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's createStorage, the admin's useBackendStorageSource
    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 storageAuthorize hook 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 uid public or download-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 bucket is 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 is GET /api/storage/metadata/<folder>/ and
    the same key in POST /api/storage/metadata. A folder/ 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
    beforeQuery scope 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 is service.

  • 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, aggregate select or groupBy, or
    vector_search naming 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. New withRelationReferencesOnly in @rebasepro/common.

  • verifyCredential reaches the verdict the HTTP routes reach.
    backend.verifyCredential(token) and the root verifyCredential(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 collections array keeps its read
    policies.
    config/collections/index.ts declares defaultSecurityRules for
    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 dev included.
    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_dumpall and pg_restore receive it in PGPASSWORD instead 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 as user:***@.

  • The CLI sends the service key only to this project's backend. With rebase dev stopped, rebase api-keys, rebase storage and rebase auth reset-password fell back to .env's PORT (3001 in every scaffold) or to a
    stopped backend's leftover address, and sent the admin service key to whatever
    listened there. They now use REBASE_BASE_URL when you set it, or the address
    the running backend recorded while its process is alive. Otherwise api-keys
    and storage refuse before sending anything, and reset-password resets
    through the database.

  • rebase dev --docker never logs into another project's database. Projects
    scaffolded side by side could get the same compose port, and --docker took
    whatever listened there for its own db service, then suggested ALTER USER
    on it. dev --docker and every rebase db … --docker now 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 pull read its flags by hand, so --anonymise,
    --anonymiz and --anon were ignored and the copy arrived with real names
    and emails, and --schem public pulled every schema. db pull, db url, db stop and db reset now 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 --from line 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 uses client.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

  • loadFunctionsFromDirectory and loadFunctionsWithDiagnostics are
    deprecated: use loadFunctions(dir), with onSkip for the skipped
    files.
    Both keep their old shape and behaviour, and warn once per
    process.

  • loadCronJobsFromDirectory and loadCronJobsWithDiagnostics are
    deprecated: use loadCrons(dir), and declareCrons(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 prefix option of bucket() is deprecated; nothing ever applied
    it.
    A source declared with prefix: "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>, or STORAGE_PATH__<KEY> for a local one), or put the
    folder in each storage property's storagePath. A declaration that sets it
    still compiles and deploys, and the backend warns once per bucket at boot.

  • DatabaseAdapter.mountRoutes and BackendBootstrapper.mountRoutes are
    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.modifyCollectionAsync and
    lifecycle.onCollectionChange are deprecated; neither was ever called.

    Fetch the data before rendering <Rebase> and build the plugin from it, so
    its hooks.modifyCollection reads data it already has; subscribe with
    context.data.collection(slug).listen(…) from lifecycle.onMount,
    unsubscribing in onUnmount. 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,
    statObject and getObjectStream.
    The file route uses them to serve a
    file, a Range request, a revalidation and a HEAD without 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 orphans lists the stored files no row names, and
    --delete deletes them.
    Every row of every collection is read,
    soft-deleted rows included; files newer than --older-than hours (24) are
    left out; nothing is deleted while a collection cannot be read
    (409 STORAGE_SCAN_INCOMPLETE). Behind GET|DELETE /api/admin/storage/orphans (database:read / database:write).

  • rebase storage move copies a folder of files to another storage source
    under the same keys,
    resumable, with --from, --to, --prefix, and
    --delete-source to delete each source copy once the target holds it.
    Behind POST /api/admin/storage/move (database:write).

  • storageTargetsInText(text) lists the objects a text names
    (references and pre-reference download URLs), from @rebasepro/types and
    @rebasepro/client.

  • Local storage warns at boot when its folder is on a case-insensitive file
    system
    (macOS, Windows), where Avatar.png and avatar.png are one file.

Server & REST

  • isDatabaseUnreachable(), ApiError.databaseUnavailable() and
    DATABASE_UNAVAILABLE_MESSAGE in @rebasepro/server
    , for a custom route or
    function that wants to answer an unreachable database the way the built-in
    routes do: 503 DATABASE_UNAVAILABLE with Retry-After: 5.

  • A list can skip its count, with ?count=none over REST or
    count: "none" on find(); a value the server does not know is a 400
    INVALID_COUNT.
    meta.total is a COUNT(*) over every matching row,
    most of what a list request costs on a large table. With none nothing is counted and
    meta.hasMore stays exact, because the read asks for one row past the page;
    over HTTP meta.total is then absent. exact is the default. The SDK's
    iterate() and findAll() 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.precondition and DeleteProps.precondition carry the 404 and
    If-Match checks into the write's own transaction, on a driver that
    declares checksRowPreconditions. All three are optional: a driver without
    them keeps the separate reads it had. Both Postgres drivers implement them.

  • jobs.timeoutMs bounds one job attempt, and every job, queue and topic
    handler gets a signal aborted at that deadline.
    A handler that hangs on a
    fetch can pass the signal on and stop with the attempt.

  • getSignal(c) in @rebasepro/server/functions is aborted when a custom
    function's request times out (504).
    Pass it to fetch so the work stops
    with the request instead of running on.

  • CORS_EXPOSED_HEADERS is exported from @rebasepro/server. A backend
    that wires its own cors() 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 } }, or rebase generate-sdk --register, see CLI) and
    context.data in collection callbacks, rebase.dataAsAdmin, and the
    rebase a function or cron receives are typed from it: rows,
    where/orderBy, include, and create/update inputs.
    defineFunction<Database>() and defineCron<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>.trash in collection callbacks, and
    rebase.data.<collection>.trash in functions and crons:
    find, count,
    restore, restoreMany, purge and purgeMany, the same trash the
    browser client has. The reads return only soft-deleted rows; a restore sets
    the soft-delete field back to null; a purge deletes the row for good.

  • databaseDefault marks 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; a null sent
    on create is left to the database; the generated Insert type makes it
    optional. Given the expression, in the template form isId takes
    (databaseDefault: "sql`gen_random_uuid()`", new type SqlExpression),
    the schema planner writes it as the column's DEFAULT, so rebase db push
    keeps it; databaseDefault: true declares a DEFAULT written elsewhere and
    writes none. "Import from table" and rebase schema introspect set the
    expression they read on such columns.

  • A built bundle can edit its collections from the admin panel. Set
    REBASE_LIVE_SCHEMA_REPOSITORY=owner/name and
    REBASE_LIVE_SCHEMA_GITHUB_TOKEN (or REBASE_LIVE_SCHEMA_GITHUB_APP_ID,
    _INSTALLATION_ID and _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_BRANCH and
    REBASE_LIVE_SCHEMA_COLLECTIONS_PATH default to main and
    config/collections.

  • createRebaseBackend builds a backend and mounts, starts and listens to
    nothing: each of those is a call you make.
    routes.auth(), data(),
    storage(), functions(), admin(), cron() and meta() 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/data included, and getUser(c) and getDriver(c) work inside
    it. mount(app, { basePath }) is the whole layout initializeRebaseBackend
    serves; attachRealtime(server), start() (cron, job workers, sweeps) and
    shutdown() are the rest. backend.rebase is 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: createRebaseBackend requires a provision
    option, which is true, false or { collections, realtime }.
    true creates what
    the backend serves: the collection tables and their RLS policies, change
    capture, the request role and the framework's own tables. false opens 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.
    initializeRebaseBackend passes true, and REBASE_MIGRATE_ON_BOOT=none
    still turns the collection schema off for any backend.

  • Functions and cron jobs are values, and loadFunctions and loadCrons
    read a directory into them.
    functions: { "send-invoice": sendInvoice }
    and crons: { nightly: defineCron({ … }) } sit beside collections; the
    record key is the URL segment or the job id. functions: await loadFunctions(dir) serves what functionsDir: dir serves, and the same holds
    for crons; onSkip receives 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.

  • composeCallbacks combines several sets of collection callbacks into
    one, the way the backend combines its own global, collection and property
    tiers.
    beforeSave and afterRead chain, beforeQuery narrowings are
    AND-ed, any false from beforeDelete vetoes, and the after* hooks run
    in order. From @rebasepro/cms-types, @rebasepro/server and
    @rebasepro/common. setCollectionCallbacks still replaces a collection's
    declared callbacks, and its warning now names composeCallbacks(declared, mine) as the way to keep them.

  • createCallContext builds the context a collection callback receives,
    so a hook can be unit-tested by calling it.
    Fields you pass are used as
    they are; a missing client, data or storageSource throws on first use,
    naming the field; user stays absent unless passed. From
    @rebasepro/server and @rebasepro/common.

  • Each backend keeps its own state in backend.services, and
    backend.verifyCredential(token) checks a credential against it.

    services.jwt (a JwtService: sessions, JWKS, download and purpose
    tokens), access (the access model and the personal-key owner resolver),
    apiKeyStore, rateLimitStore, authLimiters, collections(), topics and
    queues. createJwtService(config) builds a JWT service on its own, and
    JwtService, AccessControl, AuthLimiters, BackendServices and
    JwtConfig are exported types. Every route group sets c.var.services; set
    it yourself, c.set("services", backend.services), to make a route of your
    own answer for a backend.

  • setDefaultBackend makes a backend built by hand the process default:
    the one the module-level API answers for, namely rebase outside a request,
    verifyAccessToken and the other JWT helpers, getAccessModel,
    verifyCredential, and topics and queues published outside a request.
    initializeRebaseBackend does this for its backend.

  • installUnhandledRejectionHandler() is exported, so a backend built by
    hand can repeat initializeRebaseBackend's first step. It returns a function
    that uninstalls the handler, or undefined when one was already installed or
    REBASE_EXIT_ON_UNHANDLED_REJECTION is 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, as rebase console -e is.

  • backend.services.authAdapter: the auth adapter that judges this
    backend's sessions, the one verifyCredential uses.

  • The rule for reading a content hash from a static file name is exported
    from @rebasepro/types as isContentHashedFileName(name): what the build
    precompresses by, and what the runtime caches for a year by.

Client SDK

  • findById(id, { include }) and listenById(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/client exports the types its calls are written in:
    WriteOptions, WhereFilterOp, WhereFor, LogicalCondition,
    AggregateParams, AggregateResult, IncludeSpec, RowOf/InsertOf/
    UpdateOf, StorageSource and others.
    A helper that wraps find() or
    update() no longer has to rebuild them from Parameters<…>.

  • client.offline.status().persisted says whether the browser agreed to keep
    the offline database.
    It is asked with navigator.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 typed or() /
    and() group, FilterConditionFor and LogicalConditionFor, are exported
    too (see Changed).

  • aggregate() takes a typed orderBy, by group key or alias
    (AggregateOrderKey).
    For example orderBy: ["sum_total", "desc"]. The
    HTTP client sends it, and the in-process accessor now applies it instead of
    dropping it.

  • client.data.<slug>.trash lists, counts, restores and purges the rows a
    soft delete stamped.
    It maps to ?deleted=only, the update setting the
    stamp to null ({ field } for a custom soft-delete field), and
    ?hard=true. With offline enabled, the trash is never cached or queued, and
    a call fails with OFFLINE without the server.

  • auth.restoreOutcome() says how restoring the session on load ended:
    restored, none, refused or unreachable.

  • 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.runSqlScript and
    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().unverifiedUser is 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, ListView and CardView accept undefined entries in
    data (a row not read yet, drawn as a placeholder) and an
    onRowsRendered(range) callback; EntityTableController has sparseData
    and onRowsRendered.
    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, Avatar onClick, ResizablePanels dividerAriaLabel and
    ToggleButtonGroup aria-label / aria-labelledby.
    DateTimeField's
    locale is now applied, as the input's lang.

  • ToggleButtonGroup takes fullWidth.

  • UiLabelsProvider and useUiLabels() 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. New DateTimeFieldMode type and
    UiLabels.openTimePicker.

  • Markdown fields get the Autocomplete slash command from the AI plugin,
    behind the same gate as Autofill (the service available,
    getConfigForPath allowing the collection). A field's own
    editorProps.aiController still wins.

  • warnDeprecated(key, message) from @rebasepro/common prints 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_list and
    panel_mode.
    All four are optional on RebaseTranslations, 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.nameKey is 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
    like at, date, time or timestamp (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.

  • EntityFormPanel and CollectionsProvider put 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 without entityId) and saves it through
    <Rebase>'s data layer, with onSaved and onClose. Both work under
    <Rebase> and any React Router router, without <RebaseCMS>,
    <RebaseNavigation> or <RebaseShell>, and bring their own
    CollectionsProvider when none is above them. Records open over the page,
    in a side panel or a dialog, never by URL.

  • The modeController prop of <Rebase> hands Rebase your app's light and
    dark mode.

    Rebase then leaves <html>, prefers-color-scheme and localStorage alone.
    useBuildModeController({ enabled: false }) builds an inert controller.

  • Several layers can register collections with <Rebase> at once. A
    CollectionResolverRegistrationContext registration takes an optional
    owner as its third argument, and UrlController.routesMounted is false
    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 the rebase db push after 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 the DROP TABLE fix and never runs
    it.

  • rebase db generate names the destructive statements in the migration it
    writes, and rebase db migrate --refuse-destructive refuses one.
    The flag
    dry-runs the pending files and exits 1, applying nothing, when one destroys
    data. Without it, db migrate is unchanged.

  • rebase generate-sdk --register types server-side data: it writes the
    generated types and the Register declaration to config/generated/, where
    both the backend's and the config package's compiles see them, and
    remembers the choice in rebase.json (registerDatabase on 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 dev could only add. The
    destructive gate is unchanged.

  • REBASE_ATLAS_DEV_URL names the empty database Atlas plans against,
    instead of the derived _dev_diff.
    Rebase neither creates nor drops a
    database named this way.

  • rebase console opens a Node REPL with the project's data in scope.
    rebase is what openRebase returns and collections the project's
    collections, with top-level await. The project, .env, database and
    --collections are resolved the way rebase db push resolves 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's return, 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-downgrade lets a dist-tag move pins to an older
    release.
    Without it, rebase upgrade refuses such a move, with
    downgrade_refused under --json.

  • normalizeEsmSpecifiers(outDir, { write: false }) computes the import
    rewrite without writing anything.
    From @rebasepro/cli;
    rebase normalize-imports --check uses it.

  • A new project ships a root TypeScript config that typechecks scripts/.
    The root also declares @types/node, so tsc --noEmit at the project root
    checks something instead of printing its help and exiting 0. Each workspace
    still typechecks on its own.

  • rebase status reports the branch. It includes branch: { 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-sdk passes the port the project's backend last bound, as its printed
    snippet already did, so generated/sdk/README.md no longer says 3001.

CI & tooling

  • Three new gates: pnpm check:test-types, pnpm check:casts and
    pnpm check:test-resolution.
    Type errors in tests are counted per package
    and may not grow; the count of as any, as never and as unknown as in
    package source may not grow; and every test runner must import workspace
    packages from source, not dist.

  • 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-names fails on any IconButton in package source
    without an accessible name.

  • A package build fails when its dist contains Vite's browser stub for a
    Node built-in.
    assert-build-output.mjs looks 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, to users:read
    holders and to anyone who may write a collection with a userSelect field,
    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 only users:read holders can
    pick. New type PickedUser.

rls-check

  • An unconditional policy that nothing can reach yet is reported as latent
    info.
    For example USING (true) TO anon on 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 one GRANT would
    make it critical with no change to the policy. It is reported under
    policy-always-true at info, titled "latent", with what is missing and
    the fix. Info is below every --fail-on except info, so default runs are
    unaffected.

  • anonymous-write-allowed reports Rebase databases. Every request there
    runs as rebase_user, a signed-out one too when AUTH_REQUIRE=false or
    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 that anon inherits is
    reported too.

Studio

  • The property editor warns when a smaller max narrows a varchar or char
    column.
    On those column types validation.max is the column's width, so
    lowering it is a DDL change: rebase db push asks 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 with pg_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 the ETag a GET of it answers, so an edit made
    against what a frame showed can be saved with ifMatch and is refused with
    412 if the row has moved on. A successful PATCH answers with the ETag of
    the version it wrote, and update() 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 with TOO_MANY_SOCKETS and the socket is closed (4002). Unset, as by
    default, there is no limit.

  • REALTIME_REFETCH_MAX_WAIT_MS sets the longest a change waits for its live
    frame (default 1000 ms); REALTIME_RECOUNT_INTERVAL_MS and
    REALTIME_RECOUNT_MIN_ROWS let a big list reuse its total for a while.

    Instead of counting it on every write; such frames carry meta.totalAsOf.
    Unset, every frame counts, as before. Each has a realtime.* option.

  • realtime.path on the Postgres adapter, and attachRealtime(server, { path }), set the URL path the realtime socket answers. The default is
    the base path given to mount() with a trailing /api removed (/api →
    /, /x/api → /x), which is where the SDK dials. RealtimeSocketOptions
    in @rebasepro/types carries it to a driver as path.

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 with manual: true and that startedAt. A run
    that cannot start still answers 409. The synchronous call is unchanged and
    stays the default.

Postgres

  • openRebase opens a project's data from a script, a REPL or a test, with
    no server.
    It returns dataAsAdmin and sql, the accessors a function
    gets, withAuth(user) for that user's row-level-security-scoped data, and
    close(). Opening changes nothing in the database and reads no environment
    variable. Against a database no server has booted on, withAuth fails rather
    than run unfiltered. From @rebasepro/server-postgres; see "Scripts and the
    Console".

  • Writes through openRebase and rebase console record entity history.
    A write to a history: true collection 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 (service for dataAsAdmin, the
    user's uid under withAuth). Nothing is created for it. If the history
    table or its rebase.record_history function is missing, writes to those
    collections are refused with HISTORY_UNAVAILABLE before any callback runs,
    and reads still work. A script prunes no entries, because retention is the
    server's setting.

  • openPostgresDriver and postgresDriverConfigFromEnv are the driver open
    and the boot's environment reading, as two public pieces.

    createPostgresAdapter is one step above them, and openPostgresDriver
    reads no environment variable. The adapter's initializeDriver config takes
    provision: false, which opens the driver with no DDL of any kind, and
    realtime: false, which takes no part in realtime.

  • realtime.cdc and directConnectionString on the Postgres adapter
    config
    are the explicit forms of REALTIME_CDC and DATABASE_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 the Database that rebase generate-sdk --register
    registers, so a misspelled collection, field or enum value in a script is a
    type error. openRebase<Database>() and OpenedRebase<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-postgres runs RESET ALL
    and DEALLOCATE ALL on a PGlite database and does nothing on any other. The
    service also exports restoreDumpReplacing and findCleanExtras for a
    one-transaction clean restore.

  • The driver can reopen databases left closed. reopenClosedDatabases(url, others?) and reopenedDatabasesNotice(result) from
    @rebasepro/server-postgres reopen any of the named databases that refuse new
    connections, from the server's postgres database, 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 (an ERROR frame, also on AUTHENTICATE) and a
    subscription. They answered 503 ROLE_LOOKUP_FAILED "Could not verify your
    permissions", 500 INTERNAL_ERROR, a 500 with the code ECONNREFUSED, or,
    on the socket, AUTH_ERROR INVALID_TOKEN, which sent the SDK to refresh a
    valid session. ROLE_LOOKUP_FAILED remains 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
    answered 500 with 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=25 was 8 statements in 2 transactions and is 5 in 1 (4
    with ?count=none); with include=*, 13 → 10. The count now comes from the
    same snapshot as the page. A ?distinct=true read is no longer counted,
    since its answer never carried meta.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
    a limit with no orderBy, 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 per belongsTo per level: include=author 4 → 3,
    include=author.company 6 → 4.

  • PATCH and DELETE no longer load every relation of the row before writing
    it.
    The 404 and the If-Match check 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, with If-Match 17 → 10, a DELETE
    12 → 8. Bulk update, _batch update 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's updated_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 no onCreateUser, onAdminCreateUser or custom hashPassword;
    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-Match and If-Modified-Since, so /, a deep link or
    /logo.svg revalidates 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, .gz and
    .zst siblings 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/docs builds 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.
    With enqueueEntityChange and 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
    resolves false if it runs out (it resolved undefined). 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 its shutdown() still closes the server it
    was given; a backend from createRebaseBackend closes one only when passed
    { server }. RebaseBackendInstance is now RebaseBackend, 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. createRebaseBackend writes no process global; the module-level API
    answers for the process default, which configureJwt, configureAccess,
    setDefaultBackend and initializeRebaseBackend set. A backend given no
    auth.jwtSecret follows 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 to LOG_LEVEL.

  • A refused session says why. The data API, storage and custom functions
    answer 401 SESSION_REVOKED (signed out, or the account deleted) or 401 ACCOUNT_DISABLED, as the admin routes already did, instead of UNAUTHORIZED
    "Invalid or expired token". createBuiltinAuthAdapter().verifyRequest rejects
    with that error instead of resolving null. The service key now verifies
    through verifyCredential as kind: "service-key", as it does on the HTTP
    routes, where it used to be null.

  • A route's scope guard refuses a scope nobody could hold where the route is
    registered.
    In requireScope, a name that is not a scope, a target written into the scope, or
    a misspelt built-in such as data:raed throws at registration. An app scope
    the backend does not declare under auth.scopes answers 500 SCOPE_NOT_DECLARED, instead of 403 SCOPE_MISSING to everyone, admin
    included, with a message saying admin holds every scope.

  • loadEnv() names what is wrong. It throws an Error reading "The
    environment is not valid: DATABASE_URL: is required", with zod's issues on
    .issues, instead of a raw ZodError that printed as ZodError { 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 correct null from verifyCredential. A
    routine 401 the server marks expected, such as NO_SESSION from POST /auth/refresh for 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.
    Under rebase start, local uploads lived in dist-bundle/uploads, which the
    next rebase build deleted while the rows naming them stayed, and a file
    uploaded under rebase dev answered 404. A bundle with rebase.json above it
    now stores them in <project>/uploads, as rebase dev and an ejected server
    do, so rebase-server run from a project checkout without STORAGE_PATH does
    too. A container's storage does not move: STORAGE_PATH still 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 s lock_timeout and 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 20000 as an admin,
    7.3 s → 4 ms, and count(*) 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 or rebase db push rewrites each policy in place. A deployment that
    applies only generated migrations keeps the old, equivalent text until its
    next rebase db generate writes 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 separate SET 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 push and
    rebase db generate plan 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_USER now fails initializeDriver instead of
    initializeWebsockets, with the same message.
    createPostgresDatabaseConnection and resolveChannelBusSetting take an
    optional trailing env.

  • collectionRegistry is optional on the adapter's initializeDriver config;
    no bundled driver reads it, so a script no longer passes
    collectionRegistry: undefined as never.

  • openPostgresDriver no longer logs how authenticated requests are
    isolated.
    It returns the sentence as internals.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. openRebase logs it at debug, and
    rejects with one readable Cannot 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 a presence_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,000 pg_notify and 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=off removes 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 to off beside an api left on auto
    leaves the api listening for nothing.

  • The realtime socket reads a filter the way REST, MCP and the SDK do. A
    two-element value array is an implicit in, and an unknown operator is an
    UNKNOWN_FILTER_OPERATOR refusal, 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 for cancel_sql on the socket URL (?rebase_caps=…,cancel_sql)
    and sends CANCEL_SQL { requestId } (an admin-plane frame,
    database:write) only to a server whose hello lists 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 own upgrade listeners, 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,
    verifyAccessToken and accessModel on
    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/ui view is out of the preload set. @rebasepro/app and
    @rebasepro/cms declare "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 of include: ["*"]. 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 199 COUNT(*)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
    (default true) 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 custom admin.Field, dynamicProps,
    conditions, a function storagePath or fileName, and every field when a
    plugin has a fieldBuilder, 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 is FORMEX_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 without sharp is 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.
    A dark class the page already had is
    left.

  • @rebasepro/cms-types depends on @rebasepro/common, for the
    composeCallbacks it 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: true to show the key in its own column.

  • @rebasepro/forms: a pending onValuesChangeDeferred call runs when the
    form submits
    (before onSubmit) 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's singularName from the target collection; the
    declaring property's name still 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/ui bundles 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.wrap gets
    the field as the plugins before it left it, and all the wrappers render.

    params.Field is the previous plugin's wrapper, so the first plugin's is
    innermost; a plugin that returns null or enabled: false passes 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.css re-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-graphite ramp and points Tailwind's zinc, neutral,
    gray and stone palettes at it, so a bg-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-row SELECT no 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");
    SqlScriptResult carries truncated: true and the true rowCount. Copy and
    export include the rows shown. A result too large for one string is no
    longer reported as an SQL error. client.executeSql is 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/plan lists every file /apply commits 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 behind rebase db push, rebase db migrate --refuse-destructive and
    the report rebase db generate prints. A push that would still drop one asks
    on a terminal and refuses elsewhere; --allow-destructive applies 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 dev no 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 a config/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_Store or editor swap file appearing
    in config/, no longer restarts the backend.

  • rebase dev reaches "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 … and doctor resolve 189 modules instead of 426;
    rebase schema generate at 11 collections takes 1.2 s instead of 1.6–2.0.

  • rebase db push on 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 --version and --help load 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 upgrade with no --to, on a canary for example, takes the newest
    canary unless latest is newer. An exact --to older 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 --check used to throw, with a stack-trace hint.

  • The other commands follow the database the last dev server ran on.
    rebase dev records whether it ran with --docker. The choice is kept in
    .rebase/dev-database.json (gitignored) and ranks where --docker does,
    below every DATABASE_URL, the branch pointer and --database-url; commands
    say so on stderr. The next rebase dev without --docker switches them back
    to the managed database, and warns that it is leaving the Docker one.

  • Anonymized emails are random. rebase db pull --anonymize writes
    user-<random hex>@example.invalid (no longer user<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 --json and --write --json print the graph with
    check: { file, status } or written: { file, changed } beside version and
    resources.

  • Starting a bundle with no database URL stops before it boots.
    rebase start exits 1 when no DATABASE_URL is set, and prints
    DATABASE_URL="$(rebase db url)" rebase start for 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, and rebase db backups list, use ./backups in the
    directory you run rebase from, normally the project root: the same directory
    --out ./backups names. Before, they used backend/backups. A relative
    --out, and the dump path rebase db restore takes, resolve against that
    directory; a relative BACKUP_DESTINATION still resolves against the backend
    directory, where the server reads it. If the new location is empty and
    backend/backups holds dumps, backups list says where they are.

  • A backup takes its destination only from --out. Use --out ./backups
    instead of rebase db backup ./backups. rebase db restore refuses 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) writes spa, functions and crons only when they differ
    from their defaults, and builds the static app with pnpm in a pnpm project.
    The inferred static app is still named web.

  • A pull replaces the local database's contents. rebase db pull empties
    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.
    For rebase api-keys and rebase storage against rebase start in production mode, or a server in a
    container, set REBASE_BASE_URL; see the Security entry.

  • The branch subcommands refuse a flag they do not take. Every
    rebase db branch action, switch included, names the closest real flag
    ("did you mean --older-than?"), as db backup and db restore do. They used
    to ignore it, so --froce ran without --force.

  • Each new project gets its own compose port. rebase init takes 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 introspect on tables with row-level security off, rebase db push --dry-run lists 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, dev and the other commands on the shared
    parser said unknown or unexpected option: --x (did you mean --y?). They now
    say what db backup says: "rebase storage move has 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 build writes rebase.resources.json on every build, so a
    fresh scaffold's rebase status no longer ends with "is missing". A stale
    file is still flagged, and rebase resources --check still fails on a missing
    one.

  • New scaffolds take the admin's chunk rules from the app package.
    frontend/vite.config.ts uses manualChunks: rebaseManualChunks from
    @rebasepro/app/vitePlugin instead 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 --clean runs the dump through psql (shipped with
    pg_restore, or PSQL_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 --force runs ALTER DATABASE … ALLOW_CONNECTIONS false from the server's postgres database 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 next rebase db branch command, 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 stale and the other
    driver-checked commands say "rebase db migrate has no option --x — did you
    mean --y? Nothing was run.", as the CLI's own commands now do. A script
    matching unknown or unexpected option needs updating.

Client SDK

  • Walking a collection seeks by cursor and skips the count on every page.
    iterate() and findAll() hand the server's meta.nextCursor back as
    after instead of a growing offset, and send count: "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.
    Ten observe() 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
    Transport can take part through the optional setTokenRefresher.

  • 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 a string[] 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))) and cond("views", "==", "many")
    are now compile errors. Relation and JSON paths are not checked.

  • etagOf and withETag now live in @rebasepro/common. @rebasepro/client
    still exports etagOf.

  • 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 the token option,
    which HTTP never did. Installing a token getter or calling setToken()
    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 MiB Range, what a <video> sends on
    load and on every seek, read the whole object; it reads 1 MiB. A
    revalidation that ends in 304, a HEAD, 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
    openAsBlob in the wrong module and always fell back to reading the file.
    POST /upload still 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/metadata requests and make 1; 100 different files go 8 at a time;
    at most 2,000 URLs are kept. This covers the SDK's client.storage,
    useBackendStorageSource and the admin's thumbnails, through
    createDownloadUrlCache and createConcurrencyLimit in @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.maxConnections to 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.
    transformImage throws ImageTransformUnavailableError when sharp is not
    installed, with a message naming pnpm 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's api role 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 index rebase.idx_jobs_finished is
    built at the first boot after upgrading (4.7 s on a 2M-row table). A custom
    store takes part through the optional JobStore.sweepExpired() and
    CronStore.sweepClaims().

  • A job attempt times out after 15 minutes when jobs.timeoutMs is not
    set.
    The worker names the default in one log line at start. Set
    jobs.timeoutMs for longer jobs, or 0 for 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.mjs reads 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:hooks counts an exhaustive-deps disable that has no
    -- reason; pnpm check:gates-doc checks the "Tests" rows against the
    workflow and every package test script against a register in
    docs/gates.md; pnpm rls:check --strict-baseline fails 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>, not 0.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/config and 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.