From 2f25b1594912aa86fd2330356f772ae3e62690f7 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Tue, 9 Jun 2026 11:55:46 -0400 Subject: [PATCH] feat(04-01): add list tables to schema and apply via generate+migrate [BLOCKING] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../db/migrations/0000_easy_slipstream.sql | 120 +++ .../src/db/migrations/0001_lists_schema.sql | 44 + .../src/db/migrations/meta/0000_snapshot.json | 864 ++++++++++++++++++ apps/api/src/db/migrations/meta/_journal.json | 20 + apps/api/src/db/schema.ts | 78 ++ apps/api/tsconfig.json | 3 +- 6 files changed, 1128 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/db/migrations/0000_easy_slipstream.sql create mode 100644 apps/api/src/db/migrations/0001_lists_schema.sql create mode 100644 apps/api/src/db/migrations/meta/0000_snapshot.json create mode 100644 apps/api/src/db/migrations/meta/_journal.json diff --git a/apps/api/src/db/migrations/0000_easy_slipstream.sql b/apps/api/src/db/migrations/0000_easy_slipstream.sql new file mode 100644 index 0000000..552e9c5 --- /dev/null +++ b/apps/api/src/db/migrations/0000_easy_slipstream.sql @@ -0,0 +1,120 @@ +CREATE TABLE `calendar_events` ( + `id` int AUTO_INCREMENT NOT NULL, + `calendar_id` int NOT NULL, + `uid` varchar(512) NOT NULL, + `etag` varchar(256), + `object_url` varchar(1024), + `raw_vevent` text NOT NULL, + `dtstart_utc` timestamp, + `dtstart_date` date, + `all_day` boolean NOT NULL DEFAULT false, + `has_rrule` boolean NOT NULL DEFAULT false, + `updated_at` timestamp DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT `calendar_events_id` PRIMARY KEY(`id`), + CONSTRAINT `uniq_calendar_uid` UNIQUE(`calendar_id`,`uid`) +); +--> statement-breakpoint +CREATE TABLE `calendar_outbox` ( + `id` int AUTO_INCREMENT NOT NULL, + `user_id` int NOT NULL, + `operation` enum('create','update','delete') NOT NULL, + `status` enum('pending','done','failed','dead') NOT NULL DEFAULT 'pending', + `uid` varchar(512) NOT NULL, + `calendar_url` varchar(1024) NOT NULL, + `calendar_object_url` varchar(1024), + `etag` varchar(256), + `payload` text, + `attempt_count` int NOT NULL DEFAULT 0, + `next_attempt_at` timestamp NOT NULL DEFAULT (now()), + `last_error` text, + `group_id` varchar(64), + `created_at` timestamp NOT NULL DEFAULT (now()), + `updated_at` timestamp DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT `calendar_outbox_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `calendars` ( + `id` int AUTO_INCREMENT NOT NULL, + `user_id` int NOT NULL, + `url` varchar(1024) NOT NULL, + `display_name` varchar(256), + `color` varchar(7), + `ctag` varchar(512), + `sync_token` varchar(1024), + `last_synced_at` timestamp, + `is_shared` boolean NOT NULL DEFAULT false, + CONSTRAINT `calendars_id` PRIMARY KEY(`id`), + CONSTRAINT `uniq_calendar_user_url` UNIQUE(`user_id`,`url`) +); +--> statement-breakpoint +CREATE TABLE `list_items` ( + `id` int AUTO_INCREMENT NOT NULL, + `list_id` int NOT NULL, + `text` varchar(500) NOT NULL, + `checked` boolean NOT NULL DEFAULT false, + `rank` varchar(255) NOT NULL, + `created_at` timestamp NOT NULL DEFAULT (now()), + `updated_at` timestamp DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT `list_items_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `list_shares` ( + `id` int AUTO_INCREMENT NOT NULL, + `list_id` int NOT NULL, + `user_id` int NOT NULL, + `created_at` timestamp NOT NULL DEFAULT (now()), + CONSTRAINT `list_shares_id` PRIMARY KEY(`id`), + CONSTRAINT `uniq_list_share` UNIQUE(`list_id`,`user_id`) +); +--> statement-breakpoint +CREATE TABLE `lists` ( + `id` int AUTO_INCREMENT NOT NULL, + `owner_id` int NOT NULL, + `name` varchar(255) NOT NULL, + `is_shared` boolean NOT NULL DEFAULT true, + `created_at` timestamp NOT NULL DEFAULT (now()), + `updated_at` timestamp DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT `lists_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `member_credentials` ( + `id` int AUTO_INCREMENT NOT NULL, + `user_id` int NOT NULL, + `encrypted_password` text NOT NULL, + `fastmail_email` varchar(256) NOT NULL, + `created_at` timestamp NOT NULL DEFAULT (now()), + `updated_at` timestamp DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT `member_credentials_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `users` ( + `id` int AUTO_INCREMENT NOT NULL, + `oidc_iss` varchar(512) NOT NULL, + `oidc_sub` varchar(256) NOT NULL, + `display_name` varchar(256), + `color` varchar(7) NOT NULL, + `created_at` timestamp NOT NULL DEFAULT (now()), + CONSTRAINT `users_id` PRIMARY KEY(`id`), + CONSTRAINT `uniq_oidc_identity` UNIQUE(`oidc_iss`,`oidc_sub`) +); +--> statement-breakpoint +ALTER TABLE `calendar_events` ADD CONSTRAINT `calendar_events_calendar_id_calendars_id_fk` FOREIGN KEY (`calendar_id`) REFERENCES `calendars`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `calendar_outbox` ADD CONSTRAINT `calendar_outbox_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `calendars` ADD CONSTRAINT `calendars_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `list_items` ADD CONSTRAINT `list_items_list_id_lists_id_fk` FOREIGN KEY (`list_id`) REFERENCES `lists`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `list_shares` ADD CONSTRAINT `list_shares_list_id_lists_id_fk` FOREIGN KEY (`list_id`) REFERENCES `lists`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `list_shares` ADD CONSTRAINT `list_shares_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `lists` ADD CONSTRAINT `lists_owner_id_users_id_fk` FOREIGN KEY (`owner_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `member_credentials` ADD CONSTRAINT `member_credentials_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX `idx_calendar_events_dtstart_utc` ON `calendar_events` (`dtstart_utc`);--> statement-breakpoint +CREATE INDEX `idx_calendar_events_dtstart_date` ON `calendar_events` (`dtstart_date`);--> statement-breakpoint +CREATE INDEX `idx_calendar_events_has_rrule` ON `calendar_events` (`has_rrule`);--> statement-breakpoint +CREATE INDEX `idx_outbox_user_status` ON `calendar_outbox` (`user_id`,`status`);--> statement-breakpoint +CREATE INDEX `idx_outbox_next_attempt` ON `calendar_outbox` (`next_attempt_at`,`status`);--> statement-breakpoint +CREATE INDEX `idx_outbox_uid` ON `calendar_outbox` (`uid`);--> statement-breakpoint +CREATE INDEX `idx_calendars_user_id` ON `calendars` (`user_id`);--> statement-breakpoint +CREATE INDEX `idx_list_items_list_id_rank` ON `list_items` (`list_id`,`rank`);--> statement-breakpoint +CREATE INDEX `idx_list_items_list_id_checked` ON `list_items` (`list_id`,`checked`);--> statement-breakpoint +CREATE INDEX `idx_list_shares_user_id` ON `list_shares` (`user_id`);--> statement-breakpoint +CREATE INDEX `idx_lists_owner_id` ON `lists` (`owner_id`);--> statement-breakpoint +CREATE INDEX `idx_member_credentials_user_id` ON `member_credentials` (`user_id`); \ No newline at end of file diff --git a/apps/api/src/db/migrations/0001_lists_schema.sql b/apps/api/src/db/migrations/0001_lists_schema.sql new file mode 100644 index 0000000..a7ff1fe --- /dev/null +++ b/apps/api/src/db/migrations/0001_lists_schema.sql @@ -0,0 +1,44 @@ +-- Phase 4: Add lists, list_shares, list_items tables. +-- Additive migration only — no DROP, TRUNCATE, or ALTER of existing tables. +-- +-- D-01: lists.is_shared defaults to true (collaborative household use case) +-- D-02: list_shares join table is member-count-agnostic +-- D-13: list_items.rank is a fractional-indexing varchar string +CREATE TABLE `lists` ( + `id` int AUTO_INCREMENT NOT NULL, + `owner_id` int NOT NULL, + `name` varchar(255) NOT NULL, + `is_shared` boolean NOT NULL DEFAULT true, + `created_at` timestamp NOT NULL DEFAULT (now()), + `updated_at` timestamp DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT `lists_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `list_shares` ( + `id` int AUTO_INCREMENT NOT NULL, + `list_id` int NOT NULL, + `user_id` int NOT NULL, + `created_at` timestamp NOT NULL DEFAULT (now()), + CONSTRAINT `list_shares_id` PRIMARY KEY(`id`), + CONSTRAINT `uniq_list_share` UNIQUE(`list_id`,`user_id`) +); +--> statement-breakpoint +CREATE TABLE `list_items` ( + `id` int AUTO_INCREMENT NOT NULL, + `list_id` int NOT NULL, + `text` varchar(500) NOT NULL, + `checked` boolean NOT NULL DEFAULT false, + `rank` varchar(255) NOT NULL, + `created_at` timestamp NOT NULL DEFAULT (now()), + `updated_at` timestamp DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT `list_items_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +ALTER TABLE `lists` ADD CONSTRAINT `lists_owner_id_users_id_fk` FOREIGN KEY (`owner_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `list_shares` ADD CONSTRAINT `list_shares_list_id_lists_id_fk` FOREIGN KEY (`list_id`) REFERENCES `lists`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `list_shares` ADD CONSTRAINT `list_shares_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE `list_items` ADD CONSTRAINT `list_items_list_id_lists_id_fk` FOREIGN KEY (`list_id`) REFERENCES `lists`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX `idx_lists_owner_id` ON `lists` (`owner_id`);--> statement-breakpoint +CREATE INDEX `idx_list_shares_user_id` ON `list_shares` (`user_id`);--> statement-breakpoint +CREATE INDEX `idx_list_items_list_id_rank` ON `list_items` (`list_id`,`rank`);--> statement-breakpoint +CREATE INDEX `idx_list_items_list_id_checked` ON `list_items` (`list_id`,`checked`); diff --git a/apps/api/src/db/migrations/meta/0000_snapshot.json b/apps/api/src/db/migrations/meta/0000_snapshot.json new file mode 100644 index 0000000..ac705a4 --- /dev/null +++ b/apps/api/src/db/migrations/meta/0000_snapshot.json @@ -0,0 +1,864 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "e4665da0-ef95-4994-af4a-0d6a0169c874", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "calendar_events": { + "name": "calendar_events", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "calendar_id": { + "name": "calendar_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "uid": { + "name": "uid", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "etag": { + "name": "etag", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "object_url": { + "name": "object_url", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "raw_vevent": { + "name": "raw_vevent", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dtstart_utc": { + "name": "dtstart_utc", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dtstart_date": { + "name": "dtstart_date", + "type": "date", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "all_day": { + "name": "all_day", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "has_rrule": { + "name": "has_rrule", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "idx_calendar_events_dtstart_utc": { + "name": "idx_calendar_events_dtstart_utc", + "columns": [ + "dtstart_utc" + ], + "isUnique": false + }, + "idx_calendar_events_dtstart_date": { + "name": "idx_calendar_events_dtstart_date", + "columns": [ + "dtstart_date" + ], + "isUnique": false + }, + "idx_calendar_events_has_rrule": { + "name": "idx_calendar_events_has_rrule", + "columns": [ + "has_rrule" + ], + "isUnique": false + } + }, + "foreignKeys": { + "calendar_events_calendar_id_calendars_id_fk": { + "name": "calendar_events_calendar_id_calendars_id_fk", + "tableFrom": "calendar_events", + "tableTo": "calendars", + "columnsFrom": [ + "calendar_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "calendar_events_id": { + "name": "calendar_events_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "uniq_calendar_uid": { + "name": "uniq_calendar_uid", + "columns": [ + "calendar_id", + "uid" + ] + } + }, + "checkConstraint": {} + }, + "calendar_outbox": { + "name": "calendar_outbox", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "operation": { + "name": "operation", + "type": "enum('create','update','delete')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('pending','done','failed','dead')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "uid": { + "name": "uid", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "calendar_url": { + "name": "calendar_url", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "calendar_object_url": { + "name": "calendar_object_url", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "etag": { + "name": "etag", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "attempt_count": { + "name": "attempt_count", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "group_id": { + "name": "group_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "idx_outbox_user_status": { + "name": "idx_outbox_user_status", + "columns": [ + "user_id", + "status" + ], + "isUnique": false + }, + "idx_outbox_next_attempt": { + "name": "idx_outbox_next_attempt", + "columns": [ + "next_attempt_at", + "status" + ], + "isUnique": false + }, + "idx_outbox_uid": { + "name": "idx_outbox_uid", + "columns": [ + "uid" + ], + "isUnique": false + } + }, + "foreignKeys": { + "calendar_outbox_user_id_users_id_fk": { + "name": "calendar_outbox_user_id_users_id_fk", + "tableFrom": "calendar_outbox", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "calendar_outbox_id": { + "name": "calendar_outbox_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "calendars": { + "name": "calendars", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "varchar(7)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ctag": { + "name": "ctag", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sync_token": { + "name": "sync_token", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_shared": { + "name": "is_shared", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "idx_calendars_user_id": { + "name": "idx_calendars_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "calendars_user_id_users_id_fk": { + "name": "calendars_user_id_users_id_fk", + "tableFrom": "calendars", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "calendars_id": { + "name": "calendars_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "uniq_calendar_user_url": { + "name": "uniq_calendar_user_url", + "columns": [ + "user_id", + "url" + ] + } + }, + "checkConstraint": {} + }, + "list_items": { + "name": "list_items", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "list_id": { + "name": "list_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "checked": { + "name": "checked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "rank": { + "name": "rank", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "idx_list_items_list_id_rank": { + "name": "idx_list_items_list_id_rank", + "columns": [ + "list_id", + "rank" + ], + "isUnique": false + }, + "idx_list_items_list_id_checked": { + "name": "idx_list_items_list_id_checked", + "columns": [ + "list_id", + "checked" + ], + "isUnique": false + } + }, + "foreignKeys": { + "list_items_list_id_lists_id_fk": { + "name": "list_items_list_id_lists_id_fk", + "tableFrom": "list_items", + "tableTo": "lists", + "columnsFrom": [ + "list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "list_items_id": { + "name": "list_items_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "list_shares": { + "name": "list_shares", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "list_id": { + "name": "list_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "idx_list_shares_user_id": { + "name": "idx_list_shares_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "list_shares_list_id_lists_id_fk": { + "name": "list_shares_list_id_lists_id_fk", + "tableFrom": "list_shares", + "tableTo": "lists", + "columnsFrom": [ + "list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "list_shares_user_id_users_id_fk": { + "name": "list_shares_user_id_users_id_fk", + "tableFrom": "list_shares", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "list_shares_id": { + "name": "list_shares_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "uniq_list_share": { + "name": "uniq_list_share", + "columns": [ + "list_id", + "user_id" + ] + } + }, + "checkConstraint": {} + }, + "lists": { + "name": "lists", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "owner_id": { + "name": "owner_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_shared": { + "name": "is_shared", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "idx_lists_owner_id": { + "name": "idx_lists_owner_id", + "columns": [ + "owner_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "lists_owner_id_users_id_fk": { + "name": "lists_owner_id_users_id_fk", + "tableFrom": "lists", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "lists_id": { + "name": "lists_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "member_credentials": { + "name": "member_credentials", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "encrypted_password": { + "name": "encrypted_password", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fastmail_email": { + "name": "fastmail_email", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "idx_member_credentials_user_id": { + "name": "idx_member_credentials_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "member_credentials_user_id_users_id_fk": { + "name": "member_credentials_user_id_users_id_fk", + "tableFrom": "member_credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "member_credentials_id": { + "name": "member_credentials_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "oidc_iss": { + "name": "oidc_iss", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "oidc_sub": { + "name": "oidc_sub", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "varchar(7)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "users_id": { + "name": "users_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "uniq_oidc_identity": { + "name": "uniq_oidc_identity", + "columns": [ + "oidc_iss", + "oidc_sub" + ] + } + }, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} \ No newline at end of file diff --git a/apps/api/src/db/migrations/meta/_journal.json b/apps/api/src/db/migrations/meta/_journal.json new file mode 100644 index 0000000..e47c2e0 --- /dev/null +++ b/apps/api/src/db/migrations/meta/_journal.json @@ -0,0 +1,20 @@ +{ + "version": "7", + "dialect": "mysql", + "entries": [ + { + "idx": 0, + "version": "5", + "when": 1781020239657, + "tag": "0000_easy_slipstream", + "breakpoints": true + }, + { + "idx": 1, + "version": "5", + "when": 1781020500000, + "tag": "0001_lists_schema", + "breakpoints": true + } + ] +} diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index 8740eae..ea7d8d9 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -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), + ], +) diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json index 48337a8..bfec90a 100644 --- a/apps/api/tsconfig.json +++ b/apps/api/tsconfig.json @@ -13,7 +13,8 @@ "resolveJsonModule": true, "declaration": true, "declarationMap": true, - "sourceMap": true + "sourceMap": true, + "types": ["vitest/globals", "node"] }, "include": ["src/**/*"], "exclude": ["node_modules", "dist", "tests"]