feat(db): add foreign keys and cascade deletes

Enforce referential integrity by linking application tables to the auth user table across both PostgreSQL and SQLite schemas.

- Add foreign key references with ON DELETE CASCADE to all user-owned tables.
- Re-generate initial migrations for Postgres and SQLite providers.
- Update migration script to ensure the SQLite database directory exists.
- Remove explicit SSL configuration from the PostgreSQL connection pool.
This commit is contained in:
Richard R 2026-02-15 13:02:42 -07:00
parent bb853acaf6
commit bedbbb026a
10 changed files with 228 additions and 37 deletions

View file

@ -133,6 +133,12 @@ CREATE TABLE "verification" (
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "audiobook_chapters" ADD CONSTRAINT "audiobook_chapters_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "audiobooks" ADD CONSTRAINT "audiobooks_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "documents" ADD CONSTRAINT "documents_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user_document_progress" ADD CONSTRAINT "user_document_progress_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user_preferences" ADD CONSTRAINT "user_preferences_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user_tts_chars" ADD CONSTRAINT "user_tts_chars_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "idx_document_previews_status_lease" ON "document_previews" USING btree ("status","lease_until_ms");--> statement-breakpoint

View file

@ -1,5 +1,5 @@
{
"id": "1c067f9b-aa2c-4099-a3b0-021e1a1c7ecb",
"id": "369ec851-a528-4b18-a4c9-b8ea703283ca",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
@ -59,7 +59,21 @@
}
},
"indexes": {},
"foreignKeys": {},
"foreignKeys": {
"audiobook_chapters_user_id_user_id_fk": {
"name": "audiobook_chapters_user_id_user_id_fk",
"tableFrom": "audiobook_chapters",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"audiobook_chapters_id_user_id_pk": {
"name": "audiobook_chapters_id_user_id_pk",
@ -130,7 +144,21 @@
}
},
"indexes": {},
"foreignKeys": {},
"foreignKeys": {
"audiobooks_user_id_user_id_fk": {
"name": "audiobooks_user_id_user_id_fk",
"tableFrom": "audiobooks",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"audiobooks_id_user_id_pk": {
"name": "audiobooks_id_user_id_pk",
@ -392,7 +420,21 @@
"with": {}
}
},
"foreignKeys": {},
"foreignKeys": {
"documents_user_id_user_id_fk": {
"name": "documents_user_id_user_id_fk",
"tableFrom": "documents",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"documents_id_user_id_pk": {
"name": "documents_id_user_id_pk",
@ -486,7 +528,21 @@
"with": {}
}
},
"foreignKeys": {},
"foreignKeys": {
"user_document_progress_user_id_user_id_fk": {
"name": "user_document_progress_user_id_user_id_fk",
"tableFrom": "user_document_progress",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"user_document_progress_user_id_document_id_pk": {
"name": "user_document_progress_user_id_document_id_pk",
@ -541,7 +597,21 @@
}
},
"indexes": {},
"foreignKeys": {},
"foreignKeys": {
"user_preferences_user_id_user_id_fk": {
"name": "user_preferences_user_id_user_id_fk",
"tableFrom": "user_preferences",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
@ -603,7 +673,21 @@
"with": {}
}
},
"foreignKeys": {},
"foreignKeys": {
"user_tts_chars_user_id_user_id_fk": {
"name": "user_tts_chars_user_id_user_id_fk",
"tableFrom": "user_tts_chars",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"user_tts_chars_user_id_date_pk": {
"name": "user_tts_chars_user_id_date_pk",

View file

@ -5,8 +5,8 @@
{
"idx": 0,
"version": "7",
"when": 1771184128245,
"tag": "0000_complex_ricochet",
"when": 1771185356470,
"tag": "0000_stormy_zaladane",
"breakpoints": true
}
]

View file

@ -26,6 +26,16 @@ const hasConfigArg = extraArgs.includes('--config');
const configFile = process.env.POSTGRES_URL ? 'drizzle.config.pg.ts' : 'drizzle.config.sqlite.ts';
const configArgs = hasConfigArg ? [] : ['--config', configFile];
// Ensure the docstore directory exists for SQLite migrations.
// drizzle-kit opens the database file directly and will fail if the parent
// directory is missing (e.g. in a fresh CI checkout).
if (!process.env.POSTGRES_URL) {
const dbDir = path.join(process.cwd(), 'docstore');
if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, { recursive: true });
}
}
const result = spawnSync('drizzle-kit', ['migrate', ...configArgs, ...extraArgs], {
stdio: 'inherit',
env: process.env,

View file

@ -7,7 +7,8 @@ CREATE TABLE `audiobook_chapters` (
`duration` real DEFAULT 0,
`file_path` text NOT NULL,
`format` text NOT NULL,
PRIMARY KEY(`id`, `user_id`)
PRIMARY KEY(`id`, `user_id`),
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `audiobooks` (
@ -19,7 +20,8 @@ CREATE TABLE `audiobooks` (
`cover_path` text,
`duration` real DEFAULT 0,
`created_at` integer DEFAULT (cast(strftime('%s','now') as int) * 1000),
PRIMARY KEY(`id`, `user_id`)
PRIMARY KEY(`id`, `user_id`),
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `document_previews` (
@ -53,7 +55,8 @@ CREATE TABLE `documents` (
`last_modified` integer NOT NULL,
`file_path` text NOT NULL,
`created_at` integer DEFAULT (cast(strftime('%s','now') as int) * 1000),
PRIMARY KEY(`id`, `user_id`)
PRIMARY KEY(`id`, `user_id`),
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `idx_documents_user_id` ON `documents` (`user_id`);--> statement-breakpoint
@ -67,7 +70,8 @@ CREATE TABLE `user_document_progress` (
`client_updated_at_ms` integer DEFAULT 0 NOT NULL,
`created_at` integer DEFAULT (cast(strftime('%s','now') as int) * 1000),
`updated_at` integer DEFAULT (cast(strftime('%s','now') as int) * 1000),
PRIMARY KEY(`user_id`, `document_id`)
PRIMARY KEY(`user_id`, `document_id`),
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `idx_user_document_progress_user_id_updated_at` ON `user_document_progress` (`user_id`,`updated_at`);--> statement-breakpoint
@ -76,7 +80,8 @@ CREATE TABLE `user_preferences` (
`data_json` text DEFAULT '{}' NOT NULL,
`client_updated_at_ms` integer DEFAULT 0 NOT NULL,
`created_at` integer DEFAULT (cast(strftime('%s','now') as int) * 1000),
`updated_at` integer DEFAULT (cast(strftime('%s','now') as int) * 1000)
`updated_at` integer DEFAULT (cast(strftime('%s','now') as int) * 1000),
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `user_tts_chars` (
@ -85,7 +90,8 @@ CREATE TABLE `user_tts_chars` (
`char_count` integer DEFAULT 0,
`created_at` integer DEFAULT (cast(strftime('%s','now') as int) * 1000),
`updated_at` integer DEFAULT (cast(strftime('%s','now') as int) * 1000),
PRIMARY KEY(`user_id`, `date`)
PRIMARY KEY(`user_id`, `date`),
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `idx_user_tts_chars_date` ON `user_tts_chars` (`date`);--> statement-breakpoint

View file

@ -1,7 +1,7 @@
{
"version": "6",
"dialect": "sqlite",
"id": "b45e86c4-7d98-4cfb-9b38-5d1423b17b5e",
"id": "a55d11c6-847c-4e19-9a7c-fbc2401df061",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"audiobook_chapters": {
@ -66,7 +66,21 @@
}
},
"indexes": {},
"foreignKeys": {},
"foreignKeys": {
"audiobook_chapters_user_id_user_id_fk": {
"name": "audiobook_chapters_user_id_user_id_fk",
"tableFrom": "audiobook_chapters",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"audiobook_chapters_id_user_id_pk": {
"columns": [
@ -142,7 +156,21 @@
}
},
"indexes": {},
"foreignKeys": {},
"foreignKeys": {
"audiobooks_user_id_user_id_fk": {
"name": "audiobooks_user_id_user_id_fk",
"tableFrom": "audiobooks",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"audiobooks_id_user_id_pk": {
"columns": [
@ -389,7 +417,21 @@
"isUnique": false
}
},
"foreignKeys": {},
"foreignKeys": {
"documents_user_id_user_id_fk": {
"name": "documents_user_id_user_id_fk",
"tableFrom": "documents",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"documents_id_user_id_pk": {
"columns": [
@ -475,7 +517,21 @@
"isUnique": false
}
},
"foreignKeys": {},
"foreignKeys": {
"user_document_progress_user_id_user_id_fk": {
"name": "user_document_progress_user_id_user_id_fk",
"tableFrom": "user_document_progress",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"user_document_progress_user_id_document_id_pk": {
"columns": [
@ -532,7 +588,21 @@
}
},
"indexes": {},
"foreignKeys": {},
"foreignKeys": {
"user_preferences_user_id_user_id_fk": {
"name": "user_preferences_user_id_user_id_fk",
"tableFrom": "user_preferences",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
@ -588,7 +658,21 @@
"isUnique": false
}
},
"foreignKeys": {},
"foreignKeys": {
"user_tts_chars_user_id_user_id_fk": {
"name": "user_tts_chars_user_id_user_id_fk",
"tableFrom": "user_tts_chars",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"user_tts_chars_user_id_date_pk": {
"columns": [

View file

@ -5,8 +5,8 @@
{
"idx": 0,
"version": "6",
"when": 1771184127967,
"tag": "0000_eager_rick_jones",
"when": 1771185356176,
"tag": "0000_furry_dragon_man",
"breakpoints": true
}
]

View file

@ -17,7 +17,6 @@ function getDrizzleDB() {
if (process.env.POSTGRES_URL) {
const pool = new Pool({
connectionString: process.env.POSTGRES_URL,
ssl: process.env.NODE_ENV === "production" ? { rejectUnauthorized: false } : false,
});
dbInstance = drizzlePg(pool, { schema: { ...schema, ...authSchemaPostgres } });
} else {

View file

@ -1,8 +1,9 @@
import { pgTable, text, integer, real, timestamp, date, bigint, primaryKey, index, jsonb } from 'drizzle-orm/pg-core';
import { user } from './schema_auth_postgres';
export const documents = pgTable('documents', {
id: text('id').notNull(),
userId: text('user_id').notNull(),
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
type: text('type').notNull(), // pdf, epub, docx, html
size: bigint('size', { mode: 'number' }).notNull(),
@ -17,7 +18,7 @@ export const documents = pgTable('documents', {
export const audiobooks = pgTable('audiobooks', {
id: text('id').notNull(),
userId: text('user_id').notNull(),
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
title: text('title').notNull(),
author: text('author'),
description: text('description'),
@ -31,7 +32,7 @@ export const audiobooks = pgTable('audiobooks', {
export const audiobookChapters = pgTable('audiobook_chapters', {
id: text('id').notNull(),
bookId: text('book_id').notNull(),
userId: text('user_id').notNull(),
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
chapterIndex: integer('chapter_index').notNull(),
title: text('title').notNull(),
duration: real('duration').default(0),
@ -46,7 +47,7 @@ export const audiobookChapters = pgTable('audiobook_chapters', {
// defined here. Only application-specific tables belong in this file.
export const userTtsChars = pgTable("user_tts_chars", {
userId: text('user_id').notNull(),
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
date: date('date').notNull(),
charCount: bigint('char_count', { mode: 'number' }).default(0),
createdAt: timestamp('created_at').defaultNow(),
@ -57,7 +58,7 @@ export const userTtsChars = pgTable("user_tts_chars", {
}));
export const userPreferences = pgTable('user_preferences', {
userId: text('user_id').primaryKey(),
userId: text('user_id').primaryKey().references(() => user.id, { onDelete: 'cascade' }),
dataJson: jsonb('data_json').notNull().default({}),
clientUpdatedAtMs: bigint('client_updated_at_ms', { mode: 'number' }).notNull().default(0),
createdAt: timestamp('created_at').defaultNow(),
@ -65,7 +66,7 @@ export const userPreferences = pgTable('user_preferences', {
});
export const userDocumentProgress = pgTable('user_document_progress', {
userId: text('user_id').notNull(),
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
documentId: text('document_id').notNull(),
readerType: text('reader_type').notNull(), // pdf, epub, html
location: text('location').notNull(),

View file

@ -1,9 +1,10 @@
import { sqliteTable, text, integer, real, primaryKey, index } from 'drizzle-orm/sqlite-core';
import { sql } from 'drizzle-orm';
import { user } from './schema_auth_sqlite';
export const documents = sqliteTable('documents', {
id: text('id').notNull(),
userId: text('user_id').notNull(),
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
type: text('type').notNull(), // pdf, epub, docx, html
size: integer('size').notNull(),
@ -18,7 +19,7 @@ export const documents = sqliteTable('documents', {
export const audiobooks = sqliteTable('audiobooks', {
id: text('id').notNull(),
userId: text('user_id').notNull(),
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
title: text('title').notNull(),
author: text('author'),
description: text('description'),
@ -32,7 +33,7 @@ export const audiobooks = sqliteTable('audiobooks', {
export const audiobookChapters = sqliteTable('audiobook_chapters', {
id: text('id').notNull(),
bookId: text('book_id').notNull(),
userId: text('user_id').notNull(),
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
chapterIndex: integer('chapter_index').notNull(),
title: text('title').notNull(),
duration: real('duration').default(0),
@ -47,7 +48,7 @@ export const audiobookChapters = sqliteTable('audiobook_chapters', {
// defined here. Only application-specific tables belong in this file.
export const userTtsChars = sqliteTable("user_tts_chars", {
userId: text('user_id').notNull(),
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
date: text('date').notNull(), // SQLite doesn't have native DATE type, text YYYY-MM-DD is standard
charCount: integer('char_count').default(0),
createdAt: integer('created_at').default(sql`(cast(strftime('%s','now') as int) * 1000)`),
@ -58,7 +59,7 @@ export const userTtsChars = sqliteTable("user_tts_chars", {
}));
export const userPreferences = sqliteTable('user_preferences', {
userId: text('user_id').primaryKey(),
userId: text('user_id').primaryKey().references(() => user.id, { onDelete: 'cascade' }),
dataJson: text('data_json').notNull().default('{}'),
clientUpdatedAtMs: integer('client_updated_at_ms').notNull().default(0),
createdAt: integer('created_at').default(sql`(cast(strftime('%s','now') as int) * 1000)`),
@ -66,7 +67,7 @@ export const userPreferences = sqliteTable('user_preferences', {
});
export const userDocumentProgress = sqliteTable('user_document_progress', {
userId: text('user_id').notNull(),
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
documentId: text('document_id').notNull(),
readerType: text('reader_type').notNull(), // pdf, epub, html
location: text('location').notNull(),