Files
selfpost/docs/schema-migrations.md
T
mixandClaude Opus 5.5 8d1af89e1a panel-redesign: the 2.0 schema from zero - one baseline, no 1.x chain
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>
2026-10-09 18:31:43 +03:00

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 /data gets the baseline and nothing else.
  • A 1.x database is refused, not migrated. store.Open returns ErrForeignSchema for a file that has a schema version but not the 2.x application_id. Without that check a 1.x file at user_version = 10 would 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

  1. Migrations are embedded at build time (//go:embed migrations/*.sql).
  2. Filenames are sorted lexicographically; file order = version number.
  3. PRAGMA user_version records progress: after file i (0-based), version is i + 1.
  4. Each pending migration runs in its own transaction, then bumps user_version.
  5. There is no down-migration; fixes ship as a new 00NN_*.sql file.

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:

  1. domains.dmarc_rua_user_id is 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.
  2. Otherwise domains.dmarc_rua is 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

  1. Add internal/store/migrations/00NN_short_name.sql (next number).
  2. Update the table in Migration chain, the snapshot at the top, and the affected rows of Schema at head.
  3. Update TestBaselineSchema if tables, columns or indexes change, and add a test that opens a database at the previous version and migrates it.
  4. A data migration states in a comment which 2.x versions it is written for.