feat(db): add system user seeding and storage cleanup

- Implement ensureSystemUserExists to handle foreign key constraints for
  unclaimed data when authentication is disabled.
- Add missing foreign key relationship between audiobook chapters and
  audiobooks with cascade delete.
- Integrate a beforeDelete hook in auth config to trigger storage
  cleanup when a user is removed.
- Regenerate initial migrations for both PostgreSQL and SQLite to
  reflect schema updates.
This commit is contained in:
Richard R 2026-02-15 14:00:35 -07:00
parent d3c7831f70
commit 8cb2929116
12 changed files with 257 additions and 78 deletions

View file

@ -134,6 +134,7 @@ CREATE TABLE "verification" (
);
--> 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 "audiobook_chapters" ADD CONSTRAINT "audiobook_chapters_book_id_user_id_audiobooks_id_user_id_fk" FOREIGN KEY ("book_id","user_id") REFERENCES "public"."audiobooks"("id","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

View file

@ -1,5 +1,5 @@
{
"id": "369ec851-a528-4b18-a4c9-b8ea703283ca",
"id": "ad9a34b6-48d9-4e3a-95cd-38941cb2e055",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
@ -72,6 +72,21 @@
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"audiobook_chapters_book_id_user_id_audiobooks_id_user_id_fk": {
"name": "audiobook_chapters_book_id_user_id_audiobooks_id_user_id_fk",
"tableFrom": "audiobook_chapters",
"tableTo": "audiobooks",
"columnsFrom": [
"book_id",
"user_id"
],
"columnsTo": [
"id",
"user_id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {

View file

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

View file

@ -8,7 +8,8 @@ CREATE TABLE `audiobook_chapters` (
`file_path` text NOT NULL,
`format` text NOT NULL,
PRIMARY KEY(`id`, `user_id`),
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`book_id`,`user_id`) REFERENCES `audiobooks`(`id`,`user_id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `audiobooks` (

View file

@ -1,7 +1,7 @@
{
"version": "6",
"dialect": "sqlite",
"id": "a55d11c6-847c-4e19-9a7c-fbc2401df061",
"id": "d7c41fe0-7d08-42af-8f53-d95fd35776c2",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"audiobook_chapters": {
@ -79,6 +79,21 @@
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"audiobook_chapters_book_id_user_id_audiobooks_id_user_id_fk": {
"name": "audiobook_chapters_book_id_user_id_audiobooks_id_user_id_fk",
"tableFrom": "audiobook_chapters",
"tableTo": "audiobooks",
"columnsFrom": [
"book_id",
"user_id"
],
"columnsTo": [
"id",
"user_id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {

View file

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

View file

@ -1,5 +1,6 @@
import { drizzle as drizzlePg } from 'drizzle-orm/node-postgres';
import { drizzle as drizzleSqlite } from 'drizzle-orm/better-sqlite3';
import { sql } from 'drizzle-orm';
import { Pool } from 'pg';
import Database from 'better-sqlite3';
import path from 'path';
@ -8,13 +9,53 @@ import * as schema from './schema';
import * as authSchemaSqlite from './schema_auth_sqlite';
import * as authSchemaPostgres from './schema_auth_postgres';
const UNCLAIMED_USER_ID = 'unclaimed';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let dbInstance: any = null;
let dbIsPostgres = false;
/** Track which system user IDs we have already ensured exist this process. */
const seededUserIds = new Set<string>();
/**
* Ensure a system/placeholder user row exists in the user table for `userId`.
* All user-facing tables now have userId foreign keys with ON DELETE CASCADE
* referencing the user table. When auth is disabled the app stores data under
* the 'unclaimed' userId (and namespace variants like 'unclaimed::ns'), so
* those rows must exist before any data can be inserted.
*
* This is safe to call repeatedly it short-circuits via an in-memory Set
* and uses INSERT ON CONFLICT/OR IGNORE at the SQL level.
*/
export function ensureSystemUserExists(userId: string) {
if (seededUserIds.has(userId)) return;
const drizzleDb = getDrizzleDB();
try {
if (dbIsPostgres) {
drizzleDb.execute(sql`
INSERT INTO "user" (id, name, email, email_verified, created_at, updated_at, is_anonymous)
VALUES (${userId}, 'System User', ${userId + '@local'}, false, now(), now(), false)
ON CONFLICT (id) DO NOTHING
`).catch(() => { /* table may not exist yet */ });
} else {
drizzleDb.run(sql`
INSERT OR IGNORE INTO user (id, name, email, email_verified, created_at, updated_at, is_anonymous)
VALUES (${userId}, 'System User', ${userId + '@local'}, 0, ${Date.now()}, ${Date.now()}, 0)
`);
}
seededUserIds.add(userId);
} catch {
// Silently ignore the user table may not exist yet on first boot before migrations run.
}
}
function getDrizzleDB() {
if (dbInstance) return dbInstance;
if (process.env.POSTGRES_URL) {
dbIsPostgres = !!process.env.POSTGRES_URL;
if (dbIsPostgres) {
const pool = new Pool({
connectionString: process.env.POSTGRES_URL,
});
@ -30,6 +71,8 @@ function getDrizzleDB() {
dbInstance = drizzleSqlite(sqlite, { schema: { ...schema, ...authSchemaSqlite } });
}
ensureSystemUserExists(UNCLAIMED_USER_ID);
return dbInstance;
}
@ -44,3 +87,4 @@ export const db: any = new Proxy({} as any, {
return typeof value === 'function' ? value.bind(instance) : value;
},
});

View file

@ -1,4 +1,4 @@
import { pgTable, text, integer, real, timestamp, date, bigint, primaryKey, index, jsonb } from 'drizzle-orm/pg-core';
import { pgTable, text, integer, real, timestamp, date, bigint, primaryKey, index, jsonb, foreignKey } from 'drizzle-orm/pg-core';
import { user } from './schema_auth_postgres';
export const documents = pgTable('documents', {
@ -40,6 +40,10 @@ export const audiobookChapters = pgTable('audiobook_chapters', {
format: text('format').notNull(), // mp3, m4b
}, (table) => ({
pk: primaryKey({ columns: [table.id, table.userId] }),
bookFk: foreignKey({
columns: [table.bookId, table.userId],
foreignColumns: [audiobooks.id, audiobooks.userId],
}).onDelete('cascade'),
}));
// Auth tables (user, session, account, verification) are managed by Better Auth.

View file

@ -1,4 +1,4 @@
import { sqliteTable, text, integer, real, primaryKey, index } from 'drizzle-orm/sqlite-core';
import { sqliteTable, text, integer, real, primaryKey, index, foreignKey } from 'drizzle-orm/sqlite-core';
import { sql } from 'drizzle-orm';
import { user } from './schema_auth_sqlite';
@ -41,6 +41,10 @@ export const audiobookChapters = sqliteTable('audiobook_chapters', {
format: text('format').notNull(), // mp3, m4b
}, (table) => ({
pk: primaryKey({ columns: [table.id, table.userId] }),
bookFk: foreignKey({
columns: [table.bookId, table.userId],
foreignColumns: [audiobooks.id, audiobooks.userId],
}).onDelete('cascade'),
}));
// Auth tables (user, session, account, verification) are managed by Better Auth.

View file

@ -7,6 +7,7 @@ import type { NextRequest } from 'next/server';
import { db } from "@/db";
import { rateLimiter } from "@/lib/server/rate-limiter";
import { isAuthEnabled, isAnonymousAuthSessionsEnabled } from "@/lib/server/auth-config";
import { deleteUserStorageData } from "@/lib/server/user-data-cleanup";
import * as authSchemaSqlite from "@/db/schema_auth_sqlite";
import * as authSchemaPostgres from "@/db/schema_auth_postgres";
import {
@ -77,6 +78,15 @@ const createAuth = () => betterAuth({
user: {
deleteUser: {
enabled: true,
beforeDelete: async (user) => {
try {
await deleteUserStorageData(user.id, null);
} catch (error) {
console.error('[auth] Failed to clean up user storage before deletion:', error);
// Don't throw allow the user deletion to proceed even if S3 cleanup fails.
// Orphaned blobs are preferable to a blocked account deletion.
}
},
},
},
rateLimit: {
@ -103,76 +113,76 @@ const createAuth = () => betterAuth({
nextCookies(), // Enable Next.js cookie handling
...(isAnonymousAuthSessionsEnabled()
? [
anonymous({
onLinkAccount: async ({ anonymousUser, newUser }) => {
anonymous({
onLinkAccount: async ({ anonymousUser, newUser }) => {
try {
// Log when anonymous user links to a real account
console.log("Anonymous user linked to account:", {
anonymousUserId: anonymousUser.user.id,
newUserId: newUser.user.id,
newUserEmail: newUser.user.email,
});
// Transfer rate limiting data (TTS char counts) from anonymous user to authenticated user
try {
// Log when anonymous user links to a real account
console.log("Anonymous user linked to account:", {
anonymousUserId: anonymousUser.user.id,
newUserId: newUser.user.id,
newUserEmail: newUser.user.email,
});
// Transfer rate limiting data (TTS char counts) from anonymous user to authenticated user
try {
await rateLimiter.transferAnonymousUsage(anonymousUser.user.id, newUser.user.id);
console.log(`Successfully transferred rate limit data from anonymous user ${anonymousUser.user.id} to user ${newUser.user.id}`);
} catch (error) {
console.error("Error transferring rate limit data during account linking:", error);
// Don't throw here to prevent blocking the account linking process
}
// Transfer audiobooks from anonymous user to new authenticated user
try {
const transferred = await transferUserAudiobooks(anonymousUser.user.id, newUser.user.id);
if (transferred > 0) {
console.log(`Successfully transferred ${transferred} audiobook(s) from anonymous user ${anonymousUser.user.id} to user ${newUser.user.id}`);
}
} catch (error) {
console.error("Error transferring audiobooks during account linking:", error);
// Don't throw here to prevent blocking the account linking process
}
// Transfer documents from anonymous user to new authenticated user
try {
const transferred = await transferUserDocuments(anonymousUser.user.id, newUser.user.id);
if (transferred > 0) {
console.log(`Successfully transferred ${transferred} document(s) from anonymous user ${anonymousUser.user.id} to user ${newUser.user.id}`);
}
} catch (error) {
console.error("Error transferring documents during account linking:", error);
// Don't throw here to prevent blocking the account linking process
}
// Transfer preferences from anonymous user to new authenticated user
try {
const transferred = await transferUserPreferences(anonymousUser.user.id, newUser.user.id);
if (transferred > 0) {
console.log(`Successfully transferred preferences from anonymous user ${anonymousUser.user.id} to user ${newUser.user.id}`);
}
} catch (error) {
console.error("Error transferring preferences during account linking:", error);
// Don't throw here to prevent blocking the account linking process
}
// Transfer reading progress from anonymous user to new authenticated user
try {
const transferred = await transferUserProgress(anonymousUser.user.id, newUser.user.id);
if (transferred > 0) {
console.log(`Successfully transferred ${transferred} progress row(s) from anonymous user ${anonymousUser.user.id} to user ${newUser.user.id}`);
}
} catch (error) {
console.error("Error transferring reading progress during account linking:", error);
// Don't throw here to prevent blocking the account linking process
}
await rateLimiter.transferAnonymousUsage(anonymousUser.user.id, newUser.user.id);
console.log(`Successfully transferred rate limit data from anonymous user ${anonymousUser.user.id} to user ${newUser.user.id}`);
} catch (error) {
console.error("Error in onLinkAccount callback:", error);
console.error("Error transferring rate limit data during account linking:", error);
// Don't throw here to prevent blocking the account linking process
}
// Note: Anonymous user will be automatically deleted after this callback completes
},
}),
]
// Transfer audiobooks from anonymous user to new authenticated user
try {
const transferred = await transferUserAudiobooks(anonymousUser.user.id, newUser.user.id);
if (transferred > 0) {
console.log(`Successfully transferred ${transferred} audiobook(s) from anonymous user ${anonymousUser.user.id} to user ${newUser.user.id}`);
}
} catch (error) {
console.error("Error transferring audiobooks during account linking:", error);
// Don't throw here to prevent blocking the account linking process
}
// Transfer documents from anonymous user to new authenticated user
try {
const transferred = await transferUserDocuments(anonymousUser.user.id, newUser.user.id);
if (transferred > 0) {
console.log(`Successfully transferred ${transferred} document(s) from anonymous user ${anonymousUser.user.id} to user ${newUser.user.id}`);
}
} catch (error) {
console.error("Error transferring documents during account linking:", error);
// Don't throw here to prevent blocking the account linking process
}
// Transfer preferences from anonymous user to new authenticated user
try {
const transferred = await transferUserPreferences(anonymousUser.user.id, newUser.user.id);
if (transferred > 0) {
console.log(`Successfully transferred preferences from anonymous user ${anonymousUser.user.id} to user ${newUser.user.id}`);
}
} catch (error) {
console.error("Error transferring preferences during account linking:", error);
// Don't throw here to prevent blocking the account linking process
}
// Transfer reading progress from anonymous user to new authenticated user
try {
const transferred = await transferUserProgress(anonymousUser.user.id, newUser.user.id);
if (transferred > 0) {
console.log(`Successfully transferred ${transferred} progress row(s) from anonymous user ${anonymousUser.user.id} to user ${newUser.user.id}`);
}
} catch (error) {
console.error("Error transferring reading progress during account linking:", error);
// Don't throw here to prevent blocking the account linking process
}
} catch (error) {
console.error("Error in onLinkAccount callback:", error);
// Don't throw here to prevent blocking the account linking process
}
// Note: Anonymous user will be automatically deleted after this callback completes
},
}),
]
: []),
],
});

View file

@ -1,5 +1,6 @@
import path from 'path';
import { UNCLAIMED_USER_ID } from '@/lib/server/docstore';
import { ensureSystemUserExists } from '@/db';
const TEST_NAMESPACE_HEADER = 'x-openreader-test-namespace';
const SAFE_NAMESPACE_REGEX = /^[a-zA-Z0-9._-]{1,128}$/;
@ -24,7 +25,8 @@ export function applyOpenReaderTestNamespacePath(baseDir: string, namespace: str
}
export function getUnclaimedUserIdForNamespace(namespace: string | null): string {
if (!namespace) return UNCLAIMED_USER_ID;
return `${UNCLAIMED_USER_ID}::${namespace}`;
const userId = !namespace ? UNCLAIMED_USER_ID : `${UNCLAIMED_USER_ID}::${namespace}`;
ensureSystemUserExists(userId);
return userId;
}

View file

@ -0,0 +1,83 @@
/**
* Cleans up all S3 storage artifacts belonging to a user.
* Called from Better Auth's `beforeDelete` hook so that blobs are removed
* before the DB cascade wipes the metadata rows we query against.
*/
import { db } from '@/db';
import { documents, audiobooks } from '@/db/schema';
import { eq } from 'drizzle-orm';
import { isS3Configured } from '@/lib/server/s3';
import { deleteDocumentBlob } from '@/lib/server/documents-blobstore';
import { deleteDocumentPreviewArtifacts } from '@/lib/server/document-previews-blobstore';
import { audiobookPrefix, deleteAudiobookPrefix } from '@/lib/server/audiobooks-blobstore';
type DocumentRow = { id: string };
type AudiobookRow = { id: string };
/**
* Delete all S3 blobs owned by `userId`.
*
* This covers:
* - Document file blobs
* - Document preview images
* - Audiobook audio files (chapter mp3s, metadata json, etc.)
*
* Each item is cleaned up independently; a failure on one does not block the rest.
*/
export async function deleteUserStorageData(
userId: string,
namespace: string | null,
): Promise<void> {
if (!isS3Configured()) return;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const database = db as any;
// --- Documents & previews ---
const userDocs: DocumentRow[] = await database
.select({ id: documents.id })
.from(documents)
.where(eq(documents.userId, userId));
let docsDeleted = 0;
for (const doc of userDocs) {
try {
await deleteDocumentBlob(doc.id, namespace);
docsDeleted++;
} catch (error) {
console.error(`[user-data-cleanup] Failed to delete document blob ${doc.id}:`, error);
}
try {
await deleteDocumentPreviewArtifacts(doc.id, namespace);
} catch (error) {
console.error(`[user-data-cleanup] Failed to delete preview for ${doc.id}:`, error);
}
}
// --- Audiobooks ---
const userBooks: AudiobookRow[] = await database
.select({ id: audiobooks.id })
.from(audiobooks)
.where(eq(audiobooks.userId, userId));
let booksDeleted = 0;
for (const book of userBooks) {
try {
const prefix = audiobookPrefix(book.id, userId, namespace);
await deleteAudiobookPrefix(prefix);
booksDeleted++;
} catch (error) {
console.error(`[user-data-cleanup] Failed to delete audiobook blobs ${book.id}:`, error);
}
}
if (docsDeleted > 0 || booksDeleted > 0) {
console.log(
`[user-data-cleanup] Cleaned up S3 data for user ${userId}: ` +
`${docsDeleted}/${userDocs.length} document(s), ` +
`${booksDeleted}/${userBooks.length} audiobook(s)`,
);
}
}