-- The inbox becomes global to a user rather than per-project: a card now -- either belongs to nobody's project (project_id AND status_id both NULL -- -- sitting in that user's inbox) or to exactly one project with a status in it -- (both set). Cards also gain a direct owner_id, since inbox cards have no -- project to derive ownership from. -- -- SQLite can't relax an existing NOT NULL / add a CHECK to a live column, so -- the table is rebuilt. CREATE TABLE cards_new ( id INTEGER PRIMARY KEY AUTOINCREMENT, owner_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, project_id INTEGER NULL REFERENCES projects (id) ON DELETE CASCADE, -- Was ON DELETE SET NULL; now that a project card must have a status -- (the CHECK below), silently nulling status_id on a deleted status would -- leave project_id orphaned without it. There is no status-delete -- endpoint yet, so this is only a safety net. status_id INTEGER NULL REFERENCES card_statuses (id) ON DELETE RESTRICT, text TEXT NOT NULL, complete INTEGER NOT NULL DEFAULT 0 CHECK (complete IN (0, 1)), position INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), CHECK ((project_id IS NULL) = (status_id IS NULL)) ); -- Existing cards with no status were sitting in their project's old -- per-project inbox column; that concept is gone, so they move to the (now -- global, per-owner) inbox -- project_id cleared alongside status_id. INSERT INTO cards_new (id, owner_id, project_id, status_id, text, complete, position, created_at, updated_at) SELECT c.id, p.owner_id, CASE WHEN c.status_id IS NULL THEN NULL ELSE c.project_id END, c.status_id, c.text, c.complete, c.position, c.created_at, c.updated_at FROM cards c JOIN projects p ON p.id = c.project_id; DROP TABLE cards; ALTER TABLE cards_new RENAME TO cards; CREATE INDEX idx_cards_owner_project_status_position ON cards (owner_id, project_id, status_id, position); CREATE INDEX idx_cards_status ON cards (status_id); -- Re-pack every column -- including each owner's inbox -- to a dense 0..n-1, -- preserving relative order. Safe despite writing the table it reads: the CTE -- is evaluated against the pre-update snapshot. WITH ranked (id, rk) AS ( SELECT id, row_number() OVER ( PARTITION BY owner_id, project_id, status_id ORDER BY position, id ) - 1 FROM cards ) UPDATE cards SET position = (SELECT rk FROM ranked WHERE ranked.id = cards.id);