From 40e2cad346cbd5ed4090055a708898bd99bb3bd3 Mon Sep 17 00:00:00 2001 From: Kristian Wind Date: Sun, 16 Aug 2026 22:49:18 +0200 Subject: [PATCH] feat(store): SQLite schema, migrations and Danish-aware full-text search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Initial data model for verdande: users, sessions, invites, projects, sections, tasks, labels, reminders, comments, attachments, activity and filters, plus personal API tokens. Search indexes a generated `fold` column alongside the original text. FTS5's remove_diacritics does not touch ø and æ — Unicode treats them as letters rather than accented forms — so without it a Danish user cannot find "grøn" by typing "gron". MatchExpr quotes every term so free text containing FTS5 operators cannot reach the query planner as syntax. Co-Authored-By: Claude Opus 5 --- .gitignore | 25 ++ go.mod | 17 ++ go.sum | 50 ++++ internal/config/config.go | 142 ++++++++++ internal/store/migrations/0001_init.sql | 305 ++++++++++++++++++++ internal/store/search.go | 79 ++++++ internal/store/store.go | 153 ++++++++++ internal/store/store_test.go | 359 ++++++++++++++++++++++++ 8 files changed, 1130 insertions(+) create mode 100644 .gitignore create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/config/config.go create mode 100644 internal/store/migrations/0001_init.sql create mode 100644 internal/store/search.go create mode 100644 internal/store/store.go create mode 100644 internal/store/store_test.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dcda4bd --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# Build output +/verdande +/dist/ +*.exe + +# The data volume: database, uploaded files, backups. Never in git. +/data/ +*.db +*.db-wal +*.db-shm + +# Frontend +node_modules/ +/web/.svelte-kit/ +/web/build/ + +# Local environment +.env +.env.local + +# Editor / OS +.DS_Store +*.swp +.idea/ +.vscode/ diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a23fca0 --- /dev/null +++ b/go.mod @@ -0,0 +1,17 @@ +module github.com/kristianwind/verdande + +go 1.26.3 + +require modernc.org/sqlite v1.56.0 + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.47.0 // indirect + modernc.org/libc v1.74.4 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..1932692 --- /dev/null +++ b/go.sum @@ -0,0 +1,50 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI= +modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU= +modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI= +modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k= +modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.56.0 h1:/D8e2RfFqoy/Zc6PuC76U28zFwmI/sYx1Kjm4yEn9e0= +modernc.org/sqlite v1.56.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..66d05c8 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,142 @@ +// Package config loads verdande's settings from the environment. +// +// Everything is env-driven because verdande ships as a Rune: the panel hands the +// container an environment and a /data volume, and there is nowhere to put a config +// file that the operator would ever see. +package config + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "time" +) + +type Config struct { + // BaseURL is the address verdande answers on from outside. Invite links, + // password resets, ICS feeds and OAuth redirects are all built from it, so it + // has to be the public address, not the listen address. + BaseURL string + Addr string + DataDir string + + SessionTTL time.Duration + InviteTTL time.Duration + ResetTTL time.Duration + + SMTP SMTP + + // TrashRetention is how long a soft-deleted task or project stays recoverable. + TrashRetention time.Duration + + Dev bool +} + +type SMTP struct { + Host string + Port int + Username string + Password string + From string + // StartTLS is the common case for a self-hosted Mailcow on 587. Port 465 is + // implicit TLS instead and sets this false. + StartTLS bool + Insecure bool +} + +// Configured reports whether outbound mail can be sent. Without a host verdande +// still runs — it just surfaces invite links in the UI instead of mailing them. +func (s SMTP) Configured() bool { return s.Host != "" } + +func Load() (*Config, error) { + c := &Config{ + BaseURL: env("VERDANDE_BASE_URL", "http://localhost:8080"), + Addr: env("VERDANDE_ADDR", ":8080"), + DataDir: env("VERDANDE_DATA_DIR", "/data"), + TrashRetention: 30 * 24 * time.Hour, + Dev: envBool("VERDANDE_DEV", false), + } + + var err error + if c.SessionTTL, err = envDuration("VERDANDE_SESSION_TTL", 30*24*time.Hour); err != nil { + return nil, err + } + if c.InviteTTL, err = envDuration("VERDANDE_INVITE_TTL", 7*24*time.Hour); err != nil { + return nil, err + } + if c.ResetTTL, err = envDuration("VERDANDE_RESET_TTL", time.Hour); err != nil { + return nil, err + } + + port, err := envInt("VERDANDE_SMTP_PORT", 587) + if err != nil { + return nil, err + } + c.SMTP = SMTP{ + Host: env("VERDANDE_SMTP_HOST", ""), + Port: port, + Username: env("VERDANDE_SMTP_USER", ""), + Password: env("VERDANDE_SMTP_PASS", ""), + From: env("VERDANDE_SMTP_FROM", "verdande@localhost"), + // Port 465 is implicit TLS from the first byte; everything else negotiates + // with STARTTLS. An operator can still override the guess. + StartTLS: envBool("VERDANDE_SMTP_STARTTLS", port != 465), + Insecure: envBool("VERDANDE_SMTP_INSECURE", false), + } + + c.BaseURL = strings.TrimRight(c.BaseURL, "/") + if c.DataDir, err = filepath.Abs(c.DataDir); err != nil { + return nil, fmt.Errorf("VERDANDE_DATA_DIR: %w", err) + } + return c, nil +} + +// DBPath, FilesDir and BackupsDir are the three things that live on the /data volume. +func (c *Config) DBPath() string { return filepath.Join(c.DataDir, "verdande.db") } +func (c *Config) FilesDir() string { return filepath.Join(c.DataDir, "files") } +func (c *Config) BackupsDir() string { return filepath.Join(c.DataDir, "backups") } + +func env(key, def string) string { + if v, ok := os.LookupEnv(key); ok && v != "" { + return v + } + return def +} + +func envInt(key string, def int) (int, error) { + v, ok := os.LookupEnv(key) + if !ok || v == "" { + return def, nil + } + n, err := strconv.Atoi(v) + if err != nil { + return 0, fmt.Errorf("%s: %q is not a number", key, v) + } + return n, nil +} + +func envBool(key string, def bool) bool { + v, ok := os.LookupEnv(key) + if !ok || v == "" { + return def + } + b, err := strconv.ParseBool(v) + if err != nil { + return def + } + return b +} + +func envDuration(key string, def time.Duration) (time.Duration, error) { + v, ok := os.LookupEnv(key) + if !ok || v == "" { + return def, nil + } + d, err := time.ParseDuration(v) + if err != nil { + return 0, fmt.Errorf("%s: %q is not a duration (try 720h)", key, v) + } + return d, nil +} diff --git a/internal/store/migrations/0001_init.sql b/internal/store/migrations/0001_init.sql new file mode 100644 index 0000000..1f34d14 --- /dev/null +++ b/internal/store/migrations/0001_init.sql @@ -0,0 +1,305 @@ +-- verdande — initial schema. +-- +-- Conventions used throughout: +-- * ids are UUIDv7 stored as TEXT: time-sortable, so they index well and give a +-- stable creation order without a second column. +-- * timestamps are INTEGER unix seconds, UTC. The one exception is tasks.due_date, +-- which is a calendar date ('YYYY-MM-DD') and deliberately has no timezone — +-- "due Tuesday" means Tuesday wherever you happen to be standing. +-- * sort_order is REAL, not INTEGER, so drag-and-drop can insert between two +-- neighbours by averaging them instead of renumbering the whole list. +-- * soft delete is `deleted_at`; the 30-day trash sweeper is what makes it final. + +CREATE TABLE users ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL, + name TEXT NOT NULL, + password_hash TEXT NOT NULL, + totp_secret TEXT, + totp_enabled INTEGER NOT NULL DEFAULT 0, + avatar_color TEXT NOT NULL DEFAULT '#8a8f98', + timezone TEXT NOT NULL DEFAULT 'Europe/Copenhagen', + locale TEXT NOT NULL DEFAULT 'da', + is_admin INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +-- Email identifies a person for login and invites, so it is unique case-insensitively. +-- Addresses are also stored already lowercased; this index is the backstop. +CREATE UNIQUE INDEX idx_users_email ON users (lower(email)); + +CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE, + ua TEXT NOT NULL DEFAULT '', + ip TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL, + last_seen_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL +); + +CREATE INDEX idx_sessions_user ON sessions (user_id); +CREATE INDEX idx_sessions_expires ON sessions (expires_at); + +-- Invites carry a hashed token, never the token itself: the plaintext lives only in +-- the emailed link. A leaked database therefore cannot be used to accept invites. +CREATE TABLE invites ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL, + project_id TEXT REFERENCES projects (id) ON DELETE CASCADE, + role TEXT NOT NULL DEFAULT 'editor', + token_hash TEXT NOT NULL UNIQUE, + created_by TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + accepted_at INTEGER, + CHECK (role IN ('owner', 'editor', 'viewer')) +); + +CREATE INDEX idx_invites_email ON invites (lower(email)); + +-- Single-use tokens for "forgot my password". Same hashed-token rule as invites. +CREATE TABLE password_resets ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + used_at INTEGER +); + +CREATE TABLE projects ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + color TEXT NOT NULL DEFAULT 'graphite', + icon TEXT, + view_mode TEXT NOT NULL DEFAULT 'list', + owner_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE, + is_inbox INTEGER NOT NULL DEFAULT 0, + archived INTEGER NOT NULL DEFAULT 0, + sort_order REAL NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + deleted_at INTEGER, + CHECK (view_mode IN ('list', 'board', 'calendar')) +); + +CREATE INDEX idx_projects_owner ON projects (owner_id) WHERE deleted_at IS NULL; + +-- Exactly one Inbox per user, enforced here rather than in application code. +CREATE UNIQUE INDEX idx_projects_one_inbox ON projects (owner_id) WHERE is_inbox = 1; + +CREATE TABLE project_members ( + project_id TEXT NOT NULL REFERENCES projects (id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE, + role TEXT NOT NULL, + added_at INTEGER NOT NULL, + PRIMARY KEY (project_id, user_id), + CHECK (role IN ('owner', 'editor', 'viewer')) +); + +CREATE INDEX idx_project_members_user ON project_members (user_id); + +CREATE TABLE sections ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL REFERENCES projects (id) ON DELETE CASCADE, + name TEXT NOT NULL, + sort_order REAL NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + deleted_at INTEGER +); + +CREATE INDEX idx_sections_project ON sections (project_id) WHERE deleted_at IS NULL; + +CREATE TABLE tasks ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL REFERENCES projects (id) ON DELETE CASCADE, + section_id TEXT REFERENCES sections (id) ON DELETE SET NULL, + parent_id TEXT REFERENCES tasks (id) ON DELETE CASCADE, + content TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + priority INTEGER NOT NULL DEFAULT 4, + -- due_date is the calendar day ('YYYY-MM-DD'); due_datetime pins it to a moment + -- when the task has a clock time. A timed task sets both, so day-based queries + -- never have to do timezone maths. + due_date TEXT, + due_datetime INTEGER, + due_timezone TEXT, + duration_min INTEGER, + recurrence_rule TEXT, + assignee_id TEXT REFERENCES users (id) ON DELETE SET NULL, + created_by TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE, + completed_at INTEGER, + completed_by TEXT REFERENCES users (id) ON DELETE SET NULL, + sort_order REAL NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + deleted_at INTEGER, + -- A Danish transliteration of the searchable text, kept beside the original so + -- full-text search can match either spelling. See tasks_fts below for why. + fold TEXT GENERATED ALWAYS AS ( + replace(replace(replace(replace(replace(replace( + content || ' ' || description, + 'ø', 'o'), 'Ø', 'o'), + 'æ', 'ae'), 'Æ', 'ae'), + 'å', 'aa'), 'Å', 'aa') + ) STORED, + CHECK (priority BETWEEN 1 AND 4) +); + +-- The three access patterns that carry the app: a project's open tasks, the Today +-- and Upcoming views, and "what is assigned to me". +CREATE INDEX idx_tasks_project ON tasks (project_id, sort_order) + WHERE deleted_at IS NULL AND completed_at IS NULL; +CREATE INDEX idx_tasks_due ON tasks (due_date) + WHERE deleted_at IS NULL AND completed_at IS NULL AND due_date IS NOT NULL; +CREATE INDEX idx_tasks_assignee ON tasks (assignee_id) + WHERE deleted_at IS NULL AND completed_at IS NULL AND assignee_id IS NOT NULL; +CREATE INDEX idx_tasks_parent ON tasks (parent_id) WHERE parent_id IS NOT NULL; + +CREATE TABLE labels ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE, + name TEXT NOT NULL, + color TEXT NOT NULL DEFAULT 'graphite', + sort_order REAL NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL +); + +CREATE UNIQUE INDEX idx_labels_user_name ON labels (user_id, lower(name)); + +CREATE TABLE task_labels ( + task_id TEXT NOT NULL REFERENCES tasks (id) ON DELETE CASCADE, + label_id TEXT NOT NULL REFERENCES labels (id) ON DELETE CASCADE, + PRIMARY KEY (task_id, label_id) +); + +CREATE INDEX idx_task_labels_label ON task_labels (label_id); + +-- A reminder is either absolute (remind_at) or relative to the task's due time +-- (offset_min, negative = before). Exactly one of the two. +CREATE TABLE reminders ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL REFERENCES tasks (id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE, + remind_at INTEGER, + offset_min INTEGER, + sent_at INTEGER, + created_at INTEGER NOT NULL, + CHECK ((remind_at IS NULL) <> (offset_min IS NULL)) +); + +CREATE INDEX idx_reminders_pending ON reminders (remind_at) WHERE sent_at IS NULL; + +CREATE TABLE comments ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL REFERENCES tasks (id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE, + body TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + deleted_at INTEGER +); + +CREATE INDEX idx_comments_task ON comments (task_id) WHERE deleted_at IS NULL; + +-- An attachment hangs on a task or on a comment, never both and never neither. +CREATE TABLE attachments ( + id TEXT PRIMARY KEY, + task_id TEXT REFERENCES tasks (id) ON DELETE CASCADE, + comment_id TEXT REFERENCES comments (id) ON DELETE CASCADE, + filename TEXT NOT NULL, + mime_type TEXT NOT NULL DEFAULT 'application/octet-stream', + size INTEGER NOT NULL, + path TEXT NOT NULL, + uploaded_by TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE, + created_at INTEGER NOT NULL, + CHECK ((task_id IS NULL) <> (comment_id IS NULL)) +); + +CREATE INDEX idx_attachments_task ON attachments (task_id) WHERE task_id IS NOT NULL; +CREATE INDEX idx_attachments_comment ON attachments (comment_id) WHERE comment_id IS NOT NULL; + +CREATE TABLE activity ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL REFERENCES projects (id) ON DELETE CASCADE, + task_id TEXT REFERENCES tasks (id) ON DELETE SET NULL, + user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE, + event TEXT NOT NULL, + payload_json TEXT NOT NULL DEFAULT '{}', + created_at INTEGER NOT NULL +); + +CREATE INDEX idx_activity_project ON activity (project_id, created_at DESC); +CREATE INDEX idx_activity_task ON activity (task_id) WHERE task_id IS NOT NULL; + +CREATE TABLE filters ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE, + name TEXT NOT NULL, + query TEXT NOT NULL, + color TEXT NOT NULL DEFAULT 'graphite', + sort_order REAL NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE INDEX idx_filters_user ON filters (user_id); + +-- Personal API tokens for scripts and integrations. Hashed, like every other token. +CREATE TABLE api_tokens ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE, + name TEXT NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + prefix TEXT NOT NULL, + created_at INTEGER NOT NULL, + last_used_at INTEGER, + expires_at INTEGER +); + +CREATE INDEX idx_api_tokens_user ON api_tokens (user_id); + +-- Full-text search over tasks. External-content FTS5: the index stores no copy of +-- the text, it points back into `tasks` by rowid, and the triggers below keep the +-- two in step. `UNINDEXED` columns are carried for filtering without being tokenized. +-- +-- Three indexed columns, not two. FTS5's `remove_diacritics` strips combining marks, +-- and in Unicode "ø" and "æ" are not marked letters — they are letters in their own +-- right. Diacritic folding alone therefore leaves a Danish user unable to find +-- "grøn" by typing "gron". The generated `fold` column on `tasks` carries a +-- transliterated copy, and searching all three columns covers every spelling: +-- +-- "grøn" → content hit "gron" → fold hit +-- "århus" → content hit "aarhus" → fold hit +-- "arhus" → content hit, because remove_diacritics folds the indexed å to a +-- +CREATE VIRTUAL TABLE tasks_fts USING fts5 ( + content, + description, + fold, + id UNINDEXED, + project_id UNINDEXED, + content = 'tasks', + content_rowid = 'rowid', + tokenize = 'unicode61 remove_diacritics 2' +); + +CREATE TRIGGER tasks_fts_insert AFTER INSERT ON tasks BEGIN + INSERT INTO tasks_fts (rowid, content, description, fold, id, project_id) + VALUES (new.rowid, new.content, new.description, new.fold, new.id, new.project_id); +END; + +CREATE TRIGGER tasks_fts_delete AFTER DELETE ON tasks BEGIN + INSERT INTO tasks_fts (tasks_fts, rowid, content, description, fold, id, project_id) + VALUES ('delete', old.rowid, old.content, old.description, old.fold, old.id, old.project_id); +END; + +CREATE TRIGGER tasks_fts_update AFTER UPDATE ON tasks BEGIN + INSERT INTO tasks_fts (tasks_fts, rowid, content, description, fold, id, project_id) + VALUES ('delete', old.rowid, old.content, old.description, old.fold, old.id, old.project_id); + INSERT INTO tasks_fts (rowid, content, description, fold, id, project_id) + VALUES (new.rowid, new.content, new.description, new.fold, new.id, new.project_id); +END; diff --git a/internal/store/search.go b/internal/store/search.go new file mode 100644 index 0000000..e4bedb3 --- /dev/null +++ b/internal/store/search.go @@ -0,0 +1,79 @@ +package store + +import ( + "strings" + "unicode" +) + +// FoldDanish transliterates the Danish letters that Unicode does not treat as +// accented forms of anything, so they survive into a form FTS5 can fold further. +// +// This has to be applied to the query with exactly the same mapping the `fold` +// column on `tasks` uses. The two are a pair: change one and searching quietly +// stops finding half the words in the user's own language. +func FoldDanish(s string) string { + r := strings.NewReplacer( + "ø", "o", "Ø", "o", + "æ", "ae", "Æ", "ae", + "å", "aa", "Å", "aa", + ) + return r.Replace(s) +} + +// MatchExpr turns what someone typed into the search box into an FTS5 MATCH +// expression. +// +// User input is never passed to FTS5 as-is. The MATCH grammar has its own operators +// (`AND`, `NEAR`, `*`, `^`, `:`, `-`), so a perfectly ordinary task title like +// "budget: q3 - final" parses as a query and fails with a syntax error rather than +// finding anything. Every term is quoted, which makes it a literal. +// +// Each term must match somewhere in the row, and may match either the text as +// written or its folded form — so "gron" finds "grøn" and "grøn" still finds +// itself. Terms are prefix-matched, which is what makes the Cmd+K box feel like it +// is searching as you type. +// +// Returns "" when there is nothing searchable in the input; callers should treat +// that as "no query" rather than as "match everything". +func MatchExpr(query string) string { + terms := tokenize(query) + if len(terms) == 0 { + return "" + } + + var b strings.Builder + for i, term := range terms { + if i > 0 { + b.WriteString(" AND ") + } + folded := FoldDanish(term) + b.WriteString(`({content description} : `) + b.WriteString(quote(term)) + b.WriteString(`* OR fold : `) + b.WriteString(quote(folded)) + b.WriteString(`*)`) + } + return b.String() +} + +// tokenize splits on everything that is not a letter or a digit. Splitting on +// punctuation rather than whitespace alone means "q3/2026" searches for "q3" and +// "2026" instead of for a term FTS5's own tokenizer would never have indexed. +func tokenize(query string) []string { + fields := strings.FieldsFunc(query, func(r rune) bool { + return !unicode.IsLetter(r) && !unicode.IsDigit(r) + }) + terms := make([]string, 0, len(fields)) + for _, f := range fields { + if f != "" { + terms = append(terms, f) + } + } + return terms +} + +// quote wraps a term as an FTS5 string literal. Inside one, the only character +// with meaning is the double quote, escaped by doubling it. +func quote(term string) string { + return `"` + strings.ReplaceAll(term, `"`, `""`) + `"` +} diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..e4cea81 --- /dev/null +++ b/internal/store/store.go @@ -0,0 +1,153 @@ +// Package store owns the SQLite database: connection settings, schema migrations, +// and the query helpers the rest of verdande builds on. +package store + +import ( + "context" + "database/sql" + "embed" + "fmt" + "io/fs" + "path/filepath" + "sort" + "strings" + + _ "modernc.org/sqlite" // pure-Go driver: no cgo, so the binary stays static +) + +//go:embed migrations/*.sql +var migrationFS embed.FS + +type DB struct { + *sql.DB + path string +} + +// Open prepares the database at path and brings the schema up to date. +// +// SQLite is a single file, but it is being driven by an HTTP server with concurrent +// readers and a WebSocket fan-out, so the pragmas below matter more than usual: +// WAL lets readers run while a write is in flight, busy_timeout turns the "database +// is locked" race into a short wait, and foreign_keys is off by default in SQLite +// and has to be asked for on every connection. +func Open(path string) (*DB, error) { + dsn := "file:" + filepath.ToSlash(path) + "?" + strings.Join([]string{ + "_pragma=journal_mode(WAL)", + "_pragma=busy_timeout(5000)", + "_pragma=foreign_keys(ON)", + "_pragma=synchronous(NORMAL)", + // Keep the temp b-trees FTS5 and ORDER BY build in memory rather than + // spilling them into the data volume. + "_pragma=temp_store(MEMORY)", + }, "&") + + sqlDB, err := sql.Open("sqlite", dsn) + if err != nil { + return nil, fmt.Errorf("open sqlite: %w", err) + } + + // One writer. SQLite serialises writes anyway, and letting the pool open several + // write connections only converts that into SQLITE_BUSY churn. Reads still run + // concurrently against the WAL through this same pool. + sqlDB.SetMaxOpenConns(1) + sqlDB.SetMaxIdleConns(1) + + if err := sqlDB.PingContext(context.Background()); err != nil { + sqlDB.Close() + return nil, fmt.Errorf("ping sqlite: %w", err) + } + + db := &DB{DB: sqlDB, path: path} + if err := db.migrate(context.Background()); err != nil { + sqlDB.Close() + return nil, err + } + return db, nil +} + +func (db *DB) Path() string { return db.path } + +// migrate applies every embedded migration that has not run yet, in filename order, +// each inside its own transaction. A migration that fails leaves the schema at the +// last version that succeeded rather than half-applied. +func (db *DB) migrate(ctx context.Context) error { + if _, err := db.ExecContext(ctx, ` + CREATE TABLE IF NOT EXISTS schema_migrations ( + name TEXT PRIMARY KEY, + applied_at INTEGER NOT NULL + )`); err != nil { + return fmt.Errorf("create schema_migrations: %w", err) + } + + applied := map[string]bool{} + rows, err := db.QueryContext(ctx, `SELECT name FROM schema_migrations`) + if err != nil { + return fmt.Errorf("read schema_migrations: %w", err) + } + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + rows.Close() + return err + } + applied[name] = true + } + rows.Close() + if err := rows.Err(); err != nil { + return err + } + + entries, err := fs.Glob(migrationFS, "migrations/*.sql") + if err != nil { + return err + } + sort.Strings(entries) + + for _, entry := range entries { + name := filepath.Base(entry) + if applied[name] { + continue + } + body, err := migrationFS.ReadFile(entry) + if err != nil { + return err + } + + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return err + } + if _, err := tx.ExecContext(ctx, string(body)); err != nil { + tx.Rollback() + return fmt.Errorf("migration %s: %w", name, err) + } + if _, err := tx.ExecContext(ctx, + `INSERT INTO schema_migrations (name, applied_at) VALUES (?, unixepoch())`, name); err != nil { + tx.Rollback() + return fmt.Errorf("record migration %s: %w", name, err) + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit migration %s: %w", name, err) + } + } + return nil +} + +// Tx runs fn inside a transaction, rolling back on error or panic. +func (db *DB) Tx(ctx context.Context, fn func(*sql.Tx) error) error { + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer func() { + if p := recover(); p != nil { + tx.Rollback() + panic(p) + } + }() + if err := fn(tx); err != nil { + tx.Rollback() + return err + } + return tx.Commit() +} diff --git a/internal/store/store_test.go b/internal/store/store_test.go new file mode 100644 index 0000000..5e6d1d1 --- /dev/null +++ b/internal/store/store_test.go @@ -0,0 +1,359 @@ +package store + +import ( + "context" + "path/filepath" + "testing" +) + +// openTest gives each test its own database file. In-memory SQLite would be faster, +// but it does not exercise WAL — and WAL is the mode verdande actually runs in. +func openTest(t *testing.T) *DB { + t.Helper() + db, err := Open(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatalf("open: %v", err) + } + t.Cleanup(func() { db.Close() }) + return db +} + +func TestMigrateAppliesSchema(t *testing.T) { + db := openTest(t) + ctx := context.Background() + + // Every table the data model promises must exist after a cold start. + want := []string{ + "users", "sessions", "invites", "password_resets", "projects", + "project_members", "sections", "tasks", "labels", "task_labels", + "reminders", "comments", "attachments", "activity", "filters", + "api_tokens", "tasks_fts", + } + for _, table := range want { + var n int + err := db.QueryRowContext(ctx, + `SELECT count(*) FROM sqlite_master WHERE name = ?`, table).Scan(&n) + if err != nil { + t.Fatalf("query sqlite_master for %s: %v", table, err) + } + if n == 0 { + t.Errorf("table %s was not created", table) + } + } +} + +func TestMigrateIsIdempotent(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "test.db") + + db, err := Open(path) + if err != nil { + t.Fatalf("first open: %v", err) + } + db.Close() + + // Re-opening must not try to re-run 0001 and fail on "table already exists". + db2, err := Open(path) + if err != nil { + t.Fatalf("second open: %v", err) + } + defer db2.Close() + + var n int + if err := db2.QueryRow(`SELECT count(*) FROM schema_migrations`).Scan(&n); err != nil { + t.Fatalf("count migrations: %v", err) + } + if n != 1 { + t.Errorf("schema_migrations has %d rows, want 1", n) + } +} + +func TestPragmasAreSet(t *testing.T) { + db := openTest(t) + + var journal string + if err := db.QueryRow(`PRAGMA journal_mode`).Scan(&journal); err != nil { + t.Fatalf("journal_mode: %v", err) + } + if journal != "wal" { + t.Errorf("journal_mode = %q, want wal", journal) + } + + var fk int + if err := db.QueryRow(`PRAGMA foreign_keys`).Scan(&fk); err != nil { + t.Fatalf("foreign_keys: %v", err) + } + if fk != 1 { + t.Error("foreign_keys is off; cascading deletes would silently not happen") + } +} + +// The FTS index is kept in step with `tasks` by triggers rather than by application +// code, so a task written by any path at all is searchable. That contract is worth +// a test of its own. +func TestFTSTriggersTrackTasks(t *testing.T) { + db := openTest(t) + ctx := context.Background() + seedTaskFixtures(t, db) + + insert := `INSERT INTO tasks (id, project_id, content, description, created_by, created_at, updated_at) + VALUES ('t1', 'p1', 'Betal moms for Q3', 'husk bilag', 'u1', 0, 0)` + if _, err := db.ExecContext(ctx, insert); err != nil { + t.Fatalf("insert task: %v", err) + } + + if got := ftsCount(t, db, "moms"); got != 1 { + t.Errorf("after insert: %d hits for 'moms', want 1", got) + } + + if _, err := db.ExecContext(ctx, + `UPDATE tasks SET content = 'Betal skat for Q3' WHERE id = 't1'`); err != nil { + t.Fatalf("update task: %v", err) + } + if got := ftsCount(t, db, "moms"); got != 0 { + t.Errorf("after update: %d hits for the old word 'moms', want 0", got) + } + if got := ftsCount(t, db, "skat"); got != 1 { + t.Errorf("after update: %d hits for the new word 'skat', want 1", got) + } + + if _, err := db.ExecContext(ctx, `DELETE FROM tasks WHERE id = 't1'`); err != nil { + t.Fatalf("delete task: %v", err) + } + if got := ftsCount(t, db, "skat"); got != 0 { + t.Errorf("after delete: %d hits, want 0", got) + } +} + +// Danish is full of letters a diacritic-folding tokenizer will not touch, because +// they are not accented forms of anything. Someone typing "gron" on a keyboard they +// have not switched, or "aarhus" out of habit, still has to find their own tasks. +func TestFTSFindsDanishSpellingVariants(t *testing.T) { + db := openTest(t) + seedTaskFixtures(t, db) + + _, err := db.Exec(`INSERT INTO tasks (id, project_id, content, created_by, created_at, updated_at) + VALUES ('t1', 'p1', 'Køb grøn maling til væggen i Århus', 'u1', 0, 0)`) + if err != nil { + t.Fatalf("insert: %v", err) + } + + // Every one of these is the same task, spelled the way somebody might type it. + queries := []string{ + "grøn", "gron", "GRØN", "Gron", // ø → o + "væggen", "vaeggen", // æ → ae + "århus", "aarhus", "arhus", // å → aa, and → a via remove_diacritics + "køb", "kob", + "grø", "gro", // prefix matching, both spellings + } + for _, q := range queries { + if got := ftsCount(t, db, q); got != 1 { + t.Errorf("query %q: %d hits, want 1", q, got) + } + } + + if got := ftsCount(t, db, "blå"); got != 0 { + t.Errorf("query for an absent word returned %d hits, want 0", got) + } +} + +// The search box takes free text, and free text contains the characters FTS5 uses +// as operators. None of them may reach the query planner as syntax. +func TestFTSHandlesQuerySyntaxInUserInput(t *testing.T) { + db := openTest(t) + seedTaskFixtures(t, db) + + _, err := db.Exec(`INSERT INTO tasks (id, project_id, content, created_by, created_at, updated_at) + VALUES ('t1', 'p1', 'Budget: Q3 - "final" version (AND review)', 'u1', 0, 0)`) + if err != nil { + t.Fatalf("insert: %v", err) + } + + // Each of these would be a syntax error, or would silently mean something else, + // if it were handed to MATCH unquoted. + for _, q := range []string{ + `budget: q3`, + `"final"`, + `- final`, + `AND`, + `review)`, + `q3 NEAR final`, + `^budget`, + `*`, + } { + t.Run(q, func(t *testing.T) { + expr := MatchExpr(q) + if expr == "" { + return // nothing searchable in the input, e.g. "*" + } + var n int + err := db.QueryRow(`SELECT count(*) FROM tasks_fts WHERE tasks_fts MATCH ?`, expr).Scan(&n) + if err != nil { + t.Fatalf("query %q produced invalid FTS5 %q: %v", q, expr, err) + } + }) + } +} + +func TestMatchExprIsEmptyForUnsearchableInput(t *testing.T) { + for _, q := range []string{"", " ", "***", "-", `""`, "!@#$%"} { + if got := MatchExpr(q); got != "" { + t.Errorf("MatchExpr(%q) = %q, want empty", q, got) + } + } +} + +func TestFoldDanish(t *testing.T) { + cases := map[string]string{ + "grøn": "gron", + "Ærlig": "aerlig", + "Århus": "aarhus", + "blåbær": "blaabaer", + "plain": "plain", + "": "", + } + for in, want := range cases { + if got := FoldDanish(in); got != want { + t.Errorf("FoldDanish(%q) = %q, want %q", in, got, want) + } + } +} + +func TestSoftDeleteKeepsRowRecoverable(t *testing.T) { + db := openTest(t) + seedTaskFixtures(t, db) + + _, err := db.Exec(`INSERT INTO tasks (id, project_id, content, created_by, created_at, updated_at) + VALUES ('t1', 'p1', 'slettet', 'u1', 0, 0)`) + if err != nil { + t.Fatalf("insert: %v", err) + } + if _, err := db.Exec(`UPDATE tasks SET deleted_at = 1 WHERE id = 't1'`); err != nil { + t.Fatalf("soft delete: %v", err) + } + + var content string + if err := db.QueryRow(`SELECT content FROM tasks WHERE id = 't1'`).Scan(&content); err != nil { + t.Fatalf("soft-deleted row should still be readable: %v", err) + } + if content != "slettet" { + t.Errorf("content = %q after soft delete", content) + } +} + +func TestConstraints(t *testing.T) { + db := openTest(t) + seedTaskFixtures(t, db) + + cases := []struct { + name string + stmt string + }{{ + name: "priority outside 1-4", + stmt: `INSERT INTO tasks (id, project_id, content, priority, created_by, created_at, updated_at) + VALUES ('bad', 'p1', 'x', 9, 'u1', 0, 0)`, + }, { + name: "unknown project view_mode", + stmt: `INSERT INTO projects (id, name, view_mode, owner_id, created_at, updated_at) + VALUES ('bad', 'x', 'gantt', 'u1', 0, 0)`, + }, { + name: "unknown member role", + stmt: `INSERT INTO project_members (project_id, user_id, role, added_at) + VALUES ('p1', 'u1', 'admin', 0)`, + }, { + name: "reminder that is neither absolute nor relative", + stmt: `INSERT INTO reminders (id, task_id, user_id, created_at) + VALUES ('bad', 't-none', 'u1', 0)`, + }, { + name: "attachment on both a task and a comment", + stmt: `INSERT INTO attachments (id, task_id, comment_id, filename, size, path, uploaded_by, created_at) + VALUES ('bad', 't-none', 'c-none', 'f', 1, 'p', 'u1', 0)`, + }, { + name: "second inbox for one user", + stmt: `INSERT INTO projects (id, name, owner_id, is_inbox, created_at, updated_at) + VALUES ('inbox2', 'Inbox', 'u1', 1, 0, 0)`, + }, { + name: "duplicate label name for one user", + stmt: `INSERT INTO labels (id, user_id, name, created_at) VALUES ('l2', 'u1', 'REGNSKAB', 0)`, + }} + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if _, err := db.Exec(tc.stmt); err == nil { + t.Error("statement was accepted; the constraint is not doing its job") + } + }) + } +} + +func TestEmailUniquenessIsCaseInsensitive(t *testing.T) { + db := openTest(t) + seedTaskFixtures(t, db) + + _, err := db.Exec(`INSERT INTO users (id, email, name, password_hash, created_at, updated_at) + VALUES ('u2', 'KRISTIAN@example.com', 'Dup', 'x', 0, 0)`) + if err == nil { + t.Error("a second user differing only in case was accepted") + } +} + +func TestCascadeDeleteRemovesChildren(t *testing.T) { + db := openTest(t) + seedTaskFixtures(t, db) + + _, err := db.Exec(`INSERT INTO tasks (id, project_id, content, created_by, created_at, updated_at) + VALUES ('parent', 'p1', 'parent', 'u1', 0, 0)`) + if err != nil { + t.Fatalf("insert parent: %v", err) + } + _, err = db.Exec(`INSERT INTO tasks (id, project_id, parent_id, content, created_by, created_at, updated_at) + VALUES ('child', 'p1', 'parent', 'child', 'u1', 0, 0)`) + if err != nil { + t.Fatalf("insert child: %v", err) + } + + if _, err := db.Exec(`DELETE FROM tasks WHERE id = 'parent'`); err != nil { + t.Fatalf("delete parent: %v", err) + } + + var n int + if err := db.QueryRow(`SELECT count(*) FROM tasks WHERE id = 'child'`).Scan(&n); err != nil { + t.Fatal(err) + } + if n != 0 { + t.Error("subtask outlived its parent") + } +} + +func seedTaskFixtures(t *testing.T, db *DB) { + t.Helper() + stmts := []string{ + `INSERT INTO users (id, email, name, password_hash, created_at, updated_at) + VALUES ('u1', 'kristian@example.com', 'Kristian', 'x', 0, 0)`, + `INSERT INTO projects (id, name, owner_id, is_inbox, created_at, updated_at) + VALUES ('p1', 'Inbox', 'u1', 1, 0, 0)`, + `INSERT INTO labels (id, user_id, name, created_at) VALUES ('l1', 'u1', 'regnskab', 0)`, + } + for _, s := range stmts { + if _, err := db.Exec(s); err != nil { + t.Fatalf("seed: %v", err) + } + } +} + +// ftsCount searches the way the application does — through MatchExpr — so the tests +// exercise the query builder and the index together rather than a hand-written +// expression the app never uses. +func ftsCount(t *testing.T, db *DB, query string) int { + t.Helper() + expr := MatchExpr(query) + if expr == "" { + return 0 + } + var n int + err := db.QueryRow(`SELECT count(*) FROM tasks_fts WHERE tasks_fts MATCH ?`, expr).Scan(&n) + if err != nil { + t.Fatalf("fts query %q (expr %q): %v", query, expr, err) + } + return n +}