openreader/src/db/schema_postgres.ts
Richard R 9a8bc060d9 feat(data): enhance anonymous claim to support document settings and TTS segment copy
Expand the anonymous data claim process to include document settings transfer and TTS segment S3 prefix copying. Remove foreign key constraints from user_tts_chars to allow non-user buckets. Update claim modal and onboarding flow to display claimed document settings. Refactor TTS char count transfer to merge all dates and fix upsert logic. Add S3 copy utility for TTS segments and corresponding tests. Update migrations and schema to reflect relaxed constraints.
2026-06-06 19:12:40 -06:00

236 lines
11 KiB
TypeScript

import { sql } from 'drizzle-orm';
import { pgTable, text, integer, real, date, bigint, primaryKey, index, jsonb, foreignKey } from 'drizzle-orm/pg-core';
import { user } from './schema_auth_postgres';
const PG_NOW_MS = sql`(extract(epoch from now()) * 1000)::bigint`;
export const documents = pgTable('documents', {
id: text('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(),
lastModified: bigint('last_modified', { mode: 'number' }).notNull(),
filePath: text('file_path').notNull(),
createdAt: bigint('created_at', { mode: 'number' }).default(PG_NOW_MS),
}, (table) => [
primaryKey({ columns: [table.id, table.userId] }),
index('idx_documents_user_id').on(table.userId),
index('idx_documents_user_id_last_modified').on(table.userId, table.lastModified),
]);
export const audiobooks = pgTable('audiobooks', {
id: text('id').notNull(),
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
title: text('title').notNull(),
author: text('author'),
description: text('description'),
coverPath: text('cover_path'),
duration: real('duration').default(0),
createdAt: bigint('created_at', { mode: 'number' }).default(PG_NOW_MS),
}, (table) => [
primaryKey({ columns: [table.id, table.userId] }),
]);
export const audiobookChapters = pgTable('audiobook_chapters', {
id: text('id').notNull(),
bookId: text('book_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),
filePath: text('file_path').notNull(),
format: text('format').notNull(), // mp3, m4b
}, (table) => [
primaryKey({ columns: [table.id, table.userId] }),
foreignKey({
columns: [table.bookId, table.userId],
foreignColumns: [audiobooks.id, audiobooks.userId],
}).onDelete('cascade'),
]);
// Auth tables (user, session, account, verification) are managed by Better Auth.
// They are created/migrated via `@better-auth/cli migrate` and should NOT be
// defined here. Only application-specific tables belong in this file.
export const userTtsChars = pgTable("user_tts_chars", {
// Also stores device:* and ip:* backstop buckets, so this cannot reference user.id.
userId: text('user_id').notNull(),
date: date('date').notNull(),
charCount: bigint('char_count', { mode: 'number' }).default(0),
createdAt: bigint('created_at', { mode: 'number' }).default(PG_NOW_MS),
updatedAt: bigint('updated_at', { mode: 'number' }).default(PG_NOW_MS),
}, (table) => [
primaryKey({ columns: [table.userId, table.date] }),
index('idx_user_tts_chars_date').on(table.date),
]);
// Generic per-user job-creation ledger for rate/concurrency limiting of
// expensive compute operations (e.g. PDF layout parsing). One row per created
// worker op. A trailing-window COUNT over (user_id, action) enforces both a
// short-window burst cap and a wider sustained/concurrency cap; because the
// worker bounds each op by a hard cap, "ops created in the last hard-cap
// window" is an upper bound on in-flight ops. Old rows are pruned opportunistically.
export const userJobEvents = pgTable('user_job_events', {
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
action: text('action').notNull(),
opId: text('op_id').notNull(),
createdAt: bigint('created_at', { mode: 'number' }).notNull().default(PG_NOW_MS),
}, (table) => [
primaryKey({ columns: [table.userId, table.action, table.opId] }),
index('idx_user_job_events_user_action_created').on(table.userId, table.action, table.createdAt),
]);
export const userPreferences = pgTable('user_preferences', {
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: bigint('created_at', { mode: 'number' }).default(PG_NOW_MS),
updatedAt: bigint('updated_at', { mode: 'number' }).default(PG_NOW_MS),
});
export const documentSettings = pgTable('document_settings', {
documentId: text('document_id').notNull(),
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
dataJson: jsonb('data_json').notNull().default({}),
clientUpdatedAtMs: bigint('client_updated_at_ms', { mode: 'number' }).notNull().default(0),
createdAt: bigint('created_at', { mode: 'number' }).default(PG_NOW_MS),
updatedAt: bigint('updated_at', { mode: 'number' }).default(PG_NOW_MS),
}, (table) => [
primaryKey({ columns: [table.documentId, table.userId] }),
index('idx_document_settings_user_id').on(table.userId),
]);
export const userDocumentProgress = pgTable('user_document_progress', {
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(),
progress: real('progress'),
clientUpdatedAtMs: bigint('client_updated_at_ms', { mode: 'number' }).notNull().default(0),
createdAt: bigint('created_at', { mode: 'number' }).default(PG_NOW_MS),
updatedAt: bigint('updated_at', { mode: 'number' }).default(PG_NOW_MS),
}, (table) => [
primaryKey({ columns: [table.userId, table.documentId] }),
index('idx_user_document_progress_user_id_updated_at').on(table.userId, table.updatedAt),
]);
export const documentPreviews = pgTable('document_previews', {
documentId: text('document_id').notNull(),
namespace: text('namespace').notNull().default(''),
variant: text('variant').notNull(),
status: text('status').notNull().default('queued'),
sourceLastModifiedMs: bigint('source_last_modified_ms', { mode: 'number' }).notNull(),
objectKey: text('object_key').notNull(),
contentType: text('content_type').notNull().default('image/jpeg'),
width: integer('width').notNull(),
height: integer('height'),
byteSize: bigint('byte_size', { mode: 'number' }),
eTag: text('etag'),
leaseOwner: text('lease_owner'),
leaseUntilMs: bigint('lease_until_ms', { mode: 'number' }).notNull().default(0),
attemptCount: integer('attempt_count').notNull().default(0),
lastError: text('last_error'),
createdAtMs: bigint('created_at_ms', { mode: 'number' }).notNull().default(0),
updatedAtMs: bigint('updated_at_ms', { mode: 'number' }).notNull().default(0),
}, (table) => [
primaryKey({ columns: [table.documentId, table.namespace, table.variant] }),
index('idx_document_previews_status_lease').on(table.status, table.leaseUntilMs),
]);
export const ttsSegmentEntries = pgTable('tts_segment_entries', {
segmentEntryId: text('segment_entry_id').notNull(),
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
documentId: text('document_id').notNull(),
readerType: text('reader_type').notNull(),
documentVersion: bigint('document_version', { mode: 'number' }).notNull(),
segmentIndex: integer('segment_index').notNull(),
segmentKey: text('segment_key'),
locatorReaderRank: integer('locator_reader_rank').notNull(),
locatorReaderType: text('locator_reader_type').notNull(),
locatorPage: integer('locator_page').notNull(),
locatorSpineIndex: integer('locator_spine_index').notNull(),
locatorSpineHref: text('locator_spine_href').notNull(),
locatorCharOffset: integer('locator_char_offset').notNull(),
locatorLocation: text('locator_location').notNull(),
locatorIdentityKey: text('locator_identity_key').notNull(),
textHash: text('text_hash').notNull(),
textLength: integer('text_length').notNull().default(0),
createdAt: bigint('created_at', { mode: 'number' }).default(PG_NOW_MS),
updatedAt: bigint('updated_at', { mode: 'number' }).default(PG_NOW_MS),
}, (table) => [
primaryKey({ columns: [table.segmentEntryId, table.userId] }),
index('idx_tts_segment_entries_manifest_sort').on(
table.userId,
table.documentId,
table.documentVersion,
table.locatorReaderRank,
table.locatorSpineIndex,
table.locatorCharOffset,
table.locatorSpineHref,
table.locatorPage,
table.locatorLocation,
table.segmentIndex,
table.locatorIdentityKey,
),
index('idx_tts_segment_entries_manifest_group').on(
table.userId,
table.documentId,
table.documentVersion,
table.segmentIndex,
table.locatorIdentityKey,
),
index('idx_tts_segment_entries_scope').on(
table.userId,
table.documentId,
table.documentVersion,
),
]);
export const adminProviders = pgTable('admin_providers', {
id: text('id').primaryKey(),
slug: text('slug').notNull().unique(),
displayName: text('display_name').notNull(),
providerType: text('provider_type').notNull(),
baseUrl: text('base_url'),
apiKeyCiphertext: text('api_key_ciphertext').notNull(),
apiKeyIv: text('api_key_iv').notNull(),
apiKeyLast4: text('api_key_last4'),
defaultModel: text('default_model'),
defaultInstructions: text('default_instructions'),
enabled: integer('enabled').notNull().default(1),
createdAt: bigint('created_at', { mode: 'number' }).notNull().default(PG_NOW_MS),
updatedAt: bigint('updated_at', { mode: 'number' }).notNull().default(PG_NOW_MS),
});
export const adminSettings = pgTable('admin_settings', {
key: text('key').primaryKey(),
valueJson: jsonb('value_json').notNull(),
source: text('source').notNull().default('admin'),
updatedAt: bigint('updated_at', { mode: 'number' }).notNull().default(PG_NOW_MS),
});
export const ttsSegmentVariants = pgTable('tts_segment_variants', {
segmentId: text('segment_id').notNull(),
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
segmentEntryId: text('segment_entry_id').notNull(),
settingsHash: text('settings_hash').notNull(),
settingsJson: jsonb('settings_json').notNull(),
audioKey: text('audio_key'),
audioFormat: text('audio_format').notNull().default('mp3'),
durationMs: integer('duration_ms'),
alignmentJson: text('alignment_json'),
status: text('status').notNull().default('pending'),
error: text('error'),
createdAt: bigint('created_at', { mode: 'number' }).default(PG_NOW_MS),
updatedAt: bigint('updated_at', { mode: 'number' }).default(PG_NOW_MS),
}, (table) => [
primaryKey({ columns: [table.segmentId, table.userId] }),
foreignKey({
columns: [table.segmentEntryId, table.userId],
foreignColumns: [ttsSegmentEntries.segmentEntryId, ttsSegmentEntries.userId],
}).onDelete('cascade'),
index('idx_tts_segment_variants_entry').on(table.userId, table.segmentEntryId, table.updatedAt),
index('idx_tts_segment_variants_status').on(table.userId, table.status),
index('idx_tts_segment_variants_unique_settings').on(table.userId, table.segmentEntryId, table.settingsHash),
]);