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
+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),
],
)