Upgrading
There is no upgrade-compatibility promise yet. Pre-1.0, a minor version can include a breaking database migration or a config change without a major version bump. Treat every upgrade like the first one: read the release notes, back up first.
General procedure
- Read the release's entry in the changelog, and the version notes below.
- Back up your database. Migrations run forward-only in practice, and some
have no
downat all, so the way back to an older image is restoring this backup:pg_dump -Fc -d "$DATABASE_OWNER_URL" -f wickermoney-$(date +%F).dump - Pull or build the new image tag.
- Run migrations against the new image, using the owner credentials
(
DATABASE_OWNER_URL), before starting the new container:docker run --rm --env-file .env wickermoney node dist/db/cli.js up - Start the new container.
With the quickstart's compose file, steps 3–5 are
docker compose pull then docker compose up -d: its migrate service runs
before the app starts, every time.
A release with no migrations can go back to the previous image as it is. A
release whose migrations all have a down can go back by running
node dist/db/cli.js down once per migration, with the new image, then
starting the old one. Otherwise, the only way back is restoring the backup
from step 2. The version notes below say which kind each release is.
The running API never migrates on its own boot — it only ever holds the low-privilege app role's credentials, which deliberately can't create schemas, policies or roles. Migrations are always a separate, explicit step against the owner role.
Version notes
0.6.1 → 0.7.0
- Back up first. Two migrations, 034 and 035, both on the Debt
payoff plugin's
debtstable and both with adown. The sample compose file'smigrateservice runs them before the app starts.- 034 (
debt_payoff_plan_controls) addsincluded boolean not null default trueandextra_payment numeric(19,4) not null default 0(never negative). Every existing debt stays in the plan with no extra amount, so nothing changes until someone uses them. - 035 (
debt_payoff_timelines) adds six columns:repayment_starts_on,promo_aprandpromo_ends_on(all optional; the last two are set together),promo_deferred_interest,promo_interest_baseandcompounding. Each has a default that keeps today's behaviour, and checks refuse a half-set promotion, deferred interest without one, and unknown options.
- 034 (
- Going back to 0.6.1: run
node dist/db/cli.js downtwice with the new image (each run reverts the newest migration, 035 and then 034), then start the 0.6.1 image. The twodowns drop the columns above, so any "In plan" switch, extra amount, repayment start date, promotional rate or interest option anyone set is discarded; the debts themselves are kept. Restore the backup instead if you want those settings back. - The API now has a per-user rate limit, on by default. Each signed-in user
may make 600 requests a minute to
/api/v1, 30 CSV import requests and 5 data exports. People using the app will not meet it, but a script that calls the API in a loop (a sync, a poller) may now get429 rate_limited. RaiseAPI_RATE_LIMIT_MAX,API_RATE_LIMIT_IMPORT_MAXandAPI_RATE_LIMIT_EXPORT_MAX, or setAPI_RATE_LIMIT_ENABLED=falseto turn the limit off. See API rate limit. - CSV imports are limited to run a few at a time. A process commits at most
IMPORT_MAX_CONCURRENTimports at once (default: half ofDB_POOL_MAX). Another import waits for its turn without holding a database connection, for up toIMPORT_QUEUE_WAIT_SECONDS(default 30), and is then refused with503 import_busy; nothing is imported and sending the same file again imports it once. One person importing alone is not slowed. See Concurrent imports. - Three more plugins run in a sandboxed frame. The Until payday widget (Upcoming), the Forecast page and the Debt payoff page and widget now run in a frame, as Insights and Spending Trends did in 0.6.0. They read through the app with only their own grants. They look and behave as before. Only Budgets and Import CSV still run in the app's own page, fully trusted. A frame costs some time and memory, and a widget's frame now starts only when its card is near the screen (see below). See How much a plugin is trusted.
- Dashboard widgets in frames start when you scroll to them. A card that is more than 600 px below the window holds a placeholder until you scroll near it; frames in the first screen start at load, as before. Find in page and printing see only the frames that have started, so scroll to the bottom first if you need either. See The dashboard.
- Actions say what they did in a notification. On Transactions, Recurring, Accounts, Categories and Settings, a result such as "Deleted 'Coffee' (-$4.50)." appears as a notification at the top of the page. It has no Undo and does not close itself; see Notifications.
- The Transactions recurring column is quieter. A matched row shows the recurring item as a pill with an Unmatch button; the choosing, suggestions and dismissals moved into a list that the row's Link button opens. See Matching.
- Debt payoff does more. Each debt can be left out of the plan, can have its own monthly extra, can be a loan in deferment, can have a promotional rate (with deferred interest if you want that modelled), and can be compounded daily or charged simple daily interest. A debt that uses none of this gets the plan it got before. See Debt payoff.
- Fixed: a primary button no longer turns light grey under its light text on hover; on a phone, text buttons in the Transactions recurring column no longer overlap their neighbours; and Erase my data no longer misses a write that was open when the erase started. One narrow case remains: a request that had already passed its session check and writes after the erase can still leave a row, which the next erase removes.
- For developers:
pnpm devnow proxies the plugin frame to the API, which needs a built web app andWEB_DIST_DIRset; see Setting up a dev environment. On Windows, a bundled plugin'sdevscript no longer fails withEPERM. - For API clients: additive, apart from the rate limit and the import
refusal above. The Debt payoff routes accept and return new fields, and
GET /planalso returnsexcludedDebts; see Debt payoff. A request over a limit is429 rate_limitedwithRetry-After; an import that waited too long is503 import_busywithRetry-After. - For plugin authors:
@wickermoney/ui-kitgains an experimental toast (ToastProvider,ToastViewport,useToast) for the core app, an experimentalrowDetailprop onTableand alinkicon onIconButton. The experimental isolated protocol gains two additive messages,modalandmodal-layout. Nothing was removed and no manifest needs editing. See Writing a plugin.
0.6.0 → 0.6.1
- Back up first, as for any upgrade. One migration, 033
(
tenant_lookup_policies), runs before the app starts; the sample compose file'smigrateservice does it. It rewrites any row-level security policy that still callscore.current_user_id()bare to(SELECT core.current_user_id()), so the lookup runs once per statement, not once per row. A database migrated in order has one such policy,account_lines_isolationon the Budgets account allowances. The rewritten policy returns the same rows, so no access changes. Where nothing is left to rewrite, the migration does nothing and takes no lock. - Going back to 0.6.0: run
node dist/db/cli.js downonce with the new image, then start the 0.6.0 image. Thedownchanges nothing in your data (there is no behaviour to take back); it only removes the migration's record, so the database's migration history matches the older image's. - An owner is asked before a third-party plugin is switched on. Settings → Plugins shows a confirmation naming the plugin's origin and the tables it is granted. Bundled plugins, and switching a plugin off, don't ask. See Turning a plugin on or off.
- The Transactions table fits narrower windows. From the phone layout up the date doesn't wrap, the Edit and Delete buttons stay in view, and neither the table nor the page scrolls sideways. Every column is still there.
- The
Origin-Agent-Clusterconsole warning is gone. The API no longer sends that header. Nothing about the plugin sandbox changes. - Chrome DevTools no longer lists a "blocks the use of
eval" issue for the plugin frame. The zod library probes forevalsupport when it builds its first schema, the frame's policy refuses it, and zod falls back, so nothing was broken, only noisy. The frame now turns the probe off. The policy is unchanged:'unsafe-eval'was not added, and the frame still refuseseval. - For API clients: additive only.
GET /api/v1/plugins/registry(owner only) now returnsoriginandtableson each plugin. See the API reference.
0.5.0 → 0.6.0
- Back up first. Four migrations, 029 to 032, all with a
down. The sample compose file'smigrateservice runs them before the app starts. Run migrations (not just a restart) either way:migratealso creates the database role the new Debt payoff plugin runs under.- 029 adds two functions,
core.list_users_for_owner()andcore.set_user_role(uuid, core.user_role), behind Settings → People. No data changes. Itsdowndrops both functions and leaves every account's role as it is. - 030 creates the Debt payoff plugin's schema,
plugin_debt_payoff, with two empty tables (debtsandplan_settings). Itsdowndrops the schema and everything in it: every person's debts and plan settings. If anyone has used Debt payoff, that is data loss, so restore from your backup rather than going back if you want those debts. - 031 adds two indexes to
core.transactions,(user_id, merchant, id)and(user_id, amount, id), so sorting the transaction list by merchant or amount no longer sorts the person's whole ledger on every page. The index build blocks writes to transactions (reads continue) while each one builds, one index at a time. On a test ledger of 1,000,000 transactions each build took about 1.2 seconds and writes were held up for at most about 2.2 seconds; a much larger or slower database takes proportionally longer. Don't run it in the middle of a CSV import. Itsdowndrops the two indexes. - 032 adds
core.erase_instance()andcore.instance_activity(uuid), the functions behind "erase this entire instance". They refuse anyone who isn't an owner right now, and no data changes when they are added. Itsdowndrops both functions; an instance that has been erased stays erased.
- 029 adds two functions,
- Going back to 0.5.0: run
node dist/db/cli.js downfour times with the new image, then start the 0.5.0 image. That removes the owner-role functions, the Debt payoff tables and their data, the two indexes and the erase functions. - Debt payoff is on after the upgrade. Bundled plugins are registered switched on, so the page and the Debt-free date dashboard widget appear for everyone. An owner can turn it off from Settings → Plugins; its data is kept while it's off. See Debt payoff.
- Owners can change roles from Settings → People, and the bottom of Settings has two erase actions. Nothing happens until someone uses them.
- New settings, all optional:
DB_POOL_MAX,DB_STATEMENT_TIMEOUT,DB_LONG_STATEMENT_TIMEOUTandIMPORT_MAX_ROWS. The defaults keep today's behaviour, with one exception: the CSV import row cap rises from 50,000 to 100,000 rows (the 8 MB file limit is unchanged). A value outside its range stops the API at start-up with a message naming the variable. See Pool, statement timeouts and the import limit. If you run through PgBouncer, read the PgBouncer note first: it needs one setting changed and two limits moved onto the role. - Compression. The API now compresses its responses itself. If a reverse proxy sits in front, make sure it doesn't compress a second time; see Compression: don't compress twice. There is no setting yet to turn the app's compression off.
- Faster, and a little more accessible. The core pages load when you first visit them, the Transactions page does less work, Budgets reads the ledger at most three times per month request, recurring matches read less, and the dashboard's Upcoming widget makes one request instead of two. Dialogs and the phone drawer manage keyboard focus, charts can be shown as a table, tables have proper headers, and the light-mode green is a shade darker. The accessibility changes haven't been checked with a screen reader yet.
- The
:nextimage tag is gone. A release candidate is published under its version tag only; see Release tags. - For API clients:
- Breaking: the data export needs a password-confirmed ticket.
GET /settings/exportanswers403 export_confirmation_requiredwithout anx-wickermoney-export-ticketheader. Get a ticket from the newPOST /settings/export-ticketwith{ "password": "..." }; it is good for one download for two minutes. The Settings page does both steps with a password dialog. A script that downloads the export itself must make the two requests in order. See The data export. - Breaking:
POST /analyzeon the CSV import no longer returns every row. It returns the counts for the whole file, the first 100 rows and a page of up to 500 rows that need review. A client that read every row from the response needs to readflagged(and page it) instead;commitis unchanged. See Theanalyzeresponse. - Additive:
GET /accounts?fields=basic,accountNameon the legs of the upcoming outlook,truncatedflags on recurring matching, and the new owner-only/users, erase and plugin-token routes.
- Breaking: the data export needs a password-confirmed ticket.
- For plugin authors: one breaking change to the stable SDK root. The
five Zod manifest schemas (
pluginManifestSchemaand friends) are no longer exported; validate withparseManifest.addDaysandaddMonthshave a stable home at@wickermoney/plugin-sdk/date;RegisterRoutehas alongRunningoption; and@wickermoney/plugin-sdk/isolatedis a new experimental entry point that the app now hosts (Insights and Spending Trends use it). A manifest gains an optionalintegritypin and an optionalisolatedflag. A manifest that declaresnetwork:outbound,schedule:dailyornotifyis now refused at load; before, it loaded and was ignored.SDK_MAJOR_VERSIONis unchanged and existing manifests still load. See Writing a plugin. - Plugin UI can now run in a sandboxed frame. Insights and Spending Trends
do. They no longer share the app's page, your session or its cookies, and they
reach data only through the app, with a short-lived token that carries just
their grants. They load a little differently: the first widgets take a little
longer to appear because each frame loads its own copy of React, and a frame
costs roughly 6 to 9 MiB and 150 ms after the first. Budgets, Import CSV,
Upcoming, Forecast and Debt payoff still run in the app's own page, fully
trusted. The sandbox does not stop everything: in Chromium, a hostile plugin
can still cause some requests through
<link rel=prerender>and WebRTC and read nothing back. It is experimental and has not been independently reviewed. See How much a plugin is trusted. - Third-party plugins can be added by hand, off by default. Nothing changes
unless you set
PLUGIN_REMOTE_ORIGINSandPLUGIN_MANIFEST_DIR, both empty by default. There is still no install flow or catalog. See Adding a third-party plugin.
0.4.2 → 0.5.0
- Back up first. Two migrations, both reversible:
- 027 adds
plugin_budgets.account_linesfor account allowances. - 028 replaces the
core.register_userfunction and addscore.instance_has_users()forBOOTSTRAP_OWNER_EMAIL.
- 027 adds
- Going back to 0.4.2: run
node dist/db/cli.js downtwice with the 0.5.0 image, then start the old image. That discards account allowances and restores the earlier registration function. - Name your owner before the first start if the instance has no accounts
yet and other people can reach it: set
BOOTSTRAP_OWNER_EMAILto your address (see Owners and members). Unset, nothing changes: the first account to register is the owner. An existing instance keeps every account's role either way. In production the API now logs a warning at start-up when the instance has no accounts, registration is open and the variable is unset. - Account allowances. The Budgets page gains an "Account allowances" section, and the Budgets plugin now asks for read access to accounts. An account with an allowance counts as in use, so deleting it asks for the usual confirmation.
- A faster dashboard. The three dashboard widgets that read the monthly summary now make one request. It is a browser-side change only; see Reads are shared between widgets.
- Forms open in a dialog. On Accounts, Categories and Recurring, the add forms are buttons in the page header that open the same dialog Transactions uses (a side drawer on desktop, a full-screen sheet on a phone), so the tables get the full page width. Editing a recurring item, fixing an opening balance and resolving a delete that has history open there too. The Transactions filter row lines up.
- Faster recurring matches. The Transactions page's recurring matches now read their data once per request, 14 database statements instead of 20, the same however many items you have. Same results; nothing is kept between requests.
- Plugin trust wording. The docs,
SECURITY.mdand.env.examplenow say plainly that plugin UI code runs fully trusted in the app's origin, and that the per-plugin database role is a guardrail against honest mistakes in a bundled plugin's server code rather than a sandbox. Behaviour is unchanged. KeepPLUGIN_REMOTE_ORIGINSempty unless you fully trust every origin you list. - For API clients: the Budgets and CSV Import routes refuse an id that
PostgreSQL could not have generated (a version digit of 0, say) with the
usual
400. Ids from the app are unaffected. The Budgets routes gainPUT /account-lineandDELETE /account-line, and the Budgets export gainsaccountLines. - For plugin authors:
@wickermoney/plugin-sdk/serveris new, the SDK's entry points now have stability tiers (/recurrenceis experimental), andaddDaysandaddMonthsare exported from/recurrence. No export was removed or renamed, so nothing already published needs changing. The one difference is for anyone who copied the app's old date helpers: an impossible date such as2026-02-30now throws instead of rolling into March. See Writing a plugin.
0.4.1 → 0.4.2
- No migrations. Upgrading is a pull and a restart, and going back to 0.4.1 is safe: run the old image as it is.
- Check for placeholder passwords first. 0.4.2 refuses to start in
production if
AUTH_SECRETor the app database password still looks like a placeholder (change-me,change_me,CHANGE_MEor the other throwaway values listed under Placeholder credentials). If you generated real values, as the quickstart says to, nothing changes. If not, fix it before you pull: it takes two minutes, and the section below has the steps. - Phones and narrow windows. The sidebar becomes a drawer, and Transactions, Accounts, Recurring and Categories show as cards. Categories and Rules are collapsible, and row actions are icon buttons.
- Accessibility. Each page has its own tab title, there is a "Skip to content" link, and the light-mode warning colour is darker.
- Matching. The Transactions page finds recurring-item matches for the newest transactions on a long ledger, where it could miss them before.
- For API clients: nothing changed.
0.4.0 → 0.4.1
- No migrations. Upgrading is a pull and a restart, and going back to 0.4.0 is safe: run the old image as it is.
- Add the new starter categories. 0.4.1 adds Memberships (under Subscriptions, for store and shopping memberships such as Costco or Amazon Prime) and Domains / web hosting (under Technology). They aren't added to accounts that are already set up. Open Categories → Run setup again and finish the wizard: it adds only what's missing and leaves every category you already have as it is, renames included.
- Domains / web hosting needs the tech answer. It's created when the setup question about smart home, networking, gaming or web hosting is ticked (the wording now mentions hosting). Memberships is in everyone's base set.
- For API clients: nothing changed. The catalog is two entries larger.
0.3.0 → 0.4.0
- Back up first. Two migrations, both reversible:
- 025 adds the table that remembers dismissed match suggestions, and a
unique
(user_id, id)key oncore.transactions. Building that index blocks writes (not reads) to transactions for as long as one index build takes. - 026 makes only the first account an owner from now on.
- 025 adds the table that remembers dismissed match suggestions, and a
unique
- Going back to 0.3.0: run
node dist/db/cli.js downtwice with the 0.4.0 image, then start the 0.3.0 image. That discards dismissed suggestions, and puts back the old default that makes every new account an owner. - Existing accounts keep their role. Before 0.4.0, every account was registered as an owner, and 026 doesn't change existing rows. If several people signed up on your instance, they are all still owners, and any of them can turn plugins on and off. To check, and to demote with SQL, see Owners and members.
- For API clients:
validation_failedmessages are reworded, so a client that matches on message text needs updating. Readcodeand the newissueslist instead (see API reference).GET /api/v1/transactions?uncategorized=trueno longer returns linked transfer legs. - Building from source needs Node 22.22.2+ or 24.15+. The container image is unaffected.
- New: Settings → Plugins, matching from the Transactions page and dismissing a suggestion (see Matching), and form errors shown on the field they're about.
0.2.1 → 0.3.0
- Back up first. This upgrade can't be rolled back without that backup.
- 023 (budget windows) adds the
btree_gistextension and a constraint so a category can't have two budget lines on the same day. It's reversible: itsdowndeletes any windows and drops the constraint, which leaves monthly lines exactly as 0.2.1 had them.btree_gistis a trusted extension, so the non-superuser database owner can create it. - 024 (recurring occurrences) adds the tables behind matching, skips,
moves and per-occurrence amounts, and a nullable
core.transactions.recurring_occurrence_id. It has nodown: dropping it would discard every match and override. Going back to 0.2.1 means restoring the backup.
- 023 (budget windows) adds the
- PostgreSQL 15 or later is required by 024, because its foreign key
uses
ON DELETE SET NULL (column). 16 was already the documented minimum, so a supported install is unaffected. - For API clients: budgets refuses a planned amount with more than four
decimal places (
400) instead of quietly truncating it. Recurring occurrence views gainnominalDate,expectedDateandstatus;dateis where the list places an occurrence, which for a late one is the first projected day. - New: Matching for recurring items, and budget windows.
0.2.0 → 0.2.1
- No migrations. Upgrading is a pull and a restart, and going back to 0.2.0 is safe.
- Set your time zone. Accounts created before 0.2.1 are still on
UTC, so "today" for recurring items and "Until payday" turns over in the evening anywhere west of UTC. After upgrading, open Settings → Time zone; it offers your browser's zone in one click. Accounts registered from 0.2.1 on take the browser's zone at sign-up. - New: the Forecast page (bundled plugin
wickermoney.forecast). See Forecast.
0.1.0 → 0.2.0
- Back up first. This upgrade can't be rolled back without that backup.
Migration 021 reshapes
core.recurring_itemsinto an item plus per-account legs (core.recurring_item_legs) and adds aoncevalue to the recurrence enum. PostgreSQL can't remove an enum value, so 021 has nodown. - Migration 021 copies every existing recurring item's account, amount and transfer target into legs before dropping those columns. A row it can't represent fails the migration and names its id rather than being dropped. The backfill runs in its own transaction, so a failure leaves your recurring items as they were. Fix the row and run migrations again; 021 is safe to re-run.
- Migration 022 adds
core.accounts.spendableand marks existing checking accounts spendable, so the "safe to spend" number starts from the same accounts it would have used anyway. - Any plugin that read
account_id,amount,transfer_account_idoris_incomefromcore.recurring_itemsdirectly breaks. Bundled plugins are already updated. - Known limitation (fixed in 0.2.1): there is no time zone setting yet, so "today" follows UTC.
Two version numbers, not one
The app version (the Docker tag, e.g. 0.2.1) and the plugin API's own
contract version (SDK_MAJOR_VERSION) move independently. A plugin built
against plugin-sdk cares about the latter, not the former — see
Architecture for why the plugin contract is
versioned separately from the app itself. SDK_MAJOR_VERSION is not frozen
yet (roadmap
item, targeted ahead of 1.0), so a bundled plugin can still change shape
between app releases.
Placeholder credentials
From 0.4.2, with NODE_ENV=production (the container image's default), the API
and the migrate step refuse to start when AUTH_SECRET or DATABASE_URL
contains a placeholder or throwaway value: change-me, change_me,
changeme, testpw or _dev_password, in any case (so the quickstart's old
CHANGE_ME counts). A placeholder is publicly known: with a known
AUTH_SECRET anyone can forge a sign-in. The check never prints the value, and
no data is touched when it refuses.
You'll see this in docker compose logs migrate (or wickermoney):
Invalid configuration:
AUTH_SECRET contain a development credential. Refusing to start with NODE_ENV=production.
Are you affected?
Only if you left a placeholder in place. In the folder with your .env:
grep -i -E "change[-_]?me|testpw|_dev_password" .env
No output means you are fine. POSTGRES_PASSWORD (the owner password) is not
checked, so a hit there alone doesn't stop anything; see
rotating the owner password if you want to
change it anyway.
Fix it (Docker Compose)
-
Pick new values and put them in
.env:openssl rand -base64 48 # AUTH_SECRETopenssl rand -hex 24 # APP_DB_PASSWORDUse
-hexfor the database password: it is placed inside a connection URL, and+,/and=from base64 would break it. You only need to replace the ones that were placeholders. -
Start the stack.
migrateruns first, every time:docker compose pulldocker compose up -dIt reinstalls the tenant key from the new
AUTH_SECRETand sets the app database role's password to the newAPP_DB_PASSWORD. Look for these two lines indocker compose logs migrate:Tenant context key installed from AUTH_SECRET.Role 'wickermoney_app' configured: LOGIN granted, password set. -
Sign in again if you changed
AUTH_SECRET: every session is signed out. Nothing else is lost. Changing onlyAPP_DB_PASSWORDsigns nobody out.
If you pulled 0.4.2 first and it stopped, nothing is damaged: edit .env and
run docker compose up -d again.
Fix it (without the sample compose)
Set AUTH_SECRET, and the same new password in DATABASE_URL and
APP_DB_PASSWORD, then run migrations with the owner credentials before
starting the app, as in the general procedure:
docker run --rm --env-file .env wickermoney node dist/db/cli.js up
Rotating the owner password
POSTGRES_PASSWORD only sets the owner password when the database is first
created, so editing .env later changes nothing in the database. To rotate it,
change the role first, then .env:
docker compose exec -it postgres psql -U wickermoney -d wickermoney
# then, at the psql prompt:
\password wickermoney
Set the same value as POSTGRES_PASSWORD in .env afterwards, so migrate
can still connect.
After changing AUTH_SECRET
Re-run migrations. AUTH_SECRET derives the key that signs the tenant context
every row-level security policy checks; the API refuses to start if the
database's installed key doesn't match the configured secret. Rotating it
also invalidates every existing session — expected, not a bug.