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:
@@ -13,7 +13,8 @@
|
|||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"typecheck": "nuxt typecheck",
|
"typecheck": "nuxt typecheck",
|
||||||
"db:generate": "drizzle-kit generate",
|
"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": {
|
"dependencies": {
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
|
|||||||
37
scripts/verify-db.ts
Normal file
37
scripts/verify-db.ts
Normal 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()
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import pg from 'pg'
|
import pg from 'pg'
|
||||||
|
import { drizzle, type NodePgDatabase } from 'drizzle-orm/node-postgres'
|
||||||
|
import * as schema from './schema'
|
||||||
|
|
||||||
let pool: pg.Pool | undefined
|
let pool: pg.Pool | undefined
|
||||||
|
let db: NodePgDatabase<typeof schema> | undefined
|
||||||
|
|
||||||
/** Lazily-created shared Postgres connection pool. */
|
/** Lazily-created shared Postgres connection pool. */
|
||||||
export function getPool(): pg.Pool {
|
export function getPool(): pg.Pool {
|
||||||
@@ -10,6 +13,14 @@ export function getPool(): pg.Pool {
|
|||||||
return 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. */
|
/** Liveness probe for the database, used by /api/health. */
|
||||||
export async function checkDb(): Promise<'up' | 'down'> {
|
export async function checkDb(): Promise<'up' | 'down'> {
|
||||||
try {
|
try {
|
||||||
|
|||||||
7
server/db/migrate.ts
Normal file
7
server/db/migrate.ts
Normal 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()
|
||||||
27
server/db/migrations/0000_loving_black_bird.sql
Normal file
27
server/db/migrations/0000_loving_black_bird.sql
Normal 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;
|
||||||
181
server/db/migrations/meta/0000_snapshot.json
Normal file
181
server/db/migrations/meta/0000_snapshot.json
Normal 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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
13
server/db/migrations/meta/_journal.json
Normal file
13
server/db/migrations/meta/_journal.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"idx": 0,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1779913917235,
|
||||||
|
"tag": "0000_loving_black_bird",
|
||||||
|
"breakpoints": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
24
server/db/repositories/defects.ts
Normal file
24
server/db/repositories/defects.ts
Normal 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)
|
||||||
|
}
|
||||||
@@ -1,2 +1,31 @@
|
|||||||
// Drizzle (Postgres) schema — tables are defined in Task 3.
|
// Drizzle (Postgres) schema (C8).
|
||||||
export {}
|
// `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(),
|
||||||
|
})
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ review.
|
|||||||
- [ ] ⛳ **Checkpoint:** build + tests pass, board interactive, human review
|
- [ ] ⛳ **Checkpoint:** build + tests pass, board interactive, human review
|
||||||
|
|
||||||
## Phase 2 — File a defect (G1)
|
## 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_
|
- [ ] **T4** Projects API + ProjectAutocomplete (C4) — F3 (M) — _deps: T3_
|
||||||
- [ ] **T5** File a defect: POST /api/defects + DefectForm (C3) — F2 (M) — _deps: T2, T3, T4_
|
- [ ] **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
|
- [ ] ⛳ **Checkpoint:** defect files end-to-end (dev auth), tests pass, human review
|
||||||
|
|||||||
Reference in New Issue
Block a user