- schema.ts: export localCredentials = mysqlTable('local_credentials', {...})
- user_id FK->users(cascade), username, password_hash, createdAt, updatedAt
- UNIQUE(user_id), UNIQUE(username), INDEX(user_id)
- 0003_warm_deathstrike.sql: purely additive CREATE TABLE (no ALTER/DROP/TRUNCATE on existing tables)
- Applied to dev DB: pnpm --filter @familysync/api db:migrate exits 0
- test/setup.ts: add localCredentials to afterEach delete cleanup (FK-safe ordering)
- generate-secrets.mjs: emit LOCAL_SESSION_SECRET (base64 32-byte, >=32 chars, D-05)
- .dockerignore: add apps/api/scripts/ exclusion (D-15/IMG-02) — entire break-glass dir excluded
381 lines
17 KiB
TypeScript
381 lines
17 KiB
TypeScript
// Source: https://orm.drizzle.team/docs/sql-schema-declaration
|
|
import {
|
|
mysqlTable,
|
|
mysqlEnum,
|
|
varchar,
|
|
text,
|
|
int,
|
|
date,
|
|
timestamp,
|
|
boolean,
|
|
index,
|
|
unique,
|
|
customType,
|
|
} from 'drizzle-orm/mysql-core';
|
|
|
|
// Custom varchar type with explicit binary collation.
|
|
// Drizzle 0.45.x does not expose a first-class collation option on varchar,
|
|
// so we use customType to emit `varchar(255) COLLATE utf8mb4_bin`.
|
|
// utf8mb4_bin is required for fractional-indexing rank keys: uppercase-prefixed
|
|
// ranks (e.g. 'Zz') must sort BEFORE lowercase ranks (e.g. 'a0') — matching JS
|
|
// string order — so that drag-to-top persists across a DB ORDER BY rank.
|
|
const varcharBin = (name: string) =>
|
|
customType<{ data: string; driverData: string }>({
|
|
dataType: () => 'varchar(255) COLLATE utf8mb4_bin',
|
|
})(name);
|
|
|
|
// ── 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).
|
|
* isAdmin: first-login-wins bootstrap (D-01); gated by app_config.setup_complete in Phase 12.
|
|
*
|
|
* Phase 12 additions (D-07):
|
|
* oidcIss / oidcSub: now nullable — wizard creates a local user row before OIDC identity
|
|
* is known; first-login-claims (D-08) binds them on first OIDC login.
|
|
* claimed: false = pending wizard user (no OIDC identity bound yet);
|
|
* true = identity already bound (existing OIDC users backfilled via 0002 migration).
|
|
*
|
|
* MariaDB null semantics: multiple NULL+NULL pairs are allowed in a unique index
|
|
* (NULLs are DISTINCT per ISO SQL / MariaDB), so the uniq_oidc_identity constraint
|
|
* correctly permits multiple unclaimed rows (D-07, RESEARCH Pitfall 9).
|
|
*/
|
|
export const users = mysqlTable(
|
|
'users',
|
|
{
|
|
id: int().primaryKey().autoincrement(),
|
|
// Phase 12: nullable — set by first-login-claims (D-08) after wizard completes
|
|
oidcIss: varchar('oidc_iss', { length: 512 }),
|
|
oidcSub: varchar('oidc_sub', { length: 256 }),
|
|
displayName: varchar('display_name', { length: 256 }),
|
|
color: varchar('color', { length: 7 }).notNull(), // hex e.g. '#4A90D9'
|
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
// v1.1 (Phase 10): admin role flag — first-login-wins; Phase 12 tightens bootstrap
|
|
isAdmin: boolean('is_admin').default(false).notNull(),
|
|
// Phase 12: claimed=false → unclaimed wizard row; claimed=true → OIDC identity bound (D-07)
|
|
claimed: boolean('claimed').default(false).notNull(),
|
|
},
|
|
(t) => [
|
|
// Composite unique key — identity is iss+sub, never email (D-10).
|
|
// NULL+NULL pairs are DISTINCT in MariaDB unique indexes → multiple unclaimed rows allowed.
|
|
unique('uniq_oidc_identity').on(t.oidcIss, t.oidcSub),
|
|
],
|
|
);
|
|
|
|
/**
|
|
* Encrypted provider credentials per member (D-04 generic provider shape, Fastmail/CalDAV first).
|
|
* Keyed by user_id (oidc_sub → user row). Backend-only, never exposed to frontend.
|
|
* Stored as JSON: { iv, authTag, ciphertext } (AES-256-GCM).
|
|
* providerType: generic discriminator (D-04); 'caldav' is the only implemented provider.
|
|
* UNIQUE(user_id) enforces one-credential-per-member (D-05) and enables onDuplicateKeyUpdate upsert.
|
|
*/
|
|
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(),
|
|
// v1.1 (Phase 10, D-04): generic provider discriminator; 'caldav' default for existing rows
|
|
providerType: varchar('provider_type', { length: 64 }).notNull().default('caldav'),
|
|
},
|
|
(t) => [
|
|
index('idx_member_credentials_user_id').on(t.userId),
|
|
// v1.1 (Phase 10, D-05): one credential per member; enables Drizzle onDuplicateKeyUpdate upsert
|
|
unique('uniq_member_credential_user').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
|
|
// Readable event title extracted from VEVENT SUMMARY by sync.ts (D-02/NOTIF-01).
|
|
// Nullable: populated by Phase 5 sync update; pre-existing rows remain NULL until resynced.
|
|
title: varchar('title', { length: 500 }),
|
|
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(),
|
|
// v1.1 (Phase 10): reminder lead time in minutes; nullable — consumed by Phase 11
|
|
reminderLeadMinutes: int('reminder_lead_minutes'),
|
|
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),
|
|
],
|
|
);
|
|
|
|
/**
|
|
* Push notification subscriptions (Phase 5 — Web Push).
|
|
*
|
|
* Member-count-agnostic (D-18): one row per browser push subscription endpoint.
|
|
* endpoint is globally unique — a single device endpoint belongs to exactly one user.
|
|
* Cascade delete on user removal keeps subscriptions clean without orphan cleanup jobs.
|
|
*/
|
|
export const pushSubscriptions = mysqlTable(
|
|
'push_subscriptions',
|
|
{
|
|
id: int().primaryKey().autoincrement(),
|
|
userId: int('user_id')
|
|
.notNull()
|
|
.references(() => users.id, { onDelete: 'cascade' }),
|
|
// CR-02: varchar(2048) matches the Zod max(2048) bound and avoids the InnoDB
|
|
// silent prefix-index truncation that occurs on unbounded text columns.
|
|
// varchar(512) for p256dh matches the Zod max(512) bound.
|
|
endpoint: varchar('endpoint', { length: 2048 }).notNull(),
|
|
p256dh: varchar('p256dh', { length: 512 }).notNull(),
|
|
auth: varchar('auth', { length: 256 }).notNull(),
|
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
updatedAt: timestamp('updated_at').defaultNow().onUpdateNow(),
|
|
},
|
|
(t) => [
|
|
// One endpoint is globally unique — a single device maps to exactly one subscription row
|
|
unique('uniq_push_endpoint').on(t.endpoint),
|
|
index('idx_push_subscriptions_user_id').on(t.userId),
|
|
],
|
|
);
|
|
|
|
/**
|
|
* Global app configuration — simple key/value store.
|
|
*
|
|
* v1.1 (Phase 10): ships the table; consumed by downstream phases:
|
|
* - setup_complete (boolean string 'true'/'false'): Phase 12 setup wizard writes this key
|
|
* after first-time setup; Phase 10's first-login-wins admin bootstrap reads it in Phase 12.
|
|
* Key: 'setup_complete', Value: 'true' | 'false' | null (not yet set → treated as false).
|
|
*
|
|
* Phase 12 additional keys (written by the wizard, never by this schema):
|
|
* - 'oidc_issuer' OIDC issuer URL configured by the operator (e.g. Authelia base URL)
|
|
* - 'oidc_client_id' OIDC client_id registered in Authelia
|
|
* - 'vapid_public_key' VAPID public key (base64url) for Web Push — safe to store here
|
|
* - 'app_external_url' External URL of the PWA (used in push payloads, OIDC redirect URI)
|
|
*
|
|
* PROHIBITION (D-01 / SC-3): NEVER add columns or keys for:
|
|
* - 'vapid_private_key' — injected via docker-compose.yml env only; never persisted
|
|
* - 'app_password_encryption_key' — injected via docker-compose.yml env only; never persisted
|
|
*
|
|
* Do NOT add setup_complete gating logic here — Phase 12 owns that.
|
|
*/
|
|
export const appConfig = mysqlTable('app_config', {
|
|
key: varchar('key', { length: 128 }).primaryKey(),
|
|
value: text('value'), // nullable
|
|
updatedAt: timestamp('updated_at').defaultNow().onUpdateNow(),
|
|
});
|
|
|
|
/**
|
|
* Local authentication credentials per member (Phase 19 — D-09).
|
|
*
|
|
* Stores username + PHC-encoded scrypt password hash for members who authenticate
|
|
* via local username/password rather than (or before) OIDC.
|
|
*
|
|
* Design decisions:
|
|
* - Separate table from `users` to keep the users row identity-method-agnostic (D-09).
|
|
* - A user has a local login iff a `local_credentials` row exists (UNIQUE on user_id).
|
|
* - OIDC-link flow (D-12): when a local user links OIDC, their `local_credentials`
|
|
* row is deleted — they become OIDC-only.
|
|
* - CASCADE DELETE on users.id keeps credentials clean when a member is removed.
|
|
* - username is globally unique (login identifier, separate from displayName).
|
|
* - password_hash is PHC-encoded: scrypt$N$r$p$<salt_b64url>$<hash_b64url> (varchar 256).
|
|
*
|
|
* PROHIBITION: LOCAL_SESSION_SECRET (the signing key for this table's sessions) is an
|
|
* env-only secret and must NEVER be stored in this table or app_config (SC-3).
|
|
*/
|
|
export const localCredentials = mysqlTable(
|
|
'local_credentials',
|
|
{
|
|
id: int().primaryKey().autoincrement(),
|
|
userId: int('user_id')
|
|
.notNull()
|
|
.references(() => users.id, { onDelete: 'cascade' }),
|
|
username: varchar('username', { length: 128 }).notNull(),
|
|
// PHC-encoded: scrypt$N$r$p$<salt_base64url>$<hash_base64url> — max ~83 chars
|
|
passwordHash: varchar('password_hash', { length: 256 }).notNull(),
|
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
updatedAt: timestamp('updated_at').defaultNow().onUpdateNow(),
|
|
},
|
|
(t) => [
|
|
// One local credential per user — user_id is unique (D-09: auth method is per-user property)
|
|
unique('uniq_local_cred_user').on(t.userId),
|
|
// Username is globally unique (login identifier; case-sensitive per MariaDB default)
|
|
unique('uniq_local_cred_username').on(t.username),
|
|
// Index for fast lookup by user_id (e.g., on middleware / self-change-password)
|
|
index('idx_local_credentials_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: varcharBin('rank').notNull(), // fractional-indexing string (D-13) — COLLATE utf8mb4_bin
|
|
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),
|
|
],
|
|
);
|