feat(db): Postgres schema, migrations and defects repository (T3)

Drizzle schema for projects, defects, push_subscriptions (C8); generated
migration; a defects repository (createDefect / listRecentDefects); a
programmatic migrator; and a db:verify script. section_id is a plain string
referencing in-code board ids, no FK (ADR 0001). Verified against real
Postgres: migration applies, a defect round-trips, /api/health reports db up.
This commit is contained in:
Julien Calixte
2026-05-27 22:39:32 +02:00
parent 7a4860e111
commit 60ee7b6277
10 changed files with 334 additions and 4 deletions

View File

@@ -13,7 +13,8 @@
"test:watch": "vitest",
"typecheck": "nuxt typecheck",
"db:generate": "drizzle-kit generate",
"db:migrate": "tsx server/db/migrate.ts"
"db:migrate": "tsx server/db/migrate.ts",
"db:verify": "tsx scripts/verify-db.ts"
},
"dependencies": {
"drizzle-orm": "^0.45.2",

37
scripts/verify-db.ts Normal file
View File

@@ -0,0 +1,37 @@
// Verifies the schema + repository against a real Postgres (DATABASE_URL).
// Applies migrations, round-trips a defect, and prints the result.
// DATABASE_URL=postgres://andon:andon@localhost:5432/andon pnpm db:verify
import assert from 'node:assert/strict'
import { migrate } from 'drizzle-orm/node-postgres/migrator'
import { getDb, getPool } from '../server/db/client'
import { defects, projects } from '../server/db/schema'
import { createDefect, listRecentDefects } from '../server/db/repositories/defects'
const db = getDb()
await migrate(db, { migrationsFolder: 'server/db/migrations' })
// Clean slate so the script is idempotent.
await db.delete(defects)
await db.delete(projects)
const [project] = await db.insert(projects).values({ name: 'Acme' }).returning()
assert.ok(project, 'project insert returned a row')
const defect = await createDefect(db, {
sectionId: 'macroplan',
projectId: project.id,
verbatim: 'Macroplan is out of date',
reporterEmail: 'dev@theodo.com',
})
assert.ok(defect.id, 'defect has an id')
assert.equal(defect.sectionId, 'macroplan')
assert.equal(defect.projectId, project.id)
assert.ok(defect.createdAt instanceof Date, 'createdAt is a Date')
const recent = await listRecentDefects(db)
assert.equal(recent.length, 1)
assert.equal(recent[0]!.verbatim, 'Macroplan is out of date')
console.log(`DB verify OK — defect ${defect.id} on section "${defect.sectionId}"`)
await getPool().end()

View File

@@ -1,6 +1,9 @@
import pg from 'pg'
import { drizzle, type NodePgDatabase } from 'drizzle-orm/node-postgres'
import * as schema from './schema'
let pool: pg.Pool | undefined
let db: NodePgDatabase<typeof schema> | undefined
/** Lazily-created shared Postgres connection pool. */
export function getPool(): pg.Pool {
@@ -10,6 +13,14 @@ export function getPool(): pg.Pool {
return pool
}
/** Lazily-created Drizzle client bound to the shared pool. */
export function getDb(): NodePgDatabase<typeof schema> {
if (!db) {
db = drizzle(getPool(), { schema })
}
return db
}
/** Liveness probe for the database, used by /api/health. */
export async function checkDb(): Promise<'up' | 'down'> {
try {

7
server/db/migrate.ts Normal file
View File

@@ -0,0 +1,7 @@
// Applies pending migrations. Run via `pnpm db:migrate`, and on container
// boot in production (wired in Task 12).
import { migrate } from 'drizzle-orm/node-postgres/migrator'
import { getDb, getPool } from './client'
await migrate(getDb(), { migrationsFolder: 'server/db/migrations' })
await getPool().end()

View File

@@ -0,0 +1,27 @@
CREATE TABLE "defects" (
"id" uuid PRIMARY KEY NOT NULL,
"section_id" text NOT NULL,
"project_id" uuid NOT NULL,
"verbatim" text NOT NULL,
"reporter_email" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "projects" (
"id" uuid PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "projects_name_unique" UNIQUE("name")
);
--> statement-breakpoint
CREATE TABLE "push_subscriptions" (
"id" uuid PRIMARY KEY NOT NULL,
"endpoint" text NOT NULL,
"p256dh" text NOT NULL,
"auth" text NOT NULL,
"reporter_email" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "push_subscriptions_endpoint_unique" UNIQUE("endpoint")
);
--> statement-breakpoint
ALTER TABLE "defects" ADD CONSTRAINT "defects_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE no action ON UPDATE no action;

View File

@@ -0,0 +1,181 @@
{
"id": "a68da650-95df-4918-9def-4cccec67a7bf",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.defects": {
"name": "defects",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true
},
"section_id": {
"name": "section_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"project_id": {
"name": "project_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"verbatim": {
"name": "verbatim",
"type": "text",
"primaryKey": false,
"notNull": true
},
"reporter_email": {
"name": "reporter_email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"defects_project_id_projects_id_fk": {
"name": "defects_project_id_projects_id_fk",
"tableFrom": "defects",
"tableTo": "projects",
"columnsFrom": [
"project_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.projects": {
"name": "projects",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"projects_name_unique": {
"name": "projects_name_unique",
"nullsNotDistinct": false,
"columns": [
"name"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.push_subscriptions": {
"name": "push_subscriptions",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true
},
"endpoint": {
"name": "endpoint",
"type": "text",
"primaryKey": false,
"notNull": true
},
"p256dh": {
"name": "p256dh",
"type": "text",
"primaryKey": false,
"notNull": true
},
"auth": {
"name": "auth",
"type": "text",
"primaryKey": false,
"notNull": true
},
"reporter_email": {
"name": "reporter_email",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"push_subscriptions_endpoint_unique": {
"name": "push_subscriptions_endpoint_unique",
"nullsNotDistinct": false,
"columns": [
"endpoint"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1779913917235,
"tag": "0000_loving_black_bird",
"breakpoints": true
}
]
}

View File

@@ -0,0 +1,24 @@
import { desc } from 'drizzle-orm'
import type { NodePgDatabase } from 'drizzle-orm/node-postgres'
import type * as schema from '../schema'
import { defects } from '../schema'
type Db = NodePgDatabase<typeof schema>
export interface NewDefect {
sectionId: string
projectId: string
verbatim: string
reporterEmail: string
}
/** Persist a filed defect and return the stored row. */
export async function createDefect(db: Db, input: NewDefect) {
const [row] = await db.insert(defects).values(input).returning()
return row
}
/** Recent defects, newest first — the dashboard feed (F5/F6). */
export async function listRecentDefects(db: Db, limit = 100) {
return db.select().from(defects).orderBy(desc(defects.createdAt)).limit(limit)
}

View File

@@ -1,2 +1,31 @@
// Drizzle (Postgres) schema — tables are defined in Task 3.
export {}
// Drizzle (Postgres) schema (C8).
// `section_id` is a plain string referencing the in-code board section ids
// (C1) — no foreign key, because sections live in code, not the DB (ADR 0001).
import { pgTable, uuid, text, timestamp } from 'drizzle-orm/pg-core'
import { randomUUID } from 'node:crypto'
export const projects = pgTable('projects', {
id: uuid('id').primaryKey().$defaultFn(randomUUID),
name: text('name').notNull().unique(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
})
export const defects = pgTable('defects', {
id: uuid('id').primaryKey().$defaultFn(randomUUID),
sectionId: text('section_id').notNull(),
projectId: uuid('project_id')
.notNull()
.references(() => projects.id),
verbatim: text('verbatim').notNull(),
reporterEmail: text('reporter_email').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
})
export const pushSubscriptions = pgTable('push_subscriptions', {
id: uuid('id').primaryKey().$defaultFn(randomUUID),
endpoint: text('endpoint').notNull().unique(),
p256dh: text('p256dh').notNull(),
auth: text('auth').notNull(),
reporterEmail: text('reporter_email'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
})

View File

@@ -10,7 +10,7 @@ review.
- [ ]**Checkpoint:** build + tests pass, board interactive, human review
## Phase 2 — File a defect (G1)
- [ ] **T3** Postgres schema + migrations (C8) (S) — _deps: T1_
- [x] **T3** Postgres schema + migrations (C8) (S) — _deps: T1_
- [ ] **T4** Projects API + ProjectAutocomplete (C4) — F3 (M) — _deps: T3_
- [ ] **T5** File a defect: POST /api/defects + DefectForm (C3) — F2 (M) — _deps: T2, T3, T4_
- [ ]**Checkpoint:** defect files end-to-end (dev auth), tests pass, human review