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,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),
|
||||
],
|
||||
)
|
||||
Reference in New Issue
Block a user