Files
familysync/apps/api/src/db/schema.ts
T
Lucas Berger 2f25b15949 feat(04-01): add list tables to schema and apply via generate+migrate [BLOCKING]
- Append lists, list_shares, list_items tables to Drizzle schema (schema.ts)
- lists: owner_id FK, is_shared bool default true (D-01), idx_lists_owner_id
- list_shares: list_id + user_id FKs, uniq_list_share, idx_list_shares_user_id (D-02)
- list_items: rank varchar for fractional-indexing (D-13), checked bool, composite indexes
- Generate 0000_easy_slipstream.sql (full schema baseline) + 0001_lists_schema.sql (new tables)
- Mark 0000 as applied in __drizzle_migrations (prior tables existed from manual DDL)
- Apply 0001_lists_schema.sql via db:migrate — lists/list_shares/list_items now in MariaDB
- Add vitest/globals + node to tsconfig types for test file compatibility
- NEVER used db:push (hard project constraint — drizzle-mariadb-push-unsafe)
- typecheck passes
2026-06-09 11:55:46 -04:00

242 lines
9.7 KiB
TypeScript

// Source: https://orm.drizzle.team/docs/sql-schema-declaration
import {
mysqlTable,
mysqlEnum,
varchar,
text,
int,
date,
timestamp,
boolean,
index,
unique,
} from 'drizzle-orm/mysql-core'
// ── Phase 4: List tables ───────────────────────────────────────────────────
// Imported by test/setup.ts for afterEach cleanup — keep exports consistent.
/**
* 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).
* isShared: true when this calendar is the shared-family calendar (marked by operator).
*/
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'),
isShared: boolean('is_shared').default(false).notNull(),
},
(t) => [
index('idx_calendars_user_id').on(t.userId),
// BUG B: calendar identity is (userId, url), not url alone. The two household
// members share one Fastmail account (D-16), so the SAME collection URL is
// polled by both credentials. Without this unique key the calendar upsert's
// onDuplicateKeyUpdate never fired → a new row per poll, and the url-only
// lookup resolved to the other member's row → events cached under the wrong
// calendarId. Keying on (userId, url) makes the upsert idempotent per member.
unique('uniq_calendar_user_url').on(t.userId, t.url),
],
)
/**
* 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 }),
objectUrl: varchar('object_url', { length: 1024 }), // CalDAV object URL; populated from obj.url by sync.ts (D-08)
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(),
// hasRrule: pre-computed flag for SQL pre-filtering of recurring event masters.
// Events with hasRrule=true have dtstartUtc potentially years before any window,
// so the windowed query must include them regardless of dtstartUtc range (see RESEARCH.md §Pitfall 5).
hasRrule: boolean('has_rrule').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),
index('idx_calendar_events_has_rrule').on(t.hasRrule),
// uid is unique per calendar (idempotency key for broker upsert)
unique('uniq_calendar_uid').on(t.calendarId, t.uid),
],
)
/**
* Transactional outbox for CalDAV write-back (D-05).
* Pending rows are drained by the outbox worker (broker/outboxWorker.ts).
* Status machine: pending → done | failed | dead (D-07, D-08).
*
* groupId links the delete+create pair for edit-as-move (D-04, Pitfall 5).
* calendarObjectUrl is null for creates (URL constructed from calendarUrl + uid + '.ics').
* etag enables If-Match on update/delete to enforce conflict detection (D-08).
*/
export const calendarOutbox = mysqlTable(
'calendar_outbox',
{
id: int().primaryKey().autoincrement(),
userId: int('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
// 'create' | 'update' | 'delete'
operation: mysqlEnum(['create', 'update', 'delete']).notNull(),
// 'pending' | 'done' | 'failed' | 'dead'
status: mysqlEnum(['pending', 'done', 'failed', 'dead']).notNull().default('pending'),
uid: varchar('uid', { length: 512 }).notNull(),
calendarUrl: varchar('calendar_url', { length: 1024 }).notNull(),
calendarObjectUrl: varchar('calendar_object_url', { length: 1024 }), // null for creates
etag: varchar('etag', { length: 256 }), // cached etag for If-Match (D-08)
payload: text('payload'), // icsString for create/update; null for delete
attemptCount: int('attempt_count').notNull().default(0),
nextAttemptAt: timestamp('next_attempt_at').defaultNow().notNull(),
lastError: text('last_error'),
// groupId links the delete+create pair for edit-as-move (D-04)
groupId: varchar('group_id', { length: 64 }), // nullable
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().onUpdateNow(),
},
(t) => [
index('idx_outbox_user_status').on(t.userId, t.status),
index('idx_outbox_next_attempt').on(t.nextAttemptAt, t.status),
index('idx_outbox_uid').on(t.uid),
],
)
/**
* App-owned named lists — stored in MariaDB, NOT CalDAV (Phase 4).
*
* D-01: isShared defaults to true (collaborative household use case).
* D-02: ownership via ownerId; sharing via listShares join table (member-count-agnostic).
*/
export const lists = mysqlTable(
'lists',
{
id: int().primaryKey().autoincrement(),
ownerId: int('owner_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
name: varchar('name', { length: 255 }).notNull(),
// D-01: default shared — the primary grocery/hub use case is collaborative.
isShared: boolean('is_shared').default(true).notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().onUpdateNow(),
},
(t) => [index('idx_lists_owner_id').on(t.ownerId)],
)
/**
* List sharing join table (D-02, member-count-agnostic).
*
* One row per (list, member) pair. v1 UI treats a list as "shared" when any
* row exists; future UI can offer per-recipient granularity without a migration.
*/
export const listShares = mysqlTable(
'list_shares',
{
id: int().primaryKey().autoincrement(),
listId: int('list_id')
.notNull()
.references(() => lists.id, { onDelete: 'cascade' }),
userId: int('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
createdAt: timestamp('created_at').defaultNow().notNull(),
},
(t) => [
unique('uniq_list_share').on(t.listId, t.userId),
index('idx_list_shares_user_id').on(t.userId),
],
)
/**
* Items within a list.
*
* D-13: rank uses fractional-indexing strings (e.g. "a0", "a1", "Zz") — a single
* move rewrites only the moved item's rank (one-row write), which plays well with
* SSE live sync and concurrent reorders.
* D-05: checked items render in a "completed" section at the bottom; not removed.
*/
export const listItems = mysqlTable(
'list_items',
{
id: int().primaryKey().autoincrement(),
listId: int('list_id')
.notNull()
.references(() => lists.id, { onDelete: 'cascade' }),
text: varchar('text', { length: 500 }).notNull(),
checked: boolean('checked').default(false).notNull(),
rank: varchar('rank', { length: 255 }).notNull(), // fractional-indexing string (D-13)
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().onUpdateNow(),
},
(t) => [
// Composite index covers ordered list fetch: WHERE list_id=? ORDER BY rank
index('idx_list_items_list_id_rank').on(t.listId, t.rank),
// Secondary index for checked/unchecked split queries
index('idx_list_items_list_id_checked').on(t.listId, t.checked),
],
)