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:
1 parent
f57a9884d1
commit
dc44f8250c
10 files changed
+222
-155
No files matched your search
@@ -36,7 +36,6 @@ RUN go get github.com/go-chi/chi/v5@v5.2.0 \
|
|||||||
# Copy application source & compiled frontend SPA assets for go:embed
|
# Copy application source & compiled frontend SPA assets for go:embed
|
||||||
COPY cmd/ ./cmd/
|
COPY cmd/ ./cmd/
|
||||||
COPY internal/ ./internal/
|
COPY internal/ ./internal/
|
||||||
COPY migrations/ ./migrations/
|
|
||||||
COPY --from=frontend-builder /app/frontend/dist ./cmd/server/dist
|
COPY --from=frontend-builder /app/frontend/dist ./cmd/server/dist
|
||||||
|
|
||||||
# Run go mod tidy, static analysis (go vet), unit tests (go test), and compile final binary
|
# Run go mod tidy, static analysis (go vet), unit tests (go test), and compile final binary
|
||||||
|
|||||||
+1
-1
@@ -81,7 +81,7 @@ teams 1─n team_members n─1 users
|
|||||||
- `users.username` wird aus dem E-Mail-Präfix aufgefüllt.
|
- `users.username` wird aus dem E-Mail-Präfix aufgefüllt.
|
||||||
- Notiz-Synchronisation (`migrateDocSync`): neue Spalten an `blocks` (inkl. `deleted_at`; vorhandene Grabsteine erhalten den Migrationszeitpunkt) und `documents` (`head_seq`, `title_version`, `log_floor`), Tabellen `doc_ops` und `doc_op_results`. Blöcke im alten flachen Format (nur `level`/`position`) werden einmalig in den Baum umgewandelt.
|
- Notiz-Synchronisation (`migrateDocSync`): neue Spalten an `blocks` (inkl. `deleted_at`; vorhandene Grabsteine erhalten den Migrationszeitpunkt) und `documents` (`head_seq`, `title_version`, `log_floor`), Tabellen `doc_ops` und `doc_op_results`. Blöcke im alten flachen Format (nur `level`/`position`) werden einmalig in den Baum umgewandelt.
|
||||||
- Standardwerte in `system_settings` (`INSERT OR IGNORE`): `sharing_mode=organizations`, `allow_registration=false`, `instance_name=MindSift`, SMTP leer.
|
- Standardwerte in `system_settings` (`INSERT OR IGNORE`): `sharing_mode=organizations`, `allow_registration=false`, `instance_name=MindSift`, SMTP leer.
|
||||||
- **Schema-Version:** `PRAGMA user_version` hält `schemaVersion` (derzeit 2), damit spätere Versionen erkennen, welche Migrationen eine Datenbank schon hat. Daten werden beim Start nie gelöscht (das einmalige Zurücksetzen auf Version 2 ist entfernt, alle Installationen sind darüber hinweg). Nur der Demo-Modus leert die Datenbank bei jedem Start.
|
- **Migrationen:** [migrations.go](../internal/storage/migrations.go) führt nummerierte Schritte genau einmal aus und vermerkt sie in `schema_migrations` (`id`, `name`, `applied_at`); `PRAGMA user_version` zeigt die höchste angewandte Nummer. Schritt 3 („Basis-Schema v0.4.1“, `migrateBaseline`) baut das komplette Schema auf und bringt ältere Datenbanken auf diesen Stand; Spalten werden nur angelegt, wenn sie fehlen. Schlägt ein Schritt fehl, bricht der Start mit der Fehlermeldung ab, statt mit halbem Schema weiterzulaufen. Neue Änderungen kommen als neuer Schritt ans Ende, alte Schritte werden nie verändert. Daten werden beim Start nie gelöscht; nur der Demo-Modus leert die Datenbank.
|
||||||
|
|
||||||
## Sicherung
|
## Sicherung
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -22,7 +22,7 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** (
|
|||||||
| FLAW-10 | Freigaben | ~~`sharing_mode` wird bei Freigaben nicht durchgesetzt; Benutzerverzeichnis leakt alle Konten~~ | **Behoben** |
|
| FLAW-10 | Freigaben | ~~`sharing_mode` wird bei Freigaben nicht durchgesetzt; Benutzerverzeichnis leakt alle Konten~~ | **Behoben** |
|
||||||
| FLAW-11 | Workspace | ~~Block-Upsert (`ON CONFLICT(id)`) prüft das Dokument nicht~~ | **Behoben** |
|
| FLAW-11 | Workspace | ~~Block-Upsert (`ON CONFLICT(id)`) prüft das Dokument nicht~~ | **Behoben** |
|
||||||
| FLAW-12 | Benachrichtigungen | ~~`notify_before_minutes` wirkungslos; Alarm feuert nach Umplanung nie wieder; endlose Wiederholung bei Fehlern~~ | **Behoben** |
|
| FLAW-12 | Benachrichtigungen | ~~`notify_before_minutes` wirkungslos; Alarm feuert nach Umplanung nie wieder; endlose Wiederholung bei Fehlern~~ | **Behoben** |
|
||||||
| FLAW-13 | Datenbank | Keine Schema-Versionierung; Migrationsfehler werden verschluckt; `migrations/001_init.sql` ist toter Code | Mittel |
|
| FLAW-13 | Datenbank | ~~Keine Schema-Versionierung; Migrationsfehler werden verschluckt; `migrations/001_init.sql` ist toter Code~~ | **Behoben** |
|
||||||
| FLAW-14 | Backend | Fehler von Schreiboperationen werden häufig ignoriert (`_, _ =`) | Mittel |
|
| FLAW-14 | Backend | Fehler von Schreiboperationen werden häufig ignoriert (`_, _ =`) | Mittel |
|
||||||
| FLAW-15 | Realtime | ~~`WriteTimeout` von 30 s beendet SSE-Streams~~ | **Behoben** |
|
| FLAW-15 | Realtime | ~~`WriteTimeout` von 30 s beendet SSE-Streams~~ | **Behoben** |
|
||||||
| FLAW-16 | Setup | `/auth/setup` ist bis zum ersten Konto offen; Demo-Modus legt `admin/admin` an | Mittel |
|
| FLAW-16 | Setup | `/auth/setup` ist bis zum ersten Konto offen; Demo-Modus legt `admin/admin` an | Mittel |
|
||||||
@@ -123,8 +123,8 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** (
|
|||||||
- **Maßnahme:** Fälligkeit mit Uhrzeit speichern, `alarm_sent` bei Änderung von `due_date` zurücksetzen, Wiederholungen begrenzen.
|
- **Maßnahme:** Fälligkeit mit Uhrzeit speichern, `alarm_sent` bei Änderung von `due_date` zurücksetzen, Wiederholungen begrenzen.
|
||||||
|
|
||||||
### FLAW-13: Migrationen
|
### FLAW-13: Migrationen
|
||||||
- **Teilweise behoben:** Das Schema hat eine Version (`PRAGMA user_version`, `schemaVersion` in `sqlite.go`), auf die künftige Migrationen aufsetzen können. Die übrigen Punkte unten gelten weiter.
|
- **Status: Behoben.** `schema_migrations` mit nummerierten, einmalig ausgeführten Schritten ([migrations.go](../internal/storage/migrations.go)); das bisherige Schema ist Schritt 3 (Baseline). Spalten werden nur angelegt, wenn sie fehlen, alle anderen Fehler brechen den Start ab; Bereinigungen laufen nicht mehr bei jedem Start. `migrations/001_init.sql` ist entfernt. Test: `TestMigrationsRunOnceAndRepairOldDatabases`.
|
||||||
- **Fundstellen:** `Store.migrate` in [`internal/storage/sqlite.go`](../internal/storage/sqlite.go), [`migrations/001_init.sql`](../migrations/001_init.sql).
|
- **Fundstellen (damals):** `Store.migrate` in `internal/storage/sqlite.go`, `migrations/001_init.sql`.
|
||||||
- **Probleme:**
|
- **Probleme:**
|
||||||
- Es gibt keine Versionstabelle. Alle `ALTER TABLE`-Anweisungen laufen bei jedem Start erneut; Fehler werden pauschal ignoriert (`_, _ = s.db.Exec(q)`), echte Fehler fallen nicht auf.
|
- Es gibt keine Versionstabelle. Alle `ALTER TABLE`-Anweisungen laufen bei jedem Start erneut; Fehler werden pauschal ignoriert (`_, _ = s.db.Exec(q)`), echte Fehler fallen nicht auf.
|
||||||
- Mehrere `UPDATE`/`DROP TABLE`-Bereinigungen laufen bei jedem Start.
|
- Mehrere `UPDATE`/`DROP TABLE`-Bereinigungen laufen bei jedem Start.
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ type docQueryer interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// migrateDocSync adds the columns and tables of the collaboration model and converts old flat block lists.
|
// migrateDocSync adds the columns and tables of the collaboration model and converts old flat block lists.
|
||||||
func (s *Store) migrateDocSync() {
|
func (s *Store) migrateDocSync() error {
|
||||||
stmts := []string{
|
stmts := []string{
|
||||||
"ALTER TABLE blocks ADD COLUMN parent_id TEXT NOT NULL DEFAULT '';",
|
"ALTER TABLE blocks ADD COLUMN parent_id TEXT NOT NULL DEFAULT '';",
|
||||||
"ALTER TABLE blocks ADD COLUMN order_key TEXT NOT NULL DEFAULT '';",
|
"ALTER TABLE blocks ADD COLUMN order_key TEXT NOT NULL DEFAULT '';",
|
||||||
@@ -110,15 +110,16 @@ func (s *Store) migrateDocSync() {
|
|||||||
"CREATE INDEX IF NOT EXISTS idx_doc_op_results_created ON doc_op_results(created_at);",
|
"CREATE INDEX IF NOT EXISTS idx_doc_op_results_created ON doc_op_results(created_at);",
|
||||||
}
|
}
|
||||||
for _, q := range stmts {
|
for _, q := range stmts {
|
||||||
if _, err := s.db.Exec(q); err != nil && !strings.Contains(err.Error(), "duplicate column") {
|
if err := s.execSchemaStatement(q); err != nil {
|
||||||
slog.Error("Migration der Notiz-Synchronisation fehlgeschlagen", "error", err, "statement", q)
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Tombstones from before deleted_at existed start their retention now
|
// Tombstones from before deleted_at existed start their retention now
|
||||||
if _, err := s.db.Exec(`UPDATE blocks SET deleted_at = ? WHERE deleted = 1 AND deleted_at = ''`, domain.NowISO()); err != nil {
|
if _, err := s.db.Exec(`UPDATE blocks SET deleted_at = ? WHERE deleted = 1 AND deleted_at = ''`, domain.NowISO()); err != nil {
|
||||||
slog.Error("Migration der Notiz-Synchronisation fehlgeschlagen", "error", err)
|
return err
|
||||||
}
|
}
|
||||||
s.backfillBlockTree()
|
s.backfillBlockTree()
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// backfillBlockTree converts blocks stored as a flat list (level + position) into parent_id + order_key.
|
// backfillBlockTree converts blocks stored as a flat list (level + position) into parent_id + order_key.
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/sandrodach/mindsift/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Schema migrations (FLAW-13). Every step runs exactly once per database and is recorded in
|
||||||
|
// schema_migrations; a failing step stops the start with its error instead of being ignored.
|
||||||
|
// New schema changes are appended as new steps (next free id), never edited into old ones.
|
||||||
|
// PRAGMA user_version mirrors the highest applied id. Ids 1 and 2 were used by earlier releases
|
||||||
|
// (database wipes on start), so the first step here is 3.
|
||||||
|
|
||||||
|
type migration struct {
|
||||||
|
id int
|
||||||
|
name string
|
||||||
|
run func(s *Store) error
|
||||||
|
}
|
||||||
|
|
||||||
|
var migrations = []migration{
|
||||||
|
{3, "Basis-Schema v0.4.1", (*Store).migrateBaseline},
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) migrate() error {
|
||||||
|
if _, err := s.db.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
applied_at TEXT NOT NULL
|
||||||
|
)`); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
applied := map[int]bool{}
|
||||||
|
rows, err := s.db.Query(`SELECT id FROM schema_migrations`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for rows.Next() {
|
||||||
|
var id int
|
||||||
|
if rows.Scan(&id) == nil {
|
||||||
|
applied[id] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = rows.Close()
|
||||||
|
|
||||||
|
last := 0
|
||||||
|
for _, m := range migrations {
|
||||||
|
last = m.id
|
||||||
|
if applied[m.id] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
slog.Info("Datenbank-Migration", "id", m.id, "name", m.name)
|
||||||
|
if err := m.run(s); err != nil {
|
||||||
|
return fmt.Errorf("Migration %d (%s): %w", m.id, m.name, err)
|
||||||
|
}
|
||||||
|
if _, err := s.db.Exec(`INSERT INTO schema_migrations (id, name, applied_at) VALUES (?, ?, ?)`,
|
||||||
|
m.id, m.name, domain.NowISO()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, err = s.db.Exec(fmt.Sprintf(`PRAGMA user_version = %d`, last))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var addColumnRe = regexp.MustCompile(`(?is)^\s*ALTER\s+TABLE\s+(\w+)\s+ADD\s+COLUMN\s+(\w+)\s+(.+?);?\s*$`)
|
||||||
|
|
||||||
|
// execSchemaStatement runs one schema statement; "ALTER TABLE … ADD COLUMN" only when the column is
|
||||||
|
// missing, so that a real error is never mistaken for "already there".
|
||||||
|
func (s *Store) execSchemaStatement(q string) error {
|
||||||
|
if m := addColumnRe.FindStringSubmatch(q); m != nil {
|
||||||
|
return s.addColumnIfMissing(m[1], m[2], m[3])
|
||||||
|
}
|
||||||
|
if _, err := s.db.Exec(q); err != nil {
|
||||||
|
return fmt.Errorf("%w (%s)", err, strings.TrimSpace(q))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) addColumnIfMissing(table, column, definition string) error {
|
||||||
|
exists, err := s.columnExists(table, column)
|
||||||
|
if err != nil || exists {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := s.db.Exec(fmt.Sprintf(`ALTER TABLE %s ADD COLUMN %s %s`, table, column, definition)); err != nil {
|
||||||
|
return fmt.Errorf("Spalte %s.%s anlegen: %w", table, column, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) columnExists(table, column string) (bool, error) {
|
||||||
|
rows, err := s.db.Query(fmt.Sprintf(`PRAGMA table_info(%s)`, table))
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var cid, notNull, pk int
|
||||||
|
var name, typ string
|
||||||
|
var dflt sql.NullString
|
||||||
|
if err := rows.Scan(&cid, &name, &typ, ¬Null, &dflt, &pk); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if strings.EqualFold(name, column) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, rows.Err()
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMigrationsRunOnceAndRepairOldDatabases(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "migrate.db")
|
||||||
|
store, err := NewStore(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var n int
|
||||||
|
_ = store.db.QueryRow(`SELECT COUNT(*) FROM schema_migrations`).Scan(&n)
|
||||||
|
if n != len(migrations) {
|
||||||
|
t.Fatalf("expected %d recorded migrations, got %d", len(migrations), n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A database from before the migration table, missing a later column
|
||||||
|
if _, err := store.db.Exec(`ALTER TABLE thoughts DROP COLUMN alarm_failures`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, _ = store.db.Exec(`DROP TABLE schema_migrations`)
|
||||||
|
_ = store.Close()
|
||||||
|
|
||||||
|
store, err = NewStore(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reopen: %v", err)
|
||||||
|
}
|
||||||
|
if ok, _ := store.columnExists("thoughts", "alarm_failures"); !ok {
|
||||||
|
t.Fatal("baseline must add missing columns to older databases")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A failing step stops the start with its error and is not recorded
|
||||||
|
saved := migrations
|
||||||
|
defer func() { migrations = saved }()
|
||||||
|
migrations = append(append([]migration{}, saved...), migration{999, "kaputt", func(*Store) error { return errors.New("boom") }})
|
||||||
|
_ = store.Close()
|
||||||
|
if _, err := NewStore(path); err == nil || !strings.Contains(err.Error(), "Migration 999") {
|
||||||
|
t.Fatalf("failing migration must abort the start, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
+50
-23
@@ -57,10 +57,6 @@ func (s *Store) Close() error {
|
|||||||
return s.db.Close()
|
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.
|
// dropAllTables removes every table; used by the demo mode reset.
|
||||||
func (s *Store) dropAllTables() error {
|
func (s *Store) dropAllTables() error {
|
||||||
conn, err := s.db.Conn(context.Background())
|
conn, err := s.db.Conn(context.Background())
|
||||||
@@ -94,7 +90,9 @@ func (s *Store) dropAllTables() error {
|
|||||||
return err
|
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 := `
|
schema := `
|
||||||
PRAGMA journal_mode = WAL;
|
PRAGMA journal_mode = WAL;
|
||||||
PRAGMA synchronous = NORMAL;
|
PRAGMA synchronous = NORMAL;
|
||||||
@@ -463,23 +461,41 @@ func (s *Store) migrate() error {
|
|||||||
"ALTER TABLE thoughts ADD COLUMN recurrence_spawned INTEGER NOT NULL DEFAULT 0;",
|
"ALTER TABLE thoughts ADD COLUMN recurrence_spawned INTEGER NOT NULL DEFAULT 0;",
|
||||||
}
|
}
|
||||||
for _, q := range columnMigrations {
|
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
|
// Clean up legacy space and old document block/crdt tables
|
||||||
_, _ = s.db.Exec(`DROP TABLE IF EXISTS spaces`)
|
if _, err := s.db.Exec(`DROP TABLE IF EXISTS spaces`); err != nil {
|
||||||
_, _ = s.db.Exec(`DROP TABLE IF EXISTS space_members`)
|
return err
|
||||||
_, _ = s.db.Exec(`DROP TABLE IF EXISTS space_team_shares`)
|
}
|
||||||
_, _ = s.db.Exec(`DROP TABLE IF EXISTS document_crdt_state`)
|
if _, err := s.db.Exec(`DROP TABLE IF EXISTS space_members`); err != nil {
|
||||||
_, _ = s.db.Exec(`DROP TABLE IF EXISTS document_crdt_updates`)
|
return err
|
||||||
_, _ = 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 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
|
// 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)
|
// 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'),
|
('sharing_mode', 'organizations'),
|
||||||
('allow_registration', 'false'),
|
('allow_registration', 'false'),
|
||||||
('instance_name', 'MindSift'),
|
('instance_name', 'MindSift'),
|
||||||
@@ -490,18 +506,30 @@ func (s *Store) migrate() error {
|
|||||||
('smtp_from', ''),
|
('smtp_from', ''),
|
||||||
('smtp_tls', 'true'),
|
('smtp_tls', 'true'),
|
||||||
('smtp_skip_verify', 'false'),
|
('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
|
// Clean up legacy roles in team_members and users
|
||||||
_, _ = s.db.Exec(`UPDATE team_members SET role = 'admin' WHERE role IN ('team_admin', 'owner')`)
|
if _, err := s.db.Exec(`UPDATE team_members SET role = 'admin' WHERE role IN ('team_admin', 'owner')`); err != nil {
|
||||||
_, _ = s.db.Exec(`UPDATE team_members SET role = 'member' WHERE role NOT IN ('admin', 'member')`)
|
return err
|
||||||
_, _ = s.db.Exec(`UPDATE users SET role = 'member' WHERE role = 'team_admin'`)
|
}
|
||||||
|
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
|
// 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
|
// 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
|
// Migrate workspace documents, blocks, folders, and strip legacy folder:* tags into pure DB tables
|
||||||
s.migrateWorkspaceToPureDB()
|
s.migrateWorkspaceToPureDB()
|
||||||
@@ -518,8 +546,7 @@ func (s *Store) migrate() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := s.db.Exec(fmt.Sprintf(`PRAGMA user_version = %d`, schemaVersion))
|
return nil
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// EnsureDefaultUser is retained strictly for unit test convenience
|
// EnsureDefaultUser is retained strictly for unit test convenience
|
||||||
|
|||||||
@@ -408,10 +408,10 @@ func TestFolderManagementAndDeletion(t *testing.T) {
|
|||||||
t.Fatalf("GetFolders failed: %v", err)
|
t.Fatalf("GetFolders failed: %v", err)
|
||||||
}
|
}
|
||||||
expected := map[string]bool{
|
expected := map[string]bool{
|
||||||
"Archiv": true,
|
"Archiv": true,
|
||||||
"Projekte": true,
|
"Projekte": true,
|
||||||
"Projekte/MindSift": true,
|
"Projekte/MindSift": true,
|
||||||
"Projekte/MindSift/Sub": true,
|
"Projekte/MindSift/Sub": true,
|
||||||
}
|
}
|
||||||
for _, f := range folders {
|
for _, f := range folders {
|
||||||
delete(expected, f)
|
delete(expected, f)
|
||||||
|
|||||||
@@ -165,8 +165,8 @@ func TestDataSurvivesRestart(t *testing.T) {
|
|||||||
}
|
}
|
||||||
var v int
|
var v int
|
||||||
_ = store.db.QueryRow(`PRAGMA user_version`).Scan(&v)
|
_ = store.db.QueryRow(`PRAGMA user_version`).Scan(&v)
|
||||||
if v != schemaVersion {
|
if want := migrations[len(migrations)-1].id; v != want {
|
||||||
t.Fatalf("user_version = %d, want %d", v, schemaVersion)
|
t.Fatalf("user_version = %d, want %d", v, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,117 +0,0 @@
|
|||||||
-- MindSift Initial Database Schema (SQLite WAL / PostgreSQL Compatible)
|
|
||||||
PRAGMA journal_mode = WAL;
|
|
||||||
PRAGMA synchronous = NORMAL;
|
|
||||||
PRAGMA foreign_keys = ON;
|
|
||||||
PRAGMA busy_timeout = 5000;
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
email TEXT UNIQUE NOT NULL,
|
|
||||||
password_hash TEXT NOT NULL,
|
|
||||||
display_name TEXT NOT NULL,
|
|
||||||
created_at TEXT NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS thoughts (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
content TEXT NOT NULL,
|
|
||||||
tags TEXT NOT NULL DEFAULT '[]',
|
|
||||||
priority TEXT NOT NULL DEFAULT 'normal' CHECK(priority IN ('low', 'normal', 'high', 'urgent')),
|
|
||||||
source TEXT NOT NULL DEFAULT 'manual',
|
|
||||||
status TEXT NOT NULL DEFAULT 'inbox' CHECK(status IN ('inbox', 'kept', 'discarded', 'converted')),
|
|
||||||
is_completed INTEGER NOT NULL DEFAULT 0,
|
|
||||||
converted_type TEXT DEFAULT '',
|
|
||||||
converted_ref_id TEXT DEFAULT '',
|
|
||||||
due_date TEXT DEFAULT '',
|
|
||||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
triaged_at TEXT DEFAULT '',
|
|
||||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_thoughts_user_status ON thoughts(user_id, status, created_at DESC);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_thoughts_user_due ON thoughts(user_id, due_date);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_thoughts_list_sort ON thoughts(task_list_id, sort_order ASC, created_at DESC);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS spaces (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
icon TEXT NOT NULL DEFAULT '🏠',
|
|
||||||
description TEXT NOT NULL DEFAULT '',
|
|
||||||
invite_code TEXT UNIQUE NOT NULL,
|
|
||||||
is_personal INTEGER NOT NULL DEFAULT 0,
|
|
||||||
owner_id TEXT NOT NULL,
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
FOREIGN KEY(owner_id) REFERENCES users(id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS space_members (
|
|
||||||
space_id TEXT NOT NULL,
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
role TEXT NOT NULL DEFAULT 'editor' CHECK(role IN ('owner', 'editor', 'viewer')),
|
|
||||||
joined_at TEXT NOT NULL,
|
|
||||||
PRIMARY KEY (space_id, user_id),
|
|
||||||
FOREIGN KEY(space_id) REFERENCES spaces(id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS documents (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
space_id TEXT DEFAULT '',
|
|
||||||
title TEXT NOT NULL,
|
|
||||||
icon TEXT NOT NULL DEFAULT '📄',
|
|
||||||
content TEXT NOT NULL DEFAULT '',
|
|
||||||
blocks_json TEXT NOT NULL DEFAULT '[]',
|
|
||||||
tags TEXT NOT NULL DEFAULT '[]',
|
|
||||||
is_pinned INTEGER NOT NULL DEFAULT 0,
|
|
||||||
is_archived INTEGER NOT NULL DEFAULT 0,
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
updated_at TEXT NOT NULL,
|
|
||||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_documents_user ON documents(user_id, is_archived, updated_at DESC);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS lists (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
space_id TEXT NOT NULL,
|
|
||||||
title TEXT NOT NULL,
|
|
||||||
icon TEXT NOT NULL DEFAULT '🛒',
|
|
||||||
list_type TEXT NOT NULL DEFAULT 'shopping' CHECK(list_type IN ('shopping', 'checklist', 'vacation')),
|
|
||||||
updated_at TEXT NOT NULL,
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
FOREIGN KEY(space_id) REFERENCES spaces(id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_lists_space ON lists(space_id, updated_at DESC);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS list_items (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
list_id TEXT NOT NULL,
|
|
||||||
content TEXT NOT NULL,
|
|
||||||
category TEXT NOT NULL DEFAULT '',
|
|
||||||
quantity TEXT NOT NULL DEFAULT '',
|
|
||||||
is_completed INTEGER NOT NULL DEFAULT 0,
|
|
||||||
completed_by TEXT NOT NULL DEFAULT '',
|
|
||||||
completed_by_name TEXT NOT NULL DEFAULT '',
|
|
||||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
completed_at TEXT NOT NULL DEFAULT '',
|
|
||||||
FOREIGN KEY(list_id) REFERENCES lists(id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_list_items_sync ON list_items(list_id, is_completed, sort_order ASC);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS api_keys (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
key_prefix TEXT NOT NULL,
|
|
||||||
key_hash TEXT UNIQUE NOT NULL,
|
|
||||||
scopes TEXT NOT NULL DEFAULT 'ingest,read',
|
|
||||||
last_used_at TEXT NOT NULL DEFAULT '',
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
Reference in new issue
Block a user