diff --git a/lib/db/migrations/001_core.sql b/lib/db/migrations/001_core.sql new file mode 100644 index 0000000..3a75247 --- /dev/null +++ b/lib/db/migrations/001_core.sql @@ -0,0 +1,45 @@ +CREATE TABLE spaces ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + slug text NOT NULL UNIQUE, + name text NOT NULL, + description text, + theme text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE projects ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + space_id uuid NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, + slug text NOT NULL, + name text NOT NULL, + description text, + status text NOT NULL DEFAULT 'active' + CHECK (status IN ('idea','active','paused','done','abandoned')), + started_at timestamptz, + completed_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (space_id, slug) +); + +CREATE TABLE tasks ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + space_id uuid NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, + project_id uuid REFERENCES projects(id) ON DELETE SET NULL, + title text NOT NULL, + body text, + status text NOT NULL DEFAULT 'todo' + CHECK (status IN ('todo','doing','blocked','done')), + priority int, + due_at timestamptz, + position int, + completed_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX idx_projects_space ON projects(space_id); +CREATE INDEX idx_tasks_space ON tasks(space_id); +CREATE INDEX idx_tasks_project ON tasks(project_id); +CREATE INDEX idx_tasks_status ON tasks(status) WHERE status <> 'done'; diff --git a/tests/db/migration_001.test.js b/tests/db/migration_001.test.js new file mode 100644 index 0000000..976cf16 --- /dev/null +++ b/tests/db/migration_001.test.js @@ -0,0 +1,36 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { resetDb, withClient } from '../helpers/db.js'; +import { migrateUp } from '../../lib/db/migrate.js'; + +describe('migration 001 — core', () => { + beforeAll(async () => { await resetDb(); await migrateUp(); }); + + it('creates spaces, projects, tasks tables', async () => { + await withClient(async (c) => { + for (const t of ['spaces', 'projects', 'tasks']) { + const { rows } = await c.query( + `SELECT to_regclass('public.' || $1) AS t;`, [t] + ); + expect(rows[0].t).toBe(t); + } + }); + }); + + it('enforces UNIQUE(space_id, slug) on projects', async () => { + await withClient(async (c) => { + const { rows: [s] } = await c.query( + `INSERT INTO spaces(slug, name) VALUES('test', 'Test') RETURNING id;` + ); + await c.query( + `INSERT INTO projects(space_id, slug, name) VALUES($1, 'a', 'A');`, + [s.id] + ); + await expect( + c.query( + `INSERT INTO projects(space_id, slug, name) VALUES($1, 'a', 'B');`, + [s.id] + ) + ).rejects.toThrow(/duplicate key/i); + }); + }); +});