feat(store): SQLite schema, migrations and Danish-aware full-text search
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 <noreply@anthropic.com>
This commit is contained in:
+25
@@ -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/
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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, `"`, `""`) + `"`
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user