upgrading to 4.0 - BaryoDev/barakoCMS GitHub Wiki
4.0 will not boot against a 3.x database until you apply two SQL files. This page is what to run, what each statement does, and what to do when it goes wrong.
The whole sequence, including the rollback below, is proved in CI by scripts/upgrade-check.sh,
which stands up a real 3.21.0 database through the released image, upgrades it, then rolls it back
and boots 3.21.0 again against the result. It checks the schema with both the core host and the
Suite, and boots the Suite, which is what the published image runs. If a step here stops being
true, that job goes red. The job then runs again from a database 4.1.0 created, leaving out the two
4.0.0 files, and rolls that one back to 4.1.0.
Production runs Marten's AutoCreate.CreateOnly. It creates objects that are missing, so a fresh
database sets itself up, but it never alters an existing one. That is deliberate: the alternative
(CreateOrUpdate) retries a failing migration on every write, so a schema mismatch arrives as
random 500s on user requests rather than as a failure you can see.
4.0 moved Marten from 8.37 to 9.30. Four of core's database objects changed, so the first boot
against a 3.x database hits CreateOnly, refuses, and exits non-zero. Nothing is written and
nothing is lost; the host simply does not start.
The published image runs the Suite, core plus every module, and two modules need something too.
Files declares an index on mt_doc_stored_files.ParentFileId that 3.x never created. On an existing
database CreateOnly will not add it, and the module schema preflight refuses to start without it,
naming Files: public.mt_doc_stored_files. Forms (new in 4.2) declares a table,
mt_doc_public_forms, that a database from before the module does not have. CreateOnly would
create it on first boot, but db-assert below reports it as outstanding until it exists, so the
Forms file creates it up front and the boot then has nothing to write. A host that loads no module
(the decaf image, or your own host without Files or Forms) does not need those two files, and
running them anyway is harmless.
Take a backup. This migration drops two columns, and while both are empty in every barakoCMS database (see below), a backup is the only thing that makes the step reversible if yours is not.
Check the two columns really are empty:
select count(snapshot) as snapshot, count(snapshot_version) as snapshot_version
from public.mt_streams;Both must be 0. They are Marten 8's inline stream-snapshot columns, and barakoCMS never enabled
stream snapshots, so they are NULL for every row by construction. If yours are not zero, this
database used a feature this project does not, and you should stop and ask on the issue tracker
rather than dropping them.
The migration checks this itself and refuses rather than trusting you to have read this paragraph.
Run it with --single-transaction, as below, and a refusal leaves the database exactly as it was.
Stop the 4.0 deploy from starting yet, and with 3.x stopped, run the psql lines this prints,
from a checkout of the release you are deploying:
scripts/upgrade-check.sh --list # coming from 3.x
FROM_VERSION=4.1.0 scripts/upgrade-check.sh --list # coming from 4.0 or 4.1--list builds nothing and starts nothing. It reads the files under migrations/ and prints one
psql line for each, in the order CI applies them: core's files oldest folder first, then the
module files (the ones a module's project embeds), then the rollback. A folder no newer than
FROM_VERSION is left out, so coming from 4.0 or 4.1 the 4.0.0 file is not listed. Run the core
and module lines now and keep the rollback lines for later. The stored files index is the one line
without --single-transaction: its header says why.
The two Marten files bring the event store up to the Marten version the release you are deploying runs. Each explains itself in its header. Skip a file whose directory is newer than that release.
The event correlation file (4.6.0) adds two nullable columns to mt_events and replaces the
function that appends events, so each event can record the request that wrote it
(tracing.md). It runs after the 4.4.0 file, which replaces the same function.
Coming from 4.5 it can be applied while 4.5 is still serving, then deploy, like the two Marten
files: 4.5 writes an event with an INSERT that names its own columns, so the two new nullable
columns do not stop it, and it does not call the function. CI applies the file under a running
4.1 and writes through it. An old instance that restarts after the file fails its own start-up
schema assertion, so do not leave long between the file and the deploy.
With a 4.6.0 or later image, db-migrate applies these files in place of the psql lines.
See migrations.md, and run db-migrate --status first: CI proves the psql route
from 3.x and does not yet run db-migrate against a 3.x database.
The user file moves the unique indexes on username and email to their lowercased, trimmed forms,
which is what sign-in compares. If two existing accounts differ only by case, such as
[email protected] and [email protected], it refuses, changes nothing, and lists both accounts by
id. Rename or remove one of each pair and run it again. It does not pick one for you. A 4.0 or 4.1
database needs this file too.
The share links file creates mt_doc_site_share_links, empty. The 4.0.0 file creates the same
table, but only since 4.2.0, so coming from 3.x today this file changes nothing. A database that was
already on 4.0 or 4.1 ran the 4.0.0 file before the table was in it, and this file is the only
thing that gives it one: without it db-assert reports the table as outstanding. Coming from 4.0 or
4.1, leave the 4.0.0 file out and run the rest, which is the sequence CI runs from 4.1.0. It is
safe to run twice.
The file ends by turning row level security off on the table, which is the state db-patch emits
with Tenancy:DatabaseEnforcement off, but only when the table carries no policy. With enforcement
on and the table already there, the tenant policy and forced row level security are left alone and
the file changes nothing. With enforcement on and the table absent, which is a 4.0 or 4.1 database,
the file creates the table without the policy, and db-assert run with enforcement on then reports
the policy as outstanding. Add it with db-apply, described under
Schema changes after 4.0;
tenancy-at-the-database.md has the queries that show it is there.
The tenant profile file changes data and no schema, so db-assert passes with or without it. It
moves each tenant's logo, about text, location, social handle, email and contact link from the
tenant document into that tenant's published site entry, and prints a NOTICE for every tenant
and value it leaves where it was, with the reason. The API answers from the site entry where the
site type declares the field and from the tenant document where it does not, so a tenant the file
left alone still answers. multi-tenancy.md has the rules,
how to remove a value left behind, and the query that lists what is still on tenant documents. It is
safe to run twice, and worth running again after a tenant it left alone publishes its site entry.
Run every file with the API stopped. A running API keeps a transaction open for as long as it
runs, and an index built CONCURRENTLY waits for every transaction older than itself, so against
a live API it never finishes.
The Files file builds its index CONCURRENTLY, which is why it runs on its own, without
--single-transaction. The refresh token file is a plain build inside a transaction: with the API
stopped the table takes moments, and an invalid index left by an earlier CONCURRENTLY attempt is
dropped and built again. The Forms file creates one empty table, and the 4.6.0 Forms file creates
the two empty tables a form uses to verify an email field. All of them are safe to run twice.
The Email file creates the empty table Email.Resend uses to record which tenant sent each email, so a later bounce can be put back on that tenant. It is safe to run twice.
The ExternalAuth file creates the empty table that ties an OpenID Connect provider account, by
issuer and subject, to a user. Nothing writes to it until a provider is configured under
Oidc:Providers. It is safe to run twice.
The sensitivity file (4.6.0) changes data, not schema. It gives the seeded HR role the
view_sensitive capability, which is what its name used to grant, and rewrites the role names in
every field's visibleToRoles to role ids. Stop the API, run it, then start 4.6.0. The order
matters here more than for an index: 4.5 and earlier match those lists against the role names in
the token, so an earlier release serving a migrated database masks every listed field for every
role on its list, and only SuperAdmin reads them, until 4.6.0 is running. Nothing is disclosed in
that state and no value is changed. 4.6.0 on a database the file has not reached is safe to serve,
since it still matches names. A role named HR under any id but the seeded one is not granted, and
the file says so in a notice that names the id. It is safe to run twice.
The membership file (4.7.0) adds a unique index on a membership's user and tenant, so one person holds one membership per tenant. If two rows already share a user and a tenant it refuses, changes nothing, and says how many pairs there are; its header has the query that lists them. It does not pick which row to keep. It is safe to run twice.
The two tenant policy files (4.7.0, one for core's tables and one for the Forms tables) matter
only with Tenancy:DatabaseEnforcement on. The share links, Forms and collection syncs files, and
the 4.0.0 file, end by taking the tenant policy off the table they create, and run again by hand
on an enforced database they take it off a table that had it. Each file reads whether the database
enforces tenancy from its other tables, and where it does, puts the same policy back on any of its
tables that lacks it. Run the Forms one after the Forms files. With enforcement off they change
nothing. Both are safe to run twice.
Then confirm the schema matches what 4.0 expects, without starting the server. The command is an
argument to the 4.0 image, which hands it to the host instead of booting the web app. With compose,
from the directory holding your compose file and .env:
docker compose pull
docker compose run --rm --no-deps app db-assertThe service is app in docker-compose.prod.yml and api in quickstart/docker-compose.yml.
run gives the command the service's environment, so it checks the same database the deploy will
use. Without compose, pass the same connection string and JWT key the deploy uses:
docker run --rm \
-e ConnectionStrings__DefaultConnection="$CONNECTION_STRING" \
-e JWT__Key="$JWT_KEY" \
ghcr.io/baryodev/barako-cms:<version> db-assertExit code 0 means you can deploy. Non-zero prints the exact statements still outstanding.
The image runs BarakoCMS.Suite.dll. Through 4.1.0 the Suite ignored these commands and started
the web app against that database instead, so use a tag newer than 4.1.0 for this step even when
the version you are deploying is older. The decaf image (barako-cms-decaf) runs the core host and
has always answered them.
Now start 4.0 normally.
| Object | Change | Why it is safe |
|---|---|---|
mt_events |
adds bdata bytea NULL
|
Additive and nullable. Existing rows are untouched. |
mt_streams |
drops snapshot, snapshot_version
|
Marten 8 columns for a feature barakoCMS never enabled, NULL in every row. |
mt_quick_append_events |
replaced | Marten 9 changed its signature and body. A function, no data. |
mt_safe_unaccent |
replaced | Marten 9 schema-qualifies the unaccent call. Body only, and nothing depends on it. |
No event is rewritten, no document is touched, and the projection daemon keeps its stored progression, so it resumes where it left off rather than replaying every event and re-firing every workflow side effect.
4.0 exits at startup with Cannot derive schema migrations ... AutoCreate.CreateOnly. The
migration has not been applied, or only partly. Run db-assert to see exactly what is outstanding,
then apply the file again. Most of it is idempotent, but four statements are not: the DO $guard$
block reads mt_streams.snapshot, which the first run removes, and the three mt_streams column
changes carry no IF EXISTS or IF NOT EXISTS. Because the command above runs
--single-transaction, a second run aborts and rolls back rather than leaving the database part
way. If db-assert says the migration is outstanding after a failed run, take the outstanding
statements from the file by hand rather than re-running the whole thing.
You need to go back to 3.x.
Rolling back is lossy, and some of what it drops cannot be recovered afterwards. Have these to hand before you start:
- The email provider API key.
mt_doc_email_settingsis dropped. The stored key is encrypted and nothing decrypts it for display, so it cannot be read out first.- Every connector credential, for the same reason (
mt_doc_connectors,mt_doc_connector_secrets).- An export of your URL redirects, query definitions and request definitions. Those tables are dropped and the data is not carried anywhere else.
Also lost: queued jobs and workflow runs, the webhook delivery log, and which content types were forms (the submissions themselves are ordinary entries and stay). Every Scheduled entry is rewritten back to Draft, so anything waiting to publish will need rescheduling. Twelve tables are dropped in total; the file lists them with a comment on each.
Stop 4.0, then run the rollback lines scripts/upgrade-check.sh --list prints, newest first,
with FROM_VERSION set to the release you are going back to.
The list stops at the release you name, and from the default FROM_VERSION it goes all the way
to 3.x. Going back to 4.2 or later it leaves both 4.2.0 files out, as it must: 4.2 declares the
share links table, and running that rollback there drops every link for nothing.
Newest first. An earlier release asserts its own schema at startup. What it reports, and refuses
to boot over, is an index or column it does not declare on a table it does declare, which is why
the index and column rollbacks have to run. A whole table it does not declare is left alone and does
not stop it booting: the rollback files drop those tables so the database is back to what that
release built, not because the release needs it. One is left behind on a rollback to 4.0 or 4.1:
mt_doc_public_forms, whose only drop is in rollback-to-3.x.sql. It is harmless there, and CI
boots 4.1.0 beside it.
The tenant profile file copies each tenant's profile from its published site entry back onto the tenant document, where a release before 4.6.0 reads it. It fills blanks only and removes nothing from the site entry, so nothing is lost. For a value the forward file moved, the earlier release then serves what the site entry holds now, an edit made on 4.6.0 included. A value still on the tenant document is not overwritten: for a tenant the forward file left alone or was never run for, and for a field where the two sides differed, the earlier release serves the tenant document's value, as it did before the upgrade, even where 4.6.0 was answering with a different one from the site entry.
The collection syncs file drops mt_doc_collection_syncs. That loses the sync schedules and field
mappings, which nothing else records; the entries those syncs wrote are ordinary content and are
untouched.
The refresh token file drops the index on the token hash. A release before 4.5.0 looks refresh tokens up by their plain value, which 4.5.0 no longer stores, so anyone who signed in or refreshed on 4.5.0 has to sign in again after a rollback.
The share links file drops mt_doc_site_share_links, and every share link with it. Only the hash
of each key is stored, so the links cannot be saved first and put back: after upgrading again,
create new ones and send them out. Going back to 4.0 or 4.1, stop after the two 4.2.0 files and
leave rollback-to-3.x.sql out, which is the rollback CI runs from 4.1.0. Going back to 3.x, that last
file drops the same table, and running both is harmless.
The user file puts the username and email unique indexes back on the stored values, which is where 3.x declares them.
The Email file drops mt_doc_sent_emails, which an earlier release does not declare. It loses only
which tenant sent each email; a bounce reported after the rollback is recorded
without a tenant, as it was before.
The ExternalAuth file drops mt_doc_external_identities, which an earlier release does not declare.
It loses which OpenID Connect provider account belongs to which user. The users stay. After
upgrading again, each person is linked again by email the next time they sign in through the
provider, which needs the provider to vouch for the address.
That restores the two mt_streams columns as NULL, which is what they were, and removes bdata.
It also drops the Files ParentFileId index, which the 3.x Suite refuses to start alongside.
Events appended while 4.0 was running stay: they are ordinary events that 3.x reads fine. The one
thing rollback cannot preserve is a binary event payload in bdata, and barakoCMS opts no event
into binary serialization, so that column is NULL in every row. Check it if you are unsure:
select count(bdata) from public.mt_events;The same route applies to any 4.x patch that needs a schema change, which is why the commands are part of the host rather than a one-off script:
# writes the delta and its rollback into the current directory, changes nothing
docker compose run --rm --no-deps -v "$PWD:/out" --user "$(id -u)" app db-patch /out/upgrade.sql
# verify only, non-zero when the schema is behind
docker compose run --rm --no-deps app db-assert
# apply it
docker compose run --rm --no-deps app db-applyThe mount and --user are there because the image runs as a non-root user that cannot write to
your directory otherwise. A host built from the NuGet packages answers the same commands when its
Program.cs ends with app.RunBarakoCommandsAsync(args), as both hosts in this repository do:
dotnet YourHost.dll db-assert. Through 4.5.0 that line was app.RunJasperFxCommands(args), which
still runs these three commands and does not know db-migrate.
From 4.6.0 the files under migrations/ do not have to be applied one by one. With the API
stopped, db-migrate runs the ones a database has not had and records each in a ledger, and on a
database that already has them it records them without running them. migrations.md has the command, what
its first run does on an existing database, and what it prints.
db-patch writes two files: upgrade.sql and upgrade.drop.sql, the second being the rollback.
Read both before running either. The point of the reviewed-file route is that a destructive
statement is visible before it runs, not after.
Every package retargets from net8.0 to net10.0. Your host has to be on .NET 10. This is the
largest break in the release and it is not something a migration can help with.
A failed startup now exits non-zero. It used to exit 0, so a broken deploy reported success to
CI, docker run, systemd and Kubernetes. If your pipeline was relying on the old behaviour to get
past a failing start, it will now stop, which is the point.
A missing connection string fails at startup outside Development, naming the setting, instead of substituting a dummy that connects to localhost and fails later for an unrelated-looking reason.
/metrics needs a scrape key. The Prometheus endpoint used to answer anyone who could reach the
API, which handed out route names, per-endpoint traffic and process internals. It now refuses unless
Metrics:ScrapeKey (env Metrics__ScrapeKey) is set and the caller presents it. With nothing set it
returns 404, so scraping stops on upgrade until you configure it.
Set the key on the host:
Metrics__ScrapeKey=$(openssl rand -hex 32)Then give it to Prometheus. authorization sends it as a bearer token, which the endpoint accepts:
scrape_configs:
- job_name: barakocms
authorization:
credentials: <the same value>
static_configs:
- targets: ['barakocms:8080']The X-Metrics-Key header works too, for a scraper that would rather not use Authorization:
http_headers:
X-Metrics-Key:
values: ['<the same value>']A wrong or missing key returns 401 while a key is configured, and 404 while none is, so the status code tells you which of the two you are looking at. The key is a shared secret rather than a user, so keep it out of the repository and rotate it like any other credential.
Administrative endpoints gate on capabilities, not role names. Roles, tenants, tenant
members, users and user groups now ask for a capability the caller's roles carry (manage_roles,
manage_tenants, manage_tenant_members, manage_users, manage_user_membership,
manage_user_groups) instead of matching SuperAdmin or Admin by name, and so does every
first-party module.
This is the one behaviour change on upgrade. Auth:LegacyRoleFallback was true through 3.x,
so a role name still opened the gate it used to. From 4.0 it defaults to false. The seeder
backfills the capabilities onto the four system roles on the next start, and it now adds what a role
is missing rather than only filling an empty list, so a deployment that runs the seeder reaches
everything it used to and there is nothing to do.
A deployment that does not run the seeder, or that curates its system roles by hand, sets the old behaviour back:
Auth__LegacyRoleFallback=trueDo that before upgrading if you are unsure, then grant the capabilities and remove it. The flag is still there and still supported; only the default moved.
A role created through POST /api/roles can now be granted administrative access without a code
change, and a role named Editor gains nothing from its name. Modules gating on Roles(...) are
unaffected. See docs/access-control.md.
Self-registration no longer creates an account. POST /api/auth/register records the request
and emails a single-use token that is good for 24 hours; the account appears when the token comes
back to POST /api/auth/register/verify. Until then no user document exists, which is the point:
external sign-in matches a provider's verified email to a local account by address alone, so a user
row holding an address nobody proved handed its real owner's Google sign-in to whoever registered it
first.
Two things change for a caller. The response is now the same whether or not the address is already
registered, so a client that read the old "Username or Email already exists" error has nothing to
read. A request that fails validation, a password below the minimum length for instance, still
answers 400 as it did. And registration needs a working email provider: with the mock provider
the token is logged and never delivered, so nobody can finish registering. Configure
BarakoCMS.Email.Resend (or your own IEmailService) before you turn a public registration form
on, and set App:BaseUrl so the email carries a link rather than a bare token.
To keep the old behaviour, set both of these. It will not start with only the first:
Auth__RequireEmailVerification=false
Auth__AcknowledgeUnverifiedRegistration=trueA workflow action's Secret parameter is now encrypted regardless of action type. Only a
Webhook action's Secret was protected before; a custom action reusing that parameter name was
shown as protected (secretSet in the response) while it was actually stored in clear. It is now
encrypted the same way for every action type.
A Secret saved before it was ever encrypted now refuses to send, and says to recreate the
workflow. This covers a Webhook action created before #524, or a custom action created before
this change, whose Secret was written straight into the store. It cannot be decrypted, because it
was never encrypted, and there is no endpoint that edits a saved workflow's parameters, so the only
fix is to create a new workflow with the secret entered again. The failure row says exactly that,
distinct from the message a rotated Secrets:Key produces, which asks you to re-enter the secret
instead. Check any workflow using a Secret parameter after upgrading; one that predates encryption
stops delivering rather than sending unprotected.
Generated from docs/upgrading-to-4.0.md by scripts/wiki-sync.sh. Edit the doc in the repository, not this page.