Files
bookorbit/server/src/db/schema/metadata.ts
T
neon b0b21b2b78 fix(kobo): version cover ids from the cover, not the metadata row
Any write to book_metadata bumped updated_at and minted a fresh
CoverImageId, so a device holding that cover locally fell back to the
placeholder offline. Stamp cover_updated_at at each cover write instead;
migration 0080 backfills it from updated_at so no id churns on upgrade.

Also trims the DECIMAL(10,2) scale Booklore hands back for a series
index, which sorted 2.50 after 2.9. Closes #943.
2026-08-27 17:36:32 +00:00

340 lines
15 KiB
TypeScript

import { sql } from 'drizzle-orm';
import {
boolean,
check,
customType,
date,
index,
integer,
jsonb,
pgTable,
primaryKey,
real,
serial,
text,
timestamp,
unique,
uniqueIndex,
varchar,
} from 'drizzle-orm/pg-core';
const embedding256 = customType<{ data: number[]; driverData: string }>({
dataType: () => 'vector(256)',
toDriver: (v) => `[${v.join(',')}]`,
fromDriver: (v) => {
if (typeof v !== 'string' || !v || v === '[]') return [];
return v
.slice(1, -1)
.split(',')
.map((n) => parseFloat(n));
},
});
import { books } from './books';
import { bookSeries } from './series';
export const bookMetadata = pgTable(
'book_metadata',
{
bookId: integer('book_id')
.primaryKey()
.references(() => books.id, { onDelete: 'cascade' }),
title: varchar('title', { length: 1000 }),
subtitle: varchar('subtitle', { length: 1000 }),
description: text('description'),
isbn10: varchar('isbn10', { length: 10 }),
isbn13: varchar('isbn13', { length: 13 }),
publisher: varchar('publisher', { length: 500 }),
publishedDate: date('published_date', { mode: 'string' }),
publishedYear: integer('published_year'),
language: varchar('language', { length: 100 }),
pageCount: integer('page_count'),
seriesId: integer('series_id').references(() => bookSeries.id, { onDelete: 'set null' }),
seriesName: varchar('series_name', { length: 500 }),
seriesIndex: varchar('series_index', { length: 20 }),
rating: integer('rating'),
coverSource: varchar('cover_source', { length: 9 }),
googleBooksId: varchar('google_books_id', { length: 50 }),
goodreadsId: varchar('goodreads_id', { length: 50 }),
amazonId: varchar('amazon_id', { length: 20 }),
hardcoverId: varchar('hardcover_id', { length: 255 }),
hardcoverEditionId: varchar('hardcover_edition_id', { length: 50 }),
openLibraryId: varchar('open_library_id', { length: 50 }),
itunesId: varchar('itunes_id', { length: 50 }),
koboId: varchar('kobo_id', { length: 255 }),
metadataScore: integer('metadata_score'),
lastMetadataFetchAt: timestamp('last_metadata_fetch_at', { withTimezone: true }),
embedding: embedding256('embedding'),
lastWrittenAt: timestamp('last_written_at', { withTimezone: true }),
durationSeconds: integer('duration_seconds'),
abridged: boolean('abridged').notNull().default(false),
audibleId: varchar('audible_id', { length: 20 }),
librofmId: varchar('librofm_id', { length: 50 }),
comicvineId: varchar('comicvine_id', { length: 50 }),
ranobedbId: varchar('ranobedb_id', { length: 50 }),
lubimyczytacId: text('lubimyczytac_id'),
aladinId: varchar('aladin_id', { length: 20 }),
chapters: jsonb('chapters'),
lockedFields: text('locked_fields')
.array()
.notNull()
.default(sql`'{}'::text[]`),
// Moves only when the served cover image changes, so a Kobo device is not handed a fresh
// CoverImageId for a picture it already stores locally. See buildVersionedCoverImageId().
coverUpdatedAt: timestamp('cover_updated_at', { withTimezone: true }),
updatedAt: timestamp('updated_at', { withTimezone: true })
.defaultNow()
.notNull()
.$onUpdateFn(() => new Date()),
},
(t) => [
index('bm_title_trgm_idx').using('gin', t.title.op('gin_trgm_ops')),
index('bm_title_unaccent_trgm_idx').using('gin', sql`public.bookorbit_unaccent(${t.title}) gin_trgm_ops`),
index('bm_title_lower_idx').on(sql`lower(${t.title})`),
index('bm_title_book_id_idx').on(t.title, t.bookId),
index('bm_series_trgm_idx').using('gin', t.seriesName.op('gin_trgm_ops')),
index('bm_series_unaccent_trgm_idx').using('gin', sql`public.bookorbit_unaccent(${t.seriesName}) gin_trgm_ops`),
index('bm_series_id_idx').on(t.seriesId),
index('bm_series_id_index_book_id_idx').on(
t.seriesId,
sql`(CASE WHEN ${t.seriesIndex} IS NULL THEN NULL ELSE ARRAY[split_part(${t.seriesIndex}, '.', 1)::numeric, CASE WHEN strpos(${t.seriesIndex}, '.') = 0 THEN -1::numeric ELSE split_part(${t.seriesIndex}, '.', 2)::numeric END] END)`,
sql`${t.seriesIndex} COLLATE "C"`,
t.bookId,
),
index('bm_series_index_sort_idx').on(
sql`(CASE WHEN ${t.seriesIndex} IS NULL THEN NULL ELSE ARRAY[split_part(${t.seriesIndex}, '.', 1)::numeric, CASE WHEN strpos(${t.seriesIndex}, '.') = 0 THEN -1::numeric ELSE split_part(${t.seriesIndex}, '.', 2)::numeric END] END)`,
sql`${t.seriesIndex} COLLATE "C"`,
t.bookId,
),
index('bm_series_name_lower_btrim_idx').on(sql`lower(btrim(${t.seriesName}))`),
index('bm_publisher_trgm_idx').using('gin', t.publisher.op('gin_trgm_ops')),
index('bm_publisher_unaccent_trgm_idx').using('gin', sql`public.bookorbit_unaccent(${t.publisher}) gin_trgm_ops`),
index('bm_language_idx').on(t.language),
index('bm_language_trgm_idx').using('gin', t.language.op('gin_trgm_ops')),
index('bm_language_unaccent_trgm_idx').using('gin', sql`public.bookorbit_unaccent(${t.language}) gin_trgm_ops`),
index('bm_published_date_idx').on(t.publishedDate),
index('bm_published_year_idx').on(t.publishedYear),
index('bm_published_date_sort_idx').on(sql`coalesce(${t.publishedDate}, make_date(${t.publishedYear}, 1, 1))`),
index('bm_series_name_index_idx').on(
t.seriesName,
sql`(CASE WHEN ${t.seriesIndex} IS NULL THEN NULL ELSE ARRAY[split_part(${t.seriesIndex}, '.', 1)::numeric, CASE WHEN strpos(${t.seriesIndex}, '.') = 0 THEN -1::numeric ELSE split_part(${t.seriesIndex}, '.', 2)::numeric END] END)`,
sql`${t.seriesIndex} COLLATE "C"`,
),
index('bm_isbn10_idx').on(t.isbn10),
index('bm_isbn13_idx').on(t.isbn13),
index('bm_embedding_hnsw_cosine_idx').using('hnsw', sql`${t.embedding} vector_cosine_ops`),
check('book_metadata_rating_range_chk', sql`${t.rating} is null or (${t.rating} >= 1 and ${t.rating} <= 10)`),
check(
'book_metadata_published_date_range_chk',
sql`${t.publishedDate} is null or (extract(year from ${t.publishedDate}) >= 1000 and extract(year from ${t.publishedDate}) <= 2200)`,
),
check('book_metadata_published_year_range_chk', sql`${t.publishedYear} is null or (${t.publishedYear} >= 1000 and ${t.publishedYear} <= 2200)`),
check('book_metadata_page_count_nonnegative_chk', sql`${t.pageCount} is null or ${t.pageCount} >= 0`),
check('book_metadata_series_index_format_chk', sql`${t.seriesIndex} is null or ${t.seriesIndex} ~ '^[0-9]+([.][0-9]+)?$'`),
check('book_metadata_duration_seconds_nonnegative_chk', sql`${t.durationSeconds} is null or ${t.durationSeconds} >= 0`),
check('book_metadata_cover_source_chk', sql`${t.coverSource} is null or ${t.coverSource} in ('extracted', 'custom')`),
],
);
export const bookCommunityRatings = pgTable(
'book_community_ratings',
{
bookId: integer('book_id')
.notNull()
.references(() => books.id, { onDelete: 'cascade' }),
provider: varchar('provider', { length: 50 }).notNull(),
rating: real('rating').notNull(),
ratingCount: integer('rating_count'),
updatedAt: timestamp('updated_at', { withTimezone: true })
.defaultNow()
.notNull()
.$onUpdateFn(() => new Date()),
},
(t) => [
primaryKey({ columns: [t.bookId, t.provider] }),
index('book_community_ratings_provider_idx').on(t.provider),
index('book_community_ratings_rating_book_idx').on(t.rating, t.bookId),
index('book_community_ratings_provider_rating_book_idx').on(t.provider, t.rating, t.bookId),
check('book_community_ratings_rating_range_chk', sql`${t.rating} >= 0 and ${t.rating} <= 5`),
check('book_community_ratings_count_nonnegative_chk', sql`${t.ratingCount} is null or ${t.ratingCount} >= 0`),
],
);
export const authors = pgTable(
'authors',
{
id: serial('id').primaryKey(),
name: varchar('name', { length: 500 }).notNull(),
sortName: varchar('sort_name', { length: 500 }),
description: text('description'),
hasPhoto: boolean('has_photo').notNull().default(false),
birthDate: date('birth_date', { mode: 'string' }),
birthYear: integer('birth_year'),
deathDate: date('death_date', { mode: 'string' }),
deathYear: integer('death_year'),
website: varchar('website', { length: 2048 }),
genres: text('genres').array(),
influences: text('influences').array(),
metadataProvider: varchar('metadata_provider', { length: 50 }),
metadataProviderId: varchar('metadata_provider_id', { length: 128 }),
lastEnrichedAt: timestamp('last_enriched_at', { withTimezone: true }),
},
(t) => [
unique('authors_name_unique').on(t.name),
index('authors_name_trgm_idx').using('gin', t.name.op('gin_trgm_ops')),
index('authors_name_unaccent_trgm_idx').using('gin', sql`public.bookorbit_unaccent(${t.name}) gin_trgm_ops`),
],
);
export const bookAuthors = pgTable(
'book_authors',
{
bookId: integer('book_id')
.notNull()
.references(() => books.id, { onDelete: 'cascade' }),
authorId: integer('author_id')
.notNull()
.references(() => authors.id),
displayOrder: integer('display_order').notNull().default(0),
},
(t) => [
primaryKey({ columns: [t.bookId, t.authorId] }),
index('book_authors_author_id_idx').on(t.authorId),
index('book_authors_book_display_idx').on(t.bookId, t.displayOrder),
index('book_authors_book_display_author_idx').on(t.bookId, t.displayOrder, t.authorId),
],
);
export const authorEnrichmentQueue = pgTable(
'author_enrichment_queue',
{
authorId: integer('author_id')
.primaryKey()
.references(() => authors.id, { onDelete: 'cascade' }),
status: varchar('status', { length: 20 }).notNull().default('queued'),
reason: varchar('reason', { length: 50 }).notNull().default('unknown'),
attemptCount: integer('attempt_count').notNull().default(0),
nextAttemptAt: timestamp('next_attempt_at', { withTimezone: true }).notNull().defaultNow(),
lastAttemptAt: timestamp('last_attempt_at', { withTimezone: true }),
lastSuccessAt: timestamp('last_success_at', { withTimezone: true }),
lastError: text('last_error'),
lastHttpStatus: integer('last_http_status'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true })
.notNull()
.defaultNow()
.$onUpdateFn(() => new Date()),
},
(t) => [
index('author_enrichment_queue_status_next_attempt_idx').on(t.status, t.nextAttemptAt),
index('author_enrichment_queue_next_attempt_idx').on(t.nextAttemptAt),
uniqueIndex('author_enrichment_queue_single_processing_idx')
.on(t.status)
.where(sql`${t.status} = 'processing'`),
check('author_enrichment_queue_status_chk', sql`${t.status} in ('queued', 'processing', 'rate_limited', 'failed', 'done')`),
check(
'author_enrichment_queue_reason_chk',
sql`${t.reason} in ('unknown', 'metadata_replace', 'manual_backfill', 'manual_backfill_all', 'author_rename', 'author_merge_target')`,
),
check('author_enrichment_queue_attempt_count_nonnegative_chk', sql`${t.attemptCount} >= 0`),
],
);
export const genres = pgTable(
'genres',
{
id: serial('id').primaryKey(),
name: varchar('name', { length: 200 }).notNull().unique(),
},
(t) => [
index('genres_name_trgm_idx').using('gin', t.name.op('gin_trgm_ops')),
index('genres_name_unaccent_trgm_idx').using('gin', sql`public.bookorbit_unaccent(${t.name}) gin_trgm_ops`),
],
);
export const bookGenres = pgTable(
'book_genres',
{
bookId: integer('book_id')
.notNull()
.references(() => books.id, { onDelete: 'cascade' }),
genreId: integer('genre_id')
.notNull()
.references(() => genres.id, { onDelete: 'cascade' }),
},
(t) => [primaryKey({ columns: [t.bookId, t.genreId] }), index('book_genres_genre_id_idx').on(t.genreId)],
);
export const tags = pgTable(
'tags',
{
id: serial('id').primaryKey(),
name: varchar('name', { length: 200 }).notNull().unique(),
},
(t) => [
index('tags_name_trgm_idx').using('gin', t.name.op('gin_trgm_ops')),
index('tags_name_unaccent_trgm_idx').using('gin', sql`public.bookorbit_unaccent(${t.name}) gin_trgm_ops`),
],
);
export const bookTags = pgTable(
'book_tags',
{
bookId: integer('book_id')
.notNull()
.references(() => books.id, { onDelete: 'cascade' }),
tagId: integer('tag_id')
.notNull()
.references(() => tags.id, { onDelete: 'cascade' }),
},
(t) => [primaryKey({ columns: [t.bookId, t.tagId] }), index('book_tags_tag_id_idx').on(t.tagId)],
);
export type BookMetadata = typeof bookMetadata.$inferSelect;
export type NewBookMetadata = typeof bookMetadata.$inferInsert;
export type BookCommunityRating = typeof bookCommunityRatings.$inferSelect;
export type NewBookCommunityRating = typeof bookCommunityRatings.$inferInsert;
export type Author = typeof authors.$inferSelect;
export type NewAuthor = typeof authors.$inferInsert;
export type Genre = typeof genres.$inferSelect;
export type NewGenre = typeof genres.$inferInsert;
export type Tag = typeof tags.$inferSelect;
export type NewTag = typeof tags.$inferInsert;
export type AuthorEnrichmentQueue = typeof authorEnrichmentQueue.$inferSelect;
export type NewAuthorEnrichmentQueue = typeof authorEnrichmentQueue.$inferInsert;
export const bookMetadataFetchQueue = pgTable(
'book_metadata_fetch_queue',
{
bookId: integer('book_id')
.primaryKey()
.references(() => books.id, { onDelete: 'cascade' }),
status: varchar('status', { length: 20 }).notNull().default('queued'),
reason: varchar('reason', { length: 50 }).notNull().default('manual_trigger'),
attemptCount: integer('attempt_count').notNull().default(0),
lastAttemptAt: timestamp('last_attempt_at', { withTimezone: true }),
lastError: text('last_error'),
lastHttpStatus: integer('last_http_status'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true })
.notNull()
.defaultNow()
.$onUpdateFn(() => new Date()),
},
(t) => [
index('bmfq_status_idx').on(t.status),
index('bmfq_created_at_idx').on(t.createdAt),
index('bmfq_status_created_book_idx').on(t.status, t.createdAt, t.bookId),
check('book_metadata_fetch_queue_status_chk', sql`${t.status} in ('queued', 'processing', 'failed')`),
check('book_metadata_fetch_queue_reason_chk', sql`${t.reason} in ('event_import', 'manual_trigger', 'manual_retry')`),
check('book_metadata_fetch_queue_attempt_count_nonnegative_chk', sql`${t.attemptCount} >= 0`),
],
);
export type BookMetadataFetchQueue = typeof bookMetadataFetchQueue.$inferSelect;
export type NewBookMetadataFetchQueue = typeof bookMetadataFetchQueue.$inferInsert;