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:
@@ -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),
|
||||
},
|
||||
})
|
||||
@@ -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' })
|
||||
@@ -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),
|
||||
],
|
||||
)
|
||||
@@ -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}`)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user