fix(db): numbered migrations that run once and fail loudly (FLAW-13)

schema_migrations records every applied step; the former migrate() is
step 3, the baseline that builds the v0.4.1 schema and repairs older
databases. Columns are only added when missing, so every other error now
stops the start with a clear message instead of being swallowed, and the
cleanup statements no longer run on each start. migrations/001_init.sql
was never read and is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-29 14:49:35 +01:00
1 parent f57a9884d1
commit dc44f8250c
10 files changed
+222 -155

No files matched your search

+50 -23
View File
@@ -57,10 +57,6 @@ func (s *Store) Close() error {
return s.db.Close()
}
// schemaVersion is stored in PRAGMA user_version so later releases can tell which migrations a database
// already has. (Version 2 once wiped older databases on start; all installations are past that now.)
const schemaVersion = 2
// dropAllTables removes every table; used by the demo mode reset.
func (s *Store) dropAllTables() error {
conn, err := s.db.Conn(context.Background())
@@ -94,7 +90,9 @@ func (s *Store) dropAllTables() error {
return err
}
func (s *Store) migrate() error {
// migrateBaseline is migration 3: the complete schema as of v0.4.1, built so that it also brings any
// older database up to that state. It runs once per database (see migrations.go).
func (s *Store) migrateBaseline() error {
schema := `
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
@@ -463,23 +461,41 @@ func (s *Store) migrate() error {
"ALTER TABLE thoughts ADD COLUMN recurrence_spawned INTEGER NOT NULL DEFAULT 0;",
}
for _, q := range columnMigrations {
_, _ = s.db.Exec(q)
if err := s.execSchemaStatement(q); err != nil {
return err
}
}
// Clean up legacy space and old document block/crdt tables
_, _ = s.db.Exec(`DROP TABLE IF EXISTS spaces`)
_, _ = s.db.Exec(`DROP TABLE IF EXISTS space_members`)
_, _ = s.db.Exec(`DROP TABLE IF EXISTS space_team_shares`)
_, _ = s.db.Exec(`DROP TABLE IF EXISTS document_crdt_state`)
_, _ = s.db.Exec(`DROP TABLE IF EXISTS document_crdt_updates`)
_, _ = s.db.Exec(`DROP TABLE IF EXISTS document_blocks`)
_, _ = s.db.Exec(`DROP TABLE IF EXISTS doc_block_tags`)
if _, err := s.db.Exec(`DROP TABLE IF EXISTS spaces`); err != nil {
return err
}
if _, err := s.db.Exec(`DROP TABLE IF EXISTS space_members`); err != nil {
return err
}
if _, err := s.db.Exec(`DROP TABLE IF EXISTS space_team_shares`); err != nil {
return err
}
if _, err := s.db.Exec(`DROP TABLE IF EXISTS document_crdt_state`); err != nil {
return err
}
if _, err := s.db.Exec(`DROP TABLE IF EXISTS document_crdt_updates`); err != nil {
return err
}
if _, err := s.db.Exec(`DROP TABLE IF EXISTS document_blocks`); err != nil {
return err
}
if _, err := s.db.Exec(`DROP TABLE IF EXISTS doc_block_tags`); err != nil {
return err
}
// Backfill username for existing accounts where username is empty
_, _ = s.db.Exec(`UPDATE users SET username = LOWER(SUBSTR(email, 1, INSTR(email, '@') - 1)) WHERE (username IS NULL OR username = '') AND INSTR(email, '@') > 0`)
if _, err := s.db.Exec(`UPDATE users SET username = LOWER(SUBSTR(email, 1, INSTR(email, '@') - 1)) WHERE (username IS NULL OR username = '') AND INSTR(email, '@') > 0`); err != nil {
return err
}
// Default system settings (public registration disabled by default; invite or admin creation required)
_, _ = s.db.Exec(`INSERT OR IGNORE INTO system_settings (key, value) VALUES
if _, err := s.db.Exec(`INSERT OR IGNORE INTO system_settings (key, value) VALUES
('sharing_mode', 'organizations'),
('allow_registration', 'false'),
('instance_name', 'MindSift'),
@@ -490,18 +506,30 @@ func (s *Store) migrate() error {
('smtp_from', ''),
('smtp_tls', 'true'),
('smtp_skip_verify', 'false'),
('allow_private_webhooks', 'false')`)
('allow_private_webhooks', 'false')`); err != nil {
return err
}
// Clean up legacy roles in team_members and users
_, _ = s.db.Exec(`UPDATE team_members SET role = 'admin' WHERE role IN ('team_admin', 'owner')`)
_, _ = s.db.Exec(`UPDATE team_members SET role = 'member' WHERE role NOT IN ('admin', 'member')`)
_, _ = s.db.Exec(`UPDATE users SET role = 'member' WHERE role = 'team_admin'`)
if _, err := s.db.Exec(`UPDATE team_members SET role = 'admin' WHERE role IN ('team_admin', 'owner')`); err != nil {
return err
}
if _, err := s.db.Exec(`UPDATE team_members SET role = 'member' WHERE role NOT IN ('admin', 'member')`); err != nil {
return err
}
if _, err := s.db.Exec(`UPDATE users SET role = 'member' WHERE role = 'team_admin'`); err != nil {
return err
}
// Keys from before scopes existed ("ingest,read") may deliver into the inbox, as before
_, _ = s.db.Exec(`UPDATE api_keys SET scopes = 'webhook:triage' WHERE scopes NOT LIKE '%webhook:%'`)
if _, err := s.db.Exec(`UPDATE api_keys SET scopes = 'webhook:triage' WHERE scopes NOT LIKE '%webhook:%'`); err != nil {
return err
}
// Block tree, operation log and idempotency tables for collaborative editing
s.migrateDocSync()
if err := s.migrateDocSync(); err != nil {
return err
}
// Migrate workspace documents, blocks, folders, and strip legacy folder:* tags into pure DB tables
s.migrateWorkspaceToPureDB()
@@ -518,8 +546,7 @@ func (s *Store) migrate() error {
return err
}
_, err := s.db.Exec(fmt.Sprintf(`PRAGMA user_version = %d`, schemaVersion))
return err
return nil
}
// EnsureDefaultUser is retained strictly for unit test convenience