Squash all migrations into one clean initial schema
Replaces 001-011 (create/alter/rebuild/backfill, in the order features landed) with a single 001_initial_schema.sql that creates every table in its final shape directly -- no password_hash (added then dropped), no project description (added then dropped), cards already shaped as the global-inbox-with-a-CHECK-constraint design rather than rebuilt into it, no data-migration/backfill statements (nothing to backfill against a schema created fresh). This is a pre-release project with no data worth preserving, so the dev database (and the storage volume's generated JWT key with it) was wiped rather than migrated -- confirmed the fresh schema matches exactly (same tables/columns as before, minus the two dropped columns) and the app works end to end against it. PHPUnit's in every run already builds its database from migrations/*.sql from scratch, so the suite needed no changes and is unaffected either way: 88/88. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +0,0 @@
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||
password_hash TEXT NOT NULL,
|
||||
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'))
|
||||
);
|
||||
@@ -0,0 +1,112 @@
|
||||
-- Users. A magic-link email is the only way to sign in (see EmailVerifier /
|
||||
-- AuthController::requestLoginLink) -- there is no password. Passkeys
|
||||
-- (passkeys / webauthn_challenges below) are the only alternative.
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||
-- Null until the address is verified -- opening a magic link both signs
|
||||
-- the user in and, the first time, sets this.
|
||||
email_verified_at TEXT NULL,
|
||||
-- When the last verification / email-change link was sent, for the
|
||||
-- once-per-minute resend throttle.
|
||||
verification_email_sent_at TEXT NULL,
|
||||
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'))
|
||||
);
|
||||
|
||||
-- Projects. Each belongs to exactly one owner.
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
owner_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
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'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_projects_owner ON projects (owner_id);
|
||||
|
||||
-- Project-specific card statuses ("To do" / "Doing" / "Done" by default --
|
||||
-- see CardStatusRepository::seedDefaults -- then user-managed from there via
|
||||
-- the project configuration view).
|
||||
CREATE TABLE IF NOT EXISTS card_statuses (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
project_id INTEGER NOT NULL REFERENCES projects (id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
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'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_card_statuses_project_position
|
||||
ON card_statuses (project_id, position);
|
||||
|
||||
-- Cards. A card either sits in its owner's global inbox (project_id AND
|
||||
-- status_id both NULL) or belongs to exactly one project with a status in it
|
||||
-- (both set) -- the CHECK below enforces that pairing, never one without the
|
||||
-- other. `position` is a dense 0..n-1 rank within a "column": the cards
|
||||
-- sharing an (owner_id, project_id, status_id).
|
||||
CREATE TABLE IF NOT EXISTS cards (
|
||||
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,
|
||||
-- RESTRICT, not SET NULL: a project card must always have a status (see
|
||||
-- the CHECK below), so a status can only be deleted once its cards are
|
||||
-- reassigned elsewhere first (see CardStatusController::destroy).
|
||||
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))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_cards_owner_project_status_position
|
||||
ON cards (owner_id, project_id, status_id, position);
|
||||
CREATE INDEX IF NOT EXISTS idx_cards_status ON cards (status_id);
|
||||
|
||||
-- Magic-link tokens for verifying an email address. `new_email` is null for a
|
||||
-- plain "verify your current address" link, or the requested address for a
|
||||
-- deferred email change (applied only when the link is opened).
|
||||
CREATE TABLE IF NOT EXISTS email_verifications (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
new_email TEXT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
consumed_at TEXT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_email_verifications_user ON email_verifications (user_id);
|
||||
|
||||
-- Passkeys (WebAuthn discoverable credentials): an alternative to the email
|
||||
-- magic link. A user may register several (one per device/authenticator).
|
||||
CREATE TABLE IF NOT EXISTS passkeys (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||
credential_id TEXT NOT NULL UNIQUE,
|
||||
public_key TEXT NOT NULL,
|
||||
sign_count INTEGER NOT NULL DEFAULT 0,
|
||||
label TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
last_used_at TEXT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_passkeys_user ON passkeys (user_id);
|
||||
|
||||
-- Short-lived, single-use WebAuthn challenges bridging the "options" and
|
||||
-- "verify" calls of both the registration and login ceremonies. user_id is
|
||||
-- set for a registration (tied to the signed-in caller) and NULL for a login
|
||||
-- attempt, since who's logging in isn't known until the credential comes back.
|
||||
CREATE TABLE IF NOT EXISTS webauthn_challenges (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||
purpose TEXT NOT NULL CHECK (purpose IN ('register', 'login')),
|
||||
challenge TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
consumed_at TEXT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_expiry ON webauthn_challenges (expires_at);
|
||||
@@ -1,3 +0,0 @@
|
||||
-- Null means the email address has not been verified yet. New accounts start
|
||||
-- unverified; a later stage will add the endpoint that sets this timestamp.
|
||||
ALTER TABLE users ADD COLUMN email_verified_at TEXT NULL;
|
||||
@@ -1,10 +0,0 @@
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
owner_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
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'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_projects_owner ON projects (owner_id);
|
||||
@@ -1,11 +0,0 @@
|
||||
CREATE TABLE IF NOT EXISTS cards (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
project_id INTEGER NOT NULL REFERENCES projects (id) ON DELETE CASCADE,
|
||||
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'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_cards_project_position ON cards (project_id, position);
|
||||
@@ -1,18 +0,0 @@
|
||||
-- Magic-link tokens for verifying an email address. `new_email` is null for a
|
||||
-- plain "verify your current address" link, or the requested address for a
|
||||
-- deferred email change (applied only when the link is opened).
|
||||
CREATE TABLE IF NOT EXISTS email_verifications (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
new_email TEXT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
consumed_at TEXT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_email_verifications_user ON email_verifications (user_id);
|
||||
|
||||
-- When the last verification / change email was sent to this user, for the
|
||||
-- once-per-minute resend throttle.
|
||||
ALTER TABLE users ADD COLUMN verification_email_sent_at TEXT NULL;
|
||||
@@ -1,32 +0,0 @@
|
||||
-- Project-specific card statuses. Every project gets a "To do" / "Doing" /
|
||||
-- "Done" set when it is created (see CardStatusRepository::seedDefaults); the
|
||||
-- backfill below covers projects that already existed.
|
||||
CREATE TABLE IF NOT EXISTS card_statuses (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
project_id INTEGER NOT NULL REFERENCES projects (id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
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'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_card_statuses_project_position
|
||||
ON card_statuses (project_id, position);
|
||||
|
||||
-- Nullable, and left NULL for existing cards: a card without a status sits in
|
||||
-- the project "inbox" until the user gives it one. Losing a status (a deleted
|
||||
-- status row) clears the link rather than removing the card.
|
||||
ALTER TABLE cards
|
||||
ADD COLUMN status_id INTEGER REFERENCES card_statuses (id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_cards_status ON cards (status_id);
|
||||
|
||||
-- Backfill: give every pre-existing project the default status set.
|
||||
INSERT INTO card_statuses (project_id, name, position)
|
||||
SELECT p.id, d.name, d.position
|
||||
FROM projects p
|
||||
CROSS JOIN (
|
||||
SELECT 'To do' AS name, 0 AS position
|
||||
UNION ALL SELECT 'Doing', 1
|
||||
UNION ALL SELECT 'Done', 2
|
||||
) d;
|
||||
@@ -1,24 +0,0 @@
|
||||
-- `cards.position` was a project-wide sort key. It is now a rank *within a
|
||||
-- column* — the group of cards sharing a (project_id, status_id). The inbox
|
||||
-- (status_id IS NULL) is its own column. The "all tasks" view no longer uses
|
||||
-- position at all (it sorts alphabetically); ordering is a per-column concern
|
||||
-- driven by PUT /api/projects/{id}/cards/order.
|
||||
|
||||
DROP INDEX IF EXISTS idx_cards_project_position;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_cards_project_status_position
|
||||
ON cards (project_id, status_id, position);
|
||||
|
||||
-- Re-pack every existing column to a dense 0..n-1, preserving current order.
|
||||
-- The CTE is evaluated against the pre-update snapshot, so this is safe despite
|
||||
-- writing the same table.
|
||||
WITH ranked (id, rk) AS (
|
||||
SELECT id,
|
||||
row_number() OVER (
|
||||
PARTITION BY project_id, status_id
|
||||
ORDER BY position, id
|
||||
) - 1
|
||||
FROM cards
|
||||
)
|
||||
UPDATE cards
|
||||
SET position = (SELECT rk FROM ranked WHERE ranked.id = cards.id);
|
||||
@@ -1,6 +0,0 @@
|
||||
-- Passwordless auth: the only way in is a magic link emailed to an address
|
||||
-- (see EmailVerifier / AuthController::requestLoginLink). Opening a link both
|
||||
-- creates the account (if it's new) and signs the user in, so an authenticated
|
||||
-- session now always implies a verified email -- there is no more
|
||||
-- authenticated-but-unverified state to nag about.
|
||||
ALTER TABLE users DROP COLUMN password_hash;
|
||||
@@ -1,58 +0,0 @@
|
||||
-- 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);
|
||||
@@ -1,30 +0,0 @@
|
||||
-- Passkeys (WebAuthn discoverable credentials): an alternative to the email
|
||||
-- magic link. A user may register several (one per device/authenticator).
|
||||
CREATE TABLE IF NOT EXISTS passkeys (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||
credential_id TEXT NOT NULL UNIQUE,
|
||||
public_key TEXT NOT NULL,
|
||||
sign_count INTEGER NOT NULL DEFAULT 0,
|
||||
label TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
last_used_at TEXT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_passkeys_user ON passkeys (user_id);
|
||||
|
||||
-- Short-lived, single-use WebAuthn challenges bridging the "options" and
|
||||
-- "verify" calls of both the registration and login ceremonies. user_id is
|
||||
-- set for a registration (tied to the signed-in caller) and NULL for a login
|
||||
-- attempt, since who's logging in isn't known until the credential comes back.
|
||||
CREATE TABLE IF NOT EXISTS webauthn_challenges (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||
purpose TEXT NOT NULL CHECK (purpose IN ('register', 'login')),
|
||||
challenge TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
consumed_at TEXT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_expiry ON webauthn_challenges (expires_at);
|
||||
@@ -1,3 +0,0 @@
|
||||
-- The description field never got a UI home on the frontend and isn't used
|
||||
-- anywhere; drop it rather than carry an unused column.
|
||||
ALTER TABLE projects DROP COLUMN description;
|
||||
Reference in New Issue
Block a user