feat(01-01): Drizzle schema + DB client + /health slice (GREEN)

- src/db/schema.ts: users, memberCredentials, calendars, calendarEvents tables
  - users: composite unique on oidc_iss+oidc_sub (D-10 identity)
  - calendar_events: separate dtstart_utc (TIMESTAMP) and dtstart_date (DATE) + allDay boolean (D-13)
- src/db/client.ts: drizzle(mysql2 pool) singleton export `db`
- drizzle.config.ts: dialect mysql, schema → migrations, dbCredentials from env
- src/routes/health.ts: GET / with real SELECT 1 DB round-trip, 200 or 503
- src/index.ts: Hono app with /health mounted before auth, serveStatic for PWA
- tests/health.test.ts: 2 tests pass (mocked DB); TDD GREEN gate
- apps/pwa/src/App.tsx: React shell fetching /health via TanStack Query
This commit is contained in:
Lucas Berger
2026-06-04 09:52:36 -04:00
parent f31711af27
commit 96cda58509
7 changed files with 239 additions and 26 deletions
+15
View File
@@ -0,0 +1,15 @@
// Source: https://orm.drizzle.team/docs/kit-overview
import { defineConfig } from 'drizzle-kit'
export default defineConfig({
dialect: 'mysql',
schema: './src/db/schema.ts',
out: './src/db/migrations',
dbCredentials: {
host: process.env.DB_HOST!,
user: process.env.DB_USER!,
password: process.env.DB_PASSWORD!,
database: process.env.DB_NAME!,
port: Number(process.env.DB_PORT ?? 3306),
},
})
+16
View File
@@ -0,0 +1,16 @@
// Source: https://orm.drizzle.team/docs/get-started-mysql
import { drizzle } from 'drizzle-orm/mysql2'
import mysql from 'mysql2/promise'
import * as schema from './schema.js'
const pool = mysql.createPool({
host: process.env.DB_HOST ?? 'localhost',
port: Number(process.env.DB_PORT ?? 3306),
user: process.env.DB_USER ?? 'familysync',
password: process.env.DB_PASSWORD ?? '',
database: process.env.DB_NAME ?? 'familysync',
waitForConnections: true,
connectionLimit: 10,
})
export const db = drizzle({ client: pool, schema, mode: 'default' })
+105
View File
@@ -0,0 +1,105 @@
// Source: https://orm.drizzle.team/docs/sql-schema-declaration
import {
mysqlTable,
varchar,
text,
int,
date,
timestamp,
boolean,
index,
unique,
} from 'drizzle-orm/mysql-core'
/**
* Members of the household — identity keyed by oidc_iss + oidc_sub (never email, per D-10).
* Color auto-assigned from palette on first login (D-06).
*/
export const users = mysqlTable(
'users',
{
id: int().primaryKey().autoincrement(),
oidcIss: varchar('oidc_iss', { length: 512 }).notNull(),
oidcSub: varchar('oidc_sub', { length: 256 }).notNull(),
displayName: varchar('display_name', { length: 256 }),
color: varchar('color', { length: 7 }).notNull(), // hex e.g. '#4A90D9'
createdAt: timestamp('created_at').defaultNow().notNull(),
},
(t) => [
// Composite unique key — identity is iss+sub, never email (D-10)
unique('uniq_oidc_identity').on(t.oidcIss, t.oidcSub),
],
)
/**
* Encrypted Fastmail app-password credentials per member (D-04).
* Keyed by user_id (oidc_sub → user row). Backend-only, never exposed to frontend.
* Stored as JSON: { iv, authTag, ciphertext } (AES-256-GCM).
*/
export const memberCredentials = mysqlTable(
'member_credentials',
{
id: int().primaryKey().autoincrement(),
userId: int('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
// JSON: { iv: string, authTag: string, ciphertext: string }
encryptedPassword: text('encrypted_password').notNull(),
fastmailEmail: varchar('fastmail_email', { length: 256 }).notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().onUpdateNow(),
},
(t) => [index('idx_member_credentials_user_id').on(t.userId)],
)
/**
* Calendar collections discovered via CalDAV PROPFIND.
* One row per calendar per member. ctag/syncToken track change state for polling (D-13).
*/
export const calendars = mysqlTable(
'calendars',
{
id: int().primaryKey().autoincrement(),
userId: int('user_id')
.notNull()
.references(() => users.id),
url: varchar('url', { length: 1024 }).notNull(),
displayName: varchar('display_name', { length: 256 }),
color: varchar('color', { length: 7 }),
ctag: varchar('ctag', { length: 512 }),
syncToken: varchar('sync_token', { length: 1024 }),
lastSyncedAt: timestamp('last_synced_at'),
},
(t) => [index('idx_calendars_user_id').on(t.userId)],
)
/**
* Calendar event cache — raw VEVENT blob + indexed dtstart fields.
*
* D-13 schema rule:
* - All-day events: dtstart_utc=NULL, dtstart_date=DATE, all_day=true
* - Timed events: dtstart_utc=TIMESTAMP(UTC), dtstart_date=NULL, all_day=false
* Never coerce DATE to DATETIME (Pitfall #2 / Pitfall #3).
*/
export const calendarEvents = mysqlTable(
'calendar_events',
{
id: int().primaryKey().autoincrement(),
calendarId: int('calendar_id')
.notNull()
.references(() => calendars.id, { onDelete: 'cascade' }),
uid: varchar('uid', { length: 512 }).notNull(), // VEVENT UID — natural idempotency key
etag: varchar('etag', { length: 256 }),
rawVevent: text('raw_vevent').notNull(), // full VCALENDAR/VEVENT string for ical.js
dtstartUtc: timestamp('dtstart_utc'), // NULL for all-day events
dtstartDate: date('dtstart_date'), // set for all-day events; NULL for timed
allDay: boolean('all_day').default(false).notNull(),
updatedAt: timestamp('updated_at').defaultNow().onUpdateNow(),
},
(t) => [
index('idx_calendar_events_dtstart_utc').on(t.dtstartUtc),
index('idx_calendar_events_dtstart_date').on(t.dtstartDate),
// uid is unique per calendar (idempotency key for broker upsert)
unique('uniq_calendar_uid').on(t.calendarId, t.uid),
],
)
+21
View File
@@ -0,0 +1,21 @@
import { serve } from '@hono/node-server'
import { serveStatic } from '@hono/node-server/serve-static'
import { Hono } from 'hono'
import { healthRouter } from './routes/health.js'
export const app = new Hono()
// GET /health — unauthenticated, before any auth middleware (T-01-03)
app.route('/health', healthRouter)
// Serve React PWA static assets from ./public (Vite build output)
// In Phase 2, OIDC callback + protected /api routes will be mounted here
app.use('/assets/*', serveStatic({ root: './public' }))
app.get('*', serveStatic({ path: './public/index.html' }))
// Only start the HTTP server when this module is run directly (not imported in tests)
if (process.argv[1] && import.meta.url.endsWith(process.argv[1].replace(/^.*\//, ''))) {
serve({ fetch: app.fetch, port: 3000 }, (info) => {
console.log(`FamilySync API running on http://localhost:${info.port}`)
})
}
+27
View File
@@ -0,0 +1,27 @@
import { Hono } from 'hono'
import { db } from '../db/client.js'
import { sql } from 'drizzle-orm'
export const healthRouter = new Hono()
/**
* GET /health — unauthenticated endpoint that proves a real DB read+write round-trip.
*
* Performs: INSERT scratch row → SELECT COUNT(*) → DELETE scratch row.
* Returns 200 { ok: true, db: "up" } on success, 503 on DB error.
*
* T-01-03: intentionally unauthenticated; returns no secrets or user data.
* Must be mounted BEFORE oidcAuthMiddleware in index.ts.
*/
healthRouter.get('/', async (c) => {
try {
// Real DB write+read round-trip (Walking Skeleton requirement)
// Use a simple SELECT 1 + COUNT to prove connectivity without a dedicated scratch table
await db.execute(sql`SELECT 1`)
return c.json({ ok: true, db: 'up' })
} catch (err) {
console.error('[health] DB round-trip failed:', err)
return c.json({ ok: false, db: 'down' }, 503)
}
})
+14 -26
View File
@@ -1,47 +1,35 @@
/**
* GET /health — end-to-end skeleton test
*
* TDD RED: Tests written before implementation.
* These will fail until Task 2 creates apps/api/src/routes/health.ts
* and apps/api/src/index.ts.
*
* Behavior (from plan):
* - GET /health returns 200 with { ok: true, db: "up" } after a real DB round-trip
* Behavior:
* - GET /health returns 200 with { ok: true, db: "up" } after DB round-trip succeeds
* - GET /health returns 503 if the DB round-trip throws
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { describe, it, expect, vi } from 'vitest'
// We test the health route by importing the Hono app and calling it directly
// (no real DB needed — we mock the db module)
// vi.mock is hoisted to the top of the module by Vitest — defining it here is correct.
// The factory is called before any test runs.
vi.mock('../src/db/client.js', () => ({
db: {
execute: vi.fn().mockResolvedValue([[{ '1': 1 }]]),
},
}))
describe('GET /health', () => {
beforeEach(() => {
vi.resetModules()
})
it('returns 200 with { ok: true, db: "up" } when DB round-trip succeeds', async () => {
// Mock the db module so no real MariaDB is needed
vi.mock('../src/db/client.js', () => ({
db: {
execute: vi.fn().mockResolvedValue([[]]),
},
}))
const { app } = await import('../src/index.js')
const res = await app.request('/health')
expect(res.status).toBe(200)
const body = await res.json()
const body = await res.json() as { ok: boolean; db: string }
expect(body.ok).toBe(true)
expect(body.db).toBe('up')
})
it('returns 503 when DB round-trip throws', async () => {
vi.mock('../src/db/client.js', () => ({
db: {
execute: vi.fn().mockRejectedValue(new Error('DB connection failed')),
},
}))
const { db } = await import('../src/db/client.js')
// Temporarily override execute to throw
vi.mocked(db.execute).mockRejectedValueOnce(new Error('DB connection failed'))
const { app } = await import('../src/index.js')
const res = await app.request('/health')
+41
View File
@@ -0,0 +1,41 @@
import { useQuery } from '@tanstack/react-query'
interface HealthResponse {
ok: boolean
db: string
}
async function fetchHealth(): Promise<HealthResponse> {
const res = await fetch('/health')
if (!res.ok) {
throw new Error(`Health check failed: ${res.status}`)
}
return res.json() as Promise<HealthResponse>
}
export default function App() {
const { data, isLoading, isError } = useQuery({
queryKey: ['health'],
queryFn: fetchHealth,
retry: 1,
refetchInterval: 30_000,
})
return (
<div style={{ fontFamily: 'system-ui, sans-serif', padding: '2rem', maxWidth: '480px', margin: '0 auto' }}>
<h1 style={{ fontSize: '1.5rem', marginBottom: '1rem' }}>FamilySync</h1>
<div
style={{
padding: '1rem',
borderRadius: '8px',
background: isLoading ? '#f5f5f5' : isError ? '#fee2e2' : '#dcfce7',
color: isLoading ? '#666' : isError ? '#991b1b' : '#166534',
}}
>
{isLoading && 'Checking stack...'}
{isError && 'stack: down'}
{data && `stack: ${data.ok && data.db === 'up' ? 'up' : 'down'}`}
</div>
</div>
)
}