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
This commit is contained in:
Lucas Berger
2026-06-09 11:55:46 -04:00
parent 39d4ec84c0
commit 2f25b15949
6 changed files with 1128 additions and 1 deletions
+78
View File
@@ -12,6 +12,9 @@ import {
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).
@@ -161,3 +164,78 @@ export const calendarOutbox = mysqlTable(
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),
],
)