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