Stage 1, step 1. BREAKING: a 1.x database is not migrated.
Schema
- internal/store/migrations/ holds one file, 0001_init.sql, equal to the
2.0 schema; 0002..0010 are deleted (they stay in history at 1.9.5). No
admin table created and dropped, no ALTERs.
- users.email replaces users.dmarc_report_email: the address is the
user's own. The default DMARC report address is a user's setting
(dmarc_default_mode hosted/account/custom/none, dmarc_default_address);
the instance setting that mirrored the global user's field is gone.
- domains.dmarc_rua_user_id: a domain follows a named user's default, or
has an address of its own in dmarc_rua ('' = none). ON DELETE SET NULL
leaves it at "no reports", never at someone else's default.
- user_inbound_domains, users.all_domains, users.all_inbound_domains: in
the schema for the inbound delegation of the next step.
- users.role is 'global' / 'domain' (was 'domain_admin');
rate_limits.allowed_ips, unused since 1.9.0, is dropped.
Opening a database
- The baseline sets PRAGMA application_id ("SP20"). store.Open refuses a
file that has a schema version without it (ErrForeignSchema) and one
newer than the build. A 1.x file at user_version 10 would otherwise read
as already migrated. The refused file is left untouched.
Code
- Store.DomainDMARCRua is the one place a domain's report address is
resolved; dnscheck.ResolveDMARCRua and Store.GlobalDMARCReportEmail are
gone. dmarc.AllowedRecipients, the domain page, the dashboard and the
DNS re-check go through it.
- The old templates keep working on the new model until their pages are
restyled: the Settings form's address field is the account e-mail and
switches the user's default between it and none; a domain's "Same as
Settings" makes it follow the user who chose it; a new or imported
domain follows its creator.
- Domain export/import: a domain that follows a user exports no address.
Tests: the baseline pinned table by table (TestBaselineSchema), refusal of a
1.x and of a newer database, constraints and cascades of the new columns and
table, resolution in every default mode, the deleted-user case, and the three
bridged handlers.
docs/schema-migrations.md is rewritten for the baseline; roadmap 11/27,
schema-squash marked built.
go test ./... (Windows)
ok for every package except two that fail the same way before this commit:
internal/domain TestWriteLoadPrivateKeyRoundtrip (file mode on NTFS)
internal/logtail TestFollowTailsAndRotates (rename of an open file)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
7.0 KiB
SQLite schema and migrations
What this file is. A living reference for the embedded SQLite schema: the 2.0 baseline, the migrations added after it, and how a database is opened. When you add or review a migration, update this file in the same change.
Source of truth in code: internal/store/migrations/*.sql, applied by
internal/store/store.go (migrate()).
Related: architecture.md § Persistence; plans/panel-redesign.md § No compatibility; development.md § Documentation.
Current snapshot
| Field | Value |
|---|---|
| Chain head | user_version = 1 |
| Files | 0001_init.sql (the 2.0 baseline) |
| File mark | PRAGMA application_id = 0x53503230 ("SP20") |
| Database file | /data/selfpost.db (bind mount) |
| Compatibility | 2.x MINOR releases must boot a 2.0.0 data directory. Nothing is carried over from 1.x. |
2.0 starts from an empty data directory
Decided by the owner on 2026-09-21: the redesign is a breaking release, the
1.x migration chain (0001 … 0010, with its admin table created in the
first file and dropped in the fifth) is deleted from the binary, and there is
no upgrade path. The only live 1.x installation is the owner's and is set up
again on 2.0. The 1.x chain stays in git history (last present at 1.9.5).
What that means in practice:
- A fresh
/datagets the baseline and nothing else. - A 1.x database is refused, not migrated.
store.OpenreturnsErrForeignSchemafor a file that has a schema version but not the 2.xapplication_id. Without that check a 1.x file atuser_version = 10would read as "already migrated" and the panel would start on tables it does not know. The file is not modified. The same applies to restoring a 1.x full backup into a 2.0 container. - A database whose version is higher than the build knows is refused too.
- Domains can still be carried by hand: a 1.x domain export (JSON, with the DKIM key and applications) imports into 2.0 — the transfer format is not the database.
How migrations run
- Migrations are embedded at build time (
//go:embed migrations/*.sql). - Filenames are sorted lexicographically; file order = version number.
PRAGMA user_versionrecords progress: after file i (0-based), version is i + 1.- Each pending migration runs in its own transaction, then bumps
user_version. - There is no down-migration; fixes ship as a new
00NN_*.sqlfile.
Implication: once 2.0.0 is released, 0001_init.sql is never edited
again and no file is deleted, renamed or reordered — every 2.x database has
already run it. Until that release the baseline is still being written:
later steps of the redesign and the features queued behind it (the next one
is 0002_inbound_spam_log.sql of inbound-antispam-panel, 2.1.0) add files
after it.
Migration chain
| Ver | File | Shipped | Kind | Summary |
|---|---|---|---|---|
| 1 | 0001_init.sql |
2.0.0 | DDL | The whole 2.0 schema, below |
Kind: DDL — schema only; data — INSERT/UPDATE that must stay correct
for operators upgrading from older 2.x images.
Schema at head (v1)
| Table | Role |
|---|---|
settings |
Key/value settings of the instance — what is true whoever is signed in (send-log retention). Nothing that belongs to a user |
users |
Panel logins. role is global or domain; email is the user's own address; dmarc_default_mode / dmarc_default_address are their default DMARC report address; all_domains / all_inbound_domains widen a domain user to every domain of that direction |
sessions |
Panel login sessions (token hash, sliding idle expiry) |
domains |
Sending (outbound) domains. dmarc_rua / dmarc_rua_user_id say where aggregate reports go |
user_domains |
Outbound domains assigned to a domain user |
applications |
SASL applications per domain, with the client-IP restriction (auth_ip_restrict, auth_allowed_ips) |
application_addresses |
Explicit From addresses (list mode) |
rate_limits |
Level-2 limits per domain / application, manual or auto |
send_log |
Delivery journal |
logtail_state |
Log-tailer read position |
inbound_domains |
Inbound relay domains |
inbound_transports |
Upstream host / port / TLS per inbound domain |
inbound_recipients |
Allow-list when recipient_mode = list |
user_inbound_domains |
Inbound domains assigned to a domain user — granted separately from the outbound ones |
dmarc_reports |
Parsed aggregate report summaries |
dmarc_report_records |
Per-source rows inside a report |
TestBaselineSchema (internal/store/schema_test.go) pins every table, column
and index of this list.
What changed against the last 1.x schema
| 1.x | 2.0 | Why |
|---|---|---|
admin created in 0001, dropped in 0005 |
never exists | the reason for the squash |
users.role = global / domain_admin |
global / domain |
the role is a reach, not a rank — everyone in the panel is an administrator |
users.dmarc_report_email |
users.email |
a user's e-mail is theirs; it is what the panel writes to, and DMARC only may use it |
instance setting dmarc_report_email (mirror of the global user's field) |
users.dmarc_default_mode, users.dmarc_default_address |
the default report address is a user's setting, not the server's |
domains.dmarc_rua NULL = inherit the instance default, '' = none |
domains.dmarc_rua_user_id set = follows that user's default; otherwise dmarc_rua is the address, '' = none |
a domain can have several users, so "the default" has to be someone's |
| — | user_inbound_domains |
inbound is delegated per user, separately from outbound |
| — | users.all_domains, users.all_inbound_domains |
"All" includes domains added later |
rate_limits.allowed_ips (unused since 1.9.0) |
gone | client IPs belong to the application (auth_allowed_ips) |
Where a domain's DMARC reports go
Resolved in one place, Store.DomainDMARCRua:
domains.dmarc_rua_user_idis set → the domain follows that user's default (DMARCDefault.Resolve):hosted→ SelfPost's own mailbox for the domain (no reports while ingest is off),account→users.email,custom→users.dmarc_default_address,none→ no reports.- Otherwise
domains.dmarc_ruais the address itself;''means a policy-only record.
A new domain follows whoever created it. Deleting the followed user sets
dmarc_rua_user_id to NULL (ON DELETE SET NULL), which leaves the domain at
"no reports" — never at another user's default.
Adding a migration
- Add
internal/store/migrations/00NN_short_name.sql(next number). - Update the table in Migration chain, the snapshot at the top, and the affected rows of Schema at head.
- Update
TestBaselineSchemaif tables, columns or indexes change, and add a test that opens a database at the previous version and migrates it. - A data migration states in a comment which 2.x versions it is written for.