store, web: cite the source of what they rely on (assumptions part D)

Comments only, no behaviour changed. modernc.org/sqlite v1.60.1 (the
_pragma list on every connection, busy_timeout first; extended result
codes; a deferred BEGIN; multi-statement Exec) and SQLite 3.53.4's
documentation (transactions, WAL, foreign keys, NOCASE, upsert, julianday,
partial indexes); htmx 2.0.4 src/htmx.js; the Fetch and HTML standards for
Sec-Fetch-Site, Origin, SameSite, __Host-, CSP and defer order; Go's
html/template. Comments the source contradicts are corrected - the driver
does not serialise writes (SetMaxOpenConns(1) does), htmx:load fires per
swapped element, no-store does not keep a page out of history.

Left for their own commits: D-S3 a migration two processes can both run,
D-S20 recipient case, D-B3 Origin: null under Referrer-Policy: no-referrer,
D-H5/H6/H7/H9 (audit M11, stage 4).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-14 19:36:26 +03:00
co-authored by Claude Opus 5.5
parent b113e72c71
commit ad3d834392
25 changed files with 243 additions and 38 deletions
+10 -3
View File
@@ -12,6 +12,9 @@ import (
// ErrLoginExists is returned when an application login is already taken. The
// login is globally unique because it is the SASL identity Postfix
// authenticates (one sasldb2 across all domains, architecture.md § Mail path).
// It is unique without regard to case too (migration 0008's NOCASE index):
// Postfix 3.7.11, src/smtpd/smtpd_check.c reject_auth_sender_login_mismatch()
// compares the SASL login with the map's owners by strcasecmp_utf8.
var ErrLoginExists = errors.New("application login already exists")
// ErrApplicationNotFound is returned when an application id does not exist.
@@ -74,6 +77,7 @@ type Binding struct {
// login maps to ErrLoginExists.
func (s *Store) AddApplication(domainID int64, login, mode string, addresses []string) (Application, error) {
now := time.Now().UTC()
// The first statement is a write (see UpdateStatus).
tx, err := s.db.Begin()
if err != nil {
return Application{}, fmt.Errorf("begin add application: %w", err)
@@ -90,6 +94,8 @@ func (s *Store) AddApplication(domainID int64, login, mode string, addresses []s
}
return Application{}, fmt.Errorf("insert application: %w", err)
}
// sqlite3_last_insert_rowid() of this connection, the transaction's own
// (https://sqlite.org/c3ref/last_insert_rowid.html).
id, err := res.LastInsertId()
if err != nil {
return Application{}, fmt.Errorf("application id: %w", err)
@@ -110,6 +116,7 @@ func (s *Store) AddApplication(domainID int64, login, mode string, addresses []s
// address list atomically (product.md). The login and password are untouched.
// Returns ErrApplicationNotFound if the id does not exist.
func (s *Store) UpdateApplicationMode(id int64, mode string, addresses []string) error {
// The first statement is a write (see UpdateStatus).
tx, err := s.db.Begin()
if err != nil {
return fmt.Errorf("begin update application: %w", err)
@@ -353,9 +360,9 @@ func (s *Store) UpdateApplicationAuthIPs(id int64, restrict bool, ips []string)
return nil
}
// DeleteApplication removes an application and its addresses (via cascade),
// returning the deleted application so the caller can drop its sasldb2 entry
// (product.md). Returns ErrApplicationNotFound if no such row existed.
// DeleteApplication removes an application and its addresses (via cascade, see
// DeleteDomain), returning the deleted application so the caller can drop its
// sasldb2 entry (product.md). Returns ErrApplicationNotFound if no such row existed.
func (s *Store) DeleteApplication(id int64) (Application, error) {
a, err := s.GetApplication(id)
if err != nil {
+3 -1
View File
@@ -80,8 +80,10 @@ type DMARCIngestStats struct {
}
// InsertDMARCReport stores a parsed report and its records, replacing any prior
// row with the same reporter/report_id/domain triple.
// row with the same reporter/report_id/domain triple; the prior report's
// records go with it by ON DELETE CASCADE (see DeleteDomain).
func (s *Store) InsertDMARCReport(rep DMARCReport) (int64, error) {
// The first statement is a write (see UpdateStatus).
tx, err := s.db.Begin()
if err != nil {
return 0, fmt.Errorf("begin insert dmarc report: %w", err)
+12 -2
View File
@@ -128,7 +128,10 @@ func (s *Store) GetDomain(id int64) (Domain, error) {
// DeleteDomain removes a domain. Its applications and their address/binding rows
// go with it via ON DELETE CASCADE (product.md). Returns ErrDomainNotFound if no
// such row existed.
// such row existed. SQLite 3.53.4 acts on ON DELETE only on a connection with
// foreign_keys ON, which Open's DSN sets on every one
// (https://sqlite.org/foreignkeys.html § 2, § 4.3); the rows the cascade
// removes are not in RowsAffected (https://sqlite.org/c3ref/changes.html).
func (s *Store) DeleteDomain(id int64) error {
res, err := s.db.Exec("DELETE FROM domains WHERE id = ?", id)
if err != nil {
@@ -162,6 +165,11 @@ func scanDomain(r scanRow) (Domain, error) {
}
// isForeignKeyViolation reports whether err is a SQLite FOREIGN KEY conflict.
// modernc.org/sqlite v1.60.1, conn.go newConn(): extended result codes are on
// for every connection, so Error.Code() (error.go) is the extended code
// (https://sqlite.org/rescode.html#constraint_foreignkey, _unique,
// _primarykey); a unique index - the NOCASE one on logins too - fails as
// SQLITE_CONSTRAINT_UNIQUE.
func isForeignKeyViolation(err error) bool {
var se *sqlite.Error
return errors.As(err, &se) && se.Code() == sqlite3.SQLITE_CONSTRAINT_FOREIGNKEY
@@ -197,7 +205,9 @@ func (s *Store) SetDomainDMARCAddress(id int64, rua string) error {
// DomainExists reports whether a sending domain with this name is configured.
// Used by the DMARC ingest path to refuse reports for domains this relay does
// not send for (security.md: report XML is attacker-supplied input).
// not send for (security.md: report XML is attacker-supplied input). NOCASE
// folds ASCII letters only (https://sqlite.org/datatype3.html § 7), which is
// all a domain name the panel accepts holds.
func (s *Store) DomainExists(name string) (bool, error) {
var n int
err := s.db.QueryRow(
+2
View File
@@ -48,6 +48,7 @@ type InboundDomain struct {
// caller must have validated name (security.md).
func (s *Store) AddInboundDomain(name string) (InboundDomain, error) {
now := time.Now().UTC()
// The first statement is a write (see UpdateStatus).
tx, err := s.db.Begin()
if err != nil {
return InboundDomain{}, fmt.Errorf("begin add inbound domain: %w", err)
@@ -189,6 +190,7 @@ func (s *Store) UpdateInboundTransport(id int64, host string, port int, tlsMode
// UpdateInboundRecipients replaces the recipient mode and, in list mode, the
// address list. In any mode the stored list is cleared.
func (s *Store) UpdateInboundRecipients(id int64, mode string, addresses []string) error {
// The first statement is a write (see UpdateStatus).
tx, err := s.db.Begin()
if err != nil {
return fmt.Errorf("begin update inbound recipients: %w", err)
+4 -1
View File
@@ -72,7 +72,8 @@ func (s *Store) InsertInboundSpam(e InboundSpamEntry) error {
// detail - and says whether it wrote. The log tailer uses it for the lines it
// reads when it starts: after an unclean stop those are lines it may have read
// before, and a refusal read twice is otherwise two rows. e.At must be set; a
// row dated "now" is never the same as another.
// row dated "now" is never the same as another. The check and the insert are
// one statement, and one of no row reports 0 (see CreateGlobalUser).
func (s *Store) InsertInboundSpamOnce(e InboundSpamEntry) (bool, error) {
at := e.At.UTC().Format(time.RFC3339)
res, err := s.db.Exec(
@@ -103,6 +104,8 @@ func (s *Store) PruneInboundSpam(cutoff time.Time, keep int) (int64, error) {
n, _ := res.RowsAffected()
// Oldest by when it happened, not by id: the tailer dates a refusal by its
// log line, so after the panel was down the newest ids are old rows.
// SQLite 3.53.4, https://sqlite.org/lang_select.html § 5: a negative LIMIT
// is no upper bound, so this is every row after the newest keep.
res, err = s.db.Exec(
`DELETE FROM inbound_spam_log WHERE id IN
(SELECT id FROM inbound_spam_log ORDER BY created_at DESC, id DESC LIMIT -1 OFFSET ?)`, keep)
+6 -1
View File
@@ -137,6 +137,9 @@ func (s *Store) DeleteNotifyState(key string) error {
// ListNotifyStateKeys returns the keys that begin with prefix, so that the
// states of subjects that are gone - a deleted domain - can be found.
// SQLite's substr counts characters of a TEXT value, len(prefix) its bytes
// (https://sqlite.org/lang_corefunc.html#substr): the two agree for the ASCII
// prefixes the callers pass ("dns:", "limit:").
func (s *Store) ListNotifyStateKeys(prefix string) ([]string, error) {
rows, err := s.db.Query(
"SELECT key FROM notify_state WHERE substr(key, 1, ?) = ? ORDER BY key", len(prefix), prefix)
@@ -181,7 +184,9 @@ type LimitRefusal struct {
// reason and is still judged the old way.
func (s *Store) LimitRefusalsSince(since time.Time) ([]LimitRefusal, error) {
// The status is a literal so that the partial index of migration 0005
// answers the question.
// answers the question: the query's term matches the index's WHERE
// exactly (https://sqlite.org/partialindex.html § 3) when the statement is
// first prepared.
rows, err := s.db.Query(
`SELECT d.name, a.login, COUNT(*), MIN(l.created_at)
FROM send_log l
+4 -1
View File
@@ -100,6 +100,7 @@ func (s *Store) AddSenderCheckDeferred(at time.Time, n int64, place string, mess
if n <= 0 {
return nil
}
// The first statement is a write (see UpdateStatus).
tx, err := s.db.Begin()
if err != nil {
return fmt.Errorf("sender check deferred: %w", err)
@@ -118,7 +119,9 @@ func (s *Store) AddSenderCheckDeferred(at time.Time, n int64, place string, mess
}
// To the fraction of a second the log gives, and of a fixed width so that
// the strings compare as the times do; a re-read log must not move the
// mark back.
// mark back. SQLite 3.53.4, https://sqlite.org/lang_upsert.html § 2: a
// DO UPDATE whose WHERE is false is a no-op, not an error, and excluded.
// names the value the INSERT brought.
stamp := at.UTC().Format("2006-01-02T15:04:05.000000Z")
setting := func(key, value, cond string) error {
_, err := tx.Exec(`INSERT INTO settings (key, value) VALUES (?, ?)
+23 -4
View File
@@ -47,6 +47,11 @@ type SendLogEntry struct {
// application whose login it is, NULL for a login that is no application's
// (audit 2026-10-12, L12). The domain name of the row stays what the
// envelope said.
//
// The login is matched exactly (BINARY): it reaches the milter and the log in
// the case it is stored in, because Cyrus SASL 2.1.28 looks it up as given -
// lib/canonusr.c _canonuser_internal() only trims spaces and appends a realm,
// sasldb/allockey.c _sasl_alloc_key() copies it byte for byte into the key.
const loginDomainID = `(SELECT a.domain_id FROM applications a WHERE a.login = ?)`
// InsertQueued records an accepted message in the send log with status
@@ -108,7 +113,12 @@ func (s *Store) InsertRejected(e SendLogEntry) error {
// first and then writes fails at once with "database is locked" when another
// connection - the dmarc-ingest process opens the same file - commits in
// between, and the line would be lost; a write first takes the write lock,
// and the busy timeout waits for it.
// and the busy timeout waits for it. SQLite 3.53.4, btree.c btreeBeginTrans():
// the busy handler is retried only while the connection has no transaction
// open, so the upgrade of a read transaction fails with SQLITE_BUSY at once -
// also while the other connection merely holds the write lock - and with
// SQLITE_BUSY_SNAPSHOT after it committed (https://sqlite.org/rescode.html#busy_snapshot).
// Every other transaction of this package opens with a write for this reason.
func (s *Store) UpdateStatus(queueID, recipient, status string) (int64, error) {
now := time.Now().UTC()
tx, err := s.db.Begin()
@@ -184,7 +194,9 @@ type QueuedDelivery struct {
// about them.
//
// created_at is stored as RFC3339 UTC, so a lexical comparison against the same
// format is chronologically correct.
// format is chronologically correct. SQLite 3.53.4 compares TEXT with the
// BINARY collation, memcmp() (https://sqlite.org/datatype3.html § 7), and
// time.RFC3339 of a UTC time is fixed-width, YYYY-MM-DDTHH:MM:SSZ.
func (s *Store) ListQueuedOlderThan(cutoff time.Time) ([]QueuedDelivery, error) {
rows, err := s.db.Query(
`SELECT id, queue_id, to_addr FROM send_log
@@ -247,6 +259,12 @@ type SendLogRow struct {
// order the rows were written, so two refusals of one second take one line
// each in turn (review of stage 2b). A line already on a row - read again
// after a restart - goes on no other. It reports whether a row took the line.
//
// SQLite 3.53.4: the choice and the write are one UPDATE, a write transaction
// from its start (https://sqlite.org/lang_transaction.html § 2.1); julianday()
// reads YYYY-MM-DDTHH:MM:SSZ, the T and the Z included (lang_datefunc.html
// § 2); NOCASE folds ASCII only (datatype3.html § 7.1); ?1 is bound once and
// used twice (lang_expr.html § 4).
func (s *Store) AttachRefusalLine(clientIP, from string, at time.Time, line string) (bool, error) {
res, err := s.db.Exec(
`UPDATE send_log SET refusal_line = ?1 WHERE id = (
@@ -277,7 +295,8 @@ func (s *Store) AttachRefusalLine(clientIP, from string, at time.Time, line stri
// and nothing is. The domain is the login's, or - when the tailer did not see the login, its
// client= line read before a restart - the envelope's, so the row is still
// its domain's administrator's (review of stage 2b). It reports whether it
// wrote one.
// wrote one: the check and the insert are one statement (see
// CreateGlobalUser), and an INSERT ... SELECT of no row reports 0.
func (s *Store) RecoverRefusal(e SendLogEntry, at time.Time) (bool, error) {
stamp := at.UTC().Format(time.RFC3339)
res, err := s.db.Exec(
@@ -433,7 +452,7 @@ func sendLogWhere(f SendLogFilter) (string, []any) {
// implementing the configurable retention window (architecture.md §
// Persistence, SEND_LOG_RETENTION_DAYS). It returns the number of rows pruned.
// created_at is stored as RFC3339 UTC, so a lexical comparison against the
// same format is chronologically correct.
// same format is chronologically correct (see ListQueuedOlderThan).
func (s *Store) DeleteSendLogBefore(cutoff time.Time) (int64, error) {
res, err := s.db.Exec(
`DELETE FROM send_log WHERE created_at < ?`,
+6 -1
View File
@@ -43,7 +43,12 @@ func cutSendLogLine(line string) string {
// wrote. The tailer calls it with what it read in one pass over the log,
// whether or not send-log rows exist for the queue ids yet: a commit per line
// would be a disk sync per line, on the one connection the journal milter
// writes through.
// writes through. SQLite 3.53.4 as modernc builds it keeps synchronous at FULL
// (SQLITE_DEFAULT_WAL_SYNCHRONOUS 2), and in WAL mode FULL syncs the WAL at
// every commit (https://sqlite.org/wal.html § 2.3, pragma.html#pragma_synchronous).
// The first statement stepped is an INSERT, a write (see UpdateStatus):
// tx.Prepare leaves no transaction open (prepare.c sqlite3InitOne() commits
// the read it opens to load the schema).
//
// With once set, a line is written only if the same line is not there already
// under its queue id. That is for the lines the tailer reads again after an
+6 -1
View File
@@ -30,7 +30,10 @@ func (s *Store) CreateSession(tokenHash string, userID int64, expiresAt time.Tim
// LookupSession returns the session for tokenHash, if any. It does not check
// expiry itself — callers compare ExpiresAt against time.Now() and call
// DeleteSession on an expired row, keeping the read side lock-free.
// DeleteSession on an expired row, keeping the read side lock-free: in WAL
// mode a reader takes no write lock and is not blocked by a writer
// (https://sqlite.org/wal.html § 2.2). Within this process it still waits for
// the one pooled connection (Open).
func (s *Store) LookupSession(tokenHash string) (SessionRow, bool, error) {
var (
row SessionRow
@@ -99,6 +102,8 @@ func (s *Store) DeleteUserSessions(userID int64) error {
// abandoned session (cookie never presented again) does not sit in the table
// forever. It piggybacks on session creation rather than running as its own
// background loop.
// expires_at is RFC3339 UTC text, so < compares moments (see
// ListQueuedOlderThan).
func (s *Store) DeleteExpiredSessions(now time.Time) (int64, error) {
res, err := s.db.Exec(`DELETE FROM sessions WHERE expires_at < ?`, now.UTC().Format(time.RFC3339))
if err != nil {
+2 -1
View File
@@ -156,7 +156,8 @@ func (s *Store) GetSetting(key string) (string, error) {
return value, nil
}
// SetSetting upserts a settings key.
// SetSetting upserts a settings key (https://sqlite.org/lang_upsert.html: on
// a conflict of the key, excluded.value is the value the INSERT brought).
func (s *Store) SetSetting(key, value string) error {
_, err := s.db.Exec(
"INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
+7
View File
@@ -85,6 +85,10 @@ const domainByName = "domain_id = (SELECT d.id FROM domains d WHERE d.name = ?)"
// login can be taken again in another domain, and its numbers - statistics,
// the level-2 count - must not start with the old one's (review of audit
// stage 2). ?1 twice; a plain ? after it takes the next number.
// SQLite 3.53.4, https://sqlite.org/lang_expr.html § 4: a plain ? is numbered
// one more than the largest number already assigned; modernc.org/sqlite
// v1.60.1, conn.go bind() and argFinder.find(): ?NNN takes argument NNN, a
// plain ? the argument of its number.
const appByLogin = "app_login = ?1 AND domain_id = (SELECT a.domain_id FROM applications a WHERE a.login = ?1)"
// sendStats counts the rows that match, a condition with one placeholder
@@ -102,6 +106,9 @@ func (s *Store) sendStats(match, ref string, since time.Time, hours float64) (Se
return SendStats{}, fmt.Errorf("send stats total %q: %w", ref, err)
}
// The busiest UTC hour: substr counts characters from 1
// (https://sqlite.org/lang_corefunc.html#substr), so 1-13 of an RFC3339
// UTC stamp is YYYY-MM-DDTHH.
var peak int64
err = s.db.QueryRow(
`SELECT COALESCE(MAX(bucket_count), 0) FROM (
+26 -2
View File
@@ -29,13 +29,23 @@ type Store struct {
func Open(path string) (*Store, error) {
// _pragma parameters are applied on every pooled connection by the driver,
// so foreign-key enforcement and WAL survive connection churn.
// modernc.org/sqlite v1.60.1, conn.go newConn() -> sqlite.go
// applyQueryParams(): the _pragma list runs on every new connection,
// busy_timeout first. SQLite 3.53.4: foreign_keys is per connection
// (https://sqlite.org/foreignkeys.html § 2), journal_mode=WAL stays in the
// file (https://sqlite.org/wal.html § 3.3).
dsn := fmt.Sprintf("file:%s?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=foreign_keys(ON)", path)
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("open database: %w", err)
}
// modernc's driver serializes writes anyway; a small pool avoids
// "database is locked" surprises under WAL.
// One connection: database/sql then runs this process's statements and
// transactions one after another, so none of them meets another's lock.
// The driver does not serialise across connections (modernc.org/sqlite
// v1.60.1, conn.go: the conn's mutex is its own); between processes - the
// dmarc-ingest command opens this file too - SQLite allows one write
// transaction at a time (https://sqlite.org/lang_transaction.html § 2.1)
// and the other waits out busy_timeout.
db.SetMaxOpenConns(1)
s := &Store{db: db}
@@ -83,6 +93,10 @@ func (s *Store) migrate() error {
// against today's files would skip the baseline and leave the panel running
// on a schema it does not know, so such a file is refused outright; nothing
// is migrated from 1.x (docs/schema-migrations.md).
// SQLite 3.53.4: the application id is the header's integer at offset 68
// (https://sqlite.org/fileformat2.html § 1.3.15); btree.c newDatabase()
// zeroes bytes 24-99 of a new file's header, so it reads 0 until set - and
// 1.x never set it.
if version > 0 {
var appID int64
if err := s.db.QueryRow("PRAGMA application_id").Scan(&appID); err != nil {
@@ -109,12 +123,22 @@ func (s *Store) migrate() error {
if err != nil {
return fmt.Errorf("begin migration %s: %w", name, err)
}
// One Exec runs the whole file: modernc.org/sqlite v1.60.1, stmt.go
// stmt.exec(), "FALLBACK PATH: Multi-statement script" prepares and
// steps each statement of the tail in turn. It runs with foreign_keys
// ON, which cannot be switched off inside a transaction
// (https://sqlite.org/pragma.html#pragma_foreign_keys): an ADD COLUMN
// with REFERENCES needs a NULL default (lang_altertable.html § 4), and
// a changed collation means a rebuilt table (§ 8).
if _, err := tx.Exec(string(sqlBytes)); err != nil {
tx.Rollback()
return fmt.Errorf("apply migration %s: %w", name, err)
}
// PRAGMA does not accept a bound parameter, and target is a trusted
// loop index, so formatting it in is safe.
// SQLite 3.53.4, pragma.c sqlite3Pragma(), PragTyp_HEADER_VALUE: the
// value is the literal's text (sqlite3Atoi), written by OP_SetCookie
// under a write transaction, so a rollback takes it back with the rest.
if _, err := tx.Exec(fmt.Sprintf("PRAGMA user_version = %d", target)); err != nil {
tx.Rollback()
return fmt.Errorf("bump schema version for %s: %w", name, err)
+18 -3
View File
@@ -83,6 +83,10 @@ func (s *Store) UserExists() (bool, error) {
// CreateGlobalUser inserts the first global user during setup. The check that
// no user exists yet and the insert are one statement, so two setup requests
// at once cannot both create one (audit 2026-10-12, M7).
// SQLite 3.53.4, https://sqlite.org/lang_transaction.html § 2.1: an INSERT
// starts a write transaction, one at a time, so its SELECT reads what is
// committed; an insert of no row leaves sqlite3_changes() at 0
// (https://sqlite.org/c3ref/changes.html).
func (s *Store) CreateGlobalUser(username, passwordHash string) error {
now := time.Now().UTC().Format(time.RFC3339)
res, err := s.db.Exec(
@@ -249,6 +253,7 @@ func (s *Store) AddUser(c UserChange) (int64, error) {
if c.Role == RoleDomain && c.Reach.Empty() {
return 0, ErrEmptyReach
}
// The first statement is a write (see UpdateStatus).
tx, err := s.db.Begin()
if err != nil {
return 0, fmt.Errorf("create user: %w", err)
@@ -302,7 +307,12 @@ func (s *Store) SaveUser(c UserChange) error {
}
defer tx.Rollback()
// A write first: it takes the write lock, so what is counted below is
// what is committed.
// what is committed. SQLite 3.53.4, https://sqlite.org/lang_transaction.html
// § 2.2: after a deferred BEGIN, a first statement that writes starts a
// write transaction, and § 2.1: there is one at a time. RowsAffected counts
// the row its WHERE found even when no value changes: update.c
// sqlite3Update() writes every such row through sqlite3CompleteInsertion(),
// which marks it counted (OPFLAG_NCHANGE).
// An empty hash keeps the stored one: the form read it when it was
// opened, and the user may have changed it on Account since.
res, err := tx.Exec(
@@ -403,7 +413,9 @@ func (s *Store) SetUserReach(userID int64, reach Reach) error {
// DeleteUser removes a panel user. ErrLastGlobal when deleting the only global
// user: the delete and the count are one transaction, so two administrators
// deleting each other at once cannot both succeed (audit 2026-10-12, M7).
// deleting each other at once cannot both succeed (audit 2026-10-12, M7). The
// DELETE comes first and so takes the write lock (see SaveUser); the rows its
// cascade removes are not in RowsAffected (https://sqlite.org/c3ref/changes.html).
func (s *Store) DeleteUser(id int64) error {
tx, err := s.db.Begin()
if err != nil {
@@ -517,7 +529,10 @@ func (s *Store) setUserReach(userID int64, reach Reach) error {
// writeUserReach writes a user's reach inside tx. With an All flag set the
// rows of that direction are dropped rather than stored: the flag already
// covers them, and stale rows would come back to life the day the flag is
// cleared.
// cleared. Its first statement is a write (see UpdateStatus). A domain deleted
// meanwhile fails its INSERT at once: SQLite 3.53.4's foreign keys are
// immediate unless declared deferred (https://sqlite.org/foreignkeys.html § 4.2),
// and the code is SQLITE_CONSTRAINT_FOREIGNKEY (isForeignKeyViolation).
func writeUserReach(tx *sql.Tx, userID int64, reach Reach) error {
if _, err := tx.Exec("UPDATE users SET all_domains = ?, all_inbound_domains = ? WHERE id = ?",
reach.AllDomains, reach.AllInbound, userID); err != nil {