diff --git a/client/src/features/library/components/LibraryCreatorModal.vue b/client/src/features/library/components/LibraryCreatorModal.vue
index 65388d2ba..7b054b31e 100644
--- a/client/src/features/library/components/LibraryCreatorModal.vue
+++ b/client/src/features/library/components/LibraryCreatorModal.vue
@@ -163,6 +163,8 @@ const sectionProps = computed(() => ({
fileWriteWriteCover: form.fileWriteWriteCover,
fileWriteEpubEnabled: form.fileWriteEpubEnabled,
fileWriteEpubMaxFileSizeMb: form.fileWriteEpubMaxFileSizeMb,
+ fileWriteFb2Enabled: form.fileWriteFb2Enabled,
+ fileWriteFb2MaxFileSizeMb: form.fileWriteFb2MaxFileSizeMb,
fileWritePdfEnabled: form.fileWritePdfEnabled,
fileWritePdfMaxFileSizeMb: form.fileWritePdfMaxFileSizeMb,
fileWriteCbxEnabled: form.fileWriteCbxEnabled,
@@ -277,6 +279,8 @@ const sectionListeners = {
'update:fileWriteWriteCover': (value: boolean) => (form.fileWriteWriteCover = value),
'update:fileWriteEpubEnabled': (value: boolean) => (form.fileWriteEpubEnabled = value),
'update:fileWriteEpubMaxFileSizeMb': (value: number) => (form.fileWriteEpubMaxFileSizeMb = value),
+ 'update:fileWriteFb2Enabled': (value: boolean) => (form.fileWriteFb2Enabled = value),
+ 'update:fileWriteFb2MaxFileSizeMb': (value: number) => (form.fileWriteFb2MaxFileSizeMb = value),
'update:fileWritePdfEnabled': (value: boolean) => (form.fileWritePdfEnabled = value),
'update:fileWritePdfMaxFileSizeMb': (value: number) => (form.fileWritePdfMaxFileSizeMb = value),
'update:fileWriteCbxEnabled': (value: boolean) => (form.fileWriteCbxEnabled = value),
diff --git a/client/src/features/library/components/__tests__/LibraryCreatorFileWrite.spec.ts b/client/src/features/library/components/__tests__/LibraryCreatorFileWrite.spec.ts
index 9833505df..1d3e0ffa5 100644
--- a/client/src/features/library/components/__tests__/LibraryCreatorFileWrite.spec.ts
+++ b/client/src/features/library/components/__tests__/LibraryCreatorFileWrite.spec.ts
@@ -12,6 +12,8 @@ describe('LibraryCreatorFileWrite', () => {
fileWriteWriteCover: false,
fileWriteEpubEnabled: false,
fileWriteEpubMaxFileSizeMb: 100,
+ fileWriteFb2Enabled: false,
+ fileWriteFb2MaxFileSizeMb: 100,
fileWritePdfEnabled: false,
fileWritePdfMaxFileSizeMb: 100,
fileWriteCbxEnabled: false,
@@ -37,54 +39,94 @@ describe('LibraryCreatorFileWrite', () => {
expect(wrapper.emitted('update:fileRenameEnabled')).toEqual([[true]])
})
- it('emits file-write toggles and max-size updates when advanced controls are visible', async () => {
- const wrapper = mountComponent({
- fileRenameEnabled: true,
- fileWriteEnabled: true,
- fileWriteWriteCover: true,
- fileWriteEpubEnabled: true,
- fileWriteEpubMaxFileSizeMb: 10,
- fileWritePdfEnabled: true,
- fileWritePdfMaxFileSizeMb: 20,
- fileWriteCbxEnabled: true,
- fileWriteCbxMaxFileSizeMb: 30,
- fileWriteKindleEnabled: true,
- fileWriteKindleMaxFileSizeMb: 50,
- fileWriteAudioEnabled: true,
- fileWriteAudioMaxFileSizeMb: 40,
- })
+ const ALL_ENABLED = {
+ fileRenameEnabled: true,
+ fileWriteEnabled: true,
+ fileWriteWriteCover: true,
+ fileWriteEpubEnabled: true,
+ fileWriteEpubMaxFileSizeMb: 10,
+ fileWriteFb2Enabled: true,
+ fileWriteFb2MaxFileSizeMb: 60,
+ fileWritePdfEnabled: true,
+ fileWritePdfMaxFileSizeMb: 20,
+ fileWriteCbxEnabled: true,
+ fileWriteCbxMaxFileSizeMb: 30,
+ fileWriteKindleEnabled: true,
+ fileWriteKindleMaxFileSizeMb: 50,
+ fileWriteAudioEnabled: true,
+ fileWriteAudioMaxFileSizeMb: 40,
+ }
- const switches = wrapper.findAll('[role="switch"]')
- expect(switches).toHaveLength(8)
- await switches[1]!.trigger('click')
- await switches[2]!.trigger('click')
- await switches[3]!.trigger('click')
- await switches[4]!.trigger('click')
- await switches[5]!.trigger('click')
- await switches[6]!.trigger('click')
- await switches[7]!.trigger('click')
+ function toggleByLabel(wrapper: ReturnType
, label: string) {
+ const control = wrapper.findAll('[role="switch"]').find((node) => node.attributes('aria-label') === label)
+ if (!control) throw new Error(`no switch labelled "${label}"`)
+ return control.trigger('click')
+ }
+
+ it('emits an update for every format toggle', async () => {
+ const wrapper = mountComponent(ALL_ENABLED)
+
+ expect(wrapper.findAll('[role="switch"]')).toHaveLength(9)
+
+ await toggleByLabel(wrapper, 'Write metadata to files')
+ await toggleByLabel(wrapper, 'Write EPUB metadata')
+ await toggleByLabel(wrapper, 'Write FB2 metadata')
+ await toggleByLabel(wrapper, 'Write PDF metadata')
+ await toggleByLabel(wrapper, 'Write comic archive metadata')
+ await toggleByLabel(wrapper, 'Write Kindle metadata')
+ await toggleByLabel(wrapper, 'Write audio covers')
- const inputs = wrapper.findAll('input[type="number"]')
- expect(inputs).toHaveLength(5)
- await inputs[0]!.setValue('15')
- await inputs[1]!.setValue('25')
- await inputs[2]!.setValue('35')
- await inputs[3]!.setValue('55')
- await inputs[4]!.setValue('45')
expect(wrapper.emitted('update:fileWriteEnabled')).toEqual([[false]])
- expect(wrapper.emitted('update:fileWriteWriteCover')).toEqual([[false]])
expect(wrapper.emitted('update:fileWriteEpubEnabled')).toEqual([[false]])
+ expect(wrapper.emitted('update:fileWriteFb2Enabled')).toEqual([[false]])
expect(wrapper.emitted('update:fileWritePdfEnabled')).toEqual([[false]])
expect(wrapper.emitted('update:fileWriteCbxEnabled')).toEqual([[false]])
expect(wrapper.emitted('update:fileWriteKindleEnabled')).toEqual([[false]])
- expect(wrapper.emitted('update:fileWriteAudioEnabled')).toEqual([[false], [false]])
+ expect(wrapper.emitted('update:fileWriteAudioEnabled')).toEqual([[false]])
+ })
+
+ it('emits max-size updates for every format', async () => {
+ const wrapper = mountComponent(ALL_ENABLED)
+
+ const inputs = wrapper.findAll('input[type="number"]')
+ expect(inputs).toHaveLength(6)
+
+ await wrapper.find('#epub-max-size').setValue('15')
+ await wrapper.find('#fb2-max-size').setValue('65')
+ await wrapper.find('#pdf-max-size').setValue('25')
+ await wrapper.find('#cbx-max-size').setValue('35')
+ await wrapper.find('#kindle-max-size').setValue('55')
+ await wrapper.find('#audio-max-size').setValue('45')
+
expect(wrapper.emitted('update:fileWriteEpubMaxFileSizeMb')).toEqual([[15]])
+ expect(wrapper.emitted('update:fileWriteFb2MaxFileSizeMb')).toEqual([[65]])
expect(wrapper.emitted('update:fileWritePdfMaxFileSizeMb')).toEqual([[25]])
expect(wrapper.emitted('update:fileWriteCbxMaxFileSizeMb')).toEqual([[35]])
expect(wrapper.emitted('update:fileWriteKindleMaxFileSizeMb')).toEqual([[55]])
expect(wrapper.emitted('update:fileWriteAudioMaxFileSizeMb')).toEqual([[45]])
})
+ it('hides the FB2 size input until the FB2 toggle is on', () => {
+ const off = mountComponent({ fileWriteEnabled: true, fileWriteFb2Enabled: false })
+ expect(off.text()).toContain('FictionBook (FB2)')
+ expect(off.find('#fb2-max-size').exists()).toBe(false)
+
+ const on = mountComponent({ fileWriteEnabled: true, fileWriteFb2Enabled: true })
+ expect(on.find('#fb2-max-size').exists()).toBe(true)
+ expect(on.find('#fb2-max-size').attributes('value')).toBe('100')
+ })
+
+ it('shows FB2 controls independently of the cover-writing toggle', () => {
+ const wrapper = mountComponent({ fileWriteEnabled: true, fileWriteWriteCover: false, fileWriteFb2Enabled: true })
+
+ expect(wrapper.text()).toContain('FictionBook (FB2)')
+ expect(wrapper.find('#fb2-max-size').exists()).toBe(true)
+ })
+
+ it('hides the FB2 card until file writing is enabled', () => {
+ expect(mountComponent({ fileWriteEnabled: false, fileWriteFb2Enabled: true }).text()).not.toContain('FictionBook (FB2)')
+ })
+
it('hides the Kindle size input until the Kindle toggle is on', () => {
const off = mountComponent({ fileWriteEnabled: true, fileWriteKindleEnabled: false })
expect(off.text()).toContain('Kindle (MOBI, AZW3)')
diff --git a/client/src/features/library/composables/__tests__/useLibraries.spec.ts b/client/src/features/library/composables/__tests__/useLibraries.spec.ts
index 3e966f888..cbe109c59 100644
--- a/client/src/features/library/composables/__tests__/useLibraries.spec.ts
+++ b/client/src/features/library/composables/__tests__/useLibraries.spec.ts
@@ -28,6 +28,8 @@ function makeLibrary(overrides: Partial = {}): Library {
fileWriteWriteCover: false,
fileWriteEpubEnabled: false,
fileWriteEpubMaxFileSizeMb: 50,
+ fileWriteFb2Enabled: false,
+ fileWriteFb2MaxFileSizeMb: 100,
fileWritePdfEnabled: false,
fileWritePdfMaxFileSizeMb: 50,
fileWriteCbxEnabled: false,
diff --git a/client/src/features/library/composables/__tests__/useLibraryCreator.spec.ts b/client/src/features/library/composables/__tests__/useLibraryCreator.spec.ts
index 4df464865..ba32dcc27 100644
--- a/client/src/features/library/composables/__tests__/useLibraryCreator.spec.ts
+++ b/client/src/features/library/composables/__tests__/useLibraryCreator.spec.ts
@@ -130,6 +130,8 @@ describe('useLibraryCreator', () => {
fileWriteWriteCover: true,
fileWriteEpubEnabled: true,
fileWriteEpubMaxFileSizeMb: 100,
+ fileWriteFb2Enabled: false,
+ fileWriteFb2MaxFileSizeMb: 100,
fileWritePdfEnabled: true,
fileWritePdfMaxFileSizeMb: 100,
fileWriteCbxEnabled: false,
@@ -240,6 +242,8 @@ function makeLibrary(overrides: Partial = {}): Library {
fileWriteWriteCover: true,
fileWriteEpubEnabled: true,
fileWriteEpubMaxFileSizeMb: 100,
+ fileWriteFb2Enabled: false,
+ fileWriteFb2MaxFileSizeMb: 100,
fileWritePdfEnabled: true,
fileWritePdfMaxFileSizeMb: 100,
fileWriteCbxEnabled: false,
diff --git a/client/src/features/library/composables/useLibraryCreator.ts b/client/src/features/library/composables/useLibraryCreator.ts
index 09f2f127a..dfc3f849e 100644
--- a/client/src/features/library/composables/useLibraryCreator.ts
+++ b/client/src/features/library/composables/useLibraryCreator.ts
@@ -38,6 +38,8 @@ function blankForm() {
fileWriteWriteCover: true,
fileWriteEpubEnabled: true,
fileWriteEpubMaxFileSizeMb: 100,
+ fileWriteFb2Enabled: false,
+ fileWriteFb2MaxFileSizeMb: 100,
fileWritePdfEnabled: true,
fileWritePdfMaxFileSizeMb: 100,
fileWriteCbxEnabled: false,
@@ -117,6 +119,8 @@ export function useLibraryCreator() {
form.fileWriteWriteCover = library.fileWriteWriteCover
form.fileWriteEpubEnabled = library.fileWriteEpubEnabled
form.fileWriteEpubMaxFileSizeMb = library.fileWriteEpubMaxFileSizeMb
+ form.fileWriteFb2Enabled = library.fileWriteFb2Enabled
+ form.fileWriteFb2MaxFileSizeMb = library.fileWriteFb2MaxFileSizeMb
form.fileWritePdfEnabled = library.fileWritePdfEnabled
form.fileWritePdfMaxFileSizeMb = library.fileWritePdfMaxFileSizeMb
form.fileWriteCbxEnabled = library.fileWriteCbxEnabled
diff --git a/client/src/features/settings/__tests__/LibrariesSettings.spec.ts b/client/src/features/settings/__tests__/LibrariesSettings.spec.ts
index 51eaccbc0..aa675cf88 100644
--- a/client/src/features/settings/__tests__/LibrariesSettings.spec.ts
+++ b/client/src/features/settings/__tests__/LibrariesSettings.spec.ts
@@ -83,6 +83,8 @@ function makeLibrary(overrides: Partial = {}): Library {
fileWriteWriteCover: false,
fileWriteEpubEnabled: false,
fileWriteEpubMaxFileSizeMb: 100,
+ fileWriteFb2Enabled: false,
+ fileWriteFb2MaxFileSizeMb: 100,
fileWritePdfEnabled: false,
fileWritePdfMaxFileSizeMb: 100,
fileWriteCbxEnabled: false,
diff --git a/client/src/features/settings/metadata-auto-fetch/components/LibraryAutoFetchConfig.spec.ts b/client/src/features/settings/metadata-auto-fetch/components/LibraryAutoFetchConfig.spec.ts
index da382d20b..6d66c8db1 100644
--- a/client/src/features/settings/metadata-auto-fetch/components/LibraryAutoFetchConfig.spec.ts
+++ b/client/src/features/settings/metadata-auto-fetch/components/LibraryAutoFetchConfig.spec.ts
@@ -101,6 +101,8 @@ function makeLibrary(): Library {
fileWriteWriteCover: false,
fileWriteEpubEnabled: false,
fileWriteEpubMaxFileSizeMb: 100,
+ fileWriteFb2Enabled: false,
+ fileWriteFb2MaxFileSizeMb: 100,
fileWritePdfEnabled: false,
fileWritePdfMaxFileSizeMb: 100,
fileWriteCbxEnabled: false,
diff --git a/client/src/locales/de.json b/client/src/locales/de.json
index 77a1dcb88..e7e221003 100644
--- a/client/src/locales/de.json
+++ b/client/src/locales/de.json
@@ -5094,6 +5094,11 @@
"hint": "Schreibt Metadaten in die OPF-Datei innerhalb des EPUB-Archivs.",
"toggleAria": "EPUB-Metadaten schreiben"
},
+ "fb2": {
+ "title": "FictionBook (FB2)",
+ "hint": "Rewrites the description block inside FB2 files, leaving the book text untouched.",
+ "toggleAria": "Write FB2 metadata"
+ },
"pdf": {
"title": "PDF",
"hint": "Bettet Metadaten in das PDF-Info-Dictionary und den XMP-Stream ein.",
diff --git a/client/src/locales/en.json b/client/src/locales/en.json
index c47db8344..5c16f6550 100644
--- a/client/src/locales/en.json
+++ b/client/src/locales/en.json
@@ -5052,6 +5052,11 @@
"hint": "Writes metadata into the OPF file inside the EPUB archive.",
"toggleAria": "Write EPUB metadata"
},
+ "fb2": {
+ "title": "FictionBook (FB2)",
+ "hint": "Rewrites the description block inside FB2 files, leaving the book text untouched.",
+ "toggleAria": "Write FB2 metadata"
+ },
"pdf": {
"title": "PDF",
"hint": "Embeds metadata into PDF Info dictionary and XMP stream.",
diff --git a/client/src/locales/nl.json b/client/src/locales/nl.json
index ad646e96a..6fadd7b05 100644
--- a/client/src/locales/nl.json
+++ b/client/src/locales/nl.json
@@ -5094,6 +5094,11 @@
"hint": "Schrijft metadata naar het OPF-bestand in het EPUB-archief.",
"toggleAria": "EPUB-metadata schrijven"
},
+ "fb2": {
+ "title": "FictionBook (FB2)",
+ "hint": "Rewrites the description block inside FB2 files, leaving the book text untouched.",
+ "toggleAria": "Write FB2 metadata"
+ },
"pdf": {
"title": "PDF",
"hint": "Sluit metadata in de PDF Info-dictionary en XMP-stream in.",
diff --git a/client/src/locales/pt.json b/client/src/locales/pt.json
index 9e9d9ba7a..428d8176e 100644
--- a/client/src/locales/pt.json
+++ b/client/src/locales/pt.json
@@ -5094,6 +5094,11 @@
"hint": "Grava os metadados no arquivo OPF dentro do arquivo EPUB.",
"toggleAria": "Gravar metadados no EPUB"
},
+ "fb2": {
+ "title": "FictionBook (FB2)",
+ "hint": "Rewrites the description block inside FB2 files, leaving the book text untouched.",
+ "toggleAria": "Write FB2 metadata"
+ },
"pdf": {
"title": "PDF",
"hint": "Embutir metadados no dicionário Info do PDF e no fluxo XMP.",
diff --git a/client/src/locales/sl.json b/client/src/locales/sl.json
index 0d0ec6ae6..833887748 100644
--- a/client/src/locales/sl.json
+++ b/client/src/locales/sl.json
@@ -5094,6 +5094,11 @@
"hint": "Zapiše metapodatke v datoteko OPF znotraj arhiva EPUB.",
"toggleAria": "Zapiši metapodatke EPUB"
},
+ "fb2": {
+ "title": "FictionBook (FB2)",
+ "hint": "Rewrites the description block inside FB2 files, leaving the book text untouched.",
+ "toggleAria": "Write FB2 metadata"
+ },
"pdf": {
"title": "PDF",
"hint": "Vdela metapodatke v slovar Info datoteke PDF in tok XMP.",
diff --git a/packages/types/src/file-write.ts b/packages/types/src/file-write.ts
index cadce32d1..1cb27f130 100644
--- a/packages/types/src/file-write.ts
+++ b/packages/types/src/file-write.ts
@@ -109,6 +109,15 @@ export const EPUB_BOOK_FILE_WRITE_FIELDS = [
"coverBytes",
] as const satisfies readonly BookFileWriteField[];
+// FB2 keeps managed metadata in : standard slots cover the core
+// fields, and carries the rest.
+export const FB2_BOOK_FILE_WRITE_FIELDS = [
+ ...CORE_BOOK_FILE_WRITE_FIELDS,
+ ...COMMON_PROVIDER_BOOK_FILE_WRITE_FIELDS,
+ "itunesId",
+ "coverBytes",
+] as const satisfies readonly BookFileWriteField[];
+
export const PDF_BOOK_FILE_WRITE_FIELDS = [
...CORE_BOOK_FILE_WRITE_FIELDS,
...COMMON_PROVIDER_BOOK_FILE_WRITE_FIELDS,
@@ -158,6 +167,7 @@ export const AUDIO_BOOK_FILE_WRITE_FIELDS = [
export const BOOK_FILE_WRITE_FORMAT_FIELDS = {
epub: EPUB_BOOK_FILE_WRITE_FIELDS,
+ fb2: FB2_BOOK_FILE_WRITE_FIELDS,
pdf: PDF_BOOK_FILE_WRITE_FIELDS,
cbz: CBX_BOOK_FILE_WRITE_FIELDS,
cb7: CBX_BOOK_FILE_WRITE_FIELDS,
diff --git a/packages/types/src/library.ts b/packages/types/src/library.ts
index 009de7c22..f04ae3b21 100644
--- a/packages/types/src/library.ts
+++ b/packages/types/src/library.ts
@@ -70,6 +70,8 @@ export interface Library {
fileWriteWriteCover: boolean;
fileWriteEpubEnabled: boolean;
fileWriteEpubMaxFileSizeMb: number;
+ fileWriteFb2Enabled: boolean;
+ fileWriteFb2MaxFileSizeMb: number;
fileWritePdfEnabled: boolean;
fileWritePdfMaxFileSizeMb: number;
fileWriteCbxEnabled: boolean;
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index d059713a6..afde18860 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -427,6 +427,9 @@ importers:
fastify:
specifier: ^5.10.0
version: 5.10.0
+ iconv-lite:
+ specifier: ^0.7.3
+ version: 0.7.3
jose:
specifier: ^6.2.4
version: 6.2.4
diff --git a/server/package.json b/server/package.json
index fbf5626c6..9bc2aefb4 100644
--- a/server/package.json
+++ b/server/package.json
@@ -81,6 +81,7 @@
"fast-xml-parser": "^5.10.1",
"fastest-levenshtein": "^1.0.16",
"fastify": "^5.10.0",
+ "iconv-lite": "^0.7.3",
"jose": "^6.2.4",
"ms": "^2.1.3",
"mysql2": "^3.23.1",
diff --git a/server/src/common/utils/fb2-encoding.utils.test.ts b/server/src/common/utils/fb2-encoding.utils.test.ts
new file mode 100644
index 000000000..777a5263a
--- /dev/null
+++ b/server/src/common/utils/fb2-encoding.utils.test.ts
@@ -0,0 +1,111 @@
+import iconv from 'iconv-lite';
+
+import { decodeFb2, decodeFb2Document, detectFb2Encoding, encodeFb2, isFb2EncodingSupported } from './fb2-encoding.utils';
+
+function declaration(encoding: string | null): string {
+ const attr = encoding ? ` encoding="${encoding}"` : '';
+ return `\n`;
+}
+
+describe('detectFb2Encoding', () => {
+ it('defaults to utf-8 when the declaration omits an encoding', () => {
+ const encoding = detectFb2Encoding(Buffer.from(declaration(null), 'utf8'));
+ expect(encoding).toMatchObject({ name: 'utf-8', declared: null, byteAligned: true, bomLength: 0 });
+ });
+
+ it('normalizes the declared label so casing and spacing do not matter', () => {
+ expect(detectFb2Encoding(Buffer.from(declaration('UTF-8'), 'utf8')).name).toBe('utf-8');
+ expect(detectFb2Encoding(Buffer.from(declaration('Windows-1251'), 'latin1')).name).toBe('windows-1251');
+ expect(detectFb2Encoding(Buffer.from(declaration('KOI8_R'), 'latin1')).name).toBe('koi8-r');
+ });
+
+ it('keeps the raw declared label alongside the normalized name', () => {
+ expect(detectFb2Encoding(Buffer.from(declaration('Windows-1251'), 'latin1')).declared).toBe('Windows-1251');
+ });
+
+ it('reports a utf-8 BOM length so byte offsets stay correct', () => {
+ const withBom = Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from(declaration('utf-8'), 'utf8')]);
+ expect(detectFb2Encoding(withBom)).toMatchObject({ name: 'utf-8', bomLength: 3, byteAligned: true });
+ });
+
+ it('detects utf-16 from a BOM and marks it as not byte aligned', () => {
+ const le = Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from(declaration(null), 'utf16le')]);
+ const be = Buffer.concat([Buffer.from([0xfe, 0xff]), Buffer.from('x')]);
+ expect(detectFb2Encoding(le)).toMatchObject({ name: 'utf-16le', byteAligned: false });
+ expect(detectFb2Encoding(be)).toMatchObject({ name: 'utf-16be', byteAligned: false });
+ });
+
+ it('marks a declared utf-16 as not byte aligned even without a BOM', () => {
+ expect(detectFb2Encoding(Buffer.from(declaration('utf-16'), 'latin1')).byteAligned).toBe(false);
+ expect(detectFb2Encoding(Buffer.from(declaration('UTF-32LE'), 'latin1')).byteAligned).toBe(false);
+ expect(detectFb2Encoding(Buffer.from(declaration('ucs-2'), 'latin1')).byteAligned).toBe(false);
+ });
+
+ it('ignores an encoding attribute that appears after the declaration', () => {
+ const xml = `\n`;
+ expect(detectFb2Encoding(Buffer.from(xml, 'utf8')).declared).toBeNull();
+ });
+});
+
+describe('isFb2EncodingSupported', () => {
+ it('accepts single-byte encodings iconv-lite knows', () => {
+ expect(isFb2EncodingSupported(detectFb2Encoding(Buffer.from(declaration('utf-8'), 'utf8')))).toBe(true);
+ expect(isFb2EncodingSupported(detectFb2Encoding(Buffer.from(declaration('windows-1251'), 'latin1')))).toBe(true);
+ expect(isFb2EncodingSupported(detectFb2Encoding(Buffer.from(declaration('koi8-r'), 'latin1')))).toBe(true);
+ expect(isFb2EncodingSupported(detectFb2Encoding(Buffer.from(declaration('iso-8859-5'), 'latin1')))).toBe(true);
+ });
+
+ it('rejects utf-16 because ASCII markup is not byte aligned', () => {
+ expect(isFb2EncodingSupported(detectFb2Encoding(Buffer.from(declaration('utf-16'), 'latin1')))).toBe(false);
+ });
+
+ it('rejects encodings iconv-lite cannot handle', () => {
+ expect(isFb2EncodingSupported(detectFb2Encoding(Buffer.from(declaration('made-up-codec-9000'), 'latin1')))).toBe(false);
+ });
+});
+
+describe('decodeFb2 / encodeFb2', () => {
+ it('round-trips cyrillic through windows-1251 without loss', () => {
+ const encoding = detectFb2Encoding(Buffer.from(declaration('windows-1251'), 'latin1'));
+ const text = 'Мастер и Маргарита';
+ const bytes = encodeFb2(text, encoding);
+ expect(bytes.length).toBe(text.length);
+ expect(decodeFb2(bytes, encoding)).toBe(text);
+ });
+
+ it('round-trips cyrillic through koi8-r without loss', () => {
+ const encoding = detectFb2Encoding(Buffer.from(declaration('koi8-r'), 'latin1'));
+ const text = 'Булгаков';
+ expect(decodeFb2(encodeFb2(text, encoding), encoding)).toBe(text);
+ });
+
+ it('decodes windows-1251 bytes that utf-8 would mangle', () => {
+ const encoding = detectFb2Encoding(Buffer.from(declaration('windows-1251'), 'latin1'));
+ const bytes = iconv.encode('Привет', 'win1251');
+ expect(bytes.toString('utf8')).not.toBe('Привет');
+ expect(decodeFb2(bytes, encoding)).toBe('Привет');
+ });
+});
+
+describe('decodeFb2Document', () => {
+ it('decodes a windows-1251 document using its declared encoding', () => {
+ const xml = `Мастер`;
+ const { text, encoding } = decodeFb2Document(iconv.encode(xml, 'win1251'));
+ expect(encoding.name).toBe('windows-1251');
+ expect(text).toContain('Мастер');
+ });
+
+ it('decodes a plain utf-8 document', () => {
+ const xml = `Über`;
+ const { text, encoding } = decodeFb2Document(Buffer.from(xml, 'utf8'));
+ expect(encoding.name).toBe('utf-8');
+ expect(text).toContain('Über');
+ });
+
+ it('falls back to utf-8 when the declared encoding is unknown', () => {
+ const xml = `Plain`;
+ const { text, encoding } = decodeFb2Document(Buffer.from(xml, 'utf8'));
+ expect(encoding.name).toBe('utf-8');
+ expect(text).toContain('Plain');
+ });
+});
diff --git a/server/src/common/utils/fb2-encoding.utils.ts b/server/src/common/utils/fb2-encoding.utils.ts
new file mode 100644
index 000000000..4219c31d9
--- /dev/null
+++ b/server/src/common/utils/fb2-encoding.utils.ts
@@ -0,0 +1,86 @@
+import iconv from 'iconv-lite';
+
+/**
+ * FB2 files are plain XML whose bytes may be in any encoding the declaration
+ * names. Single-byte encodings keep ASCII markup at ASCII byte values, so tag
+ * offsets can be found without decoding the whole document. UTF-16/UTF-32 do
+ * not, which is why they are rejected for byte-level editing.
+ */
+export type Fb2Encoding = {
+ /** Encoding label accepted by iconv-lite. */
+ name: string;
+ /** Label as it appeared in the XML declaration, when present. */
+ declared: string | null;
+ /** True when ASCII markup is byte-aligned, so byte offsets can be trusted. */
+ byteAligned: boolean;
+ bomLength: number;
+};
+
+const XML_DECL_ENCODING_RE = /<\?xml[^>]*?encoding\s*=\s*["']([^"']+)["'][^>]*\?>/i;
+const DECLARATION_SCAN_BYTES = 512;
+
+const UTF8_BOM = Buffer.from([0xef, 0xbb, 0xbf]);
+const UTF16LE_BOM = Buffer.from([0xff, 0xfe]);
+const UTF16BE_BOM = Buffer.from([0xfe, 0xff]);
+
+function normalizeLabel(label: string): string {
+ return label
+ .trim()
+ .toLowerCase()
+ .replace(/[\s_]+/g, '-');
+}
+
+function isUtf16Or32(label: string): boolean {
+ return /^(utf-?16|utf-?32|ucs-?2|ucs-?4|unicodefffe|unicode)/.test(label.replace(/-/g, ''));
+}
+
+/**
+ * Inspects the leading bytes of an FB2 file and resolves the encoding to use
+ * for its metadata block. Never decodes the whole buffer.
+ */
+export function detectFb2Encoding(head: Buffer): Fb2Encoding {
+ if (head.subarray(0, 2).equals(UTF16LE_BOM)) {
+ return { name: 'utf-16le', declared: null, byteAligned: false, bomLength: 2 };
+ }
+ if (head.subarray(0, 2).equals(UTF16BE_BOM)) {
+ return { name: 'utf-16be', declared: null, byteAligned: false, bomLength: 2 };
+ }
+
+ const bomLength = head.subarray(0, 3).equals(UTF8_BOM) ? 3 : 0;
+ const declaration = head.subarray(bomLength, bomLength + DECLARATION_SCAN_BYTES).toString('latin1');
+ const declared = declaration.match(XML_DECL_ENCODING_RE)?.[1] ?? null;
+ if (!declared) {
+ return { name: 'utf-8', declared: null, byteAligned: true, bomLength };
+ }
+
+ const name = normalizeLabel(declared);
+ if (isUtf16Or32(name)) {
+ return { name, declared, byteAligned: false, bomLength };
+ }
+
+ return { name, declared, byteAligned: true, bomLength };
+}
+
+export function isFb2EncodingSupported(encoding: Fb2Encoding): boolean {
+ return encoding.byteAligned && iconv.encodingExists(encoding.name);
+}
+
+export function decodeFb2(bytes: Buffer, encoding: Fb2Encoding): string {
+ return iconv.decode(bytes, encoding.name);
+}
+
+export function encodeFb2(text: string, encoding: Fb2Encoding): Buffer {
+ return iconv.encode(text, encoding.name);
+}
+
+/**
+ * Decodes a whole FB2 buffer for reading. Falls back to UTF-8 when the declared
+ * encoding is unknown to iconv-lite so extraction still yields ASCII metadata.
+ */
+export function decodeFb2Document(buffer: Buffer): { text: string; encoding: Fb2Encoding } {
+ const encoding = detectFb2Encoding(buffer.subarray(0, DECLARATION_SCAN_BYTES + 3));
+ if (!iconv.encodingExists(encoding.name)) {
+ return { text: buffer.toString('utf8'), encoding: { ...encoding, name: 'utf-8' } };
+ }
+ return { text: decodeFb2(buffer, encoding), encoding };
+}
diff --git a/server/src/db/migrations/0058_add_fb2_file_write_settings.sql b/server/src/db/migrations/0058_add_fb2_file_write_settings.sql
new file mode 100644
index 000000000..a90e6d88b
--- /dev/null
+++ b/server/src/db/migrations/0058_add_fb2_file_write_settings.sql
@@ -0,0 +1,2 @@
+ALTER TABLE "libraries" ADD COLUMN "file_write_fb2_enabled" boolean DEFAULT false NOT NULL;--> statement-breakpoint
+ALTER TABLE "libraries" ADD COLUMN "file_write_fb2_max_file_size_mb" integer DEFAULT 100 NOT NULL;
\ No newline at end of file
diff --git a/server/src/db/migrations/meta/0058_snapshot.json b/server/src/db/migrations/meta/0058_snapshot.json
new file mode 100644
index 000000000..1a34b666c
--- /dev/null
+++ b/server/src/db/migrations/meta/0058_snapshot.json
@@ -0,0 +1,15579 @@
+{
+ "id": "5a43f922-b650-4e21-83b1-19a63025524f",
+ "prevId": "10b44bb3-b07b-48fe-8137-d3dd135bb563",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.audit_log": {
+ "name": "audit_log",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "bigserial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "actor_username": {
+ "name": "actor_username",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "action": {
+ "name": "action",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "resource": {
+ "name": "resource",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "resource_id": {
+ "name": "resource_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ip": {
+ "name": "ip",
+ "type": "varchar(45)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "meta": {
+ "name": "meta",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "idx_audit_user_id": {
+ "name": "idx_audit_user_id",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_audit_actor_username": {
+ "name": "idx_audit_actor_username",
+ "columns": [
+ {
+ "expression": "actor_username",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_audit_actor_username_trgm": {
+ "name": "idx_audit_actor_username_trgm",
+ "columns": [
+ {
+ "expression": "actor_username",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "gin_trgm_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "gin",
+ "with": {}
+ },
+ "idx_audit_resource": {
+ "name": "idx_audit_resource",
+ "columns": [
+ {
+ "expression": "resource",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "resource_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_audit_resource_id": {
+ "name": "idx_audit_resource_id",
+ "columns": [
+ {
+ "expression": "resource_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_audit_resource_trgm": {
+ "name": "idx_audit_resource_trgm",
+ "columns": [
+ {
+ "expression": "resource",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "gin_trgm_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "gin",
+ "with": {}
+ },
+ "idx_audit_action": {
+ "name": "idx_audit_action",
+ "columns": [
+ {
+ "expression": "action",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_audit_action_trgm": {
+ "name": "idx_audit_action_trgm",
+ "columns": [
+ {
+ "expression": "action",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "gin_trgm_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "gin",
+ "with": {}
+ },
+ "idx_audit_description_trgm": {
+ "name": "idx_audit_description_trgm",
+ "columns": [
+ {
+ "expression": "description",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "gin_trgm_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "gin",
+ "with": {}
+ },
+ "idx_audit_meta_text_trgm": {
+ "name": "idx_audit_meta_text_trgm",
+ "columns": [
+ {
+ "expression": "(\"meta\"::text) gin_trgm_ops",
+ "asc": true,
+ "isExpression": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "gin",
+ "with": {}
+ },
+ "idx_audit_ip": {
+ "name": "idx_audit_ip",
+ "columns": [
+ {
+ "expression": "ip",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_audit_created_at": {
+ "name": "idx_audit_created_at",
+ "columns": [
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_audit_reading_insights_access": {
+ "name": "idx_audit_reading_insights_access",
+ "columns": [
+ {
+ "expression": "resource",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "resource_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "action",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "audit_log_user_id_users_id_fk": {
+ "name": "audit_log_user_id_users_id_fk",
+ "tableFrom": "audit_log",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.app_settings": {
+ "name": "app_settings",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "key": {
+ "name": "key",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "app_settings_key_unique": {
+ "name": "app_settings_key_unique",
+ "nullsNotDistinct": false,
+ "columns": ["key"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.magic_access_tokens": {
+ "name": "magic_access_tokens",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "label": {
+ "name": "label",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token_hash": {
+ "name": "token_hash",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "raw_token": {
+ "name": "raw_token",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_used_at": {
+ "name": "last_used_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "use_count": {
+ "name": "use_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "revoked_at": {
+ "name": "revoked_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "magic_access_tokens_user_id_idx": {
+ "name": "magic_access_tokens_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "magic_access_tokens_user_id_users_id_fk": {
+ "name": "magic_access_tokens_user_id_users_id_fk",
+ "tableFrom": "magic_access_tokens",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "magic_access_tokens_created_by_users_id_fk": {
+ "name": "magic_access_tokens_created_by_users_id_fk",
+ "tableFrom": "magic_access_tokens",
+ "tableTo": "users",
+ "columnsFrom": ["created_by"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "magic_access_tokens_token_hash_unique": {
+ "name": "magic_access_tokens_token_hash_unique",
+ "nullsNotDistinct": false,
+ "columns": ["token_hash"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {
+ "magic_access_tokens_use_count_nonneg_chk": {
+ "name": "magic_access_tokens_use_count_nonneg_chk",
+ "value": "\"magic_access_tokens\".\"use_count\" >= 0"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.password_reset_tokens": {
+ "name": "password_reset_tokens",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token_hash": {
+ "name": "token_hash",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "used_at": {
+ "name": "used_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "password_reset_tokens_user_id_idx": {
+ "name": "password_reset_tokens_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "password_reset_tokens_expires_at_idx": {
+ "name": "password_reset_tokens_expires_at_idx",
+ "columns": [
+ {
+ "expression": "expires_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "password_reset_tokens_user_id_users_id_fk": {
+ "name": "password_reset_tokens_user_id_users_id_fk",
+ "tableFrom": "password_reset_tokens",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "password_reset_tokens_token_hash_unique": {
+ "name": "password_reset_tokens_token_hash_unique",
+ "nullsNotDistinct": false,
+ "columns": ["token_hash"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {
+ "password_reset_tokens_expires_after_created_chk": {
+ "name": "password_reset_tokens_expires_after_created_chk",
+ "value": "\"password_reset_tokens\".\"expires_at\" > \"password_reset_tokens\".\"created_at\""
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.refresh_tokens": {
+ "name": "refresh_tokens",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token_hash": {
+ "name": "token_hash",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "revoked_at": {
+ "name": "revoked_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "rotated_at": {
+ "name": "rotated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "replaced_by_token_hash": {
+ "name": "replaced_by_token_hash",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "refresh_tokens_user_id_idx": {
+ "name": "refresh_tokens_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "refresh_tokens_expires_at_idx": {
+ "name": "refresh_tokens_expires_at_idx",
+ "columns": [
+ {
+ "expression": "expires_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "refresh_tokens_user_id_users_id_fk": {
+ "name": "refresh_tokens_user_id_users_id_fk",
+ "tableFrom": "refresh_tokens",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "refresh_tokens_token_hash_unique": {
+ "name": "refresh_tokens_token_hash_unique",
+ "nullsNotDistinct": false,
+ "columns": ["token_hash"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {
+ "refresh_tokens_expires_after_created_chk": {
+ "name": "refresh_tokens_expires_after_created_chk",
+ "value": "\"refresh_tokens\".\"expires_at\" > \"refresh_tokens\".\"created_at\""
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.user_content_filter_genres": {
+ "name": "user_content_filter_genres",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "filter_type": {
+ "name": "filter_type",
+ "type": "content_filter_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "genre_id": {
+ "name": "genre_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "user_content_filter_genres_genre_id_idx": {
+ "name": "user_content_filter_genres_genre_id_idx",
+ "columns": [
+ {
+ "expression": "genre_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "user_content_filter_genres_user_id_users_id_fk": {
+ "name": "user_content_filter_genres_user_id_users_id_fk",
+ "tableFrom": "user_content_filter_genres",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "user_content_filter_genres_genre_id_genres_id_fk": {
+ "name": "user_content_filter_genres_genre_id_genres_id_fk",
+ "tableFrom": "user_content_filter_genres",
+ "tableTo": "genres",
+ "columnsFrom": ["genre_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "user_content_filter_genres_user_id_filter_type_genre_id_pk": {
+ "name": "user_content_filter_genres_user_id_filter_type_genre_id_pk",
+ "columns": ["user_id", "filter_type", "genre_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user_content_filter_tags": {
+ "name": "user_content_filter_tags",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "filter_type": {
+ "name": "filter_type",
+ "type": "content_filter_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "tag_id": {
+ "name": "tag_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "user_content_filter_tags_tag_id_idx": {
+ "name": "user_content_filter_tags_tag_id_idx",
+ "columns": [
+ {
+ "expression": "tag_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "user_content_filter_tags_user_id_users_id_fk": {
+ "name": "user_content_filter_tags_user_id_users_id_fk",
+ "tableFrom": "user_content_filter_tags",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "user_content_filter_tags_tag_id_tags_id_fk": {
+ "name": "user_content_filter_tags_tag_id_tags_id_fk",
+ "tableFrom": "user_content_filter_tags",
+ "tableTo": "tags",
+ "columnsFrom": ["tag_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "user_content_filter_tags_user_id_filter_type_tag_id_pk": {
+ "name": "user_content_filter_tags_user_id_filter_type_tag_id_pk",
+ "columns": ["user_id", "filter_type", "tag_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user_library_access": {
+ "name": "user_library_access",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "library_id": {
+ "name": "library_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "access_level": {
+ "name": "access_level",
+ "type": "library_access_level",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'viewer'"
+ }
+ },
+ "indexes": {
+ "user_library_access_library_user_idx": {
+ "name": "user_library_access_library_user_idx",
+ "columns": [
+ {
+ "expression": "library_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "user_library_access_user_id_users_id_fk": {
+ "name": "user_library_access_user_id_users_id_fk",
+ "tableFrom": "user_library_access",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "user_library_access_library_id_libraries_id_fk": {
+ "name": "user_library_access_library_id_libraries_id_fk",
+ "tableFrom": "user_library_access",
+ "tableTo": "libraries",
+ "columnsFrom": ["library_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "user_library_access_user_id_library_id_pk": {
+ "name": "user_library_access_user_id_library_id_pk",
+ "columns": ["user_id", "library_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user_permissions": {
+ "name": "user_permissions",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "permission_name": {
+ "name": "permission_name",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "user_permissions_user_id_users_id_fk": {
+ "name": "user_permissions_user_id_users_id_fk",
+ "tableFrom": "user_permissions",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "user_permissions_user_id_permission_name_pk": {
+ "name": "user_permissions_user_id_permission_name_pk",
+ "columns": ["user_id", "permission_name"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.users": {
+ "name": "users",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "username": {
+ "name": "username",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "password_hash": {
+ "name": "password_hash",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "active": {
+ "name": "active",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "is_superuser": {
+ "name": "is_superuser",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "is_default_password": {
+ "name": "is_default_password",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "token_version": {
+ "name": "token_version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "failed_login_attempts": {
+ "name": "failed_login_attempts",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "locked_until": {
+ "name": "locked_until",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "settings": {
+ "name": "settings",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "oidc_subject": {
+ "name": "oidc_subject",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "oidc_issuer": {
+ "name": "oidc_issuer",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "avatar_url": {
+ "name": "avatar_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "avatar_source": {
+ "name": "avatar_source",
+ "type": "user_avatar_source",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'none'"
+ },
+ "avatar_version": {
+ "name": "avatar_version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "provisioning_method": {
+ "name": "provisioning_method",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'local'"
+ },
+ "last_login_at": {
+ "name": "last_login_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_authenticated_at": {
+ "name": "last_authenticated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "reading_insights_sharing_level": {
+ "name": "reading_insights_sharing_level",
+ "type": "reading_insights_sharing_level",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'private'"
+ },
+ "reading_insights_consented_at": {
+ "name": "reading_insights_consented_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "users_username_lower_uidx": {
+ "name": "users_username_lower_uidx",
+ "columns": [
+ {
+ "expression": "lower(\"username\")",
+ "asc": true,
+ "isExpression": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "users_email_lower_uidx": {
+ "name": "users_email_lower_uidx",
+ "columns": [
+ {
+ "expression": "lower(\"email\")",
+ "asc": true,
+ "isExpression": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"users\".\"email\" is not null",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "users_oidc_subject_issuer_uidx": {
+ "name": "users_oidc_subject_issuer_uidx",
+ "columns": [
+ {
+ "expression": "oidc_subject",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "oidc_issuer",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"users\".\"oidc_subject\" is not null and \"users\".\"oidc_issuer\" is not null",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "users_last_authenticated_at_idx": {
+ "name": "users_last_authenticated_at_idx",
+ "columns": [
+ {
+ "expression": "last_authenticated_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "users_username_unique": {
+ "name": "users_username_unique",
+ "nullsNotDistinct": false,
+ "columns": ["username"]
+ },
+ "users_email_unique": {
+ "name": "users_email_unique",
+ "nullsNotDistinct": false,
+ "columns": ["email"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {
+ "users_provisioning_method_chk": {
+ "name": "users_provisioning_method_chk",
+ "value": "\"users\".\"provisioning_method\" in ('local', 'manual', 'oidc', 'shared')"
+ },
+ "users_token_version_nonnegative_chk": {
+ "name": "users_token_version_nonnegative_chk",
+ "value": "\"users\".\"token_version\" >= 0"
+ },
+ "users_failed_login_attempts_nonnegative_chk": {
+ "name": "users_failed_login_attempts_nonnegative_chk",
+ "value": "\"users\".\"failed_login_attempts\" >= 0"
+ },
+ "users_avatar_version_nonnegative_chk": {
+ "name": "users_avatar_version_nonnegative_chk",
+ "value": "\"users\".\"avatar_version\" >= 0"
+ },
+ "users_reading_insights_consent_chk": {
+ "name": "users_reading_insights_consent_chk",
+ "value": "(\"users\".\"reading_insights_sharing_level\" = 'private' and \"users\".\"reading_insights_consented_at\" is null) or (\"users\".\"reading_insights_sharing_level\" <> 'private' and \"users\".\"reading_insights_consented_at\" is not null)"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.comic_metadata": {
+ "name": "comic_metadata",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "book_id": {
+ "name": "book_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "issue_number": {
+ "name": "issue_number",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "volume_name": {
+ "name": "volume_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "pencillers": {
+ "name": "pencillers",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "inkers": {
+ "name": "inkers",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "colorists": {
+ "name": "colorists",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "letterers": {
+ "name": "letterers",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cover_artists": {
+ "name": "cover_artists",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "characters": {
+ "name": "characters",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "teams": {
+ "name": "teams",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "locations": {
+ "name": "locations",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "story_arcs": {
+ "name": "story_arcs",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "comic_metadata_book_id_books_id_fk": {
+ "name": "comic_metadata_book_id_books_id_fk",
+ "tableFrom": "comic_metadata",
+ "tableTo": "books",
+ "columnsFrom": ["book_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "comic_metadata_book_id_unique": {
+ "name": "comic_metadata_book_id_unique",
+ "nullsNotDistinct": false,
+ "columns": ["book_id"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.book_custom_metadata_values": {
+ "name": "book_custom_metadata_values",
+ "schema": "",
+ "columns": {
+ "book_id": {
+ "name": "book_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "field_id": {
+ "name": "field_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value_text": {
+ "name": "value_text",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "value_number": {
+ "name": "value_number",
+ "type": "double precision",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "value_date": {
+ "name": "value_date",
+ "type": "date",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "value_boolean": {
+ "name": "value_boolean",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "book_custom_metadata_values_field_idx": {
+ "name": "book_custom_metadata_values_field_idx",
+ "columns": [
+ {
+ "expression": "field_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "book_custom_metadata_values_book_id_books_id_fk": {
+ "name": "book_custom_metadata_values_book_id_books_id_fk",
+ "tableFrom": "book_custom_metadata_values",
+ "tableTo": "books",
+ "columnsFrom": ["book_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "book_custom_metadata_values_field_id_custom_metadata_fields_id_fk": {
+ "name": "book_custom_metadata_values_field_id_custom_metadata_fields_id_fk",
+ "tableFrom": "book_custom_metadata_values",
+ "tableTo": "custom_metadata_fields",
+ "columnsFrom": ["field_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "book_custom_metadata_values_book_id_field_id_pk": {
+ "name": "book_custom_metadata_values_book_id_field_id_pk",
+ "columns": ["book_id", "field_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "book_custom_metadata_values_single_value_chk": {
+ "name": "book_custom_metadata_values_single_value_chk",
+ "value": "(\n (\"book_custom_metadata_values\".\"value_text\" is not null)::int +\n (\"book_custom_metadata_values\".\"value_number\" is not null)::int +\n (\"book_custom_metadata_values\".\"value_date\" is not null)::int +\n (\"book_custom_metadata_values\".\"value_boolean\" is not null)::int\n ) <= 1"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.custom_metadata_fields": {
+ "name": "custom_metadata_fields",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "key": {
+ "name": "key",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "label": {
+ "name": "label",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "display_order": {
+ "name": "display_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "archived_at": {
+ "name": "archived_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "custom_metadata_fields_key_uidx": {
+ "name": "custom_metadata_fields_key_uidx",
+ "columns": [
+ {
+ "expression": "key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "custom_metadata_fields_active_order_idx": {
+ "name": "custom_metadata_fields_active_order_idx",
+ "columns": [
+ {
+ "expression": "archived_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "display_order",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "label",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "custom_metadata_fields_type_chk": {
+ "name": "custom_metadata_fields_type_chk",
+ "value": "\"custom_metadata_fields\".\"type\" in ('text', 'url', 'number', 'date', 'boolean')"
+ },
+ "custom_metadata_fields_key_format_chk": {
+ "name": "custom_metadata_fields_key_format_chk",
+ "value": "\"custom_metadata_fields\".\"key\" ~ '^[a-z0-9][a-z0-9_]{0,99}$'"
+ },
+ "custom_metadata_fields_label_not_blank_chk": {
+ "name": "custom_metadata_fields_label_not_blank_chk",
+ "value": "length(btrim(\"custom_metadata_fields\".\"label\")) > 0"
+ },
+ "custom_metadata_fields_display_order_nonnegative_chk": {
+ "name": "custom_metadata_fields_display_order_nonnegative_chk",
+ "value": "\"custom_metadata_fields\".\"display_order\" >= 0"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.custom_metadata_library_fields": {
+ "name": "custom_metadata_library_fields",
+ "schema": "",
+ "columns": {
+ "field_id": {
+ "name": "field_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "library_id": {
+ "name": "library_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "display_order": {
+ "name": "display_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "custom_metadata_library_fields_field_idx": {
+ "name": "custom_metadata_library_fields_field_idx",
+ "columns": [
+ {
+ "expression": "field_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "custom_metadata_library_fields_library_order_idx": {
+ "name": "custom_metadata_library_fields_library_order_idx",
+ "columns": [
+ {
+ "expression": "library_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "display_order",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "field_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "custom_metadata_library_fields_field_id_custom_metadata_fields_id_fk": {
+ "name": "custom_metadata_library_fields_field_id_custom_metadata_fields_id_fk",
+ "tableFrom": "custom_metadata_library_fields",
+ "tableTo": "custom_metadata_fields",
+ "columnsFrom": ["field_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "custom_metadata_library_fields_library_id_libraries_id_fk": {
+ "name": "custom_metadata_library_fields_library_id_libraries_id_fk",
+ "tableFrom": "custom_metadata_library_fields",
+ "tableTo": "libraries",
+ "columnsFrom": ["library_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "custom_metadata_library_fields_library_id_field_id_pk": {
+ "name": "custom_metadata_library_fields_library_id_field_id_pk",
+ "columns": ["library_id", "field_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "custom_metadata_library_fields_display_order_nonnegative_chk": {
+ "name": "custom_metadata_library_fields_display_order_nonnegative_chk",
+ "value": "\"custom_metadata_library_fields\".\"display_order\" >= 0"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.custom_icons": {
+ "name": "custom_icons",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "slug": {
+ "name": "slug",
+ "type": "varchar(80)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(120)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "original_file_name": {
+ "name": "original_file_name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "stored_file_name": {
+ "name": "stored_file_name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "file_size": {
+ "name": "file_size",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "file_hash": {
+ "name": "file_hash",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "custom_icons_slug_uidx": {
+ "name": "custom_icons_slug_uidx",
+ "columns": [
+ {
+ "expression": "slug",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "custom_icons_slug_chk": {
+ "name": "custom_icons_slug_chk",
+ "value": "\"custom_icons\".\"slug\" ~ '^[a-z0-9][a-z0-9-]*[a-z0-9]$' or \"custom_icons\".\"slug\" ~ '^[a-z0-9]$'"
+ },
+ "custom_icons_file_size_positive_chk": {
+ "name": "custom_icons_file_size_positive_chk",
+ "value": "\"custom_icons\".\"file_size\" > 0"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.migration_plan_artifacts": {
+ "name": "migration_plan_artifacts",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "source_id": {
+ "name": "source_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "profile_id": {
+ "name": "profile_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_snapshot_hash": {
+ "name": "source_snapshot_hash",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "profile_hash": {
+ "name": "profile_hash",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "plan_hash": {
+ "name": "plan_hash",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "plan": {
+ "name": "plan",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_data": {
+ "name": "source_data",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "summary": {
+ "name": "summary",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_by_user_id": {
+ "name": "created_by_user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "migration_plan_artifacts_plan_hash_uidx": {
+ "name": "migration_plan_artifacts_plan_hash_uidx",
+ "columns": [
+ {
+ "expression": "plan_hash",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "migration_plan_artifacts_source_id_idx": {
+ "name": "migration_plan_artifacts_source_id_idx",
+ "columns": [
+ {
+ "expression": "source_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "migration_plan_artifacts_profile_id_idx": {
+ "name": "migration_plan_artifacts_profile_id_idx",
+ "columns": [
+ {
+ "expression": "profile_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "migration_plan_artifacts_source_id_migration_sources_id_fk": {
+ "name": "migration_plan_artifacts_source_id_migration_sources_id_fk",
+ "tableFrom": "migration_plan_artifacts",
+ "tableTo": "migration_sources",
+ "columnsFrom": ["source_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "migration_plan_artifacts_profile_id_migration_profiles_id_fk": {
+ "name": "migration_plan_artifacts_profile_id_migration_profiles_id_fk",
+ "tableFrom": "migration_plan_artifacts",
+ "tableTo": "migration_profiles",
+ "columnsFrom": ["profile_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "migration_plan_artifacts_created_by_user_id_users_id_fk": {
+ "name": "migration_plan_artifacts_created_by_user_id_users_id_fk",
+ "tableFrom": "migration_plan_artifacts",
+ "tableTo": "users",
+ "columnsFrom": ["created_by_user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "migration_plan_artifacts_profile_source_fk": {
+ "name": "migration_plan_artifacts_profile_source_fk",
+ "tableFrom": "migration_plan_artifacts",
+ "tableTo": "migration_profiles",
+ "columnsFrom": ["profile_id", "source_id"],
+ "columnsTo": ["id", "source_id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "migration_plan_artifacts_id_source_profile_unique": {
+ "name": "migration_plan_artifacts_id_source_profile_unique",
+ "nullsNotDistinct": false,
+ "columns": ["id", "source_id", "profile_id"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.migration_profiles": {
+ "name": "migration_profiles",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "source_id": {
+ "name": "source_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "user_mappings": {
+ "name": "user_mappings",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "path_mappings": {
+ "name": "path_mappings",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "scope": {
+ "name": "scope",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "created_by_user_id": {
+ "name": "created_by_user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "migration_profiles_source_name_version_uidx": {
+ "name": "migration_profiles_source_name_version_uidx",
+ "columns": [
+ {
+ "expression": "source_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "name",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "version",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "migration_profiles_source_id_idx": {
+ "name": "migration_profiles_source_id_idx",
+ "columns": [
+ {
+ "expression": "source_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "migration_profiles_source_id_migration_sources_id_fk": {
+ "name": "migration_profiles_source_id_migration_sources_id_fk",
+ "tableFrom": "migration_profiles",
+ "tableTo": "migration_sources",
+ "columnsFrom": ["source_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "migration_profiles_created_by_user_id_users_id_fk": {
+ "name": "migration_profiles_created_by_user_id_users_id_fk",
+ "tableFrom": "migration_profiles",
+ "tableTo": "users",
+ "columnsFrom": ["created_by_user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "migration_profiles_id_source_id_unique": {
+ "name": "migration_profiles_id_source_id_unique",
+ "nullsNotDistinct": false,
+ "columns": ["id", "source_id"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {
+ "migration_profiles_version_positive_chk": {
+ "name": "migration_profiles_version_positive_chk",
+ "value": "\"migration_profiles\".\"version\" >= 1"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.migration_run_metrics": {
+ "name": "migration_run_metrics",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "run_id": {
+ "name": "run_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "stage": {
+ "name": "stage",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "entity_type": {
+ "name": "entity_type",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "processed": {
+ "name": "processed",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "imported": {
+ "name": "imported",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "skipped": {
+ "name": "skipped",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "unresolved": {
+ "name": "unresolved",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "failed": {
+ "name": "failed",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "migration_run_metrics_run_stage_entity_uidx": {
+ "name": "migration_run_metrics_run_stage_entity_uidx",
+ "columns": [
+ {
+ "expression": "run_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "stage",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "entity_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "migration_run_metrics_run_id_idx": {
+ "name": "migration_run_metrics_run_id_idx",
+ "columns": [
+ {
+ "expression": "run_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "migration_run_metrics_run_id_migration_runs_id_fk": {
+ "name": "migration_run_metrics_run_id_migration_runs_id_fk",
+ "tableFrom": "migration_run_metrics",
+ "tableTo": "migration_runs",
+ "columnsFrom": ["run_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "migration_run_metrics_processed_nonnegative_chk": {
+ "name": "migration_run_metrics_processed_nonnegative_chk",
+ "value": "\"migration_run_metrics\".\"processed\" >= 0"
+ },
+ "migration_run_metrics_imported_nonnegative_chk": {
+ "name": "migration_run_metrics_imported_nonnegative_chk",
+ "value": "\"migration_run_metrics\".\"imported\" >= 0"
+ },
+ "migration_run_metrics_skipped_nonnegative_chk": {
+ "name": "migration_run_metrics_skipped_nonnegative_chk",
+ "value": "\"migration_run_metrics\".\"skipped\" >= 0"
+ },
+ "migration_run_metrics_unresolved_nonnegative_chk": {
+ "name": "migration_run_metrics_unresolved_nonnegative_chk",
+ "value": "\"migration_run_metrics\".\"unresolved\" >= 0"
+ },
+ "migration_run_metrics_failed_nonnegative_chk": {
+ "name": "migration_run_metrics_failed_nonnegative_chk",
+ "value": "\"migration_run_metrics\".\"failed\" >= 0"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.migration_runs": {
+ "name": "migration_runs",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "source_id": {
+ "name": "source_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "profile_id": {
+ "name": "profile_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "plan_artifact_id": {
+ "name": "plan_artifact_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "target_key": {
+ "name": "target_key",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'bookorbit'"
+ },
+ "state": {
+ "name": "state",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'draft'"
+ },
+ "current_stage": {
+ "name": "current_stage",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "triggered_by_user_id": {
+ "name": "triggered_by_user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ended_at": {
+ "name": "ended_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "error_message": {
+ "name": "error_message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "migration_runs_source_target_state_idx": {
+ "name": "migration_runs_source_target_state_idx",
+ "columns": [
+ {
+ "expression": "source_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "target_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "state",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "migration_runs_state_idx": {
+ "name": "migration_runs_state_idx",
+ "columns": [
+ {
+ "expression": "state",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "migration_runs_source_id_migration_sources_id_fk": {
+ "name": "migration_runs_source_id_migration_sources_id_fk",
+ "tableFrom": "migration_runs",
+ "tableTo": "migration_sources",
+ "columnsFrom": ["source_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "migration_runs_profile_id_migration_profiles_id_fk": {
+ "name": "migration_runs_profile_id_migration_profiles_id_fk",
+ "tableFrom": "migration_runs",
+ "tableTo": "migration_profiles",
+ "columnsFrom": ["profile_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "migration_runs_plan_artifact_id_migration_plan_artifacts_id_fk": {
+ "name": "migration_runs_plan_artifact_id_migration_plan_artifacts_id_fk",
+ "tableFrom": "migration_runs",
+ "tableTo": "migration_plan_artifacts",
+ "columnsFrom": ["plan_artifact_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "migration_runs_triggered_by_user_id_users_id_fk": {
+ "name": "migration_runs_triggered_by_user_id_users_id_fk",
+ "tableFrom": "migration_runs",
+ "tableTo": "users",
+ "columnsFrom": ["triggered_by_user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "migration_runs_profile_source_fk": {
+ "name": "migration_runs_profile_source_fk",
+ "tableFrom": "migration_runs",
+ "tableTo": "migration_profiles",
+ "columnsFrom": ["profile_id", "source_id"],
+ "columnsTo": ["id", "source_id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "migration_runs_plan_artifact_source_profile_fk": {
+ "name": "migration_runs_plan_artifact_source_profile_fk",
+ "tableFrom": "migration_runs",
+ "tableTo": "migration_plan_artifacts",
+ "columnsFrom": ["plan_artifact_id", "source_id", "profile_id"],
+ "columnsTo": ["id", "source_id", "profile_id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "migration_runs_state_chk": {
+ "name": "migration_runs_state_chk",
+ "value": "\"state\" IN ('draft', 'preflight_failed', 'dry_run_ready', 'running', 'failed', 'completed')"
+ },
+ "migration_runs_started_before_ended_chk": {
+ "name": "migration_runs_started_before_ended_chk",
+ "value": "\"migration_runs\".\"ended_at\" is null or \"migration_runs\".\"started_at\" is null or \"migration_runs\".\"ended_at\" >= \"migration_runs\".\"started_at\""
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.migration_sources": {
+ "name": "migration_sources",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "connection_config": {
+ "name": "connection_config",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "capabilities": {
+ "name": "capabilities",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_validated_at": {
+ "name": "last_validated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_by_user_id": {
+ "name": "created_by_user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "migration_sources_type_name_uidx": {
+ "name": "migration_sources_type_name_uidx",
+ "columns": [
+ {
+ "expression": "type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "name",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "migration_sources_created_by_user_id_idx": {
+ "name": "migration_sources_created_by_user_id_idx",
+ "columns": [
+ {
+ "expression": "created_by_user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "migration_sources_created_by_user_id_users_id_fk": {
+ "name": "migration_sources_created_by_user_id_users_id_fk",
+ "tableFrom": "migration_sources",
+ "tableTo": "users",
+ "columnsFrom": ["created_by_user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.libraries": {
+ "name": "libraries",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "icon": {
+ "name": "icon",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "display_order": {
+ "name": "display_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "cover_aspect_ratio": {
+ "name": "cover_aspect_ratio",
+ "type": "varchar(10)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'2/3'"
+ },
+ "watch": {
+ "name": "watch",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "auto_scan_cron_expression": {
+ "name": "auto_scan_cron_expression",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "metadata_precedence": {
+ "name": "metadata_precedence",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[\"folderStructure\",\"embedded\",\"nfoFile\",\"opfFile\",\"sidecar\"]'::jsonb"
+ },
+ "format_priority": {
+ "name": "format_priority",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[\"epub\",\"pdf\",\"cbz\",\"cbr\",\"cb7\",\"mobi\",\"azw3\",\"azw\",\"fb2\",\"m4b\",\"mp3\",\"m4a\",\"opus\",\"ogg\",\"flac\"]'::jsonb"
+ },
+ "allowed_formats": {
+ "name": "allowed_formats",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "organization_mode": {
+ "name": "organization_mode",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'book_per_folder'"
+ },
+ "exclude_patterns": {
+ "name": "exclude_patterns",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "reading_threshold": {
+ "name": "reading_threshold",
+ "type": "double precision",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0.25
+ },
+ "mark_as_finished_percent_complete": {
+ "name": "mark_as_finished_percent_complete",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 98
+ },
+ "file_write_enabled": {
+ "name": "file_write_enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "file_write_write_cover": {
+ "name": "file_write_write_cover",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "file_write_epub_enabled": {
+ "name": "file_write_epub_enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "file_write_epub_max_file_size_mb": {
+ "name": "file_write_epub_max_file_size_mb",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 100
+ },
+ "file_write_fb2_enabled": {
+ "name": "file_write_fb2_enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "file_write_fb2_max_file_size_mb": {
+ "name": "file_write_fb2_max_file_size_mb",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 100
+ },
+ "file_write_pdf_enabled": {
+ "name": "file_write_pdf_enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "file_write_pdf_max_file_size_mb": {
+ "name": "file_write_pdf_max_file_size_mb",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 100
+ },
+ "file_write_cbx_enabled": {
+ "name": "file_write_cbx_enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "file_write_cbx_max_file_size_mb": {
+ "name": "file_write_cbx_max_file_size_mb",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 500
+ },
+ "file_write_kindle_enabled": {
+ "name": "file_write_kindle_enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "file_write_kindle_max_file_size_mb": {
+ "name": "file_write_kindle_max_file_size_mb",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 100
+ },
+ "file_write_audio_enabled": {
+ "name": "file_write_audio_enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "file_write_audio_max_file_size_mb": {
+ "name": "file_write_audio_max_file_size_mb",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 500
+ },
+ "file_rename_enabled": {
+ "name": "file_rename_enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "file_naming_pattern": {
+ "name": "file_naming_pattern",
+ "type": "varchar(500)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "metadata_fetch_preferences": {
+ "name": "metadata_fetch_preferences",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "book_metadata_fetch_config": {
+ "name": "book_metadata_fetch_config",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "book_metadata_fetch_last_run_at": {
+ "name": "book_metadata_fetch_last_run_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "book_metadata_fetch_last_queued_count": {
+ "name": "book_metadata_fetch_last_queued_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scan_mode": {
+ "name": "scan_mode",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'auto'"
+ },
+ "poll_interval_seconds": {
+ "name": "poll_interval_seconds",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "default": 300
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "libraries_name_lower_uidx": {
+ "name": "libraries_name_lower_uidx",
+ "columns": [
+ {
+ "expression": "lower(\"name\")",
+ "asc": true,
+ "isExpression": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "libraries_display_order_nonnegative_chk": {
+ "name": "libraries_display_order_nonnegative_chk",
+ "value": "\"libraries\".\"display_order\" >= 0"
+ },
+ "libraries_organization_mode_chk": {
+ "name": "libraries_organization_mode_chk",
+ "value": "\"libraries\".\"organization_mode\" in ('book_per_folder', 'book_per_file')"
+ },
+ "libraries_reading_threshold_range_chk": {
+ "name": "libraries_reading_threshold_range_chk",
+ "value": "\"libraries\".\"reading_threshold\" >= 0 and \"libraries\".\"reading_threshold\" <= 100"
+ },
+ "libraries_mark_finished_percent_range_chk": {
+ "name": "libraries_mark_finished_percent_range_chk",
+ "value": "\"libraries\".\"mark_as_finished_percent_complete\" >= 0 and \"libraries\".\"mark_as_finished_percent_complete\" <= 100"
+ },
+ "libraries_scan_mode_chk": {
+ "name": "libraries_scan_mode_chk",
+ "value": "\"libraries\".\"scan_mode\" in ('auto', 'manual')"
+ },
+ "libraries_poll_interval_nonnegative_chk": {
+ "name": "libraries_poll_interval_nonnegative_chk",
+ "value": "\"libraries\".\"poll_interval_seconds\" is null or \"libraries\".\"poll_interval_seconds\" >= 0"
+ },
+ "libraries_file_write_epub_max_size_chk": {
+ "name": "libraries_file_write_epub_max_size_chk",
+ "value": "\"libraries\".\"file_write_epub_max_file_size_mb\" >= 1"
+ },
+ "libraries_file_write_pdf_max_size_chk": {
+ "name": "libraries_file_write_pdf_max_size_chk",
+ "value": "\"libraries\".\"file_write_pdf_max_file_size_mb\" >= 1"
+ },
+ "libraries_file_write_cbx_max_size_chk": {
+ "name": "libraries_file_write_cbx_max_size_chk",
+ "value": "\"libraries\".\"file_write_cbx_max_file_size_mb\" >= 1"
+ },
+ "libraries_file_write_kindle_max_size_chk": {
+ "name": "libraries_file_write_kindle_max_size_chk",
+ "value": "\"libraries\".\"file_write_kindle_max_file_size_mb\" >= 1"
+ },
+ "libraries_file_write_audio_max_size_chk": {
+ "name": "libraries_file_write_audio_max_size_chk",
+ "value": "\"libraries\".\"file_write_audio_max_file_size_mb\" >= 1"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.library_folders": {
+ "name": "library_folders",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "library_id": {
+ "name": "library_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "path": {
+ "name": "path",
+ "type": "varchar(4096)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "library_folders_library_id_idx": {
+ "name": "library_folders_library_id_idx",
+ "columns": [
+ {
+ "expression": "library_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "library_folders_library_path_uidx": {
+ "name": "library_folders_library_path_uidx",
+ "columns": [
+ {
+ "expression": "library_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "path",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "library_folders_library_id_libraries_id_fk": {
+ "name": "library_folders_library_id_libraries_id_fk",
+ "tableFrom": "library_folders",
+ "tableTo": "libraries",
+ "columnsFrom": ["library_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "library_folders_id_library_id_unique": {
+ "name": "library_folders_id_library_id_unique",
+ "nullsNotDistinct": false,
+ "columns": ["id", "library_id"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.book_files": {
+ "name": "book_files",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "book_id": {
+ "name": "book_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "library_folder_id": {
+ "name": "library_folder_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "absolute_path": {
+ "name": "absolute_path",
+ "type": "varchar(4096)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "rel_path": {
+ "name": "rel_path",
+ "type": "varchar(4096)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ino": {
+ "name": "ino",
+ "type": "numeric(20, 0)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "size_bytes": {
+ "name": "size_bytes",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "mtime": {
+ "name": "mtime",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "file_hash": {
+ "name": "file_hash",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "format": {
+ "name": "format",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "role": {
+ "name": "role",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'content'"
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "duration_seconds": {
+ "name": "duration_seconds",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "book_files_absolute_path_uidx": {
+ "name": "book_files_absolute_path_uidx",
+ "columns": [
+ {
+ "expression": "absolute_path",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "book_files_book_id_idx": {
+ "name": "book_files_book_id_idx",
+ "columns": [
+ {
+ "expression": "book_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "book_files_library_folder_id_idx": {
+ "name": "book_files_library_folder_id_idx",
+ "columns": [
+ {
+ "expression": "library_folder_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "book_files_file_hash_idx": {
+ "name": "book_files_file_hash_idx",
+ "columns": [
+ {
+ "expression": "file_hash",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "book_files_ino_idx": {
+ "name": "book_files_ino_idx",
+ "columns": [
+ {
+ "expression": "ino",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "book_files_format_idx": {
+ "name": "book_files_format_idx",
+ "columns": [
+ {
+ "expression": "format",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "book_files_library_folder_file_hash_idx": {
+ "name": "book_files_library_folder_file_hash_idx",
+ "columns": [
+ {
+ "expression": "library_folder_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "file_hash",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "book_files_library_folder_ino_idx": {
+ "name": "book_files_library_folder_ino_idx",
+ "columns": [
+ {
+ "expression": "library_folder_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "ino",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "book_files_book_id_books_id_fk": {
+ "name": "book_files_book_id_books_id_fk",
+ "tableFrom": "book_files",
+ "tableTo": "books",
+ "columnsFrom": ["book_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "book_files_library_folder_id_library_folders_id_fk": {
+ "name": "book_files_library_folder_id_library_folders_id_fk",
+ "tableFrom": "book_files",
+ "tableTo": "library_folders",
+ "columnsFrom": ["library_folder_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "book_files_book_folder_consistency_fk": {
+ "name": "book_files_book_folder_consistency_fk",
+ "tableFrom": "book_files",
+ "tableTo": "books",
+ "columnsFrom": ["book_id", "library_folder_id"],
+ "columnsTo": ["id", "library_folder_id"],
+ "onDelete": "cascade",
+ "onUpdate": "cascade"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "book_files_role_chk": {
+ "name": "book_files_role_chk",
+ "value": "\"book_files\".\"role\" in ('content', 'cover', 'metadata', 'supplement')"
+ },
+ "book_files_size_bytes_nonnegative_chk": {
+ "name": "book_files_size_bytes_nonnegative_chk",
+ "value": "\"book_files\".\"size_bytes\" is null or \"book_files\".\"size_bytes\" >= 0"
+ },
+ "book_files_duration_seconds_nonnegative_chk": {
+ "name": "book_files_duration_seconds_nonnegative_chk",
+ "value": "\"book_files\".\"duration_seconds\" is null or \"book_files\".\"duration_seconds\" >= 0"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.books": {
+ "name": "books",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "library_id": {
+ "name": "library_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "library_folder_id": {
+ "name": "library_folder_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "primary_file_id": {
+ "name": "primary_file_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "primary_author_sort_name": {
+ "name": "primary_author_sort_name",
+ "type": "varchar(500)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "folder_path": {
+ "name": "folder_path",
+ "type": "varchar(4096)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'present'"
+ },
+ "added_at": {
+ "name": "added_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "books_library_id_folder_path_idx": {
+ "name": "books_library_id_folder_path_idx",
+ "columns": [
+ {
+ "expression": "library_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "folder_path",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "books_primary_file_id_idx": {
+ "name": "books_primary_file_id_idx",
+ "columns": [
+ {
+ "expression": "primary_file_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "books_library_status_idx": {
+ "name": "books_library_status_idx",
+ "columns": [
+ {
+ "expression": "library_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "books_library_added_at_idx": {
+ "name": "books_library_added_at_idx",
+ "columns": [
+ {
+ "expression": "library_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "\"added_at\" desc",
+ "asc": true,
+ "isExpression": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "books_library_visible_added_id_idx": {
+ "name": "books_library_visible_added_id_idx",
+ "columns": [
+ {
+ "expression": "library_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "\"added_at\" desc",
+ "asc": true,
+ "isExpression": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "\"books\".\"status\" <> 'processing'",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "books_library_author_sort_id_idx": {
+ "name": "books_library_author_sort_id_idx",
+ "columns": [
+ {
+ "expression": "library_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "\"primary_author_sort_name\" asc nulls last",
+ "asc": true,
+ "isExpression": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "\"books\".\"status\" <> 'processing'",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "books_library_author_sort_desc_id_idx": {
+ "name": "books_library_author_sort_desc_id_idx",
+ "columns": [
+ {
+ "expression": "library_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "\"primary_author_sort_name\" desc nulls last",
+ "asc": true,
+ "isExpression": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "\"books\".\"status\" <> 'processing'",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "books_library_id_libraries_id_fk": {
+ "name": "books_library_id_libraries_id_fk",
+ "tableFrom": "books",
+ "tableTo": "libraries",
+ "columnsFrom": ["library_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "books_library_folder_id_library_folders_id_fk": {
+ "name": "books_library_folder_id_library_folders_id_fk",
+ "tableFrom": "books",
+ "tableTo": "library_folders",
+ "columnsFrom": ["library_folder_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "books_primary_file_id_book_files_id_fk": {
+ "name": "books_primary_file_id_book_files_id_fk",
+ "tableFrom": "books",
+ "tableTo": "book_files",
+ "columnsFrom": ["primary_file_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "books_library_folder_library_fk": {
+ "name": "books_library_folder_library_fk",
+ "tableFrom": "books",
+ "tableTo": "library_folders",
+ "columnsFrom": ["library_folder_id", "library_id"],
+ "columnsTo": ["id", "library_id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "books_id_library_folder_id_unique": {
+ "name": "books_id_library_folder_id_unique",
+ "nullsNotDistinct": false,
+ "columns": ["id", "library_folder_id"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {
+ "books_status_chk": {
+ "name": "books_status_chk",
+ "value": "\"books\".\"status\" in ('present', 'missing', 'processing')"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.book_duplicate_group_members": {
+ "name": "book_duplicate_group_members",
+ "schema": "",
+ "columns": {
+ "group_id": {
+ "name": "group_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "scan_id": {
+ "name": "scan_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "book_id": {
+ "name": "book_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "book_duplicate_group_members_group_idx": {
+ "name": "book_duplicate_group_members_group_idx",
+ "columns": [
+ {
+ "expression": "group_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "book_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "book_duplicate_group_members_group_id_book_duplicate_groups_id_fk": {
+ "name": "book_duplicate_group_members_group_id_book_duplicate_groups_id_fk",
+ "tableFrom": "book_duplicate_group_members",
+ "tableTo": "book_duplicate_groups",
+ "columnsFrom": ["group_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "book_duplicate_group_members_scan_id_book_duplicate_scans_id_fk": {
+ "name": "book_duplicate_group_members_scan_id_book_duplicate_scans_id_fk",
+ "tableFrom": "book_duplicate_group_members",
+ "tableTo": "book_duplicate_scans",
+ "columnsFrom": ["scan_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "book_duplicate_group_members_book_id_books_id_fk": {
+ "name": "book_duplicate_group_members_book_id_books_id_fk",
+ "tableFrom": "book_duplicate_group_members",
+ "tableTo": "books",
+ "columnsFrom": ["book_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "book_duplicate_group_members_scan_id_book_id_pk": {
+ "name": "book_duplicate_group_members_scan_id_book_id_pk",
+ "columns": ["scan_id", "book_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.book_duplicate_groups": {
+ "name": "book_duplicate_groups",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "scan_id": {
+ "name": "scan_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "root_book_id": {
+ "name": "root_book_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "reasons": {
+ "name": "reasons",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "max_title_similarity": {
+ "name": "max_title_similarity",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "member_count": {
+ "name": "member_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "book_duplicate_groups_scan_root_uidx": {
+ "name": "book_duplicate_groups_scan_root_uidx",
+ "columns": [
+ {
+ "expression": "scan_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "root_book_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "book_duplicate_groups_scan_id_idx": {
+ "name": "book_duplicate_groups_scan_id_idx",
+ "columns": [
+ {
+ "expression": "scan_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "book_duplicate_groups_scan_member_count_idx": {
+ "name": "book_duplicate_groups_scan_member_count_idx",
+ "columns": [
+ {
+ "expression": "scan_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "member_count",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "book_duplicate_groups_scan_id_book_duplicate_scans_id_fk": {
+ "name": "book_duplicate_groups_scan_id_book_duplicate_scans_id_fk",
+ "tableFrom": "book_duplicate_groups",
+ "tableTo": "book_duplicate_scans",
+ "columnsFrom": ["scan_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "book_duplicate_groups_root_book_id_books_id_fk": {
+ "name": "book_duplicate_groups_root_book_id_books_id_fk",
+ "tableFrom": "book_duplicate_groups",
+ "tableTo": "books",
+ "columnsFrom": ["root_book_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.book_duplicate_pairs": {
+ "name": "book_duplicate_pairs",
+ "schema": "",
+ "columns": {
+ "scan_id": {
+ "name": "scan_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "group_id": {
+ "name": "group_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "book_id_a": {
+ "name": "book_id_a",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "book_id_b": {
+ "name": "book_id_b",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "reasons": {
+ "name": "reasons",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "title_similarity": {
+ "name": "title_similarity",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "book_duplicate_pairs_group_idx": {
+ "name": "book_duplicate_pairs_group_idx",
+ "columns": [
+ {
+ "expression": "group_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "book_duplicate_pairs_scan_a_idx": {
+ "name": "book_duplicate_pairs_scan_a_idx",
+ "columns": [
+ {
+ "expression": "scan_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "book_id_a",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "book_duplicate_pairs_scan_b_idx": {
+ "name": "book_duplicate_pairs_scan_b_idx",
+ "columns": [
+ {
+ "expression": "scan_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "book_id_b",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "book_duplicate_pairs_scan_id_book_duplicate_scans_id_fk": {
+ "name": "book_duplicate_pairs_scan_id_book_duplicate_scans_id_fk",
+ "tableFrom": "book_duplicate_pairs",
+ "tableTo": "book_duplicate_scans",
+ "columnsFrom": ["scan_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "book_duplicate_pairs_group_id_book_duplicate_groups_id_fk": {
+ "name": "book_duplicate_pairs_group_id_book_duplicate_groups_id_fk",
+ "tableFrom": "book_duplicate_pairs",
+ "tableTo": "book_duplicate_groups",
+ "columnsFrom": ["group_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "book_duplicate_pairs_book_id_a_books_id_fk": {
+ "name": "book_duplicate_pairs_book_id_a_books_id_fk",
+ "tableFrom": "book_duplicate_pairs",
+ "tableTo": "books",
+ "columnsFrom": ["book_id_a"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "book_duplicate_pairs_book_id_b_books_id_fk": {
+ "name": "book_duplicate_pairs_book_id_b_books_id_fk",
+ "tableFrom": "book_duplicate_pairs",
+ "tableTo": "books",
+ "columnsFrom": ["book_id_b"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "book_duplicate_pairs_scan_id_book_id_a_book_id_b_pk": {
+ "name": "book_duplicate_pairs_scan_id_book_id_a_book_id_b_pk",
+ "columns": ["scan_id", "book_id_a", "book_id_b"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "book_duplicate_pairs_order_chk": {
+ "name": "book_duplicate_pairs_order_chk",
+ "value": "\"book_duplicate_pairs\".\"book_id_a\" < \"book_duplicate_pairs\".\"book_id_b\""
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.book_duplicate_scan_keys": {
+ "name": "book_duplicate_scan_keys",
+ "schema": "",
+ "columns": {
+ "scan_id": {
+ "name": "scan_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "book_id": {
+ "name": "book_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "kind": {
+ "name": "kind",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "book_duplicate_scan_keys_lookup_idx": {
+ "name": "book_duplicate_scan_keys_lookup_idx",
+ "columns": [
+ {
+ "expression": "scan_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "kind",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "value",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "book_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "book_duplicate_scan_keys_scan_id_book_duplicate_scans_id_fk": {
+ "name": "book_duplicate_scan_keys_scan_id_book_duplicate_scans_id_fk",
+ "tableFrom": "book_duplicate_scan_keys",
+ "tableTo": "book_duplicate_scans",
+ "columnsFrom": ["scan_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "book_duplicate_scan_keys_book_id_books_id_fk": {
+ "name": "book_duplicate_scan_keys_book_id_books_id_fk",
+ "tableFrom": "book_duplicate_scan_keys",
+ "tableTo": "books",
+ "columnsFrom": ["book_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "book_duplicate_scan_keys_scan_id_book_id_kind_value_pk": {
+ "name": "book_duplicate_scan_keys_scan_id_book_id_kind_value_pk",
+ "columns": ["scan_id", "book_id", "kind", "value"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "book_duplicate_scan_keys_kind_chk": {
+ "name": "book_duplicate_scan_keys_kind_chk",
+ "value": "\"book_duplicate_scan_keys\".\"kind\" in ('file_hash', 'isbn', 'exact_metadata')"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.book_duplicate_scans": {
+ "name": "book_duplicate_scans",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "library_ids": {
+ "name": "library_ids",
+ "type": "integer[]",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "requested_library_id": {
+ "name": "requested_library_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "similarity_percent": {
+ "name": "similarity_percent",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'queued'"
+ },
+ "processed_books": {
+ "name": "processed_books",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "total_books": {
+ "name": "total_books",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "total_groups": {
+ "name": "total_groups",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "error_code": {
+ "name": "error_code",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "completed_at": {
+ "name": "completed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "book_duplicate_scans_user_created_idx": {
+ "name": "book_duplicate_scans_user_created_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "\"created_at\" desc",
+ "asc": true,
+ "isExpression": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "book_duplicate_scans_status_idx": {
+ "name": "book_duplicate_scans_status_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "book_duplicate_scans_user_id_users_id_fk": {
+ "name": "book_duplicate_scans_user_id_users_id_fk",
+ "tableFrom": "book_duplicate_scans",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "book_duplicate_scans_similarity_chk": {
+ "name": "book_duplicate_scans_similarity_chk",
+ "value": "\"book_duplicate_scans\".\"similarity_percent\" between 70 and 100"
+ },
+ "book_duplicate_scans_status_chk": {
+ "name": "book_duplicate_scans_status_chk",
+ "value": "\"book_duplicate_scans\".\"status\" in ('queued', 'running', 'completed', 'failed')"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.collection_books": {
+ "name": "collection_books",
+ "schema": "",
+ "columns": {
+ "collection_id": {
+ "name": "collection_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "book_id": {
+ "name": "book_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "added_at": {
+ "name": "added_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "collection_books_book_id_idx": {
+ "name": "collection_books_book_id_idx",
+ "columns": [
+ {
+ "expression": "book_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "collection_books_collection_id_collections_id_fk": {
+ "name": "collection_books_collection_id_collections_id_fk",
+ "tableFrom": "collection_books",
+ "tableTo": "collections",
+ "columnsFrom": ["collection_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "collection_books_book_id_books_id_fk": {
+ "name": "collection_books_book_id_books_id_fk",
+ "tableFrom": "collection_books",
+ "tableTo": "books",
+ "columnsFrom": ["book_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "collection_books_collection_id_book_id_pk": {
+ "name": "collection_books_collection_id_book_id_pk",
+ "columns": ["collection_id", "book_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.collections": {
+ "name": "collections",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "icon": {
+ "name": "icon",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sync_to_kobo": {
+ "name": "sync_to_kobo",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "display_order": {
+ "name": "display_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "collections_user_name_uidx": {
+ "name": "collections_user_name_uidx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "name",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "collections_user_display_name_idx": {
+ "name": "collections_user_display_name_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "display_order",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "name",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "collections_user_id_users_id_fk": {
+ "name": "collections_user_id_users_id_fk",
+ "tableFrom": "collections",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.smart_scopes": {
+ "name": "smart_scopes",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "icon": {
+ "name": "icon",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "filter": {
+ "name": "filter",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "default_sort": {
+ "name": "default_sort",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "is_public": {
+ "name": "is_public",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "sync_to_kobo": {
+ "name": "sync_to_kobo",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "display_order": {
+ "name": "display_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "smart_scopes_user_id_users_id_fk": {
+ "name": "smart_scopes_user_id_users_id_fk",
+ "tableFrom": "smart_scopes",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "smart_scopes_user_id_name_unique": {
+ "name": "smart_scopes_user_id_name_unique",
+ "nullsNotDistinct": false,
+ "columns": ["user_id", "name"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.book_series": {
+ "name": "book_series",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(500)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "normalized_name": {
+ "name": "normalized_name",
+ "type": "varchar(500)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "book_series_normalized_name_uidx": {
+ "name": "book_series_normalized_name_uidx",
+ "columns": [
+ {
+ "expression": "normalized_name",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "book_series_name_trgm_idx": {
+ "name": "book_series_name_trgm_idx",
+ "columns": [
+ {
+ "expression": "name",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "gin_trgm_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "gin",
+ "with": {}
+ },
+ "book_series_name_unaccent_trgm_idx": {
+ "name": "book_series_name_unaccent_trgm_idx",
+ "columns": [
+ {
+ "expression": "public.bookorbit_unaccent(\"name\") gin_trgm_ops",
+ "asc": true,
+ "isExpression": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "gin",
+ "with": {}
+ },
+ "book_series_name_lower_idx": {
+ "name": "book_series_name_lower_idx",
+ "columns": [
+ {
+ "expression": "lower(\"name\")",
+ "asc": true,
+ "isExpression": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.book_series_memberships": {
+ "name": "book_series_memberships",
+ "schema": "",
+ "columns": {
+ "book_id": {
+ "name": "book_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "series_id": {
+ "name": "series_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "series_index": {
+ "name": "series_index",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "display_order": {
+ "name": "display_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "book_series_memberships_book_display_uidx": {
+ "name": "book_series_memberships_book_display_uidx",
+ "columns": [
+ {
+ "expression": "book_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "display_order",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "book_series_memberships_series_index_book_idx": {
+ "name": "book_series_memberships_series_index_book_idx",
+ "columns": [
+ {
+ "expression": "series_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "series_index",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "book_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "book_series_memberships_book_display_idx": {
+ "name": "book_series_memberships_book_display_idx",
+ "columns": [
+ {
+ "expression": "book_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "display_order",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "book_series_memberships_book_id_books_id_fk": {
+ "name": "book_series_memberships_book_id_books_id_fk",
+ "tableFrom": "book_series_memberships",
+ "tableTo": "books",
+ "columnsFrom": ["book_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "book_series_memberships_series_id_book_series_id_fk": {
+ "name": "book_series_memberships_series_id_book_series_id_fk",
+ "tableFrom": "book_series_memberships",
+ "tableTo": "book_series",
+ "columnsFrom": ["series_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "book_series_memberships_book_id_series_id_pk": {
+ "name": "book_series_memberships_book_id_series_id_pk",
+ "columns": ["book_id", "series_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "book_series_memberships_display_order_nonnegative_chk": {
+ "name": "book_series_memberships_display_order_nonnegative_chk",
+ "value": "\"book_series_memberships\".\"display_order\" >= 0"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.author_enrichment_queue": {
+ "name": "author_enrichment_queue",
+ "schema": "",
+ "columns": {
+ "author_id": {
+ "name": "author_id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'queued'"
+ },
+ "reason": {
+ "name": "reason",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'unknown'"
+ },
+ "attempt_count": {
+ "name": "attempt_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "next_attempt_at": {
+ "name": "next_attempt_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "last_attempt_at": {
+ "name": "last_attempt_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_success_at": {
+ "name": "last_success_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_error": {
+ "name": "last_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_http_status": {
+ "name": "last_http_status",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "author_enrichment_queue_status_next_attempt_idx": {
+ "name": "author_enrichment_queue_status_next_attempt_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "next_attempt_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "author_enrichment_queue_next_attempt_idx": {
+ "name": "author_enrichment_queue_next_attempt_idx",
+ "columns": [
+ {
+ "expression": "next_attempt_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "author_enrichment_queue_single_processing_idx": {
+ "name": "author_enrichment_queue_single_processing_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"author_enrichment_queue\".\"status\" = 'processing'",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "author_enrichment_queue_author_id_authors_id_fk": {
+ "name": "author_enrichment_queue_author_id_authors_id_fk",
+ "tableFrom": "author_enrichment_queue",
+ "tableTo": "authors",
+ "columnsFrom": ["author_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "author_enrichment_queue_status_chk": {
+ "name": "author_enrichment_queue_status_chk",
+ "value": "\"author_enrichment_queue\".\"status\" in ('queued', 'processing', 'rate_limited', 'failed', 'done')"
+ },
+ "author_enrichment_queue_reason_chk": {
+ "name": "author_enrichment_queue_reason_chk",
+ "value": "\"author_enrichment_queue\".\"reason\" in ('unknown', 'metadata_replace', 'manual_backfill', 'manual_backfill_all', 'author_rename', 'author_merge_target')"
+ },
+ "author_enrichment_queue_attempt_count_nonnegative_chk": {
+ "name": "author_enrichment_queue_attempt_count_nonnegative_chk",
+ "value": "\"author_enrichment_queue\".\"attempt_count\" >= 0"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.authors": {
+ "name": "authors",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(500)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "sort_name": {
+ "name": "sort_name",
+ "type": "varchar(500)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "has_photo": {
+ "name": "has_photo",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "last_enriched_at": {
+ "name": "last_enriched_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "authors_name_trgm_idx": {
+ "name": "authors_name_trgm_idx",
+ "columns": [
+ {
+ "expression": "name",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "gin_trgm_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "gin",
+ "with": {}
+ },
+ "authors_name_unaccent_trgm_idx": {
+ "name": "authors_name_unaccent_trgm_idx",
+ "columns": [
+ {
+ "expression": "public.bookorbit_unaccent(\"name\") gin_trgm_ops",
+ "asc": true,
+ "isExpression": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "gin",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "authors_name_unique": {
+ "name": "authors_name_unique",
+ "nullsNotDistinct": false,
+ "columns": ["name"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.book_authors": {
+ "name": "book_authors",
+ "schema": "",
+ "columns": {
+ "book_id": {
+ "name": "book_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "author_id": {
+ "name": "author_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "display_order": {
+ "name": "display_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ }
+ },
+ "indexes": {
+ "book_authors_author_id_idx": {
+ "name": "book_authors_author_id_idx",
+ "columns": [
+ {
+ "expression": "author_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "book_authors_book_display_idx": {
+ "name": "book_authors_book_display_idx",
+ "columns": [
+ {
+ "expression": "book_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "display_order",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "book_authors_book_display_author_idx": {
+ "name": "book_authors_book_display_author_idx",
+ "columns": [
+ {
+ "expression": "book_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "display_order",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "author_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "book_authors_book_id_books_id_fk": {
+ "name": "book_authors_book_id_books_id_fk",
+ "tableFrom": "book_authors",
+ "tableTo": "books",
+ "columnsFrom": ["book_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "book_authors_author_id_authors_id_fk": {
+ "name": "book_authors_author_id_authors_id_fk",
+ "tableFrom": "book_authors",
+ "tableTo": "authors",
+ "columnsFrom": ["author_id"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "book_authors_book_id_author_id_pk": {
+ "name": "book_authors_book_id_author_id_pk",
+ "columns": ["book_id", "author_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.book_community_ratings": {
+ "name": "book_community_ratings",
+ "schema": "",
+ "columns": {
+ "book_id": {
+ "name": "book_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider": {
+ "name": "provider",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "rating": {
+ "name": "rating",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "rating_count": {
+ "name": "rating_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "book_community_ratings_provider_idx": {
+ "name": "book_community_ratings_provider_idx",
+ "columns": [
+ {
+ "expression": "provider",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "book_community_ratings_rating_book_idx": {
+ "name": "book_community_ratings_rating_book_idx",
+ "columns": [
+ {
+ "expression": "rating",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "book_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "book_community_ratings_provider_rating_book_idx": {
+ "name": "book_community_ratings_provider_rating_book_idx",
+ "columns": [
+ {
+ "expression": "provider",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "rating",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "book_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "book_community_ratings_book_id_books_id_fk": {
+ "name": "book_community_ratings_book_id_books_id_fk",
+ "tableFrom": "book_community_ratings",
+ "tableTo": "books",
+ "columnsFrom": ["book_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "book_community_ratings_book_id_provider_pk": {
+ "name": "book_community_ratings_book_id_provider_pk",
+ "columns": ["book_id", "provider"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "book_community_ratings_rating_range_chk": {
+ "name": "book_community_ratings_rating_range_chk",
+ "value": "\"book_community_ratings\".\"rating\" >= 0 and \"book_community_ratings\".\"rating\" <= 5"
+ },
+ "book_community_ratings_count_nonnegative_chk": {
+ "name": "book_community_ratings_count_nonnegative_chk",
+ "value": "\"book_community_ratings\".\"rating_count\" is null or \"book_community_ratings\".\"rating_count\" >= 0"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.book_genres": {
+ "name": "book_genres",
+ "schema": "",
+ "columns": {
+ "book_id": {
+ "name": "book_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "genre_id": {
+ "name": "genre_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "book_genres_genre_id_idx": {
+ "name": "book_genres_genre_id_idx",
+ "columns": [
+ {
+ "expression": "genre_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "book_genres_book_id_books_id_fk": {
+ "name": "book_genres_book_id_books_id_fk",
+ "tableFrom": "book_genres",
+ "tableTo": "books",
+ "columnsFrom": ["book_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "book_genres_genre_id_genres_id_fk": {
+ "name": "book_genres_genre_id_genres_id_fk",
+ "tableFrom": "book_genres",
+ "tableTo": "genres",
+ "columnsFrom": ["genre_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "book_genres_book_id_genre_id_pk": {
+ "name": "book_genres_book_id_genre_id_pk",
+ "columns": ["book_id", "genre_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.book_metadata": {
+ "name": "book_metadata",
+ "schema": "",
+ "columns": {
+ "book_id": {
+ "name": "book_id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "varchar(1000)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "subtitle": {
+ "name": "subtitle",
+ "type": "varchar(1000)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "isbn10": {
+ "name": "isbn10",
+ "type": "varchar(10)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "isbn13": {
+ "name": "isbn13",
+ "type": "varchar(13)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "publisher": {
+ "name": "publisher",
+ "type": "varchar(500)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "published_date": {
+ "name": "published_date",
+ "type": "date",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "published_year": {
+ "name": "published_year",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "language": {
+ "name": "language",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "page_count": {
+ "name": "page_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "series_id": {
+ "name": "series_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "series_name": {
+ "name": "series_name",
+ "type": "varchar(500)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "series_index": {
+ "name": "series_index",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "rating": {
+ "name": "rating",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cover_source": {
+ "name": "cover_source",
+ "type": "varchar(9)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "google_books_id": {
+ "name": "google_books_id",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "goodreads_id": {
+ "name": "goodreads_id",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "amazon_id": {
+ "name": "amazon_id",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "hardcover_id": {
+ "name": "hardcover_id",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "hardcover_edition_id": {
+ "name": "hardcover_edition_id",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "open_library_id": {
+ "name": "open_library_id",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "itunes_id": {
+ "name": "itunes_id",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "kobo_id": {
+ "name": "kobo_id",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "metadata_score": {
+ "name": "metadata_score",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_metadata_fetch_at": {
+ "name": "last_metadata_fetch_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "embedding": {
+ "name": "embedding",
+ "type": "vector(256)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_written_at": {
+ "name": "last_written_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "duration_seconds": {
+ "name": "duration_seconds",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "abridged": {
+ "name": "abridged",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "audible_id": {
+ "name": "audible_id",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "librofm_id": {
+ "name": "librofm_id",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "comicvine_id": {
+ "name": "comicvine_id",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ranobedb_id": {
+ "name": "ranobedb_id",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "lubimyczytac_id": {
+ "name": "lubimyczytac_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "aladin_id": {
+ "name": "aladin_id",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "chapters": {
+ "name": "chapters",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "locked_fields": {
+ "name": "locked_fields",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::text[]"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "bm_title_trgm_idx": {
+ "name": "bm_title_trgm_idx",
+ "columns": [
+ {
+ "expression": "title",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "gin_trgm_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "gin",
+ "with": {}
+ },
+ "bm_title_unaccent_trgm_idx": {
+ "name": "bm_title_unaccent_trgm_idx",
+ "columns": [
+ {
+ "expression": "public.bookorbit_unaccent(\"title\") gin_trgm_ops",
+ "asc": true,
+ "isExpression": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "gin",
+ "with": {}
+ },
+ "bm_title_lower_idx": {
+ "name": "bm_title_lower_idx",
+ "columns": [
+ {
+ "expression": "lower(\"title\")",
+ "asc": true,
+ "isExpression": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "bm_title_book_id_idx": {
+ "name": "bm_title_book_id_idx",
+ "columns": [
+ {
+ "expression": "title",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "book_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "bm_series_trgm_idx": {
+ "name": "bm_series_trgm_idx",
+ "columns": [
+ {
+ "expression": "series_name",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "gin_trgm_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "gin",
+ "with": {}
+ },
+ "bm_series_unaccent_trgm_idx": {
+ "name": "bm_series_unaccent_trgm_idx",
+ "columns": [
+ {
+ "expression": "public.bookorbit_unaccent(\"series_name\") gin_trgm_ops",
+ "asc": true,
+ "isExpression": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "gin",
+ "with": {}
+ },
+ "bm_series_id_idx": {
+ "name": "bm_series_id_idx",
+ "columns": [
+ {
+ "expression": "series_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "bm_series_id_index_book_id_idx": {
+ "name": "bm_series_id_index_book_id_idx",
+ "columns": [
+ {
+ "expression": "series_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "series_index",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "book_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "bm_series_name_lower_btrim_idx": {
+ "name": "bm_series_name_lower_btrim_idx",
+ "columns": [
+ {
+ "expression": "lower(btrim(\"series_name\"))",
+ "asc": true,
+ "isExpression": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "bm_publisher_trgm_idx": {
+ "name": "bm_publisher_trgm_idx",
+ "columns": [
+ {
+ "expression": "publisher",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "gin_trgm_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "gin",
+ "with": {}
+ },
+ "bm_publisher_unaccent_trgm_idx": {
+ "name": "bm_publisher_unaccent_trgm_idx",
+ "columns": [
+ {
+ "expression": "public.bookorbit_unaccent(\"publisher\") gin_trgm_ops",
+ "asc": true,
+ "isExpression": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "gin",
+ "with": {}
+ },
+ "bm_language_idx": {
+ "name": "bm_language_idx",
+ "columns": [
+ {
+ "expression": "language",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "bm_language_trgm_idx": {
+ "name": "bm_language_trgm_idx",
+ "columns": [
+ {
+ "expression": "language",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "gin_trgm_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "gin",
+ "with": {}
+ },
+ "bm_language_unaccent_trgm_idx": {
+ "name": "bm_language_unaccent_trgm_idx",
+ "columns": [
+ {
+ "expression": "public.bookorbit_unaccent(\"language\") gin_trgm_ops",
+ "asc": true,
+ "isExpression": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "gin",
+ "with": {}
+ },
+ "bm_published_date_idx": {
+ "name": "bm_published_date_idx",
+ "columns": [
+ {
+ "expression": "published_date",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "bm_published_year_idx": {
+ "name": "bm_published_year_idx",
+ "columns": [
+ {
+ "expression": "published_year",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "bm_published_date_sort_idx": {
+ "name": "bm_published_date_sort_idx",
+ "columns": [
+ {
+ "expression": "coalesce(\"published_date\", make_date(\"published_year\", 1, 1))",
+ "asc": true,
+ "isExpression": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "bm_series_name_index_idx": {
+ "name": "bm_series_name_index_idx",
+ "columns": [
+ {
+ "expression": "series_name",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "series_index",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "bm_isbn10_idx": {
+ "name": "bm_isbn10_idx",
+ "columns": [
+ {
+ "expression": "isbn10",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "bm_isbn13_idx": {
+ "name": "bm_isbn13_idx",
+ "columns": [
+ {
+ "expression": "isbn13",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "bm_embedding_hnsw_cosine_idx": {
+ "name": "bm_embedding_hnsw_cosine_idx",
+ "columns": [
+ {
+ "expression": "\"embedding\" vector_cosine_ops",
+ "asc": true,
+ "isExpression": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "hnsw",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "book_metadata_book_id_books_id_fk": {
+ "name": "book_metadata_book_id_books_id_fk",
+ "tableFrom": "book_metadata",
+ "tableTo": "books",
+ "columnsFrom": ["book_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "book_metadata_series_id_book_series_id_fk": {
+ "name": "book_metadata_series_id_book_series_id_fk",
+ "tableFrom": "book_metadata",
+ "tableTo": "book_series",
+ "columnsFrom": ["series_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "book_metadata_rating_range_chk": {
+ "name": "book_metadata_rating_range_chk",
+ "value": "\"book_metadata\".\"rating\" is null or (\"book_metadata\".\"rating\" >= 1 and \"book_metadata\".\"rating\" <= 10)"
+ },
+ "book_metadata_published_date_range_chk": {
+ "name": "book_metadata_published_date_range_chk",
+ "value": "\"book_metadata\".\"published_date\" is null or (extract(year from \"book_metadata\".\"published_date\") >= 1000 and extract(year from \"book_metadata\".\"published_date\") <= 2200)"
+ },
+ "book_metadata_published_year_range_chk": {
+ "name": "book_metadata_published_year_range_chk",
+ "value": "\"book_metadata\".\"published_year\" is null or (\"book_metadata\".\"published_year\" >= 1000 and \"book_metadata\".\"published_year\" <= 2200)"
+ },
+ "book_metadata_page_count_nonnegative_chk": {
+ "name": "book_metadata_page_count_nonnegative_chk",
+ "value": "\"book_metadata\".\"page_count\" is null or \"book_metadata\".\"page_count\" >= 0"
+ },
+ "book_metadata_duration_seconds_nonnegative_chk": {
+ "name": "book_metadata_duration_seconds_nonnegative_chk",
+ "value": "\"book_metadata\".\"duration_seconds\" is null or \"book_metadata\".\"duration_seconds\" >= 0"
+ },
+ "book_metadata_cover_source_chk": {
+ "name": "book_metadata_cover_source_chk",
+ "value": "\"book_metadata\".\"cover_source\" is null or \"book_metadata\".\"cover_source\" in ('extracted', 'custom')"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.book_metadata_fetch_queue": {
+ "name": "book_metadata_fetch_queue",
+ "schema": "",
+ "columns": {
+ "book_id": {
+ "name": "book_id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'queued'"
+ },
+ "reason": {
+ "name": "reason",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'manual_trigger'"
+ },
+ "attempt_count": {
+ "name": "attempt_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "last_attempt_at": {
+ "name": "last_attempt_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_error": {
+ "name": "last_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_http_status": {
+ "name": "last_http_status",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "bmfq_status_idx": {
+ "name": "bmfq_status_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "bmfq_created_at_idx": {
+ "name": "bmfq_created_at_idx",
+ "columns": [
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "bmfq_status_created_book_idx": {
+ "name": "bmfq_status_created_book_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "book_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "book_metadata_fetch_queue_book_id_books_id_fk": {
+ "name": "book_metadata_fetch_queue_book_id_books_id_fk",
+ "tableFrom": "book_metadata_fetch_queue",
+ "tableTo": "books",
+ "columnsFrom": ["book_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "book_metadata_fetch_queue_status_chk": {
+ "name": "book_metadata_fetch_queue_status_chk",
+ "value": "\"book_metadata_fetch_queue\".\"status\" in ('queued', 'processing', 'failed')"
+ },
+ "book_metadata_fetch_queue_reason_chk": {
+ "name": "book_metadata_fetch_queue_reason_chk",
+ "value": "\"book_metadata_fetch_queue\".\"reason\" in ('event_import', 'manual_trigger', 'manual_retry')"
+ },
+ "book_metadata_fetch_queue_attempt_count_nonnegative_chk": {
+ "name": "book_metadata_fetch_queue_attempt_count_nonnegative_chk",
+ "value": "\"book_metadata_fetch_queue\".\"attempt_count\" >= 0"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.book_tags": {
+ "name": "book_tags",
+ "schema": "",
+ "columns": {
+ "book_id": {
+ "name": "book_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "tag_id": {
+ "name": "tag_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "book_tags_tag_id_idx": {
+ "name": "book_tags_tag_id_idx",
+ "columns": [
+ {
+ "expression": "tag_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "book_tags_book_id_books_id_fk": {
+ "name": "book_tags_book_id_books_id_fk",
+ "tableFrom": "book_tags",
+ "tableTo": "books",
+ "columnsFrom": ["book_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "book_tags_tag_id_tags_id_fk": {
+ "name": "book_tags_tag_id_tags_id_fk",
+ "tableFrom": "book_tags",
+ "tableTo": "tags",
+ "columnsFrom": ["tag_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "book_tags_book_id_tag_id_pk": {
+ "name": "book_tags_book_id_tag_id_pk",
+ "columns": ["book_id", "tag_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.genres": {
+ "name": "genres",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(200)",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "genres_name_trgm_idx": {
+ "name": "genres_name_trgm_idx",
+ "columns": [
+ {
+ "expression": "name",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "gin_trgm_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "gin",
+ "with": {}
+ },
+ "genres_name_unaccent_trgm_idx": {
+ "name": "genres_name_unaccent_trgm_idx",
+ "columns": [
+ {
+ "expression": "public.bookorbit_unaccent(\"name\") gin_trgm_ops",
+ "asc": true,
+ "isExpression": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "gin",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "genres_name_unique": {
+ "name": "genres_name_unique",
+ "nullsNotDistinct": false,
+ "columns": ["name"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.tags": {
+ "name": "tags",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(200)",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "tags_name_trgm_idx": {
+ "name": "tags_name_trgm_idx",
+ "columns": [
+ {
+ "expression": "name",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "gin_trgm_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "gin",
+ "with": {}
+ },
+ "tags_name_unaccent_trgm_idx": {
+ "name": "tags_name_unaccent_trgm_idx",
+ "columns": [
+ {
+ "expression": "public.bookorbit_unaccent(\"name\") gin_trgm_ops",
+ "asc": true,
+ "isExpression": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "gin",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "tags_name_unique": {
+ "name": "tags_name_unique",
+ "nullsNotDistinct": false,
+ "columns": ["name"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.book_narrators": {
+ "name": "book_narrators",
+ "schema": "",
+ "columns": {
+ "book_id": {
+ "name": "book_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "narrator_id": {
+ "name": "narrator_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "display_order": {
+ "name": "display_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ }
+ },
+ "indexes": {
+ "book_narrators_narrator_id_idx": {
+ "name": "book_narrators_narrator_id_idx",
+ "columns": [
+ {
+ "expression": "narrator_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "book_narrators_book_id_books_id_fk": {
+ "name": "book_narrators_book_id_books_id_fk",
+ "tableFrom": "book_narrators",
+ "tableTo": "books",
+ "columnsFrom": ["book_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "book_narrators_narrator_id_narrators_id_fk": {
+ "name": "book_narrators_narrator_id_narrators_id_fk",
+ "tableFrom": "book_narrators",
+ "tableTo": "narrators",
+ "columnsFrom": ["narrator_id"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "book_narrators_book_id_narrator_id_pk": {
+ "name": "book_narrators_book_id_narrator_id_pk",
+ "columns": ["book_id", "narrator_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.narrators": {
+ "name": "narrators",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(500)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "sort_name": {
+ "name": "sort_name",
+ "type": "varchar(500)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "narrators_name_trgm_idx": {
+ "name": "narrators_name_trgm_idx",
+ "columns": [
+ {
+ "expression": "name",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "gin_trgm_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "gin",
+ "with": {}
+ },
+ "narrators_name_unaccent_trgm_idx": {
+ "name": "narrators_name_unaccent_trgm_idx",
+ "columns": [
+ {
+ "expression": "public.bookorbit_unaccent(\"name\") gin_trgm_ops",
+ "asc": true,
+ "isExpression": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "gin",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "narrators_name_unique": {
+ "name": "narrators_name_unique",
+ "nullsNotDistinct": false,
+ "columns": ["name"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.library_dir_scan_state": {
+ "name": "library_dir_scan_state",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "library_folder_id": {
+ "name": "library_folder_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "dir_path": {
+ "name": "dir_path",
+ "type": "varchar(4096)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "last_seen_mtime_ms": {
+ "name": "last_seen_mtime_ms",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "library_dir_scan_state_folder_dir_uidx": {
+ "name": "library_dir_scan_state_folder_dir_uidx",
+ "columns": [
+ {
+ "expression": "library_folder_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "dir_path",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "library_dir_scan_state_folder_idx": {
+ "name": "library_dir_scan_state_folder_idx",
+ "columns": [
+ {
+ "expression": "library_folder_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "library_dir_scan_state_library_folder_id_library_folders_id_fk": {
+ "name": "library_dir_scan_state_library_folder_id_library_folders_id_fk",
+ "tableFrom": "library_dir_scan_state",
+ "tableTo": "library_folders",
+ "columnsFrom": ["library_folder_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.scan_jobs": {
+ "name": "scan_jobs",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "library_id": {
+ "name": "library_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'running'"
+ },
+ "triggered_by": {
+ "name": "triggered_by",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "added_count": {
+ "name": "added_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "updated_count": {
+ "name": "updated_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "missing_count": {
+ "name": "missing_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "error_message": {
+ "name": "error_message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "completed_at": {
+ "name": "completed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "scan_jobs_library_status_idx": {
+ "name": "scan_jobs_library_status_idx",
+ "columns": [
+ {
+ "expression": "library_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "scan_jobs_library_id_libraries_id_fk": {
+ "name": "scan_jobs_library_id_libraries_id_fk",
+ "tableFrom": "scan_jobs",
+ "tableTo": "libraries",
+ "columnsFrom": ["library_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "scan_jobs_status_chk": {
+ "name": "scan_jobs_status_chk",
+ "value": "\"scan_jobs\".\"status\" in ('running', 'completed', 'failed')"
+ },
+ "scan_jobs_triggered_by_chk": {
+ "name": "scan_jobs_triggered_by_chk",
+ "value": "\"scan_jobs\".\"triggered_by\" in ('manual', 'watcher', 'schedule')"
+ },
+ "scan_jobs_added_count_nonnegative_chk": {
+ "name": "scan_jobs_added_count_nonnegative_chk",
+ "value": "\"scan_jobs\".\"added_count\" >= 0"
+ },
+ "scan_jobs_updated_count_nonnegative_chk": {
+ "name": "scan_jobs_updated_count_nonnegative_chk",
+ "value": "\"scan_jobs\".\"updated_count\" >= 0"
+ },
+ "scan_jobs_missing_count_nonnegative_chk": {
+ "name": "scan_jobs_missing_count_nonnegative_chk",
+ "value": "\"scan_jobs\".\"missing_count\" >= 0"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.annotation_positions": {
+ "name": "annotation_positions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "annotation_id": {
+ "name": "annotation_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "book_file_id": {
+ "name": "book_file_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "format": {
+ "name": "format",
+ "type": "varchar(12)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "pos0": {
+ "name": "pos0",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "pos1": {
+ "name": "pos1",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "varchar(10)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'exact'"
+ },
+ "converter_version": {
+ "name": "converter_version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "extras": {
+ "name": "extras",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "annotation_positions_annotation_format_uidx": {
+ "name": "annotation_positions_annotation_format_uidx",
+ "columns": [
+ {
+ "expression": "annotation_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "format",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "annotation_positions_user_idx": {
+ "name": "annotation_positions_user_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "annotation_positions_book_file_id_idx": {
+ "name": "annotation_positions_book_file_id_idx",
+ "columns": [
+ {
+ "expression": "book_file_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "annotation_positions_format_status_idx": {
+ "name": "annotation_positions_format_status_idx",
+ "columns": [
+ {
+ "expression": "format",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "annotation_positions_annotation_id_annotations_id_fk": {
+ "name": "annotation_positions_annotation_id_annotations_id_fk",
+ "tableFrom": "annotation_positions",
+ "tableTo": "annotations",
+ "columnsFrom": ["annotation_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "annotation_positions_user_id_users_id_fk": {
+ "name": "annotation_positions_user_id_users_id_fk",
+ "tableFrom": "annotation_positions",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "annotation_positions_book_file_id_book_files_id_fk": {
+ "name": "annotation_positions_book_file_id_book_files_id_fk",
+ "tableFrom": "annotation_positions",
+ "tableTo": "book_files",
+ "columnsFrom": ["book_file_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "annotation_positions_format_chk": {
+ "name": "annotation_positions_format_chk",
+ "value": "\"annotation_positions\".\"format\" in ('cfi', 'xpointer', 'pdf', 'kobo_span')"
+ },
+ "annotation_positions_status_chk": {
+ "name": "annotation_positions_status_chk",
+ "value": "\"annotation_positions\".\"status\" in ('exact', 'repaired', 'failed', 'pending')"
+ },
+ "annotation_positions_pos0_chk": {
+ "name": "annotation_positions_pos0_chk",
+ "value": "\"annotation_positions\".\"status\" in ('failed', 'pending') or \"annotation_positions\".\"pos0\" is not null"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.annotation_sync_state": {
+ "name": "annotation_sync_state",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "annotation_id": {
+ "name": "annotation_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source": {
+ "name": "source",
+ "type": "varchar(10)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "device_id": {
+ "name": "device_id",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "external_key": {
+ "name": "external_key",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "external_created_at": {
+ "name": "external_created_at",
+ "type": "varchar(19)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_applied_version": {
+ "name": "last_applied_version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "delete_acked_at": {
+ "name": "delete_acked_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "first_synced_at": {
+ "name": "first_synced_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "last_synced_at": {
+ "name": "last_synced_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "annotation_sync_state_annotation_source_device_uidx": {
+ "name": "annotation_sync_state_annotation_source_device_uidx",
+ "columns": [
+ {
+ "expression": "annotation_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "source",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "device_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "annotation_sync_state_user_source_device_key_idx": {
+ "name": "annotation_sync_state_user_source_device_key_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "source",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "device_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "external_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "annotation_sync_state_user_key_idx": {
+ "name": "annotation_sync_state_user_key_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "external_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "annotation_sync_state_annotation_id_idx": {
+ "name": "annotation_sync_state_annotation_id_idx",
+ "columns": [
+ {
+ "expression": "annotation_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "annotation_sync_state_annotation_id_annotations_id_fk": {
+ "name": "annotation_sync_state_annotation_id_annotations_id_fk",
+ "tableFrom": "annotation_sync_state",
+ "tableTo": "annotations",
+ "columnsFrom": ["annotation_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "annotation_sync_state_user_id_users_id_fk": {
+ "name": "annotation_sync_state_user_id_users_id_fk",
+ "tableFrom": "annotation_sync_state",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "annotation_sync_state_source_chk": {
+ "name": "annotation_sync_state_source_chk",
+ "value": "\"annotation_sync_state\".\"source\" in ('koreader', 'kobo')"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.annotations": {
+ "name": "annotations",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "book_id": {
+ "name": "book_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "text": {
+ "name": "text",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "color": {
+ "name": "color",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'yellow'"
+ },
+ "style": {
+ "name": "style",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'highlight'"
+ },
+ "note": {
+ "name": "note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "chapter_title": {
+ "name": "chapter_title",
+ "type": "varchar(500)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "origin": {
+ "name": "origin",
+ "type": "varchar(10)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'web'"
+ },
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "device_created_at": {
+ "name": "device_created_at",
+ "type": "varchar(19)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "device_updated_at": {
+ "name": "device_updated_at",
+ "type": "varchar(19)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "annotations_user_id_idx": {
+ "name": "annotations_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "annotations_user_id_id_idx": {
+ "name": "annotations_user_id_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "annotations_user_book_idx": {
+ "name": "annotations_user_book_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "book_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "annotations_book_id_idx": {
+ "name": "annotations_book_id_idx",
+ "columns": [
+ {
+ "expression": "book_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "annotations_user_book_active_idx": {
+ "name": "annotations_user_book_active_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "book_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "\"annotations\".\"deleted_at\" is null",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "annotations_user_id_users_id_fk": {
+ "name": "annotations_user_id_users_id_fk",
+ "tableFrom": "annotations",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "annotations_book_id_books_id_fk": {
+ "name": "annotations_book_id_books_id_fk",
+ "tableFrom": "annotations",
+ "tableTo": "books",
+ "columnsFrom": ["book_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "annotations_style_chk": {
+ "name": "annotations_style_chk",
+ "value": "\"annotations\".\"style\" in ('highlight', 'underline', 'strikethrough', 'squiggly', 'invert')"
+ },
+ "annotations_origin_chk": {
+ "name": "annotations_origin_chk",
+ "value": "\"annotations\".\"origin\" in ('web', 'koreader', 'kobo')"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.audiobook_progress": {
+ "name": "audiobook_progress",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "book_id": {
+ "name": "book_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "percentage": {
+ "name": "percentage",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "current_file_id": {
+ "name": "current_file_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "position_seconds": {
+ "name": "position_seconds",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "abp_user_id_idx": {
+ "name": "abp_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "audiobook_progress_book_id_idx": {
+ "name": "audiobook_progress_book_id_idx",
+ "columns": [
+ {
+ "expression": "book_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "audiobook_progress_current_file_id_idx": {
+ "name": "audiobook_progress_current_file_id_idx",
+ "columns": [
+ {
+ "expression": "current_file_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "audiobook_progress_user_id_users_id_fk": {
+ "name": "audiobook_progress_user_id_users_id_fk",
+ "tableFrom": "audiobook_progress",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "audiobook_progress_book_id_books_id_fk": {
+ "name": "audiobook_progress_book_id_books_id_fk",
+ "tableFrom": "audiobook_progress",
+ "tableTo": "books",
+ "columnsFrom": ["book_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "audiobook_progress_current_file_id_book_files_id_fk": {
+ "name": "audiobook_progress_current_file_id_book_files_id_fk",
+ "tableFrom": "audiobook_progress",
+ "tableTo": "book_files",
+ "columnsFrom": ["current_file_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "audiobook_progress_user_id_book_id_pk": {
+ "name": "audiobook_progress_user_id_book_id_pk",
+ "columns": ["user_id", "book_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "audiobook_progress_percentage_range_chk": {
+ "name": "audiobook_progress_percentage_range_chk",
+ "value": "\"audiobook_progress\".\"percentage\" >= 0 and \"audiobook_progress\".\"percentage\" <= 100"
+ },
+ "audiobook_progress_position_seconds_nonnegative_chk": {
+ "name": "audiobook_progress_position_seconds_nonnegative_chk",
+ "value": "\"audiobook_progress\".\"position_seconds\" >= 0"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.bookmarks": {
+ "name": "bookmarks",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "book_id": {
+ "name": "book_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "cfi": {
+ "name": "cfi",
+ "type": "varchar(2000)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "title": {
+ "name": "title",
+ "type": "varchar(500)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "position_seconds": {
+ "name": "position_seconds",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "origin": {
+ "name": "origin",
+ "type": "varchar(10)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'web'"
+ },
+ "device_pos": {
+ "name": "device_pos",
+ "type": "varchar(4000)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "pageno": {
+ "name": "pageno",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "bookmarks_user_book_idx": {
+ "name": "bookmarks_user_book_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "book_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "bookmarks_book_id_idx": {
+ "name": "bookmarks_book_id_idx",
+ "columns": [
+ {
+ "expression": "book_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "bookmarks_deleted_at_idx": {
+ "name": "bookmarks_deleted_at_idx",
+ "columns": [
+ {
+ "expression": "deleted_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "\"bookmarks\".\"deleted_at\" is not null",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "bookmarks_user_book_cfi_uidx": {
+ "name": "bookmarks_user_book_cfi_uidx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "book_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "cfi",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"bookmarks\".\"cfi\" is not null",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "bookmarks_user_book_pos_uidx": {
+ "name": "bookmarks_user_book_pos_uidx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "book_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "position_seconds",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"bookmarks\".\"position_seconds\" is not null and \"bookmarks\".\"cfi\" is null",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "bookmarks_user_id_users_id_fk": {
+ "name": "bookmarks_user_id_users_id_fk",
+ "tableFrom": "bookmarks",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "bookmarks_book_id_books_id_fk": {
+ "name": "bookmarks_book_id_books_id_fk",
+ "tableFrom": "bookmarks",
+ "tableTo": "books",
+ "columnsFrom": ["book_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "bookmarks_origin_chk": {
+ "name": "bookmarks_origin_chk",
+ "value": "\"bookmarks\".\"origin\" in ('web', 'koreader')"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.reader_default_preferences": {
+ "name": "reader_default_preferences",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "format_group": {
+ "name": "format_group",
+ "type": "varchar(10)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "settings": {
+ "name": "settings",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "rdp_user_format_idx": {
+ "name": "rdp_user_format_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "format_group",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "reader_default_preferences_user_id_users_id_fk": {
+ "name": "reader_default_preferences_user_id_users_id_fk",
+ "tableFrom": "reader_default_preferences",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "reader_default_preferences_format_group_chk": {
+ "name": "reader_default_preferences_format_group_chk",
+ "value": "\"reader_default_preferences\".\"format_group\" in ('epub', 'pdf', 'cbx', 'audio')"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.reader_preferences": {
+ "name": "reader_preferences",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "book_file_id": {
+ "name": "book_file_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "settings": {
+ "name": "settings",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "rp_user_file_idx": {
+ "name": "rp_user_file_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "book_file_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "reader_preferences_book_file_id_idx": {
+ "name": "reader_preferences_book_file_id_idx",
+ "columns": [
+ {
+ "expression": "book_file_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "reader_preferences_user_id_users_id_fk": {
+ "name": "reader_preferences_user_id_users_id_fk",
+ "tableFrom": "reader_preferences",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "reader_preferences_book_file_id_book_files_id_fk": {
+ "name": "reader_preferences_book_file_id_book_files_id_fk",
+ "tableFrom": "reader_preferences",
+ "tableTo": "book_files",
+ "columnsFrom": ["book_file_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.reading_attempts": {
+ "name": "reading_attempts",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "book_id": {
+ "name": "book_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "started_on": {
+ "name": "started_on",
+ "type": "date",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ended_on": {
+ "name": "ended_on",
+ "type": "date",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "outcome": {
+ "name": "outcome",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "origin": {
+ "name": "origin",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "external_provider": {
+ "name": "external_provider",
+ "type": "varchar(40)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "external_id": {
+ "name": "external_id",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "reading_attempts_user_book_idx": {
+ "name": "reading_attempts_user_book_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "book_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "reading_attempts_book_id_idx": {
+ "name": "reading_attempts_book_id_idx",
+ "columns": [
+ {
+ "expression": "book_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "reading_attempts_user_outcome_ended_idx": {
+ "name": "reading_attempts_user_outcome_ended_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "outcome",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "ended_on",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "reading_attempts_one_active_uidx": {
+ "name": "reading_attempts_one_active_uidx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "book_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"reading_attempts\".\"outcome\" is null and \"reading_attempts\".\"deleted_at\" is null",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "reading_attempts_external_uidx": {
+ "name": "reading_attempts_external_uidx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "external_provider",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "external_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"reading_attempts\".\"external_provider\" is not null and \"reading_attempts\".\"external_id\" is not null",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "reading_attempts_user_id_users_id_fk": {
+ "name": "reading_attempts_user_id_users_id_fk",
+ "tableFrom": "reading_attempts",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "reading_attempts_book_id_books_id_fk": {
+ "name": "reading_attempts_book_id_books_id_fk",
+ "tableFrom": "reading_attempts",
+ "tableTo": "books",
+ "columnsFrom": ["book_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "reading_attempts_outcome_chk": {
+ "name": "reading_attempts_outcome_chk",
+ "value": "\"reading_attempts\".\"outcome\" is null or \"reading_attempts\".\"outcome\" in ('completed', 'skimmed', 'abandoned')"
+ },
+ "reading_attempts_origin_chk": {
+ "name": "reading_attempts_origin_chk",
+ "value": "\"reading_attempts\".\"origin\" in ('manual', 'bookorbit', 'kobo', 'koreader', 'hardcover', 'migration')"
+ },
+ "reading_attempts_end_after_start_chk": {
+ "name": "reading_attempts_end_after_start_chk",
+ "value": "\"reading_attempts\".\"ended_on\" is null or \"reading_attempts\".\"started_on\" is null or \"reading_attempts\".\"ended_on\" >= \"reading_attempts\".\"started_on\""
+ },
+ "reading_attempts_closed_has_outcome_chk": {
+ "name": "reading_attempts_closed_has_outcome_chk",
+ "value": "\"reading_attempts\".\"ended_on\" is null or \"reading_attempts\".\"outcome\" is not null"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.reading_progress": {
+ "name": "reading_progress",
+ "schema": "",
+ "columns": {
+ "book_file_id": {
+ "name": "book_file_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "percentage": {
+ "name": "percentage",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "cfi": {
+ "name": "cfi",
+ "type": "varchar(2000)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "page_number": {
+ "name": "page_number",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "position_seconds": {
+ "name": "position_seconds",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "kobo_location_source": {
+ "name": "kobo_location_source",
+ "type": "varchar(4096)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "kobo_location_type": {
+ "name": "kobo_location_type",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "kobo_location_value": {
+ "name": "kobo_location_value",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "kobo_content_source_progress_percent": {
+ "name": "kobo_content_source_progress_percent",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "koreader_progress": {
+ "name": "koreader_progress",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "reading_progress_user_id_idx": {
+ "name": "reading_progress_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "reading_progress_user_updated_at_idx": {
+ "name": "reading_progress_user_updated_at_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "updated_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "reading_progress_book_file_id_book_files_id_fk": {
+ "name": "reading_progress_book_file_id_book_files_id_fk",
+ "tableFrom": "reading_progress",
+ "tableTo": "book_files",
+ "columnsFrom": ["book_file_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "reading_progress_user_id_users_id_fk": {
+ "name": "reading_progress_user_id_users_id_fk",
+ "tableFrom": "reading_progress",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "reading_progress_book_file_id_user_id_pk": {
+ "name": "reading_progress_book_file_id_user_id_pk",
+ "columns": ["book_file_id", "user_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "reading_progress_percentage_range_chk": {
+ "name": "reading_progress_percentage_range_chk",
+ "value": "\"reading_progress\".\"percentage\" >= 0 and \"reading_progress\".\"percentage\" <= 100"
+ },
+ "reading_progress_page_number_nonnegative_chk": {
+ "name": "reading_progress_page_number_nonnegative_chk",
+ "value": "\"reading_progress\".\"page_number\" is null or \"reading_progress\".\"page_number\" >= 0"
+ },
+ "reading_progress_position_seconds_nonnegative_chk": {
+ "name": "reading_progress_position_seconds_nonnegative_chk",
+ "value": "\"reading_progress\".\"position_seconds\" is null or \"reading_progress\".\"position_seconds\" >= 0"
+ },
+ "reading_progress_kobo_content_source_progress_range_chk": {
+ "name": "reading_progress_kobo_content_source_progress_range_chk",
+ "value": "\"reading_progress\".\"kobo_content_source_progress_percent\" is null or (\"reading_progress\".\"kobo_content_source_progress_percent\" >= 0 and \"reading_progress\".\"kobo_content_source_progress_percent\" <= 100)"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.reading_sessions": {
+ "name": "reading_sessions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "book_file_id": {
+ "name": "book_file_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "book_id": {
+ "name": "book_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "attempt_id": {
+ "name": "attempt_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "session_id": {
+ "name": "session_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source": {
+ "name": "source",
+ "type": "varchar(10)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ended_at": {
+ "name": "ended_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "duration_seconds": {
+ "name": "duration_seconds",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "progress_delta": {
+ "name": "progress_delta",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "end_progress": {
+ "name": "end_progress",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "rs_user_session_id_uidx": {
+ "name": "rs_user_session_id_uidx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "session_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "rs_user_started_at_idx": {
+ "name": "rs_user_started_at_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "started_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "rs_book_file_started_at_idx": {
+ "name": "rs_book_file_started_at_idx",
+ "columns": [
+ {
+ "expression": "book_file_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "started_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "rs_user_book_file_idx": {
+ "name": "rs_user_book_file_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "book_file_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "rs_user_book_started_at_idx": {
+ "name": "rs_user_book_started_at_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "book_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "started_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "reading_sessions_book_id_idx": {
+ "name": "reading_sessions_book_id_idx",
+ "columns": [
+ {
+ "expression": "book_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "rs_attempt_started_at_idx": {
+ "name": "rs_attempt_started_at_idx",
+ "columns": [
+ {
+ "expression": "attempt_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "started_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "reading_sessions_user_id_users_id_fk": {
+ "name": "reading_sessions_user_id_users_id_fk",
+ "tableFrom": "reading_sessions",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "reading_sessions_book_file_id_book_files_id_fk": {
+ "name": "reading_sessions_book_file_id_book_files_id_fk",
+ "tableFrom": "reading_sessions",
+ "tableTo": "book_files",
+ "columnsFrom": ["book_file_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "reading_sessions_book_id_books_id_fk": {
+ "name": "reading_sessions_book_id_books_id_fk",
+ "tableFrom": "reading_sessions",
+ "tableTo": "books",
+ "columnsFrom": ["book_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "reading_sessions_attempt_id_reading_attempts_id_fk": {
+ "name": "reading_sessions_attempt_id_reading_attempts_id_fk",
+ "tableFrom": "reading_sessions",
+ "tableTo": "reading_attempts",
+ "columnsFrom": ["attempt_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "reading_sessions_source_chk": {
+ "name": "reading_sessions_source_chk",
+ "value": "\"reading_sessions\".\"source\" in ('web', 'koreader', 'manual', 'kobo')"
+ },
+ "reading_sessions_duration_seconds_nonnegative_chk": {
+ "name": "reading_sessions_duration_seconds_nonnegative_chk",
+ "value": "\"reading_sessions\".\"duration_seconds\" >= 0"
+ },
+ "reading_sessions_end_progress_range_chk": {
+ "name": "reading_sessions_end_progress_range_chk",
+ "value": "\"reading_sessions\".\"end_progress\" is null or (\"reading_sessions\".\"end_progress\" >= 0 and \"reading_sessions\".\"end_progress\" <= 100)"
+ },
+ "reading_sessions_ended_after_started_chk": {
+ "name": "reading_sessions_ended_after_started_chk",
+ "value": "\"reading_sessions\".\"ended_at\" >= \"reading_sessions\".\"started_at\""
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.user_book_notes": {
+ "name": "user_book_notes",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "book_id": {
+ "name": "book_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "note": {
+ "name": "note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "ubn_user_id_idx": {
+ "name": "ubn_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "ubn_book_id_idx": {
+ "name": "ubn_book_id_idx",
+ "columns": [
+ {
+ "expression": "book_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "ubn_book_user_idx": {
+ "name": "ubn_book_user_idx",
+ "columns": [
+ {
+ "expression": "book_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "user_book_notes_user_id_users_id_fk": {
+ "name": "user_book_notes_user_id_users_id_fk",
+ "tableFrom": "user_book_notes",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "user_book_notes_book_id_books_id_fk": {
+ "name": "user_book_notes_book_id_books_id_fk",
+ "tableFrom": "user_book_notes",
+ "tableTo": "books",
+ "columnsFrom": ["book_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "user_book_notes_user_id_book_id_pk": {
+ "name": "user_book_notes_user_id_book_id_pk",
+ "columns": ["user_id", "book_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "user_book_notes_note_length_chk": {
+ "name": "user_book_notes_note_length_chk",
+ "value": "\"user_book_notes\".\"note\" is null or char_length(\"user_book_notes\".\"note\") <= 10000"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.user_book_ratings": {
+ "name": "user_book_ratings",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "book_id": {
+ "name": "book_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "rating": {
+ "name": "rating",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "ubr_user_id_idx": {
+ "name": "ubr_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "ubr_book_id_idx": {
+ "name": "ubr_book_id_idx",
+ "columns": [
+ {
+ "expression": "book_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "ubr_book_user_rating_idx": {
+ "name": "ubr_book_user_rating_idx",
+ "columns": [
+ {
+ "expression": "book_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "user_book_ratings_user_id_users_id_fk": {
+ "name": "user_book_ratings_user_id_users_id_fk",
+ "tableFrom": "user_book_ratings",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "user_book_ratings_book_id_books_id_fk": {
+ "name": "user_book_ratings_book_id_books_id_fk",
+ "tableFrom": "user_book_ratings",
+ "tableTo": "books",
+ "columnsFrom": ["book_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "user_book_ratings_user_id_book_id_pk": {
+ "name": "user_book_ratings_user_id_book_id_pk",
+ "columns": ["user_id", "book_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "user_book_ratings_rating_range_chk": {
+ "name": "user_book_ratings_rating_range_chk",
+ "value": "\"user_book_ratings\".\"rating\" is null or (\"user_book_ratings\".\"rating\" >= 1 and \"user_book_ratings\".\"rating\" <= 5)"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.user_book_status": {
+ "name": "user_book_status",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "book_id": {
+ "name": "book_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'unread'"
+ },
+ "source": {
+ "name": "source",
+ "type": "varchar(10)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'auto'"
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "finished_at": {
+ "name": "finished_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "ubs_user_id_idx": {
+ "name": "ubs_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "ubs_user_status_idx": {
+ "name": "ubs_user_status_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "ubs_book_user_idx": {
+ "name": "ubs_book_user_idx",
+ "columns": [
+ {
+ "expression": "book_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "user_book_status_user_id_users_id_fk": {
+ "name": "user_book_status_user_id_users_id_fk",
+ "tableFrom": "user_book_status",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "user_book_status_book_id_books_id_fk": {
+ "name": "user_book_status_book_id_books_id_fk",
+ "tableFrom": "user_book_status",
+ "tableTo": "books",
+ "columnsFrom": ["book_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "user_book_status_user_id_book_id_pk": {
+ "name": "user_book_status_user_id_book_id_pk",
+ "columns": ["user_id", "book_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "user_book_status_status_chk": {
+ "name": "user_book_status_status_chk",
+ "value": "\"user_book_status\".\"status\" in ('unread', 'want_to_read', 'reading', 'on_hold', 'rereading', 'read', 'skimmed', 'abandoned')"
+ },
+ "user_book_status_source_chk": {
+ "name": "user_book_status_source_chk",
+ "value": "\"user_book_status\".\"source\" in ('auto', 'manual')"
+ },
+ "user_book_status_finished_after_started_chk": {
+ "name": "user_book_status_finished_after_started_chk",
+ "value": "\"user_book_status\".\"finished_at\" is null or \"user_book_status\".\"started_at\" is null or \"user_book_status\".\"finished_at\" >= \"user_book_status\".\"started_at\""
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.user_reading_daily_stats": {
+ "name": "user_reading_daily_stats",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "library_id": {
+ "name": "library_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "day": {
+ "name": "day",
+ "type": "date",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "reading_seconds": {
+ "name": "reading_seconds",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "progress_delta": {
+ "name": "progress_delta",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "sessions_count": {
+ "name": "sessions_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "urds_user_day_idx": {
+ "name": "urds_user_day_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "day",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "user_reading_daily_stats_library_id_idx": {
+ "name": "user_reading_daily_stats_library_id_idx",
+ "columns": [
+ {
+ "expression": "library_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "user_reading_daily_stats_user_id_users_id_fk": {
+ "name": "user_reading_daily_stats_user_id_users_id_fk",
+ "tableFrom": "user_reading_daily_stats",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "user_reading_daily_stats_library_id_libraries_id_fk": {
+ "name": "user_reading_daily_stats_library_id_libraries_id_fk",
+ "tableFrom": "user_reading_daily_stats",
+ "tableTo": "libraries",
+ "columnsFrom": ["library_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "user_reading_daily_stats_user_id_library_id_day_pk": {
+ "name": "user_reading_daily_stats_user_id_library_id_day_pk",
+ "columns": ["user_id", "library_id", "day"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "user_reading_daily_stats_reading_seconds_nonnegative_chk": {
+ "name": "user_reading_daily_stats_reading_seconds_nonnegative_chk",
+ "value": "\"user_reading_daily_stats\".\"reading_seconds\" >= 0"
+ },
+ "user_reading_daily_stats_sessions_count_nonnegative_chk": {
+ "name": "user_reading_daily_stats_sessions_count_nonnegative_chk",
+ "value": "\"user_reading_daily_stats\".\"sessions_count\" >= 0"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.oidc_group_mappings": {
+ "name": "oidc_group_mappings",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "oidc_group_claim": {
+ "name": "oidc_group_claim",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "permission_name": {
+ "name": "permission_name",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "oidc_group_mappings_provider_claim_uidx": {
+ "name": "oidc_group_mappings_provider_claim_uidx",
+ "columns": [
+ {
+ "expression": "provider_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "oidc_group_claim",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "oidc_group_mappings_provider_id_oidc_providers_id_fk": {
+ "name": "oidc_group_mappings_provider_id_oidc_providers_id_fk",
+ "tableFrom": "oidc_group_mappings",
+ "tableTo": "oidc_providers",
+ "columnsFrom": ["provider_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.oidc_identities": {
+ "name": "oidc_identities",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "oidc_subject": {
+ "name": "oidc_subject",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "oidc_issuer": {
+ "name": "oidc_issuer",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "linked_at": {
+ "name": "linked_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "oidc_identities_provider_subject_uidx": {
+ "name": "oidc_identities_provider_subject_uidx",
+ "columns": [
+ {
+ "expression": "provider_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "oidc_subject",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "oidc_identities_user_provider_uidx": {
+ "name": "oidc_identities_user_provider_uidx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "provider_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "oidc_identities_issuer_subject_uidx": {
+ "name": "oidc_identities_issuer_subject_uidx",
+ "columns": [
+ {
+ "expression": "oidc_issuer",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "oidc_subject",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "oidc_identities_user_id_idx": {
+ "name": "oidc_identities_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "oidc_identities_user_id_users_id_fk": {
+ "name": "oidc_identities_user_id_users_id_fk",
+ "tableFrom": "oidc_identities",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "oidc_identities_provider_id_oidc_providers_id_fk": {
+ "name": "oidc_identities_provider_id_oidc_providers_id_fk",
+ "tableFrom": "oidc_identities",
+ "tableTo": "oidc_providers",
+ "columnsFrom": ["provider_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.oidc_permission_grants": {
+ "name": "oidc_permission_grants",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "permission_name": {
+ "name": "permission_name",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "oidc_permission_grants_user_id_users_id_fk": {
+ "name": "oidc_permission_grants_user_id_users_id_fk",
+ "tableFrom": "oidc_permission_grants",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "oidc_permission_grants_provider_id_oidc_providers_id_fk": {
+ "name": "oidc_permission_grants_provider_id_oidc_providers_id_fk",
+ "tableFrom": "oidc_permission_grants",
+ "tableTo": "oidc_providers",
+ "columnsFrom": ["provider_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "oidc_permission_grants_user_id_provider_id_permission_name_pk": {
+ "name": "oidc_permission_grants_user_id_provider_id_permission_name_pk",
+ "columns": ["user_id", "provider_id", "permission_name"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.oidc_providers": {
+ "name": "oidc_providers",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "slug": {
+ "name": "slug",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "display_name": {
+ "name": "display_name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "issuer_uri": {
+ "name": "issuer_uri",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "client_id": {
+ "name": "client_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "client_secret": {
+ "name": "client_secret",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scopes": {
+ "name": "scopes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'openid profile email'"
+ },
+ "icon_url": {
+ "name": "icon_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "claim_mapping": {
+ "name": "claim_mapping",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{\"username\":\"preferred_username\",\"name\":\"name\",\"email\":\"email\",\"groups\":\"groups\"}'::jsonb"
+ },
+ "auto_provision": {
+ "name": "auto_provision",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{\"enabled\":false,\"allowLocalLinking\":false,\"defaultPermissionNames\":[]}'::jsonb"
+ },
+ "display_order": {
+ "name": "display_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "oidc_providers_enabled_order_idx": {
+ "name": "oidc_providers_enabled_order_idx",
+ "columns": [
+ {
+ "expression": "enabled",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "display_order",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "oidc_providers_slug_unique": {
+ "name": "oidc_providers_slug_unique",
+ "nullsNotDistinct": false,
+ "columns": ["slug"]
+ },
+ "oidc_providers_issuer_uri_unique": {
+ "name": "oidc_providers_issuer_uri_unique",
+ "nullsNotDistinct": false,
+ "columns": ["issuer_uri"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.oidc_sessions": {
+ "name": "oidc_sessions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "oidc_subject": {
+ "name": "oidc_subject",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "oidc_issuer": {
+ "name": "oidc_issuer",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "oidc_session_id": {
+ "name": "oidc_session_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "id_token_hint": {
+ "name": "id_token_hint",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "idp_refresh_token": {
+ "name": "idp_refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "revoked": {
+ "name": "revoked",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "oidc_sessions_user_active_idx": {
+ "name": "oidc_sessions_user_active_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "\"oidc_sessions\".\"revoked\" = false",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "oidc_sessions_subject_issuer_idx": {
+ "name": "oidc_sessions_subject_issuer_idx",
+ "columns": [
+ {
+ "expression": "oidc_subject",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "oidc_issuer",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "oidc_sessions_sid_idx": {
+ "name": "oidc_sessions_sid_idx",
+ "columns": [
+ {
+ "expression": "oidc_session_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "oidc_sessions_expires_at_idx": {
+ "name": "oidc_sessions_expires_at_idx",
+ "columns": [
+ {
+ "expression": "expires_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "oidc_sessions_provider_id_idx": {
+ "name": "oidc_sessions_provider_id_idx",
+ "columns": [
+ {
+ "expression": "provider_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "oidc_sessions_user_id_users_id_fk": {
+ "name": "oidc_sessions_user_id_users_id_fk",
+ "tableFrom": "oidc_sessions",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "oidc_sessions_provider_id_oidc_providers_id_fk": {
+ "name": "oidc_sessions_provider_id_oidc_providers_id_fk",
+ "tableFrom": "oidc_sessions",
+ "tableTo": "oidc_providers",
+ "columnsFrom": ["provider_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.oidc_states": {
+ "name": "oidc_states",
+ "schema": "",
+ "columns": {
+ "state": {
+ "name": "state",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "meta": {
+ "name": "meta",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "oidc_states_expires_at_idx": {
+ "name": "oidc_states_expires_at_idx",
+ "columns": [
+ {
+ "expression": "expires_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "oidc_states_provider_id_oidc_providers_id_fk": {
+ "name": "oidc_states_provider_id_oidc_providers_id_fk",
+ "tableFrom": "oidc_states",
+ "tableTo": "oidc_providers",
+ "columnsFrom": ["provider_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.oidc_used_jtis": {
+ "name": "oidc_used_jtis",
+ "schema": "",
+ "columns": {
+ "jti": {
+ "name": "jti",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "oidc_used_jtis_expires_at_idx": {
+ "name": "oidc_used_jtis_expires_at_idx",
+ "columns": [
+ {
+ "expression": "expires_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.opds_users": {
+ "name": "opds_users",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "username": {
+ "name": "username",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "password_hash": {
+ "name": "password_hash",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "opds_sort_order",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'recent'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "opds_users_username_lower_uidx": {
+ "name": "opds_users_username_lower_uidx",
+ "columns": [
+ {
+ "expression": "lower(\"username\")",
+ "asc": true,
+ "isExpression": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "opds_users_user_id_users_id_fk": {
+ "name": "opds_users_user_id_users_id_fk",
+ "tableFrom": "opds_users",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "opds_users_username_unique": {
+ "name": "opds_users_username_unique",
+ "nullsNotDistinct": false,
+ "columns": ["username"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.kobo_book_entitlements": {
+ "name": "kobo_book_entitlements",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "book_id": {
+ "name": "book_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "entitlement_id": {
+ "name": "entitlement_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "cover_image_id": {
+ "name": "cover_image_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "needs_legacy_numeric_removal": {
+ "name": "needs_legacy_numeric_removal",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "kobo_book_entitlements_user_id_idx": {
+ "name": "kobo_book_entitlements_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "kobo_book_entitlements_book_id_idx": {
+ "name": "kobo_book_entitlements_book_id_idx",
+ "columns": [
+ {
+ "expression": "book_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "kobo_book_entitlements_user_id_users_id_fk": {
+ "name": "kobo_book_entitlements_user_id_users_id_fk",
+ "tableFrom": "kobo_book_entitlements",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "kobo_book_entitlements_book_id_books_id_fk": {
+ "name": "kobo_book_entitlements_book_id_books_id_fk",
+ "tableFrom": "kobo_book_entitlements",
+ "tableTo": "books",
+ "columnsFrom": ["book_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "kobo_book_entitlements_user_book_unique": {
+ "name": "kobo_book_entitlements_user_book_unique",
+ "nullsNotDistinct": false,
+ "columns": ["user_id", "book_id"]
+ },
+ "kobo_book_entitlements_user_entitlement_unique": {
+ "name": "kobo_book_entitlements_user_entitlement_unique",
+ "nullsNotDistinct": false,
+ "columns": ["user_id", "entitlement_id"]
+ },
+ "kobo_book_entitlements_user_cover_unique": {
+ "name": "kobo_book_entitlements_user_cover_unique",
+ "nullsNotDistinct": false,
+ "columns": ["user_id", "cover_image_id"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.kobo_devices": {
+ "name": "kobo_devices",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "client_device_id": {
+ "name": "client_device_id",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_seen_at": {
+ "name": "last_seen_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "kobo_devices_user_id_idx": {
+ "name": "kobo_devices_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "kobo_devices_user_id_users_id_fk": {
+ "name": "kobo_devices_user_id_users_id_fk",
+ "tableFrom": "kobo_devices",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "kobo_devices_token_unique": {
+ "name": "kobo_devices_token_unique",
+ "nullsNotDistinct": false,
+ "columns": ["token"]
+ },
+ "kobo_devices_client_device_id_unique": {
+ "name": "kobo_devices_client_device_id_unique",
+ "nullsNotDistinct": false,
+ "columns": ["client_device_id"]
+ },
+ "kobo_devices_id_user_id_unique": {
+ "name": "kobo_devices_id_user_id_unique",
+ "nullsNotDistinct": false,
+ "columns": ["id", "user_id"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.kobo_library_snapshots": {
+ "name": "kobo_library_snapshots",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "legacy_device_cutoff_at": {
+ "name": "legacy_device_cutoff_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "kobo_library_snapshots_user_id_users_id_fk": {
+ "name": "kobo_library_snapshots_user_id_users_id_fk",
+ "tableFrom": "kobo_library_snapshots",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "kobo_library_snapshots_user_id_unique": {
+ "name": "kobo_library_snapshots_user_id_unique",
+ "nullsNotDistinct": false,
+ "columns": ["user_id"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.kobo_snapshot_books": {
+ "name": "kobo_snapshot_books",
+ "schema": "",
+ "columns": {
+ "snapshot_id": {
+ "name": "snapshot_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "book_id": {
+ "name": "book_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "synced": {
+ "name": "synced",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "pending_delete": {
+ "name": "pending_delete",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "is_new": {
+ "name": "is_new",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "removed_by_device": {
+ "name": "removed_by_device",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "file_hash": {
+ "name": "file_hash",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "delivery_hash": {
+ "name": "delivery_hash",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "metadata_hash": {
+ "name": "metadata_hash",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "kobo_snapshot_books_book_id_idx": {
+ "name": "kobo_snapshot_books_book_id_idx",
+ "columns": [
+ {
+ "expression": "book_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "kobo_snapshot_books_snapshot_synced_book_idx": {
+ "name": "kobo_snapshot_books_snapshot_synced_book_idx",
+ "columns": [
+ {
+ "expression": "snapshot_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "synced",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "book_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "kobo_snapshot_books_snapshot_id_kobo_library_snapshots_id_fk": {
+ "name": "kobo_snapshot_books_snapshot_id_kobo_library_snapshots_id_fk",
+ "tableFrom": "kobo_snapshot_books",
+ "tableTo": "kobo_library_snapshots",
+ "columnsFrom": ["snapshot_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "kobo_snapshot_books_book_id_books_id_fk": {
+ "name": "kobo_snapshot_books_book_id_books_id_fk",
+ "tableFrom": "kobo_snapshot_books",
+ "tableTo": "books",
+ "columnsFrom": ["book_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "kobo_snapshot_books_snapshot_id_book_id_pk": {
+ "name": "kobo_snapshot_books_snapshot_id_book_id_pk",
+ "columns": ["snapshot_id", "book_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.kobo_device_library_snapshots": {
+ "name": "kobo_device_library_snapshots",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "device_id": {
+ "name": "device_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "kobo_device_library_snapshots_user_id_idx": {
+ "name": "kobo_device_library_snapshots_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "kobo_device_library_snapshots_user_id_users_id_fk": {
+ "name": "kobo_device_library_snapshots_user_id_users_id_fk",
+ "tableFrom": "kobo_device_library_snapshots",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "kobo_device_library_snapshots_device_owner_fk": {
+ "name": "kobo_device_library_snapshots_device_owner_fk",
+ "tableFrom": "kobo_device_library_snapshots",
+ "tableTo": "kobo_devices",
+ "columnsFrom": ["device_id", "user_id"],
+ "columnsTo": ["id", "user_id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "kobo_device_library_snapshots_device_id_unique": {
+ "name": "kobo_device_library_snapshots_device_id_unique",
+ "nullsNotDistinct": false,
+ "columns": ["device_id"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.kobo_reading_states": {
+ "name": "kobo_reading_states",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "book_id": {
+ "name": "book_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "entitlement_id": {
+ "name": "entitlement_id",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at_kobo": {
+ "name": "created_at_kobo",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_modified_kobo": {
+ "name": "last_modified_kobo",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "priority_timestamp": {
+ "name": "priority_timestamp",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "current_bookmark": {
+ "name": "current_bookmark",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "statistics": {
+ "name": "statistics",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status_info": {
+ "name": "status_info",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "kobo_reading_states_book_id_idx": {
+ "name": "kobo_reading_states_book_id_idx",
+ "columns": [
+ {
+ "expression": "book_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "kobo_reading_states_user_id_users_id_fk": {
+ "name": "kobo_reading_states_user_id_users_id_fk",
+ "tableFrom": "kobo_reading_states",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "kobo_reading_states_book_id_books_id_fk": {
+ "name": "kobo_reading_states_book_id_books_id_fk",
+ "tableFrom": "kobo_reading_states",
+ "tableTo": "books",
+ "columnsFrom": ["book_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "kobo_reading_states_user_id_book_id_unique": {
+ "name": "kobo_reading_states_user_id_book_id_unique",
+ "nullsNotDistinct": false,
+ "columns": ["user_id", "book_id"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.kobo_device_snapshot_books": {
+ "name": "kobo_device_snapshot_books",
+ "schema": "",
+ "columns": {
+ "snapshot_id": {
+ "name": "snapshot_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "book_id": {
+ "name": "book_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "synced": {
+ "name": "synced",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "pending_delete": {
+ "name": "pending_delete",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "is_new": {
+ "name": "is_new",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "removed_by_device": {
+ "name": "removed_by_device",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "needs_legacy_numeric_removal": {
+ "name": "needs_legacy_numeric_removal",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "file_hash": {
+ "name": "file_hash",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "delivery_hash": {
+ "name": "delivery_hash",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "metadata_hash": {
+ "name": "metadata_hash",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "kobo_device_snapshot_books_book_id_idx": {
+ "name": "kobo_device_snapshot_books_book_id_idx",
+ "columns": [
+ {
+ "expression": "book_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "kobo_device_snapshot_books_snapshot_synced_book_idx": {
+ "name": "kobo_device_snapshot_books_snapshot_synced_book_idx",
+ "columns": [
+ {
+ "expression": "snapshot_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "synced",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "book_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "kobo_device_snapshot_books_snapshot_id_kobo_device_library_snapshots_id_fk": {
+ "name": "kobo_device_snapshot_books_snapshot_id_kobo_device_library_snapshots_id_fk",
+ "tableFrom": "kobo_device_snapshot_books",
+ "tableTo": "kobo_device_library_snapshots",
+ "columnsFrom": ["snapshot_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "kobo_device_snapshot_books_book_id_books_id_fk": {
+ "name": "kobo_device_snapshot_books_book_id_books_id_fk",
+ "tableFrom": "kobo_device_snapshot_books",
+ "tableTo": "books",
+ "columnsFrom": ["book_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "kobo_device_snapshot_books_snapshot_id_book_id_pk": {
+ "name": "kobo_device_snapshot_books_snapshot_id_book_id_pk",
+ "columns": ["snapshot_id", "book_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.kobo_sync_history": {
+ "name": "kobo_sync_history",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "device_id": {
+ "name": "device_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "event": {
+ "name": "event",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "varchar(16)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "counts": {
+ "name": "counts",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "duration_ms": {
+ "name": "duration_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "error_class": {
+ "name": "error_class",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "error": {
+ "name": "error",
+ "type": "varchar(500)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "kobo_sync_history_user_created_idx": {
+ "name": "kobo_sync_history_user_created_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "kobo_sync_history_device_created_idx": {
+ "name": "kobo_sync_history_device_created_idx",
+ "columns": [
+ {
+ "expression": "device_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "kobo_sync_history_user_id_users_id_fk": {
+ "name": "kobo_sync_history_user_id_users_id_fk",
+ "tableFrom": "kobo_sync_history",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "kobo_sync_history_device_id_kobo_devices_id_fk": {
+ "name": "kobo_sync_history_device_id_kobo_devices_id_fk",
+ "tableFrom": "kobo_sync_history",
+ "tableTo": "kobo_devices",
+ "columnsFrom": ["device_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "kobo_sync_history_event_chk": {
+ "name": "kobo_sync_history_event_chk",
+ "value": "\"kobo_sync_history\".\"event\" in ('library_sync', 'book_download', 'progress_update', 'annotations_pull', 'annotations_push')"
+ },
+ "kobo_sync_history_status_chk": {
+ "name": "kobo_sync_history_status_chk",
+ "value": "\"kobo_sync_history\".\"status\" in ('success', 'failed')"
+ },
+ "kobo_sync_history_duration_nonnegative_chk": {
+ "name": "kobo_sync_history_duration_nonnegative_chk",
+ "value": "\"kobo_sync_history\".\"duration_ms\" >= 0"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.kobo_sync_settings": {
+ "name": "kobo_sync_settings",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "reading_threshold": {
+ "name": "reading_threshold",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "finished_threshold": {
+ "name": "finished_threshold",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 99
+ },
+ "convert_to_kepub": {
+ "name": "convert_to_kepub",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "force_enable_hyphenation": {
+ "name": "force_enable_hyphenation",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "kepub_conversion_limit_mb": {
+ "name": "kepub_conversion_limit_mb",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 100
+ },
+ "two_way_progress_sync": {
+ "name": "two_way_progress_sync",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "sync_bookorbit_annotations_to_kobo": {
+ "name": "sync_bookorbit_annotations_to_kobo",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "kobo_sync_settings_user_id_users_id_fk": {
+ "name": "kobo_sync_settings_user_id_users_id_fk",
+ "tableFrom": "kobo_sync_settings",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "kobo_sync_settings_user_id_unique": {
+ "name": "kobo_sync_settings_user_id_unique",
+ "nullsNotDistinct": false,
+ "columns": ["user_id"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {
+ "kobo_sync_settings_reading_threshold_range_chk": {
+ "name": "kobo_sync_settings_reading_threshold_range_chk",
+ "value": "\"kobo_sync_settings\".\"reading_threshold\" >= 0 and \"kobo_sync_settings\".\"reading_threshold\" <= 100"
+ },
+ "kobo_sync_settings_finished_threshold_range_chk": {
+ "name": "kobo_sync_settings_finished_threshold_range_chk",
+ "value": "\"kobo_sync_settings\".\"finished_threshold\" >= 0 and \"kobo_sync_settings\".\"finished_threshold\" <= 100"
+ },
+ "kobo_sync_settings_conversion_limit_nonnegative_chk": {
+ "name": "kobo_sync_settings_conversion_limit_nonnegative_chk",
+ "value": "\"kobo_sync_settings\".\"kepub_conversion_limit_mb\" >= 0"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.book_dock_files": {
+ "name": "book_dock_files",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "file_name": {
+ "name": "file_name",
+ "type": "varchar(500)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "absolute_path": {
+ "name": "absolute_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "file_size": {
+ "name": "file_size",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "format": {
+ "name": "format",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "embedded_metadata": {
+ "name": "embedded_metadata",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "selected_metadata": {
+ "name": "selected_metadata",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "fetched_metadata": {
+ "name": "fetched_metadata",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cover_path": {
+ "name": "cover_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "target_library_id": {
+ "name": "target_library_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "target_folder_id": {
+ "name": "target_folder_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "confidence": {
+ "name": "confidence",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "fetched_metadata_sources": {
+ "name": "fetched_metadata_sources",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "error_message": {
+ "name": "error_message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "metadata_edited_at": {
+ "name": "metadata_edited_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "uploaded_by": {
+ "name": "uploaded_by",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "book_dock_files_status_idx": {
+ "name": "book_dock_files_status_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "book_dock_files_target_library_id_idx": {
+ "name": "book_dock_files_target_library_id_idx",
+ "columns": [
+ {
+ "expression": "target_library_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "book_dock_files_uploaded_by_idx": {
+ "name": "book_dock_files_uploaded_by_idx",
+ "columns": [
+ {
+ "expression": "uploaded_by",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "book_dock_files_target_library_id_libraries_id_fk": {
+ "name": "book_dock_files_target_library_id_libraries_id_fk",
+ "tableFrom": "book_dock_files",
+ "tableTo": "libraries",
+ "columnsFrom": ["target_library_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "book_dock_files_target_folder_id_library_folders_id_fk": {
+ "name": "book_dock_files_target_folder_id_library_folders_id_fk",
+ "tableFrom": "book_dock_files",
+ "tableTo": "library_folders",
+ "columnsFrom": ["target_folder_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "book_dock_files_uploaded_by_users_id_fk": {
+ "name": "book_dock_files_uploaded_by_users_id_fk",
+ "tableFrom": "book_dock_files",
+ "tableTo": "users",
+ "columnsFrom": ["uploaded_by"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "book_dock_files_absolute_path_unique": {
+ "name": "book_dock_files_absolute_path_unique",
+ "nullsNotDistinct": false,
+ "columns": ["absolute_path"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {
+ "book_dock_files_status_chk": {
+ "name": "book_dock_files_status_chk",
+ "value": "\"book_dock_files\".\"status\" in ('pending', 'extracting', 'fetching', 'ready', 'error')"
+ },
+ "book_dock_files_confidence_range_chk": {
+ "name": "book_dock_files_confidence_range_chk",
+ "value": "\"book_dock_files\".\"confidence\" is null or (\"book_dock_files\".\"confidence\" >= 0 and \"book_dock_files\".\"confidence\" <= 100)"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.file_write_log": {
+ "name": "file_write_log",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "book_id": {
+ "name": "book_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "book_file_id": {
+ "name": "book_file_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "format": {
+ "name": "format",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "varchar(10)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "fields_written": {
+ "name": "fields_written",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "error_message": {
+ "name": "error_message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "duration_ms": {
+ "name": "duration_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "triggered_by": {
+ "name": "triggered_by",
+ "type": "varchar(10)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "written_at": {
+ "name": "written_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "fwl_book_id_written_at_idx": {
+ "name": "fwl_book_id_written_at_idx",
+ "columns": [
+ {
+ "expression": "book_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "written_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "file_write_log_book_file_id_idx": {
+ "name": "file_write_log_book_file_id_idx",
+ "columns": [
+ {
+ "expression": "book_file_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "file_write_log_book_id_books_id_fk": {
+ "name": "file_write_log_book_id_books_id_fk",
+ "tableFrom": "file_write_log",
+ "tableTo": "books",
+ "columnsFrom": ["book_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "file_write_log_book_file_id_book_files_id_fk": {
+ "name": "file_write_log_book_file_id_book_files_id_fk",
+ "tableFrom": "file_write_log",
+ "tableTo": "book_files",
+ "columnsFrom": ["book_file_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "file_write_log_user_id_users_id_fk": {
+ "name": "file_write_log_user_id_users_id_fk",
+ "tableFrom": "file_write_log",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "file_write_log_status_chk": {
+ "name": "file_write_log_status_chk",
+ "value": "\"file_write_log\".\"status\" in ('success', 'skipped', 'failed')"
+ },
+ "file_write_log_triggered_by_chk": {
+ "name": "file_write_log_triggered_by_chk",
+ "value": "\"file_write_log\".\"triggered_by\" in ('auto', 'sync')"
+ },
+ "file_write_log_duration_ms_nonnegative_chk": {
+ "name": "file_write_log_duration_ms_nonnegative_chk",
+ "value": "\"file_write_log\".\"duration_ms\" is null or \"file_write_log\".\"duration_ms\" >= 0"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.email_templates": {
+ "name": "email_templates",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "subject": {
+ "name": "subject",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "body_text": {
+ "name": "body_text",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "is_default": {
+ "name": "is_default",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "is_system": {
+ "name": "is_system",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "email_templates_one_default_per_user_uidx": {
+ "name": "email_templates_one_default_per_user_uidx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"email_templates\".\"is_default\" = true and \"email_templates\".\"user_id\" is not null",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "email_templates_system_name_unique": {
+ "name": "email_templates_system_name_unique",
+ "columns": [
+ {
+ "expression": "name",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"email_templates\".\"user_id\" is null",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "email_templates_user_id_users_id_fk": {
+ "name": "email_templates_user_id_users_id_fk",
+ "tableFrom": "email_templates",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "email_templates_user_id_name_unique": {
+ "name": "email_templates_user_id_name_unique",
+ "nullsNotDistinct": false,
+ "columns": ["user_id", "name"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.email_providers": {
+ "name": "email_providers",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "host": {
+ "name": "host",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "port": {
+ "name": "port",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "username": {
+ "name": "username",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "password_enc": {
+ "name": "password_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "from_name": {
+ "name": "from_name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "from_address": {
+ "name": "from_address",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "auth": {
+ "name": "auth",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "ssl": {
+ "name": "ssl",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "start_tls": {
+ "name": "start_tls",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "is_default": {
+ "name": "is_default",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "is_shared": {
+ "name": "is_shared",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "is_system_provider": {
+ "name": "is_system_provider",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "tls_reject_unauthorized": {
+ "name": "tls_reject_unauthorized",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "email_providers_one_default_per_user_uidx": {
+ "name": "email_providers_one_default_per_user_uidx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"email_providers\".\"is_default\" = true and \"email_providers\".\"user_id\" is not null",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "email_providers_one_system_provider_uidx": {
+ "name": "email_providers_one_system_provider_uidx",
+ "columns": [
+ {
+ "expression": "is_system_provider",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"email_providers\".\"is_system_provider\" = true",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "email_providers_user_id_users_id_fk": {
+ "name": "email_providers_user_id_users_id_fk",
+ "tableFrom": "email_providers",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "email_providers_user_id_name_unique": {
+ "name": "email_providers_user_id_name_unique",
+ "nullsNotDistinct": false,
+ "columns": ["user_id", "name"]
+ },
+ "email_providers_id_user_id_unique": {
+ "name": "email_providers_id_user_id_unique",
+ "nullsNotDistinct": false,
+ "columns": ["id", "user_id"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {
+ "email_providers_port_range_chk": {
+ "name": "email_providers_port_range_chk",
+ "value": "\"email_providers\".\"port\" >= 1 and \"email_providers\".\"port\" <= 65535"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.email_recipient_group_members": {
+ "name": "email_recipient_group_members",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "group_id": {
+ "name": "group_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "recipient_id": {
+ "name": "recipient_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "email_recipient_group_members_user_id_users_id_fk": {
+ "name": "email_recipient_group_members_user_id_users_id_fk",
+ "tableFrom": "email_recipient_group_members",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "email_recipient_group_members_group_id_email_recipient_groups_id_fk": {
+ "name": "email_recipient_group_members_group_id_email_recipient_groups_id_fk",
+ "tableFrom": "email_recipient_group_members",
+ "tableTo": "email_recipient_groups",
+ "columnsFrom": ["group_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "email_recipient_group_members_recipient_id_email_recipients_id_fk": {
+ "name": "email_recipient_group_members_recipient_id_email_recipients_id_fk",
+ "tableFrom": "email_recipient_group_members",
+ "tableTo": "email_recipients",
+ "columnsFrom": ["recipient_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "email_recipient_group_members_group_user_fk": {
+ "name": "email_recipient_group_members_group_user_fk",
+ "tableFrom": "email_recipient_group_members",
+ "tableTo": "email_recipient_groups",
+ "columnsFrom": ["group_id", "user_id"],
+ "columnsTo": ["id", "user_id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "email_recipient_group_members_recipient_user_fk": {
+ "name": "email_recipient_group_members_recipient_user_fk",
+ "tableFrom": "email_recipient_group_members",
+ "tableTo": "email_recipients",
+ "columnsFrom": ["recipient_id", "user_id"],
+ "columnsTo": ["id", "user_id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "email_recipient_group_members_group_id_recipient_id_pk": {
+ "name": "email_recipient_group_members_group_id_recipient_id_pk",
+ "columns": ["group_id", "recipient_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.email_recipient_groups": {
+ "name": "email_recipient_groups",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "default_template_id": {
+ "name": "default_template_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "email_recipient_groups_user_id_users_id_fk": {
+ "name": "email_recipient_groups_user_id_users_id_fk",
+ "tableFrom": "email_recipient_groups",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "email_recipient_groups_default_template_id_email_templates_id_fk": {
+ "name": "email_recipient_groups_default_template_id_email_templates_id_fk",
+ "tableFrom": "email_recipient_groups",
+ "tableTo": "email_templates",
+ "columnsFrom": ["default_template_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "email_recipient_groups_user_id_name_unique": {
+ "name": "email_recipient_groups_user_id_name_unique",
+ "nullsNotDistinct": false,
+ "columns": ["user_id", "name"]
+ },
+ "email_recipient_groups_id_user_id_unique": {
+ "name": "email_recipient_groups_id_user_id_unique",
+ "nullsNotDistinct": false,
+ "columns": ["id", "user_id"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.email_recipients": {
+ "name": "email_recipients",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "is_default": {
+ "name": "is_default",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "device_type": {
+ "name": "device_type",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "preferred_format": {
+ "name": "preferred_format",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "default_template_id": {
+ "name": "default_template_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "email_recipients_user_email_lower_uidx": {
+ "name": "email_recipients_user_email_lower_uidx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "lower(\"email\")",
+ "asc": true,
+ "isExpression": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "email_recipients_one_default_per_user_uidx": {
+ "name": "email_recipients_one_default_per_user_uidx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"email_recipients\".\"is_default\" = true",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "email_recipients_user_id_users_id_fk": {
+ "name": "email_recipients_user_id_users_id_fk",
+ "tableFrom": "email_recipients",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "email_recipients_default_template_id_email_templates_id_fk": {
+ "name": "email_recipients_default_template_id_email_templates_id_fk",
+ "tableFrom": "email_recipients",
+ "tableTo": "email_templates",
+ "columnsFrom": ["default_template_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "email_recipients_user_id_email_unique": {
+ "name": "email_recipients_user_id_email_unique",
+ "nullsNotDistinct": false,
+ "columns": ["user_id", "email"]
+ },
+ "email_recipients_id_user_id_unique": {
+ "name": "email_recipients_id_user_id_unique",
+ "nullsNotDistinct": false,
+ "columns": ["id", "user_id"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {
+ "email_recipients_device_type_chk": {
+ "name": "email_recipients_device_type_chk",
+ "value": "\"email_recipients\".\"device_type\" is null or \"email_recipients\".\"device_type\" in ('kindle', 'kobo', 'other')"
+ },
+ "email_recipients_preferred_format_chk": {
+ "name": "email_recipients_preferred_format_chk",
+ "value": "\"email_recipients\".\"preferred_format\" is null or \"email_recipients\".\"preferred_format\" in ('epub', 'pdf', 'mobi', 'azw3', 'cbz', 'cbr')"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.email_preferences": {
+ "name": "email_preferences",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "default_provider_id": {
+ "name": "default_provider_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "default_recipient_id": {
+ "name": "default_recipient_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "default_template_id": {
+ "name": "default_template_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "email_preferences_user_id_users_id_fk": {
+ "name": "email_preferences_user_id_users_id_fk",
+ "tableFrom": "email_preferences",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "email_preferences_default_provider_id_email_providers_id_fk": {
+ "name": "email_preferences_default_provider_id_email_providers_id_fk",
+ "tableFrom": "email_preferences",
+ "tableTo": "email_providers",
+ "columnsFrom": ["default_provider_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "email_preferences_default_recipient_id_email_recipients_id_fk": {
+ "name": "email_preferences_default_recipient_id_email_recipients_id_fk",
+ "tableFrom": "email_preferences",
+ "tableTo": "email_recipients",
+ "columnsFrom": ["default_recipient_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "email_preferences_default_template_id_email_templates_id_fk": {
+ "name": "email_preferences_default_template_id_email_templates_id_fk",
+ "tableFrom": "email_preferences",
+ "tableTo": "email_templates",
+ "columnsFrom": ["default_template_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "email_preferences_default_recipient_owner_fk": {
+ "name": "email_preferences_default_recipient_owner_fk",
+ "tableFrom": "email_preferences",
+ "tableTo": "email_recipients",
+ "columnsFrom": ["default_recipient_id", "user_id"],
+ "columnsTo": ["id", "user_id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "email_preferences_user_id_unique": {
+ "name": "email_preferences_user_id_unique",
+ "nullsNotDistinct": false,
+ "columns": ["user_id"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.email_send_log": {
+ "name": "email_send_log",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "book_id": {
+ "name": "book_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "book_file_id": {
+ "name": "book_file_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "template_id": {
+ "name": "template_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "to_email": {
+ "name": "to_email",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "to_name": {
+ "name": "to_name",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "subject": {
+ "name": "subject",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "attempt_count": {
+ "name": "attempt_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "next_retry_at": {
+ "name": "next_retry_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "error_message": {
+ "name": "error_message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sent_at": {
+ "name": "sent_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "email_send_log_user_created_at_idx": {
+ "name": "email_send_log_user_created_at_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "email_send_log_book_id_idx": {
+ "name": "email_send_log_book_id_idx",
+ "columns": [
+ {
+ "expression": "book_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "email_send_log_book_file_id_idx": {
+ "name": "email_send_log_book_file_id_idx",
+ "columns": [
+ {
+ "expression": "book_file_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "email_send_log_created_at_idx": {
+ "name": "email_send_log_created_at_idx",
+ "columns": [
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "email_send_log_status_next_retry_idx": {
+ "name": "email_send_log_status_next_retry_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "next_retry_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "email_send_log_user_id_users_id_fk": {
+ "name": "email_send_log_user_id_users_id_fk",
+ "tableFrom": "email_send_log",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "email_send_log_book_id_books_id_fk": {
+ "name": "email_send_log_book_id_books_id_fk",
+ "tableFrom": "email_send_log",
+ "tableTo": "books",
+ "columnsFrom": ["book_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "email_send_log_book_file_id_book_files_id_fk": {
+ "name": "email_send_log_book_file_id_book_files_id_fk",
+ "tableFrom": "email_send_log",
+ "tableTo": "book_files",
+ "columnsFrom": ["book_file_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "email_send_log_provider_id_email_providers_id_fk": {
+ "name": "email_send_log_provider_id_email_providers_id_fk",
+ "tableFrom": "email_send_log",
+ "tableTo": "email_providers",
+ "columnsFrom": ["provider_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "email_send_log_template_id_email_templates_id_fk": {
+ "name": "email_send_log_template_id_email_templates_id_fk",
+ "tableFrom": "email_send_log",
+ "tableTo": "email_templates",
+ "columnsFrom": ["template_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "email_send_log_status_chk": {
+ "name": "email_send_log_status_chk",
+ "value": "\"email_send_log\".\"status\" in ('pending', 'sent', 'failed')"
+ },
+ "email_send_log_attempt_count_nonnegative_chk": {
+ "name": "email_send_log_attempt_count_nonnegative_chk",
+ "value": "\"email_send_log\".\"attempt_count\" >= 0"
+ },
+ "email_send_log_sent_after_created_chk": {
+ "name": "email_send_log_sent_after_created_chk",
+ "value": "\"email_send_log\".\"sent_at\" is null or \"email_send_log\".\"sent_at\" >= \"email_send_log\".\"created_at\""
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.notifications": {
+ "name": "notifications",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "message": {
+ "name": "message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "action_url": {
+ "name": "action_url",
+ "type": "varchar(2048)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "meta": {
+ "name": "meta",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "read": {
+ "name": "read",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "notifications_user_id_idx": {
+ "name": "notifications_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "notifications_user_unread_idx": {
+ "name": "notifications_user_unread_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "read",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "notifications_created_at_idx": {
+ "name": "notifications_created_at_idx",
+ "columns": [
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "notifications_user_id_users_id_fk": {
+ "name": "notifications_user_id_users_id_fk",
+ "tableFrom": "notifications",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.dismissed_duplicate_pairs": {
+ "name": "dismissed_duplicate_pairs",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "entity_type": {
+ "name": "entity_type",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "entity_id_a": {
+ "name": "entity_id_a",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "entity_id_b": {
+ "name": "entity_id_b",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "reason": {
+ "name": "reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "dismissed_by": {
+ "name": "dismissed_by",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "dismissed_at": {
+ "name": "dismissed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "dismissed_duplicate_pairs_entity_a_idx": {
+ "name": "dismissed_duplicate_pairs_entity_a_idx",
+ "columns": [
+ {
+ "expression": "entity_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "entity_id_a",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "dismissed_duplicate_pairs_entity_b_idx": {
+ "name": "dismissed_duplicate_pairs_entity_b_idx",
+ "columns": [
+ {
+ "expression": "entity_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "entity_id_b",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "dismissed_duplicate_pairs_dismissed_by_users_id_fk": {
+ "name": "dismissed_duplicate_pairs_dismissed_by_users_id_fk",
+ "tableFrom": "dismissed_duplicate_pairs",
+ "tableTo": "users",
+ "columnsFrom": ["dismissed_by"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "dismissed_duplicate_pairs_unique": {
+ "name": "dismissed_duplicate_pairs_unique",
+ "nullsNotDistinct": false,
+ "columns": ["entity_type", "entity_id_a", "entity_id_b"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.dismissed_inline_duplicate_pairs": {
+ "name": "dismissed_inline_duplicate_pairs",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "entity_type": {
+ "name": "entity_type",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value_a": {
+ "name": "value_a",
+ "type": "varchar(500)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value_b": {
+ "name": "value_b",
+ "type": "varchar(500)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "reason": {
+ "name": "reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "dismissed_by": {
+ "name": "dismissed_by",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "dismissed_at": {
+ "name": "dismissed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "dismissed_inline_pairs_entity_a_idx": {
+ "name": "dismissed_inline_pairs_entity_a_idx",
+ "columns": [
+ {
+ "expression": "entity_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "value_a",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "dismissed_inline_pairs_entity_b_idx": {
+ "name": "dismissed_inline_pairs_entity_b_idx",
+ "columns": [
+ {
+ "expression": "entity_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "value_b",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "dismissed_inline_duplicate_pairs_dismissed_by_users_id_fk": {
+ "name": "dismissed_inline_duplicate_pairs_dismissed_by_users_id_fk",
+ "tableFrom": "dismissed_inline_duplicate_pairs",
+ "tableTo": "users",
+ "columnsFrom": ["dismissed_by"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "dismissed_inline_duplicate_pairs_unique": {
+ "name": "dismissed_inline_duplicate_pairs_unique",
+ "nullsNotDistinct": false,
+ "columns": ["entity_type", "value_a", "value_b"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.entity_duplicate_candidates": {
+ "name": "entity_duplicate_candidates",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "entity_type": {
+ "name": "entity_type",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "entity_id_a": {
+ "name": "entity_id_a",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "entity_id_b": {
+ "name": "entity_id_b",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "sim_score": {
+ "name": "sim_score",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "computed_at": {
+ "name": "computed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "entity_dup_candidates_type_sim_idx": {
+ "name": "entity_dup_candidates_type_sim_idx",
+ "columns": [
+ {
+ "expression": "entity_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "sim_score",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "entity_dup_candidates_entity_a_idx": {
+ "name": "entity_dup_candidates_entity_a_idx",
+ "columns": [
+ {
+ "expression": "entity_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "entity_id_a",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "entity_dup_candidates_entity_b_idx": {
+ "name": "entity_dup_candidates_entity_b_idx",
+ "columns": [
+ {
+ "expression": "entity_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "entity_id_b",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "entity_duplicate_candidates_unique": {
+ "name": "entity_duplicate_candidates_unique",
+ "nullsNotDistinct": false,
+ "columns": ["entity_type", "entity_id_a", "entity_id_b"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.entity_duplicate_scan_status": {
+ "name": "entity_duplicate_scan_status",
+ "schema": "",
+ "columns": {
+ "entity_type": {
+ "name": "entity_type",
+ "type": "varchar(50)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "is_computing": {
+ "name": "is_computing",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "computed_at": {
+ "name": "computed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "total_pairs": {
+ "name": "total_pairs",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "threshold": {
+ "name": "threshold",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "processed_count": {
+ "name": "processed_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "total_count": {
+ "name": "total_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "error_message": {
+ "name": "error_message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user_fonts": {
+ "name": "user_fonts",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "family_name": {
+ "name": "family_name",
+ "type": "varchar(200)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "original_file_name": {
+ "name": "original_file_name",
+ "type": "varchar(500)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "stored_file_name": {
+ "name": "stored_file_name",
+ "type": "varchar(500)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "format": {
+ "name": "format",
+ "type": "varchar(10)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "weight": {
+ "name": "weight",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 400
+ },
+ "style": {
+ "name": "style",
+ "type": "varchar(10)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'normal'"
+ },
+ "file_size": {
+ "name": "file_size",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "file_hash": {
+ "name": "file_hash",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "uf_user_hash_uidx": {
+ "name": "uf_user_hash_uidx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "file_hash",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "uf_user_family_weight_style_uidx": {
+ "name": "uf_user_family_weight_style_uidx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "family_name",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "weight",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "style",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "user_fonts_user_id_users_id_fk": {
+ "name": "user_fonts_user_id_users_id_fk",
+ "tableFrom": "user_fonts",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "user_fonts_format_chk": {
+ "name": "user_fonts_format_chk",
+ "value": "\"user_fonts\".\"format\" in ('ttf', 'otf', 'woff', 'woff2')"
+ },
+ "user_fonts_weight_chk": {
+ "name": "user_fonts_weight_chk",
+ "value": "\"user_fonts\".\"weight\" >= 100 and \"user_fonts\".\"weight\" <= 900 and \"user_fonts\".\"weight\" % 100 = 0"
+ },
+ "user_fonts_style_chk": {
+ "name": "user_fonts_style_chk",
+ "value": "\"user_fonts\".\"style\" in ('normal', 'italic')"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.book_file_chapters": {
+ "name": "book_file_chapters",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "book_file_id": {
+ "name": "book_file_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "chapter_index": {
+ "name": "chapter_index",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "varchar(500)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "href": {
+ "name": "href",
+ "type": "varchar(1000)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "spine_index": {
+ "name": "spine_index",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "book_file_chapters_book_file_id_chapter_index_uidx": {
+ "name": "book_file_chapters_book_file_id_chapter_index_uidx",
+ "columns": [
+ {
+ "expression": "book_file_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "chapter_index",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "book_file_chapters_book_file_id_idx": {
+ "name": "book_file_chapters_book_file_id_idx",
+ "columns": [
+ {
+ "expression": "book_file_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "book_file_chapters_book_file_id_book_files_id_fk": {
+ "name": "book_file_chapters_book_file_id_book_files_id_fk",
+ "tableFrom": "book_file_chapters",
+ "tableTo": "book_files",
+ "columnsFrom": ["book_file_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.book_file_hash_history": {
+ "name": "book_file_hash_history",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "book_file_id": {
+ "name": "book_file_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "file_hash": {
+ "name": "file_hash",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "reason": {
+ "name": "reason",
+ "type": "varchar(30)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "book_file_hash_history_book_file_id_file_hash_idx": {
+ "name": "book_file_hash_history_book_file_id_file_hash_idx",
+ "columns": [
+ {
+ "expression": "book_file_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "file_hash",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "book_file_hash_history_file_hash_idx": {
+ "name": "book_file_hash_history_file_hash_idx",
+ "columns": [
+ {
+ "expression": "file_hash",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "book_file_hash_history_book_file_id_book_files_id_fk": {
+ "name": "book_file_hash_history_book_file_id_book_files_id_fk",
+ "tableFrom": "book_file_hash_history",
+ "tableTo": "book_files",
+ "columnsFrom": ["book_file_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "book_file_hash_history_reason_chk": {
+ "name": "book_file_hash_history_reason_chk",
+ "value": "\"book_file_hash_history\".\"reason\" in ('file_write', 'external_change', 'rescan')"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.koreader_book_hash_links": {
+ "name": "koreader_book_hash_links",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "hash": {
+ "name": "hash",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "book_file_id": {
+ "name": "book_file_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "koreader_title": {
+ "name": "koreader_title",
+ "type": "varchar(500)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "koreader_authors": {
+ "name": "koreader_authors",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "koreader_last_open": {
+ "name": "koreader_last_open",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "koreader_book_hash_links_user_hash_uidx": {
+ "name": "koreader_book_hash_links_user_hash_uidx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "hash",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "koreader_book_hash_links_user_updated_idx": {
+ "name": "koreader_book_hash_links_user_updated_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "\"updated_at\" desc",
+ "asc": true,
+ "isExpression": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "koreader_book_hash_links_book_file_id_idx": {
+ "name": "koreader_book_hash_links_book_file_id_idx",
+ "columns": [
+ {
+ "expression": "book_file_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "koreader_book_hash_links_user_id_users_id_fk": {
+ "name": "koreader_book_hash_links_user_id_users_id_fk",
+ "tableFrom": "koreader_book_hash_links",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "koreader_book_hash_links_book_file_id_book_files_id_fk": {
+ "name": "koreader_book_hash_links_book_file_id_book_files_id_fk",
+ "tableFrom": "koreader_book_hash_links",
+ "tableTo": "book_files",
+ "columnsFrom": ["book_file_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "koreader_book_hash_links_hash_chk": {
+ "name": "koreader_book_hash_links_hash_chk",
+ "value": "\"koreader_book_hash_links\".\"hash\" ~ '^[0-9a-f]{32}$'"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.koreader_bookmark_links": {
+ "name": "koreader_bookmark_links",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "bookmark_id": {
+ "name": "bookmark_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "device_id": {
+ "name": "device_id",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "koreader_key": {
+ "name": "koreader_key",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "device_datetime": {
+ "name": "device_datetime",
+ "type": "varchar(19)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "applied_at": {
+ "name": "applied_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "koreader_bookmark_links_bookmark_device_uidx": {
+ "name": "koreader_bookmark_links_bookmark_device_uidx",
+ "columns": [
+ {
+ "expression": "bookmark_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "device_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "koreader_bookmark_links_user_device_key_idx": {
+ "name": "koreader_bookmark_links_user_device_key_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "device_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "koreader_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "koreader_bookmark_links_user_device_idx": {
+ "name": "koreader_bookmark_links_user_device_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "device_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "koreader_bookmark_links_bookmark_id_idx": {
+ "name": "koreader_bookmark_links_bookmark_id_idx",
+ "columns": [
+ {
+ "expression": "bookmark_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "koreader_bookmark_links_bookmark_id_bookmarks_id_fk": {
+ "name": "koreader_bookmark_links_bookmark_id_bookmarks_id_fk",
+ "tableFrom": "koreader_bookmark_links",
+ "tableTo": "bookmarks",
+ "columnsFrom": ["bookmark_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "koreader_bookmark_links_user_id_users_id_fk": {
+ "name": "koreader_bookmark_links_user_id_users_id_fk",
+ "tableFrom": "koreader_bookmark_links",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.koreader_device_progress": {
+ "name": "koreader_device_progress",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "book_file_id": {
+ "name": "book_file_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "device": {
+ "name": "device",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'KOReader'"
+ },
+ "device_id": {
+ "name": "device_id",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "percentage": {
+ "name": "percentage",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "progress": {
+ "name": "progress",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "chapter_index": {
+ "name": "chapter_index",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sync_timestamp": {
+ "name": "sync_timestamp",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "orphaned": {
+ "name": "orphaned",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "orphaned_hash": {
+ "name": "orphaned_hash",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "koreader_device_progress_book_user_device_uidx": {
+ "name": "koreader_device_progress_book_user_device_uidx",
+ "columns": [
+ {
+ "expression": "book_file_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "device",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "device_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"koreader_device_progress\".\"orphaned\" = false",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "koreader_device_progress_orphaned_hash_idx": {
+ "name": "koreader_device_progress_orphaned_hash_idx",
+ "columns": [
+ {
+ "expression": "orphaned_hash",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "\"koreader_device_progress\".\"orphaned\" = true",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "koreader_device_progress_user_updated_at_idx": {
+ "name": "koreader_device_progress_user_updated_at_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "updated_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "koreader_device_progress_book_file_id_idx": {
+ "name": "koreader_device_progress_book_file_id_idx",
+ "columns": [
+ {
+ "expression": "book_file_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "\"koreader_device_progress\".\"book_file_id\" is not null",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "koreader_device_progress_book_file_id_book_files_id_fk": {
+ "name": "koreader_device_progress_book_file_id_book_files_id_fk",
+ "tableFrom": "koreader_device_progress",
+ "tableTo": "book_files",
+ "columnsFrom": ["book_file_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "koreader_device_progress_user_id_users_id_fk": {
+ "name": "koreader_device_progress_user_id_users_id_fk",
+ "tableFrom": "koreader_device_progress",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "koreader_device_progress_percentage_range_chk": {
+ "name": "koreader_device_progress_percentage_range_chk",
+ "value": "\"koreader_device_progress\".\"percentage\" is null or (\"koreader_device_progress\".\"percentage\" >= 0 and \"koreader_device_progress\".\"percentage\" <= 1)"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.koreader_device_settings": {
+ "name": "koreader_device_settings",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "device_id": {
+ "name": "device_id",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "file_naming_pattern": {
+ "name": "file_naming_pattern",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "series_file_naming_pattern": {
+ "name": "series_file_naming_pattern",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "standalone_file_naming_pattern": {
+ "name": "standalone_file_naming_pattern",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "koreader_device_settings_user_id_idx": {
+ "name": "koreader_device_settings_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "koreader_device_settings_user_id_users_id_fk": {
+ "name": "koreader_device_settings_user_id_users_id_fk",
+ "tableFrom": "koreader_device_settings",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "koreader_device_settings_user_id_device_id_pk": {
+ "name": "koreader_device_settings_user_id_device_id_pk",
+ "columns": ["user_id", "device_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.koreader_device_sweeps": {
+ "name": "koreader_device_sweeps",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "device_id": {
+ "name": "device_id",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "device_model": {
+ "name": "device_model",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'KOReader'"
+ },
+ "plugin_version": {
+ "name": "plugin_version",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_sweep_at": {
+ "name": "last_sweep_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "last_sweep_books_matched": {
+ "name": "last_sweep_books_matched",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "last_sweep_page_stats": {
+ "name": "last_sweep_page_stats",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "last_sweep_annotations": {
+ "name": "last_sweep_annotations",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "kds_user_id_idx": {
+ "name": "kds_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "koreader_device_sweeps_user_id_users_id_fk": {
+ "name": "koreader_device_sweeps_user_id_users_id_fk",
+ "tableFrom": "koreader_device_sweeps",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "koreader_device_sweeps_user_id_device_id_pk": {
+ "name": "koreader_device_sweeps_user_id_device_id_pk",
+ "columns": ["user_id", "device_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.koreader_page_stats": {
+ "name": "koreader_page_stats",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "book_file_id": {
+ "name": "book_file_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "device_id": {
+ "name": "device_id",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "page": {
+ "name": "page",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "start_time": {
+ "name": "start_time",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "duration_seconds": {
+ "name": "duration_seconds",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "total_pages": {
+ "name": "total_pages",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "kps_user_file_device_page_start_uidx": {
+ "name": "kps_user_file_device_page_start_uidx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "book_file_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "device_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "page",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "start_time",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "kps_user_file_device_start_idx": {
+ "name": "kps_user_file_device_start_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "book_file_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "device_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "start_time",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "kps_user_id_idx": {
+ "name": "kps_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "koreader_page_stats_book_file_id_idx": {
+ "name": "koreader_page_stats_book_file_id_idx",
+ "columns": [
+ {
+ "expression": "book_file_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "koreader_page_stats_user_id_users_id_fk": {
+ "name": "koreader_page_stats_user_id_users_id_fk",
+ "tableFrom": "koreader_page_stats",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "koreader_page_stats_book_file_id_book_files_id_fk": {
+ "name": "koreader_page_stats_book_file_id_book_files_id_fk",
+ "tableFrom": "koreader_page_stats",
+ "tableTo": "book_files",
+ "columnsFrom": ["book_file_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "koreader_page_stats_duration_nonnegative_chk": {
+ "name": "koreader_page_stats_duration_nonnegative_chk",
+ "value": "\"koreader_page_stats\".\"duration_seconds\" >= 0"
+ },
+ "koreader_page_stats_page_nonnegative_chk": {
+ "name": "koreader_page_stats_page_nonnegative_chk",
+ "value": "\"koreader_page_stats\".\"page\" >= 0"
+ },
+ "koreader_page_stats_total_pages_positive_chk": {
+ "name": "koreader_page_stats_total_pages_positive_chk",
+ "value": "\"koreader_page_stats\".\"total_pages\" > 0"
+ },
+ "koreader_page_stats_start_time_positive_chk": {
+ "name": "koreader_page_stats_start_time_positive_chk",
+ "value": "\"koreader_page_stats\".\"start_time\" > 0"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.koreader_unmatched_book_devices": {
+ "name": "koreader_unmatched_book_devices",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "hash": {
+ "name": "hash",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "device_id": {
+ "name": "device_id",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "last_seen_at": {
+ "name": "last_seen_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "koreader_unmatched_book_devices_user_device_idx": {
+ "name": "koreader_unmatched_book_devices_user_device_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "device_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "koreader_unmatched_book_devices_user_id_users_id_fk": {
+ "name": "koreader_unmatched_book_devices_user_id_users_id_fk",
+ "tableFrom": "koreader_unmatched_book_devices",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "koreader_unmatched_book_devices_user_hash_fk": {
+ "name": "koreader_unmatched_book_devices_user_hash_fk",
+ "tableFrom": "koreader_unmatched_book_devices",
+ "tableTo": "koreader_unmatched_books",
+ "columnsFrom": ["user_id", "hash"],
+ "columnsTo": ["user_id", "hash"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "koreader_unmatched_book_devices_user_id_hash_device_id_pk": {
+ "name": "koreader_unmatched_book_devices_user_id_hash_device_id_pk",
+ "columns": ["user_id", "hash", "device_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "koreader_unmatched_book_devices_hash_chk": {
+ "name": "koreader_unmatched_book_devices_hash_chk",
+ "value": "\"koreader_unmatched_book_devices\".\"hash\" ~ '^[0-9a-f]{32}$'"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.koreader_unmatched_books": {
+ "name": "koreader_unmatched_books",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "hash": {
+ "name": "hash",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "varchar(500)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "authors": {
+ "name": "authors",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_open": {
+ "name": "last_open",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "source": {
+ "name": "source",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'statistics'"
+ },
+ "metadata_ambiguous": {
+ "name": "metadata_ambiguous",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "first_seen_at": {
+ "name": "first_seen_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "last_seen_at": {
+ "name": "last_seen_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "koreader_unmatched_books_user_seen_idx": {
+ "name": "koreader_unmatched_books_user_seen_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "\"last_seen_at\" desc",
+ "asc": true,
+ "isExpression": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "koreader_unmatched_books_user_id_users_id_fk": {
+ "name": "koreader_unmatched_books_user_id_users_id_fk",
+ "tableFrom": "koreader_unmatched_books",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "koreader_unmatched_books_user_id_hash_pk": {
+ "name": "koreader_unmatched_books_user_id_hash_pk",
+ "columns": ["user_id", "hash"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "koreader_unmatched_books_hash_chk": {
+ "name": "koreader_unmatched_books_hash_chk",
+ "value": "\"koreader_unmatched_books\".\"hash\" ~ '^[0-9a-f]{32}$'"
+ },
+ "koreader_unmatched_books_source_chk": {
+ "name": "koreader_unmatched_books_source_chk",
+ "value": "\"koreader_unmatched_books\".\"source\" in ('current_file', 'file', 'statistics')"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.koreader_user_settings": {
+ "name": "koreader_user_settings",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "default_file_naming_pattern": {
+ "name": "default_file_naming_pattern",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "koreader_user_settings_user_id_users_id_fk": {
+ "name": "koreader_user_settings_user_id_users_id_fk",
+ "tableFrom": "koreader_user_settings",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.koreader_users": {
+ "name": "koreader_users",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "username": {
+ "name": "username",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "password_hash": {
+ "name": "password_hash",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "password_md5": {
+ "name": "password_md5",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sync_enabled": {
+ "name": "sync_enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "koreader_users_user_id_uidx": {
+ "name": "koreader_users_user_id_uidx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "koreader_users_username_uidx": {
+ "name": "koreader_users_username_uidx",
+ "columns": [
+ {
+ "expression": "username",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "koreader_users_user_id_users_id_fk": {
+ "name": "koreader_users_user_id_users_id_fk",
+ "tableFrom": "koreader_users",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.achievements": {
+ "name": "achievements",
+ "schema": "",
+ "columns": {
+ "key": {
+ "name": "key",
+ "type": "varchar(64)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "group_key": {
+ "name": "group_key",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "tier": {
+ "name": "tier",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "category": {
+ "name": "category",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "varchar(500)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "icon_name": {
+ "name": "icon_name",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "rarity": {
+ "name": "rarity",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "threshold": {
+ "name": "threshold",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "hidden": {
+ "name": "hidden",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "achievements_category_sort_idx": {
+ "name": "achievements_category_sort_idx",
+ "columns": [
+ {
+ "expression": "category",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "sort_order",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "achievements_group_key_idx": {
+ "name": "achievements_group_key_idx",
+ "columns": [
+ {
+ "expression": "group_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "achievements_category_chk": {
+ "name": "achievements_category_chk",
+ "value": "\"achievements\".\"category\" in ('reading', 'library', 'exploration', 'dedication', 'devices')"
+ },
+ "achievements_rarity_chk": {
+ "name": "achievements_rarity_chk",
+ "value": "\"achievements\".\"rarity\" in ('common', 'rare', 'epic', 'legendary')"
+ },
+ "achievements_tier_chk": {
+ "name": "achievements_tier_chk",
+ "value": "\"achievements\".\"tier\" is null or (\"achievements\".\"tier\" >= 1 and \"achievements\".\"tier\" <= 4)"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.user_achievements": {
+ "name": "user_achievements",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "achievement_key": {
+ "name": "achievement_key",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "awarded_at": {
+ "name": "awarded_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "context_json": {
+ "name": "context_json",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "user_achievements_user_key_uidx": {
+ "name": "user_achievements_user_key_uidx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "achievement_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "user_achievements_user_id_idx": {
+ "name": "user_achievements_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "user_achievements_user_awarded_at_idx": {
+ "name": "user_achievements_user_awarded_at_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "awarded_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "user_achievements_user_id_users_id_fk": {
+ "name": "user_achievements_user_id_users_id_fk",
+ "tableFrom": "user_achievements",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "user_achievements_achievement_key_achievements_key_fk": {
+ "name": "user_achievements_achievement_key_achievements_key_fk",
+ "tableFrom": "user_achievements",
+ "tableTo": "achievements",
+ "columnsFrom": ["achievement_key"],
+ "columnsTo": ["key"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.hardcover_book_state": {
+ "name": "hardcover_book_state",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "book_id": {
+ "name": "book_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "hardcover_book_id": {
+ "name": "hardcover_book_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "hardcover_edition_id": {
+ "name": "hardcover_edition_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "hardcover_user_book_id": {
+ "name": "hardcover_user_book_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "hardcover_read_id": {
+ "name": "hardcover_read_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "match_method": {
+ "name": "match_method",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "match_error": {
+ "name": "match_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_synced_at": {
+ "name": "last_synced_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_synced_status": {
+ "name": "last_synced_status",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_synced_progress": {
+ "name": "last_synced_progress",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_synced_rating": {
+ "name": "last_synced_rating",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_synced_started_at": {
+ "name": "last_synced_started_at",
+ "type": "varchar(10)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_synced_finished_at": {
+ "name": "last_synced_finished_at",
+ "type": "varchar(10)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sync_error": {
+ "name": "sync_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sync_override": {
+ "name": "sync_override",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sync_excluded": {
+ "name": "sync_excluded",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "hardcover_book_state_book_id_idx": {
+ "name": "hardcover_book_state_book_id_idx",
+ "columns": [
+ {
+ "expression": "book_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "hardcover_book_state_user_id_users_id_fk": {
+ "name": "hardcover_book_state_user_id_users_id_fk",
+ "tableFrom": "hardcover_book_state",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "hardcover_book_state_book_id_books_id_fk": {
+ "name": "hardcover_book_state_book_id_books_id_fk",
+ "tableFrom": "hardcover_book_state",
+ "tableTo": "books",
+ "columnsFrom": ["book_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "hardcover_book_state_user_book_uidx": {
+ "name": "hardcover_book_state_user_book_uidx",
+ "nullsNotDistinct": false,
+ "columns": ["user_id", "book_id"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.hardcover_user_settings": {
+ "name": "hardcover_user_settings",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "api_token": {
+ "name": "api_token",
+ "type": "varchar(2048)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "book_sync_mode": {
+ "name": "book_sync_mode",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'all_eligible'"
+ },
+ "auto_sync_on_status_change": {
+ "name": "auto_sync_on_status_change",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "auto_sync_on_progress_update": {
+ "name": "auto_sync_on_progress_update",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "auto_sync_on_rating_change": {
+ "name": "auto_sync_on_rating_change",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "privacy_setting_id": {
+ "name": "privacy_setting_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 3
+ },
+ "last_synced_at": {
+ "name": "last_synced_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "hardcover_user_settings_user_id_users_id_fk": {
+ "name": "hardcover_user_settings_user_id_users_id_fk",
+ "tableFrom": "hardcover_user_settings",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "hardcover_user_settings_user_id_unique": {
+ "name": "hardcover_user_settings_user_id_unique",
+ "nullsNotDistinct": false,
+ "columns": ["user_id"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.readwise_user_settings": {
+ "name": "readwise_user_settings",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "api_token": {
+ "name": "api_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "last_synced_annotation_id": {
+ "name": "last_synced_annotation_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "disabled_reason": {
+ "name": "disabled_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_synced_at": {
+ "name": "last_synced_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "readwise_user_settings_user_id_users_id_fk": {
+ "name": "readwise_user_settings_user_id_users_id_fk",
+ "tableFrom": "readwise_user_settings",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "readwise_user_settings_user_id_unique": {
+ "name": "readwise_user_settings_user_id_unique",
+ "nullsNotDistinct": false,
+ "columns": ["user_id"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.storygraph_book_state": {
+ "name": "storygraph_book_state",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "book_id": {
+ "name": "book_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "storygraph_book_id": {
+ "name": "storygraph_book_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "match_method": {
+ "name": "match_method",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "match_error": {
+ "name": "match_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_synced_at": {
+ "name": "last_synced_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_synced_status": {
+ "name": "last_synced_status",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_synced_progress": {
+ "name": "last_synced_progress",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sync_error": {
+ "name": "sync_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sync_override": {
+ "name": "sync_override",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "storygraph_book_state_book_id_idx": {
+ "name": "storygraph_book_state_book_id_idx",
+ "columns": [
+ {
+ "expression": "book_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "storygraph_book_state_user_sync_override_idx": {
+ "name": "storygraph_book_state_user_sync_override_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "sync_override",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "book_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "storygraph_book_state_user_sync_error_idx": {
+ "name": "storygraph_book_state_user_sync_error_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "book_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "\"storygraph_book_state\".\"sync_error\" is not null",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "storygraph_book_state_user_id_users_id_fk": {
+ "name": "storygraph_book_state_user_id_users_id_fk",
+ "tableFrom": "storygraph_book_state",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "storygraph_book_state_book_id_books_id_fk": {
+ "name": "storygraph_book_state_book_id_books_id_fk",
+ "tableFrom": "storygraph_book_state",
+ "tableTo": "books",
+ "columnsFrom": ["book_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "storygraph_book_state_user_book_uidx": {
+ "name": "storygraph_book_state_user_book_uidx",
+ "nullsNotDistinct": false,
+ "columns": ["user_id", "book_id"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.storygraph_user_settings": {
+ "name": "storygraph_user_settings",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "session_cookie": {
+ "name": "session_cookie",
+ "type": "varchar(4096)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "remember_token": {
+ "name": "remember_token",
+ "type": "varchar(4096)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "book_sync_mode": {
+ "name": "book_sync_mode",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'all_eligible'"
+ },
+ "auto_sync_on_status_change": {
+ "name": "auto_sync_on_status_change",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "auto_sync_on_progress_update": {
+ "name": "auto_sync_on_progress_update",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "last_synced_at": {
+ "name": "last_synced_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "storygraph_user_settings_user_id_users_id_fk": {
+ "name": "storygraph_user_settings_user_id_users_id_fk",
+ "tableFrom": "storygraph_user_settings",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "storygraph_user_settings_user_id_unique": {
+ "name": "storygraph_user_settings_user_id_unique",
+ "nullsNotDistinct": false,
+ "columns": ["user_id"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.shared_reading_insight_view_sessions": {
+ "name": "shared_reading_insight_view_sessions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "viewer_user_id": {
+ "name": "viewer_user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "subject_user_id": {
+ "name": "subject_user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "sharing_level": {
+ "name": "sharing_level",
+ "type": "reading_insights_sharing_level",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "shared_insight_view_subject_created_idx": {
+ "name": "shared_insight_view_subject_created_idx",
+ "columns": [
+ {
+ "expression": "subject_user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "shared_insight_view_expires_idx": {
+ "name": "shared_insight_view_expires_idx",
+ "columns": [
+ {
+ "expression": "expires_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "shared_reading_insight_view_sessions_viewer_user_id_users_id_fk": {
+ "name": "shared_reading_insight_view_sessions_viewer_user_id_users_id_fk",
+ "tableFrom": "shared_reading_insight_view_sessions",
+ "tableTo": "users",
+ "columnsFrom": ["viewer_user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "shared_reading_insight_view_sessions_subject_user_id_users_id_fk": {
+ "name": "shared_reading_insight_view_sessions_subject_user_id_users_id_fk",
+ "tableFrom": "shared_reading_insight_view_sessions",
+ "tableTo": "users",
+ "columnsFrom": ["subject_user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user_preferences": {
+ "name": "user_preferences",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "category": {
+ "name": "category",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "data": {
+ "name": "data",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "up_user_category_idx": {
+ "name": "up_user_category_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "category",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "user_preferences_user_id_users_id_fk": {
+ "name": "user_preferences_user_id_users_id_fk",
+ "tableFrom": "user_preferences",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {
+ "public.content_filter_type": {
+ "name": "content_filter_type",
+ "schema": "public",
+ "values": ["include", "exclude"]
+ },
+ "public.library_access_level": {
+ "name": "library_access_level",
+ "schema": "public",
+ "values": ["viewer", "editor", "owner"]
+ },
+ "public.reading_insights_sharing_level": {
+ "name": "reading_insights_sharing_level",
+ "schema": "public",
+ "values": ["private", "summary", "detailed"]
+ },
+ "public.user_avatar_source": {
+ "name": "user_avatar_source",
+ "schema": "public",
+ "values": ["none", "external", "uploaded"]
+ },
+ "public.opds_sort_order": {
+ "name": "opds_sort_order",
+ "schema": "public",
+ "values": ["recent", "title_asc", "title_desc", "author_asc", "author_desc", "series_asc", "series_desc"]
+ }
+ },
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
diff --git a/server/src/db/migrations/meta/_journal.json b/server/src/db/migrations/meta/_journal.json
index b49fb2a58..92bf1a3fb 100644
--- a/server/src/db/migrations/meta/_journal.json
+++ b/server/src/db/migrations/meta/_journal.json
@@ -407,6 +407,13 @@
"when": 1785215769241,
"tag": "0057_add_kindle_file_write_settings",
"breakpoints": true
+ },
+ {
+ "idx": 58,
+ "version": "7",
+ "when": 1785219732532,
+ "tag": "0058_add_fb2_file_write_settings",
+ "breakpoints": true
}
]
}
diff --git a/server/src/db/schema/libraries.ts b/server/src/db/schema/libraries.ts
index 8a9b5269e..85529804c 100644
--- a/server/src/db/schema/libraries.ts
+++ b/server/src/db/schema/libraries.ts
@@ -51,6 +51,8 @@ export const libraries = pgTable(
fileWriteWriteCover: boolean('file_write_write_cover').notNull().default(true),
fileWriteEpubEnabled: boolean('file_write_epub_enabled').notNull().default(true),
fileWriteEpubMaxFileSizeMb: integer('file_write_epub_max_file_size_mb').notNull().default(100),
+ fileWriteFb2Enabled: boolean('file_write_fb2_enabled').notNull().default(false),
+ fileWriteFb2MaxFileSizeMb: integer('file_write_fb2_max_file_size_mb').notNull().default(100),
fileWritePdfEnabled: boolean('file_write_pdf_enabled').notNull().default(true),
fileWritePdfMaxFileSizeMb: integer('file_write_pdf_max_file_size_mb').notNull().default(100),
fileWriteCbxEnabled: boolean('file_write_cbx_enabled').notNull().default(false),
diff --git a/server/src/modules/file-write/file-write.constants.ts b/server/src/modules/file-write/file-write.constants.ts
index ae15d22a6..d5ed4cd51 100644
--- a/server/src/modules/file-write/file-write.constants.ts
+++ b/server/src/modules/file-write/file-write.constants.ts
@@ -2,6 +2,7 @@ import { BOOK_FILE_WRITE_FIELDS } from '@bookorbit/types';
import type { BookWritePayloadKey } from './interfaces/book-write-payload.interface';
export const FORMAT_EPUB = 'epub';
+export const FORMAT_FB2 = 'fb2';
export const FORMAT_PDF = 'pdf';
export const FORMAT_CBZ = 'cbz';
export const FORMAT_CB7 = 'cb7';
diff --git a/server/src/modules/file-write/file-write.module.test.ts b/server/src/modules/file-write/file-write.module.test.ts
index a0473c5ff..a0490d9b1 100644
--- a/server/src/modules/file-write/file-write.module.test.ts
+++ b/server/src/modules/file-write/file-write.module.test.ts
@@ -14,6 +14,7 @@ import { AudioMetadataEmbedder } from './formats/audio/audio-metadata-embedder';
import { Cb7FormatWriter } from './formats/cbx/cb7-format-writer';
import { CbzFormatWriter } from './formats/cbx/cbz-format-writer';
import { EpubFormatWriter } from './formats/epub/epub-format-writer';
+import { Fb2FormatWriter } from './formats/fb2/fb2-format-writer';
import { Azw3FormatWriter, AzwFormatWriter, MobiEbookFormatWriter } from './formats/mobi/mobi-format-writer';
import { PdfFormatWriter } from './formats/pdf/pdf-format-writer';
import { FORMAT_WRITERS } from './interfaces/format-writer.interface';
@@ -34,6 +35,7 @@ describe('FileWriteModule', () => {
FileLockService,
AudioMetadataEmbedder,
EpubFormatWriter,
+ Fb2FormatWriter,
PdfFormatWriter,
CbzFormatWriter,
Cb7FormatWriter,
@@ -58,6 +60,7 @@ describe('FileWriteModule', () => {
expect(writerProvider).toBeDefined();
expect(writerProvider.inject).toEqual([
EpubFormatWriter,
+ Fb2FormatWriter,
PdfFormatWriter,
CbzFormatWriter,
Cb7FormatWriter,
@@ -71,6 +74,7 @@ describe('FileWriteModule', () => {
]);
const epub = { format: 'epub' };
+ const fb2 = { format: 'fb2' };
const pdf = { format: 'pdf' };
const cbz = { format: 'cbz' };
const cb7 = { format: 'cb7' };
@@ -81,8 +85,9 @@ describe('FileWriteModule', () => {
const m4a = { format: 'm4a' };
const mp3 = { format: 'mp3' };
const flac = { format: 'flac' };
- expect(writerProvider.useFactory(epub, pdf, cbz, cb7, mobi, azw3, azw, m4b, m4a, mp3, flac)).toEqual([
+ expect(writerProvider.useFactory(epub, fb2, pdf, cbz, cb7, mobi, azw3, azw, m4b, m4a, mp3, flac)).toEqual([
epub,
+ fb2,
pdf,
cbz,
cb7,
diff --git a/server/src/modules/file-write/file-write.module.ts b/server/src/modules/file-write/file-write.module.ts
index 2a1b8c9c9..d02fb0b88 100644
--- a/server/src/modules/file-write/file-write.module.ts
+++ b/server/src/modules/file-write/file-write.module.ts
@@ -15,6 +15,7 @@ import { AudioMetadataEmbedder } from './formats/audio/audio-metadata-embedder';
import { Cb7FormatWriter } from './formats/cbx/cb7-format-writer';
import { CbzFormatWriter } from './formats/cbx/cbz-format-writer';
import { EpubFormatWriter } from './formats/epub/epub-format-writer';
+import { Fb2FormatWriter } from './formats/fb2/fb2-format-writer';
import { Azw3FormatWriter, AzwFormatWriter, MobiEbookFormatWriter } from './formats/mobi/mobi-format-writer';
import { PdfFormatWriter } from './formats/pdf/pdf-format-writer';
import { FORMAT_WRITERS } from './interfaces/format-writer.interface';
@@ -30,6 +31,7 @@ import { FORMAT_WRITERS } from './interfaces/format-writer.interface';
BulkRenameRepository,
AudioMetadataEmbedder,
EpubFormatWriter,
+ Fb2FormatWriter,
PdfFormatWriter,
CbzFormatWriter,
Cb7FormatWriter,
@@ -44,6 +46,7 @@ import { FORMAT_WRITERS } from './interfaces/format-writer.interface';
provide: FORMAT_WRITERS,
useFactory: (
epub: EpubFormatWriter,
+ fb2: Fb2FormatWriter,
pdf: PdfFormatWriter,
cbz: CbzFormatWriter,
cb7: Cb7FormatWriter,
@@ -54,9 +57,10 @@ import { FORMAT_WRITERS } from './interfaces/format-writer.interface';
m4a: M4aAudioFormatWriter,
mp3: Mp3AudioFormatWriter,
flac: FlacAudioFormatWriter,
- ) => [epub, pdf, cbz, cb7, mobi, azw3, azw, m4b, m4a, mp3, flac],
+ ) => [epub, fb2, pdf, cbz, cb7, mobi, azw3, azw, m4b, m4a, mp3, flac],
inject: [
EpubFormatWriter,
+ Fb2FormatWriter,
PdfFormatWriter,
CbzFormatWriter,
Cb7FormatWriter,
diff --git a/server/src/modules/file-write/file-write.repository.ts b/server/src/modules/file-write/file-write.repository.ts
index 5ff696e3c..881272db2 100644
--- a/server/src/modules/file-write/file-write.repository.ts
+++ b/server/src/modules/file-write/file-write.repository.ts
@@ -68,6 +68,8 @@ export class FileWriteRepository {
fileWriteWriteCover: libraries.fileWriteWriteCover,
fileWriteEpubEnabled: libraries.fileWriteEpubEnabled,
fileWriteEpubMaxFileSizeMb: libraries.fileWriteEpubMaxFileSizeMb,
+ fileWriteFb2Enabled: libraries.fileWriteFb2Enabled,
+ fileWriteFb2MaxFileSizeMb: libraries.fileWriteFb2MaxFileSizeMb,
fileWritePdfEnabled: libraries.fileWritePdfEnabled,
fileWritePdfMaxFileSizeMb: libraries.fileWritePdfMaxFileSizeMb,
fileWriteCbxEnabled: libraries.fileWriteCbxEnabled,
diff --git a/server/src/modules/file-write/file-write.service.test.ts b/server/src/modules/file-write/file-write.service.test.ts
index 7f7d9eb7d..5985f2858 100644
--- a/server/src/modules/file-write/file-write.service.test.ts
+++ b/server/src/modules/file-write/file-write.service.test.ts
@@ -1,7 +1,13 @@
import { ConfigService } from '@nestjs/config';
import type { MockedFunction } from 'vitest';
import { readdir, readFile, stat } from 'fs/promises';
-import { AUDIO_BOOK_FILE_WRITE_FIELDS, EPUB_BOOK_FILE_WRITE_FIELDS, MOBI_BOOK_FILE_WRITE_FIELDS, NotificationType } from '@bookorbit/types';
+import {
+ AUDIO_BOOK_FILE_WRITE_FIELDS,
+ EPUB_BOOK_FILE_WRITE_FIELDS,
+ FB2_BOOK_FILE_WRITE_FIELDS,
+ MOBI_BOOK_FILE_WRITE_FIELDS,
+ NotificationType,
+} from '@bookorbit/types';
import { SelfWriteRegistry } from '../../common/services/self-write-registry.service';
const { computeFileHashMock } = vi.hoisted(() => ({
@@ -35,6 +41,8 @@ const DEFAULT_LIB_CONFIG = {
fileWriteWriteCover: true,
fileWriteEpubEnabled: true,
fileWriteEpubMaxFileSizeMb: 100,
+ fileWriteFb2Enabled: true,
+ fileWriteFb2MaxFileSizeMb: 100,
fileWritePdfEnabled: true,
fileWritePdfMaxFileSizeMb: 100,
fileWriteCbxEnabled: true,
@@ -177,6 +185,64 @@ describe('FileWriteService', () => {
});
});
+ it('returns the FB2 field set for fb2 files', () => {
+ const { service, registry } = makeService();
+ registry.supports.mockImplementation((value: string) => value === 'fb2');
+
+ expect(service.resolveBookFileWriteStatus(DEFAULT_LIB_CONFIG, [{ id: 1, format: 'fb2', sizeBytes: 1024 }], 1)).toEqual({
+ enabled: true,
+ reason: null,
+ writableFormats: ['fb2'],
+ writableFields: [...FB2_BOOK_FILE_WRITE_FIELDS],
+ });
+ });
+
+ it('honours the FB2 enable toggle', () => {
+ const { service, registry } = makeService();
+ registry.supports.mockImplementation((value: string) => value === 'fb2');
+
+ expect(
+ service.resolveBookFileWriteStatus({ ...DEFAULT_LIB_CONFIG, fileWriteFb2Enabled: false }, [{ id: 1, format: 'fb2', sizeBytes: 1024 }], 1),
+ ).toEqual({
+ enabled: false,
+ reason: 'format_disabled',
+ writableFormats: [],
+ writableFields: [],
+ });
+ });
+
+ it('honours the FB2 size limit', () => {
+ const { service, registry } = makeService();
+ registry.supports.mockImplementation((value: string) => value === 'fb2');
+
+ expect(
+ service.resolveBookFileWriteStatus(
+ { ...DEFAULT_LIB_CONFIG, fileWriteFb2MaxFileSizeMb: 1 },
+ [{ id: 1, format: 'fb2', sizeBytes: 2 * 1024 * 1024 }],
+ 1,
+ ),
+ ).toEqual({
+ enabled: false,
+ reason: 'file_exceeds_size_limit',
+ writableFormats: [],
+ writableFields: [],
+ });
+ });
+
+ it('drops the cover field for FB2 when the library disables cover writing', () => {
+ const { service, registry } = makeService();
+ registry.supports.mockImplementation((value: string) => value === 'fb2');
+
+ const status = service.resolveBookFileWriteStatus(
+ { ...DEFAULT_LIB_CONFIG, fileWriteWriteCover: false },
+ [{ id: 1, format: 'fb2', sizeBytes: 1024 }],
+ 1,
+ );
+
+ expect(status.writableFields).not.toContain('coverBytes');
+ expect(status.writableFields).toContain('seriesName');
+ });
+
it.each(['mobi', 'azw3', 'azw'])('returns the MOBI field set for %s files', (format) => {
const { service, registry } = makeService();
registry.supports.mockImplementation((value: string) => value === format);
diff --git a/server/src/modules/file-write/file-write.service.ts b/server/src/modules/file-write/file-write.service.ts
index 4228c65ee..837bd8862 100644
--- a/server/src/modules/file-write/file-write.service.ts
+++ b/server/src/modules/file-write/file-write.service.ts
@@ -17,6 +17,7 @@ import {
FORMAT_CB7,
FORMAT_CBZ,
FORMAT_EPUB,
+ FORMAT_FB2,
FORMAT_MOBI,
FORMAT_PDF,
createBookWriteFieldMask,
@@ -579,6 +580,8 @@ type LibraryFileWriteConfig = {
fileWriteWriteCover: boolean;
fileWriteEpubEnabled: boolean;
fileWriteEpubMaxFileSizeMb: number;
+ fileWriteFb2Enabled: boolean;
+ fileWriteFb2MaxFileSizeMb: number;
fileWritePdfEnabled: boolean;
fileWritePdfMaxFileSizeMb: number;
fileWriteCbxEnabled: boolean;
@@ -593,6 +596,8 @@ function resolveFormatSettings(config: LibraryFileWriteConfig, format: string):
switch (format) {
case FORMAT_EPUB:
return { enabled: config.fileWriteEpubEnabled, maxFileSizeBytes: config.fileWriteEpubMaxFileSizeMb * 1024 * 1024 };
+ case FORMAT_FB2:
+ return { enabled: config.fileWriteFb2Enabled, maxFileSizeBytes: config.fileWriteFb2MaxFileSizeMb * 1024 * 1024 };
case FORMAT_PDF:
return { enabled: config.fileWritePdfEnabled, maxFileSizeBytes: config.fileWritePdfMaxFileSizeMb * 1024 * 1024 };
case FORMAT_CBZ:
@@ -658,6 +663,8 @@ function isCompleteLibraryFileWriteConfig(config: FileWriteCapabilityLibraryConf
typeof config.fileWriteWriteCover === 'boolean' &&
typeof config.fileWriteEpubEnabled === 'boolean' &&
typeof config.fileWriteEpubMaxFileSizeMb === 'number' &&
+ typeof config.fileWriteFb2Enabled === 'boolean' &&
+ typeof config.fileWriteFb2MaxFileSizeMb === 'number' &&
typeof config.fileWritePdfEnabled === 'boolean' &&
typeof config.fileWritePdfMaxFileSizeMb === 'number' &&
typeof config.fileWriteCbxEnabled === 'boolean' &&
diff --git a/server/src/modules/file-write/formats/epub/epub-cover-image.ts b/server/src/modules/file-write/formats/epub/epub-cover-image.ts
index 05cc2dba3..fd64ba834 100644
--- a/server/src/modules/file-write/formats/epub/epub-cover-image.ts
+++ b/server/src/modules/file-write/formats/epub/epub-cover-image.ts
@@ -1,34 +1,2 @@
-import sharp from 'sharp';
-
-const FORMAT_DETAILS = {
- jpeg: { mediaType: 'image/jpeg', extension: 'jpg' },
- png: { mediaType: 'image/png', extension: 'png' },
- gif: { mediaType: 'image/gif', extension: 'gif' },
- webp: { mediaType: 'image/webp', extension: 'webp' },
- tiff: { mediaType: 'image/tiff', extension: 'tiff' },
- svg: { mediaType: 'image/svg+xml', extension: 'svg' },
- jp2: { mediaType: 'image/jp2', extension: 'jp2' },
- jxl: { mediaType: 'image/jxl', extension: 'jxl' },
-} as const;
-
-export interface EpubCoverImage {
- mediaType: string;
- extension: string;
-}
-
-export async function inspectCoverImage(bytes: Buffer): Promise {
- const metadata = await sharp(bytes, { failOn: 'error' }).metadata();
- const knownFormat = metadata.format ? FORMAT_DETAILS[metadata.format as keyof typeof FORMAT_DETAILS] : undefined;
- if (knownFormat) return knownFormat;
-
- if (metadata.format === 'heif') {
- return metadata.compression === 'av1' ? { mediaType: 'image/avif', extension: 'avif' } : { mediaType: 'image/heic', extension: 'heic' };
- }
-
- if (isBitmap(bytes)) return { mediaType: 'image/bmp', extension: 'bmp' };
- throw new Error(`Unsupported EPUB cover image format: ${metadata.format || 'unknown'}`);
-}
-
-function isBitmap(bytes: Buffer): boolean {
- return bytes.length >= 2 && bytes[0] === 0x42 && bytes[1] === 0x4d;
-}
+export type { CoverImageInfo as EpubCoverImage } from '../shared/cover-image';
+export { inspectCoverImage } from '../shared/cover-image';
diff --git a/server/src/modules/file-write/formats/fb2/fb2-binary-locator.test.ts b/server/src/modules/file-write/formats/fb2/fb2-binary-locator.test.ts
new file mode 100644
index 000000000..f352ca5e5
--- /dev/null
+++ b/server/src/modules/file-write/formats/fb2/fb2-binary-locator.test.ts
@@ -0,0 +1,162 @@
+import { mkdtemp, open, rm, writeFile } from 'fs/promises';
+import { tmpdir } from 'os';
+import { join } from 'path';
+
+import { buildBinaryElement, findBinarySpan, findRootCloseOffset } from './fb2-binary-locator';
+
+const CHUNK_BYTES = 256 * 1024;
+
+let dir: string;
+
+beforeEach(async () => {
+ dir = await mkdtemp(join(tmpdir(), 'fb2-binary-'));
+});
+
+afterEach(async () => {
+ await rm(dir, { recursive: true, force: true });
+});
+
+async function withFile(content: string, run: (handle: Awaited>, size: number) => Promise): Promise {
+ const path = join(dir, 'book.fb2');
+ const bytes = Buffer.from(content, 'latin1');
+ await writeFile(path, bytes);
+ const handle = await open(path, 'r');
+ try {
+ return await run(handle, bytes.length);
+ } finally {
+ await handle.close();
+ }
+}
+
+async function spanOf(content: string, id: string, from = 0): Promise {
+ return withFile(content, async (handle, size) => {
+ const span = await findBinarySpan(handle, id, from, size);
+ return span ? content.slice(span.start, span.end) : null;
+ });
+}
+
+describe('findBinarySpan', () => {
+ it('finds a binary element by id', async () => {
+ const content = 'AAA';
+ expect(await spanOf(content, 'cover.jpg')).toBe('AAA');
+ });
+
+ it('finds a binary whose attributes are in the opposite order', async () => {
+ const content = 'AAA';
+ expect(await spanOf(content, 'cover.jpg')).toBe(content);
+ });
+
+ it('finds a binary with a single-quoted id', async () => {
+ const content = "AAA";
+ expect(await spanOf(content, 'cover.jpg')).toBe(content);
+ });
+
+ it('finds an id containing regex special characters', async () => {
+ const content = 'AAA';
+ expect(await spanOf(content, 'cover(1).jpg')).toBe(content);
+ });
+
+ it('picks the requested binary out of several', async () => {
+ const content =
+ 'ONE\n' +
+ 'TWO\n' +
+ 'THREE';
+ expect(await spanOf(content, 'cover.jpg')).toContain('TWO');
+ expect(await spanOf(content, 'img_2')).toContain('THREE');
+ });
+
+ it('returns null when no binary carries the id', async () => {
+ const content = 'AAA';
+ expect(await spanOf(content, 'cover.jpg')).toBeNull();
+ });
+
+ it('returns null for a file with no binaries at all', async () => {
+ expect(await spanOf('', 'cover.jpg')).toBeNull();
+ });
+
+ it('ignores binaries before the requested start offset', async () => {
+ const first = 'EARLY';
+ const content = `${first}LATE`;
+ expect(await spanOf(content, 'cover.jpg', first.length)).toContain('LATE');
+ });
+
+ it('finds a start tag that straddles a chunk boundary', async () => {
+ const tag = '';
+ // Place the tag so its closing '>' falls into the next chunk read.
+ const padding = 'x'.repeat(CHUNK_BYTES - 3);
+ const content = `${padding}${tag}PAYLOAD`;
+ expect(await spanOf(content, 'cover.jpg')).toBe(`${tag}PAYLOAD`);
+ });
+
+ it('finds a closing tag several chunks after the start tag', async () => {
+ const payload = 'A'.repeat(CHUNK_BYTES * 3);
+ const content = `${payload}`;
+ const span = await spanOf(content, 'cover.jpg');
+ expect(span?.startsWith('')).toBe(true);
+ expect(span?.endsWith('')).toBe(true);
+ expect(span).toHaveLength(''.length + payload.length + ''.length);
+ });
+
+ it('finds a binary that sits past several megabytes of body text', async () => {
+ const body = `${'word '.repeat(400_000)}
`;
+ const content = `${body}PAYLOAD`;
+ expect(await spanOf(content, 'cover.jpg')).toBe('PAYLOAD');
+ });
+
+ it('does not match a different id that shares a prefix', async () => {
+ const content = 'AAA';
+ expect(await spanOf(content, 'cover.jpg')).toBeNull();
+ });
+});
+
+describe('findRootCloseOffset', () => {
+ it('finds the offset of the root closing tag', async () => {
+ const content = '\n';
+ const offset = await withFile(content, (handle, size) => findRootCloseOffset(handle, size));
+ expect(offset).toBe(content.indexOf(''));
+ });
+
+ it('finds the root close after a large trailing binary', async () => {
+ const content = `${'A'.repeat(CHUNK_BYTES * 2)}\n\n`;
+ const offset = await withFile(content, (handle, size) => findRootCloseOffset(handle, size));
+ expect(offset).toBe(content.lastIndexOf(''));
+ });
+
+ it('uses the last root close when the text mentions an earlier one', async () => {
+ const content = 'ends with </FictionBook>
';
+ const offset = await withFile(content, (handle, size) => findRootCloseOffset(handle, size));
+ expect(offset).toBe(content.lastIndexOf(''));
+ });
+
+ it('returns null when there is no root closing tag', async () => {
+ const offset = await withFile('', (handle, size) => findRootCloseOffset(handle, size));
+ expect(offset).toBeNull();
+ });
+});
+
+describe('buildBinaryElement', () => {
+ it('emits base64 content with the given id and content type', () => {
+ const element = buildBinaryElement('cover.jpg', 'image/png', Buffer.from('hello'));
+ expect(element).toBe('aGVsbG8=');
+ });
+
+ it('wraps long base64 at 76 columns without a trailing newline', () => {
+ const element = buildBinaryElement('c', 'image/jpeg', Buffer.alloc(600, 7));
+ const body = element.slice(element.indexOf('>') + 1, element.lastIndexOf(''));
+ const lines = body.split('\n');
+ expect(lines.length).toBeGreaterThan(1);
+ expect(lines.every((line) => line.length <= 76)).toBe(true);
+ expect(body.endsWith('\n')).toBe(false);
+ });
+
+ it('round-trips the original bytes through base64', () => {
+ const bytes = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]);
+ const element = buildBinaryElement('c', 'image/jpeg', bytes);
+ const body = element.slice(element.indexOf('>') + 1, element.lastIndexOf(''));
+ expect(Buffer.from(body.replace(/\s+/g, ''), 'base64').equals(bytes)).toBe(true);
+ });
+
+ it('escapes an id that contains markup characters', () => {
+ expect(buildBinaryElement('a"b&c elements after the book body, and a
+ * single cover can be hundreds of kilobytes. These helpers locate spans by
+ * scanning the file in fixed-size chunks so replacing a cover never needs the
+ * whole document in memory.
+ */
+
+const CHUNK_BYTES = 256 * 1024;
+const BINARY_OPEN = ' start tag that straddles
+ * a boundary appears whole in the following chunk. Re-seeing an element in the
+ * overlap is harmless: a non-matching id is skipped either way.
+ */
+const OVERLAP_BYTES = 1024;
+
+export type ByteSpan = { start: number; end: number };
+
+/**
+ * Finds the full span of the element carrying the given id, searching
+ * from `from` to the end of the file. Returns null when no such element exists.
+ */
+export async function findBinarySpan(handle: FileHandle, id: string, from: number, fileSize: number): Promise {
+ const buffer = Buffer.alloc(Math.min(CHUNK_BYTES, Math.max(fileSize - from, 1)));
+ let position = from;
+ let carry = '';
+ let carryStart = from;
+ let openAt: number | null = null;
+
+ while (position < fileSize) {
+ const { bytesRead } = await handle.read(buffer, 0, Math.min(buffer.length, fileSize - position), position);
+ if (bytesRead === 0) break;
+
+ // Markup is ASCII in every encoding this writer accepts, so latin1 keeps one
+ // character per byte and offsets stay byte-accurate.
+ const chunk = carry + buffer.subarray(0, bytesRead).toString('latin1');
+ const chunkStart = carryStart;
+ const isLastChunk = position + bytesRead >= fileSize;
+ let cursor = 0;
+
+ while (cursor < chunk.length) {
+ if (openAt === null) {
+ const at = chunk.indexOf(BINARY_OPEN, cursor);
+ if (at < 0) break;
+ const tagEnd = chunk.indexOf('>', at);
+ if (tagEnd < 0) break;
+ if (readAttribute(chunk.slice(at, tagEnd + 1), 'id') === id) {
+ openAt = chunkStart + at;
+ }
+ cursor = tagEnd + 1;
+ continue;
+ }
+
+ const closeAt = chunk.indexOf(BINARY_CLOSE, cursor);
+ if (closeAt < 0) break;
+ return { start: openAt, end: chunkStart + closeAt + BINARY_CLOSE.length };
+ }
+
+ if (isLastChunk) break;
+ position += bytesRead;
+ carry = chunk.slice(Math.max(0, chunk.length - OVERLAP_BYTES));
+ carryStart = position - carry.length;
+ }
+
+ return null;
+}
+
+/** Finds the offset of the root closing tag, scanning backwards from the end. */
+export async function findRootCloseOffset(handle: FileHandle, fileSize: number): Promise {
+ const window = Math.min(CHUNK_BYTES, fileSize);
+ const buffer = Buffer.alloc(Math.max(window, 1));
+ const windowStart = Math.max(0, fileSize - window);
+ const { bytesRead } = await handle.read(buffer, 0, window, windowStart);
+ const at = buffer.subarray(0, bytesRead).toString('latin1').lastIndexOf(ROOT_CLOSE);
+ if (at < 0) return null;
+ return windowStart + at;
+}
+
+/** Serializes cover bytes as a base64 element, wrapped at 76 columns. */
+export function buildBinaryElement(id: string, contentType: string, bytes: Buffer): string {
+ const base64 = bytes
+ .toString('base64')
+ .replace(/(.{76})/g, '$1\n')
+ .replace(/\n$/, '');
+ const escapedId = id.replace(/&/g, '&').replace(/${base64}`;
+}
diff --git a/server/src/modules/file-write/formats/fb2/fb2-description-builder.test.ts b/server/src/modules/file-write/formats/fb2/fb2-description-builder.test.ts
new file mode 100644
index 000000000..662b72449
--- /dev/null
+++ b/server/src/modules/file-write/formats/fb2/fb2-description-builder.test.ts
@@ -0,0 +1,456 @@
+import { FB2_BOOK_FILE_WRITE_FIELDS } from '@bookorbit/types';
+
+import type { BookWritePayload, BookWritePayloadKey } from '../../interfaces/book-write-payload.interface';
+import { buildFb2Description, readCoverEntryId } from './fb2-description-builder';
+import { locateElement, scanChildElements } from './fb2-element-scanner';
+
+const FULL_MASK = new Set(FB2_BOOK_FILE_WRITE_FIELDS);
+
+function description(titleInfo: string, extra = ''): string {
+ return `\n \n${titleInfo}\n \n${extra}`;
+}
+
+const MINIMAL = description(' sf\n Old Title\n en');
+
+function build(
+ xml: string,
+ payload: BookWritePayload,
+ mask: Set = FULL_MASK,
+ options: { xlinkPrefix?: string; coverEntryId?: string | null } = {},
+) {
+ const result = buildFb2Description(xml, payload, mask, { xlinkPrefix: options.xlinkPrefix ?? 'l', coverEntryId: options.coverEntryId ?? null });
+ if (result.status !== 'built') throw new Error(`expected a built description, got skipped: ${result.reason}`);
+ return result;
+}
+
+function maskOf(...fields: BookWritePayloadKey[]): Set {
+ return new Set(fields);
+}
+
+function childOrder(xml: string, parent: string): string[] {
+ const element = locateElement(xml, parent);
+ if (!element || element.selfClosing) throw new Error(`missing <${parent}>`);
+ return scanChildElements(xml, element.contentStart, element.contentEnd).map((child) => child.name);
+}
+
+describe('buildFb2Description field mapping', () => {
+ it('writes the title into book-title', () => {
+ const { descriptionXml, fieldsWritten } = build(MINIMAL, { title: 'New Title' });
+ expect(descriptionXml).toContain('New Title');
+ expect(descriptionXml).not.toContain('Old Title');
+ expect(fieldsWritten).toEqual(['title']);
+ });
+
+ it('writes the language into lang', () => {
+ expect(build(MINIMAL, { language: 'ru' }).descriptionXml).toContain('ru');
+ });
+
+ it('writes genres as one genre element each, replacing the originals', () => {
+ const { descriptionXml, fieldsWritten } = build(MINIMAL, { genres: ['Fantasy', 'Epic'] });
+ expect(descriptionXml).toContain('Fantasy');
+ expect(descriptionXml).toContain('Epic');
+ expect(descriptionXml).not.toContain('sf');
+ expect(fieldsWritten).toEqual(['genres']);
+ });
+
+ it('writes tags into keywords as a comma separated list', () => {
+ expect(build(MINIMAL, { tags: ['favourite', 'to-reread'] }).descriptionXml).toContain('favourite, to-reread');
+ });
+
+ it('writes the description into an annotation with one paragraph per blank line', () => {
+ const { descriptionXml } = build(MINIMAL, { description: 'First para.\n\nSecond para.' });
+ expect(descriptionXml).toContain('First para.
Second para.
');
+ });
+
+ it('collapses newlines inside a paragraph', () => {
+ const { descriptionXml } = build(MINIMAL, { description: 'One\nline\nwrapped' });
+ expect(descriptionXml).toContain('One line wrapped
');
+ });
+
+ it('writes an iso published date to title-info date with a value attribute', () => {
+ const { descriptionXml, fieldsWritten } = build(MINIMAL, { publishedDate: '2018-03-14' });
+ expect(descriptionXml).toContain('2018-03-14');
+ expect(descriptionXml).toContain('2018');
+ expect(fieldsWritten).toEqual(['publishedDate']);
+ });
+
+ it('writes a bare year when only publishedYear is known', () => {
+ const { descriptionXml, fieldsWritten } = build(MINIMAL, { publishedYear: 1999 });
+ expect(descriptionXml).toContain('1999');
+ expect(descriptionXml).toContain('1999');
+ expect(fieldsWritten).toEqual(['publishedYear']);
+ });
+
+ it('ignores a non-iso published date for the date element but still writes the year', () => {
+ const { descriptionXml } = build(MINIMAL, { publishedDate: '2018' });
+ expect(descriptionXml).not.toContain('2018');
+ });
+
+ it('writes publisher and isbn13 into publish-info', () => {
+ const { descriptionXml, fieldsWritten } = build(MINIMAL, { publisher: 'Pub & Co', isbn13: '9780000000001' });
+ expect(descriptionXml).toContain('Pub & Co');
+ expect(descriptionXml).toContain('9780000000001');
+ expect(fieldsWritten).toEqual(expect.arrayContaining(['publisher', 'isbn13']));
+ });
+
+ it('creates publish-info when the source document has none', () => {
+ expect(MINIMAL).not.toContain('publish-info');
+ const { descriptionXml } = build(MINIMAL, { publisher: 'New Publisher' });
+ expect(descriptionXml).toContain('');
+ expect(descriptionXml).toContain('');
+ });
+
+ it('writes series name and index into a sequence element', () => {
+ const { descriptionXml, fieldsWritten } = build(MINIMAL, { seriesName: 'My "Series"', seriesIndex: 3 });
+ expect(descriptionXml).toContain('');
+ expect(fieldsWritten).toEqual(expect.arrayContaining(['seriesName', 'seriesIndex']));
+ });
+
+ it('keeps a fractional series index, which real readers accept', () => {
+ const { descriptionXml, fieldsWritten } = build(MINIMAL, { seriesName: 'S', seriesIndex: 1.5 });
+ expect(descriptionXml).toContain('number="1.5"');
+ expect(fieldsWritten).toContain('seriesIndex');
+ });
+
+ it('writes the sequence without a number when no index is known', () => {
+ const { descriptionXml, fieldsWritten } = build(MINIMAL, { seriesName: 'S', seriesIndex: null });
+ expect(descriptionXml).toContain('');
+ expect(fieldsWritten).not.toContain('seriesIndex');
+ });
+
+ it('writes fields without an FB2 slot into namespaced custom-info', () => {
+ const { descriptionXml, fieldsWritten } = build(MINIMAL, {
+ subtitle: 'A Subtitle',
+ pageCount: 412,
+ rating: 4.5,
+ isbn10: '1729419001',
+ goodreadsId: '12345',
+ });
+ expect(descriptionXml).toContain('A Subtitle');
+ expect(descriptionXml).toContain('412');
+ expect(descriptionXml).toContain('4.5');
+ expect(descriptionXml).toContain('1729419001');
+ expect(descriptionXml).toContain('12345');
+ expect(fieldsWritten).toEqual(expect.arrayContaining(['subtitle', 'pageCount', 'rating', 'isbn10', 'goodreadsId']));
+ });
+
+ it('writes custom metadata entries under a custom prefix', () => {
+ const { descriptionXml } = build(MINIMAL, {
+ customMetadata: [{ fieldId: 1, key: 'shelf', label: 'Shelf', type: 'text', displayOrder: 0, value: 'Attic' }],
+ });
+ expect(descriptionXml).toContain('Attic');
+ });
+
+ it('skips custom metadata entries with no value', () => {
+ const { descriptionXml } = build(MINIMAL, {
+ customMetadata: [{ fieldId: 1, key: 'shelf', label: 'Shelf', type: 'text', displayOrder: 0, value: null }],
+ });
+ expect(descriptionXml).not.toContain('bookorbit:custom:shelf');
+ });
+});
+
+describe('buildFb2Description authors', () => {
+ it('splits a sort name into first, middle and last names', () => {
+ const { descriptionXml } = build(MINIMAL, { authors: [{ name: 'James David Victor', sortName: 'Victor, James David' }] });
+ expect(descriptionXml).toContain('JamesDavidVictor');
+ });
+
+ it('falls back to the display name when there is no sort name', () => {
+ const { descriptionXml } = build(MINIMAL, { authors: [{ name: 'Cat Bruno', sortName: null }] });
+ expect(descriptionXml).toContain('CatBruno');
+ });
+
+ it('writes a single-word author as a nickname, which FB2 allows', () => {
+ const { descriptionXml } = build(MINIMAL, { authors: [{ name: 'Prince', sortName: null }] });
+ expect(descriptionXml).toContain('Prince');
+ });
+
+ it('writes every author in payload order', () => {
+ const { descriptionXml } = build(MINIMAL, {
+ authors: [
+ { name: 'Chuck DeVore', sortName: 'DeVore, Chuck' },
+ { name: 'Steven Mosher', sortName: 'Mosher, Steven' },
+ ],
+ });
+ expect(descriptionXml.indexOf('DeVore')).toBeLessThan(descriptionXml.indexOf('Mosher'));
+ expect(descriptionXml.match(//g)).toHaveLength(2);
+ });
+
+ it('drops blank authors instead of emitting an empty author element', () => {
+ const { descriptionXml, fieldsWritten } = build(MINIMAL, {
+ authors: [
+ { name: ' ', sortName: null },
+ { name: 'Real Person', sortName: null },
+ ],
+ });
+ expect(descriptionXml).not.toContain('');
+ expect(descriptionXml.match(//g)).toHaveLength(1);
+ expect(fieldsWritten).toContain('authors');
+ });
+
+ it('reports nothing written when every author is blank', () => {
+ const { descriptionXml, fieldsWritten } = build(MINIMAL, { authors: [{ name: '', sortName: null }] });
+ expect(fieldsWritten).not.toContain('authors');
+ expect(descriptionXml).not.toContain('');
+ });
+
+ it('ignores a sort name with an empty surname', () => {
+ const { descriptionXml } = build(MINIMAL, { authors: [{ name: 'Ada Lovelace', sortName: ', Ada' }] });
+ expect(descriptionXml).toContain('AdaLovelace');
+ });
+});
+
+describe('buildFb2Description escaping', () => {
+ it('escapes xml significant characters in text', () => {
+ const { descriptionXml } = build(MINIMAL, { title: 'A & B < C > D' });
+ expect(descriptionXml).toContain('A & B < C > D');
+ });
+
+ it('escapes quotes in attribute values', () => {
+ const { descriptionXml } = build(MINIMAL, { seriesName: 'He said "hi" & left' });
+ expect(descriptionXml).toContain('name="He said "hi" & left"');
+ });
+
+ it('keeps non-ascii characters as-is for the encoder to handle', () => {
+ const { descriptionXml } = build(MINIMAL, { title: 'Мастер и Маргарита' });
+ expect(descriptionXml).toContain('Мастер и Маргарита');
+ });
+});
+
+describe('buildFb2Description structure', () => {
+ it('emits title-info children in schema order even when the source is out of order', () => {
+ const outOfOrder = description(' T\n en\n antique');
+ const { descriptionXml } = build(outOfOrder, {
+ title: 'T2',
+ authors: [{ name: 'A B', sortName: 'B, A' }],
+ description: 'Note.',
+ tags: ['x'],
+ publishedDate: '2001-02-03',
+ seriesName: 'S',
+ seriesIndex: 1,
+ });
+ expect(childOrder(descriptionXml, 'title-info')).toEqual(['genre', 'author', 'book-title', 'annotation', 'keywords', 'date', 'lang', 'sequence']);
+ });
+
+ it('emits description children in schema order', () => {
+ const xml = description(
+ ' sf\n T',
+ ' x\n 2001\n abc\n',
+ );
+ const { descriptionXml } = build(xml, { title: 'T2' });
+ expect(childOrder(descriptionXml, 'description')).toEqual(['title-info', 'document-info', 'publish-info', 'custom-info']);
+ });
+
+ it('preserves document-info byte for byte', () => {
+ const documentInfo =
+ ' abc-1231.0]]>\n';
+ const xml = description(' sf\n T', documentInfo);
+ const { descriptionXml } = build(xml, { title: 'T2' });
+ expect(descriptionXml).toContain(documentInfo.trim());
+ });
+
+ it('preserves src-title-info and unknown description children', () => {
+ const xml = description(
+ ' sf\n T',
+ ' Original\n \n',
+ );
+ const { descriptionXml } = build(xml, { title: 'T2' });
+ expect(descriptionXml).toContain('Original');
+ expect(descriptionXml).toContain('');
+ });
+
+ it('preserves foreign custom-info but replaces our own', () => {
+ const xml = description(
+ ' sf\n T',
+ ' keep-me\n 1\n',
+ );
+ const { descriptionXml } = build(xml, { rating: 5 });
+ expect(descriptionXml).toContain('keep-me');
+ expect(descriptionXml).toContain('5');
+ expect(descriptionXml).not.toContain('>1');
+ });
+
+ it('preserves publish-info elements it does not manage', () => {
+ const xml = description(
+ ' sf\n T',
+ ' Publisher TitleNew York1975\n',
+ );
+ const { descriptionXml } = build(xml, { publisher: 'New Pub' });
+ expect(descriptionXml).toContain('Publisher Title');
+ expect(descriptionXml).toContain('New York');
+ expect(descriptionXml).toContain('1975');
+ expect(descriptionXml).toContain('New Pub');
+ });
+
+ it('preserves attributes on the description and title-info start tags', () => {
+ const xml = '\n \n sf\n \n';
+ const { descriptionXml } = build(xml, { title: 'T' });
+ expect(descriptionXml.startsWith('')).toBe(true);
+ expect(descriptionXml).toContain('');
+ });
+
+ it('preserves the crlf line style of the source block', () => {
+ const xml = MINIMAL.replace(/\n/g, '\r\n');
+ const { descriptionXml } = build(xml, { title: 'T' });
+ expect(descriptionXml).toContain('\r\n');
+ expect(descriptionXml.replace(/\r\n/g, '')).not.toContain('\n');
+ });
+
+ it('keeps the indentation style of the source block', () => {
+ const xml = '\n\t\n\t\tsf\n\t\n';
+ const { descriptionXml } = build(xml, { title: 'T' });
+ expect(descriptionXml).toContain('\n\t');
+ expect(descriptionXml).toContain('\n\t\tT');
+ });
+
+ it('falls back to two-space indentation when the block is on one line', () => {
+ const xml = 'sfT';
+ const { descriptionXml } = build(xml, { title: 'T2' });
+ expect(descriptionXml).toContain('\n ');
+ expect(descriptionXml).toContain('\n T2');
+ expect(descriptionXml).not.toContain('');
+ });
+
+ it('is idempotent: rebuilding its own output changes nothing', () => {
+ const payload: BookWritePayload = {
+ title: 'Stable',
+ authors: [{ name: 'A B', sortName: 'B, A' }],
+ genres: ['Fantasy'],
+ tags: ['x'],
+ language: 'en',
+ publishedDate: '2020-01-02',
+ publisher: 'Pub',
+ isbn13: '9780000000001',
+ seriesName: 'S',
+ seriesIndex: 2,
+ rating: 4,
+ description: 'Note.',
+ };
+ const first = build(MINIMAL, payload).descriptionXml;
+ const second = build(first, payload).descriptionXml;
+ expect(second).toBe(first);
+ });
+});
+
+describe('buildFb2Description masking', () => {
+ it('writes only fields present in the mask', () => {
+ const { descriptionXml, fieldsWritten } = build(MINIMAL, { title: 'New', language: 'de' }, maskOf('title'));
+ expect(descriptionXml).toContain('New');
+ expect(descriptionXml).toContain('en');
+ expect(fieldsWritten).toEqual(['title']);
+ });
+
+ it('ignores fields FB2 cannot represent even when masked in', () => {
+ const { fieldsWritten } = build(
+ MINIMAL,
+ { comicIssueNumber: '3', narrators: ['Reader'] },
+ new Set(['comicIssueNumber', 'narrators'] as BookWritePayloadKey[]),
+ );
+ expect(fieldsWritten).toEqual([]);
+ });
+
+ it('leaves existing values untouched for null payload fields', () => {
+ const { descriptionXml, fieldsWritten } = build(MINIMAL, { title: null, language: undefined });
+ expect(descriptionXml).toContain('Old Title');
+ expect(descriptionXml).toContain('en');
+ expect(fieldsWritten).toEqual([]);
+ });
+
+ it('treats blank strings as no value', () => {
+ const { descriptionXml, fieldsWritten } = build(MINIMAL, { title: ' ' });
+ expect(descriptionXml).toContain('Old Title');
+ expect(fieldsWritten).toEqual([]);
+ });
+
+ it('treats empty arrays as no value', () => {
+ const { descriptionXml, fieldsWritten } = build(MINIMAL, { genres: [], tags: [] });
+ expect(descriptionXml).toContain('sf');
+ expect(fieldsWritten).toEqual([]);
+ });
+
+ it('trims values before writing them', () => {
+ expect(build(MINIMAL, { title: ' Trimmed ' }).descriptionXml).toContain('Trimmed');
+ });
+});
+
+describe('buildFb2Description coverpage', () => {
+ it('adds a coverpage pointing at the requested binary id', () => {
+ const { descriptionXml } = build(MINIMAL, {}, FULL_MASK, { coverEntryId: 'cover.jpg' });
+ expect(descriptionXml).toContain('');
+ });
+
+ it('uses the document xlink prefix', () => {
+ const { descriptionXml } = build(MINIMAL, {}, FULL_MASK, { coverEntryId: 'c.jpg', xlinkPrefix: 'xlink' });
+ expect(descriptionXml).toContain('');
+ });
+
+ it('replaces an existing coverpage rather than adding a second one', () => {
+ const xml = description(' sf\n \n en');
+ const { descriptionXml } = build(xml, {}, FULL_MASK, { coverEntryId: 'new.jpg' });
+ expect(descriptionXml.match(//g)).toHaveLength(1);
+ expect(descriptionXml).toContain('#new.jpg');
+ expect(descriptionXml).not.toContain('#old.jpg');
+ });
+
+ it('leaves the coverpage alone when no cover is being written', () => {
+ const xml = description(' sf\n ');
+ expect(build(xml, { title: 'T' }).descriptionXml).toContain('');
+ });
+});
+
+describe('buildFb2Description refusals', () => {
+ it('skips a description without title-info', () => {
+ const result = buildFb2Description('1', { title: 'T' }, FULL_MASK, {
+ xlinkPrefix: 'l',
+ });
+ expect(result).toEqual({ status: 'skipped', reason: 'description has no title-info' });
+ });
+
+ it('skips a self-closing description', () => {
+ const result = buildFb2Description('', { title: 'T' }, FULL_MASK, { xlinkPrefix: 'l' });
+ expect(result.status).toBe('skipped');
+ });
+
+ it('skips an unclosed description instead of throwing', () => {
+ const result = buildFb2Description('T', { title: 'T' }, FULL_MASK, { xlinkPrefix: 'l' });
+ expect(result).toMatchObject({ status: 'skipped' });
+ expect(result.status === 'skipped' && result.reason).toMatch(/unclosed/);
+ });
+});
+
+describe('readCoverEntryId', () => {
+ it('reads the id from a coverpage href', () => {
+ const xml = description(' ');
+ expect(readCoverEntryId(xml, 'l')).toBe('img_0');
+ });
+
+ it('reads a single-quoted href', () => {
+ const xml = description(" ");
+ expect(readCoverEntryId(xml, 'l')).toBe('cover(1).jpg');
+ });
+
+ it('reads an href written with a different prefix than the document declares', () => {
+ const xml = description(' ');
+ expect(readCoverEntryId(xml, 'l')).toBe('c.jpg');
+ });
+
+ it('reads an unprefixed href', () => {
+ const xml = description(' ');
+ expect(readCoverEntryId(xml, 'l')).toBe('c.jpg');
+ });
+
+ it('returns the first image when a coverpage lists several', () => {
+ const xml = description(' ');
+ expect(readCoverEntryId(xml, 'l')).toBe('first.jpg');
+ });
+
+ it('returns null when there is no coverpage', () => {
+ expect(readCoverEntryId(MINIMAL, 'l')).toBeNull();
+ });
+
+ it('ignores a coverpage that belongs to src-title-info', () => {
+ const xml = description(' sf', ' \n');
+ expect(readCoverEntryId(xml, 'l')).toBeNull();
+ });
+});
diff --git a/server/src/modules/file-write/formats/fb2/fb2-description-builder.ts b/server/src/modules/file-write/formats/fb2/fb2-description-builder.ts
new file mode 100644
index 000000000..0c73d4933
--- /dev/null
+++ b/server/src/modules/file-write/formats/fb2/fb2-description-builder.ts
@@ -0,0 +1,400 @@
+import { FB2_BOOK_FILE_WRITE_FIELDS } from '@bookorbit/types';
+
+import { BOOKORBIT_NS_PREFIX } from '../shared/bookorbit-ns';
+import type { BookWritePayload, BookWritePayloadKey } from '../../interfaces/book-write-payload.interface';
+import {
+ type Fb2Element,
+ Fb2StructureError,
+ elementText,
+ findElementClose,
+ findTagEnd,
+ locateElement,
+ scanChildElements,
+ startTagOf,
+} from './fb2-element-scanner';
+
+/**
+ * Child order required by the FB2 schema. Real-world files frequently deviate,
+ * so the builder reads in any order and always emits in this one.
+ */
+const TITLE_INFO_ORDER = [
+ 'genre',
+ 'author',
+ 'book-title',
+ 'annotation',
+ 'keywords',
+ 'date',
+ 'coverpage',
+ 'lang',
+ 'src-lang',
+ 'translator',
+ 'sequence',
+] as const;
+
+const DESCRIPTION_ORDER = ['title-info', 'src-title-info', 'document-info', 'publish-info', 'custom-info', 'output'] as const;
+
+const PUBLISH_INFO_ORDER = ['book-name', 'publisher', 'city', 'year', 'isbn', 'sequence'] as const;
+
+/** Fields with no standard FB2 slot, carried in . */
+const CUSTOM_INFO_FIELDS = [
+ 'subtitle',
+ 'pageCount',
+ 'rating',
+ 'isbn10',
+ 'googleBooksId',
+ 'goodreadsId',
+ 'amazonId',
+ 'hardcoverId',
+ 'hardcoverEditionId',
+ 'openLibraryId',
+ 'ranobedbId',
+ 'koboId',
+ 'lubimyczytacId',
+ 'aladinId',
+ 'itunesId',
+] as const satisfies readonly BookWritePayloadKey[];
+
+const PUBLISH_INFO_FIELDS = ['publisher', 'publishedDate', 'publishedYear', 'isbn13'] as const satisfies readonly BookWritePayloadKey[];
+
+const FB2_WRITABLE_FIELDS = new Set(FB2_BOOK_FILE_WRITE_FIELDS);
+const CUSTOM_INFO_PREFIX = `${BOOKORBIT_NS_PREFIX}:`;
+const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
+
+export type Fb2DescriptionBuild =
+ | { status: 'built'; descriptionXml: string; fieldsWritten: BookWritePayloadKey[]; coverEntryId: string | null }
+ | { status: 'skipped'; reason: string };
+
+export type BuildFb2DescriptionOptions = {
+ /** Namespace prefix bound to xlink in the source document, e.g. 'l' or 'xlink'. */
+ xlinkPrefix: string;
+ /** Binary id the coverpage must point at, or null to leave the coverpage alone. */
+ coverEntryId?: string | null;
+};
+
+function escapeText(value: string | number): string {
+ return String(value).replace(/&/g, '&').replace(//g, '>');
+}
+
+function escapeAttribute(value: string | number): string {
+ return escapeText(value).replace(/"/g, '"');
+}
+
+function hasValue(value: unknown): boolean {
+ if (value === null || value === undefined) return false;
+ if (typeof value === 'string') return value.trim().length > 0;
+ if (Array.isArray(value)) return value.length > 0;
+ return true;
+}
+
+function splitAuthorName(
+ name: string,
+ sortName: string | null,
+): {
+ first: string | null;
+ middle: string | null;
+ last: string | null;
+ nickname: string | null;
+} {
+ // "Last, First Middle" is the most reliable split we have; fall back to the
+ // display name when no sort name was stored.
+ if (sortName && sortName.includes(',')) {
+ const commaAt = sortName.indexOf(',');
+ const last = sortName.slice(0, commaAt).trim();
+ const rest = sortName
+ .slice(commaAt + 1)
+ .trim()
+ .split(/\s+/)
+ .filter(Boolean);
+ if (last) {
+ return { first: rest[0] ?? null, middle: rest.slice(1).join(' ') || null, last, nickname: null };
+ }
+ }
+
+ const parts = name.trim().split(/\s+/).filter(Boolean);
+ if (parts.length === 0) return { first: null, middle: null, last: null, nickname: null };
+ if (parts.length === 1) return { first: null, middle: null, last: null, nickname: parts[0]! };
+ return { first: parts[0]!, middle: parts.slice(1, -1).join(' ') || null, last: parts[parts.length - 1]!, nickname: null };
+}
+
+function buildAuthorElement(author: { name: string; sortName: string | null }, indent: string): string {
+ const { first, middle, last, nickname } = splitAuthorName(author.name, author.sortName);
+ const parts: string[] = [];
+ if (first) parts.push(`${escapeText(first)}`);
+ if (middle) parts.push(`${escapeText(middle)}`);
+ if (last) parts.push(`${escapeText(last)}`);
+ if (nickname) parts.push(`${escapeText(nickname)}`);
+ return `${indent}${parts.join('')}`;
+}
+
+function buildAnnotationElement(description: string, indent: string): string | null {
+ const paragraphs = description
+ .split(/\r?\n\s*\r?\n/)
+ .map((paragraph) => paragraph.replace(/\s+/g, ' ').trim())
+ .filter(Boolean);
+ if (paragraphs.length === 0) return null;
+ return `${indent}${paragraphs.map((paragraph) => `${escapeText(paragraph)}
`).join('')}`;
+}
+
+function resolveIndent(descriptionXml: string, firstChild: Fb2Element | undefined): string {
+ if (!firstChild) return ' ';
+ const lineStart = descriptionXml.lastIndexOf('\n', firstChild.start) + 1;
+ const lead = descriptionXml.slice(lineStart, firstChild.start);
+ return /^[ \t]+$/.test(lead) ? lead : ' ';
+}
+
+function groupByName(children: Fb2Element[]): Map {
+ const grouped = new Map();
+ for (const child of children) {
+ const existing = grouped.get(child.name);
+ if (existing) existing.push(child);
+ else grouped.set(child.name, [child]);
+ }
+ return grouped;
+}
+
+function reindent(raw: string, indent: string): string {
+ return indent + raw.trim();
+}
+
+/**
+ * Rewrites an FB2 block from a write payload.
+ *
+ * Managed elements are regenerated; everything else (document-info, src-title-info,
+ * foreign custom-info, publish-info extras) is copied verbatim from the source so
+ * unrelated metadata keeps its exact bytes.
+ */
+export function buildFb2Description(
+ descriptionXml: string,
+ payload: BookWritePayload,
+ fieldMask: Set,
+ options: BuildFb2DescriptionOptions,
+): Fb2DescriptionBuild {
+ const mask = new Set([...fieldMask].filter((field) => FB2_WRITABLE_FIELDS.has(field)));
+ const writes = (field: BookWritePayloadKey): boolean => mask.has(field) && hasValue(payload[field]);
+ const fieldsWritten: BookWritePayloadKey[] = [];
+ const record = (field: BookWritePayloadKey): void => {
+ if (!fieldsWritten.includes(field)) fieldsWritten.push(field);
+ };
+
+ let openTagEnd: number;
+ let closeTagStart: number;
+ try {
+ openTagEnd = findTagEnd(descriptionXml, 0);
+ closeTagStart = findElementClose(descriptionXml, 'description', openTagEnd + 1);
+ } catch (error) {
+ return { status: 'skipped', reason: error instanceof Fb2StructureError ? error.message : 'unreadable description block' };
+ }
+
+ const children = scanChildElements(descriptionXml, openTagEnd + 1, closeTagStart);
+ const grouped = groupByName(children);
+ const titleInfo = grouped.get('title-info')?.[0];
+ if (!titleInfo || titleInfo.selfClosing) {
+ return { status: 'skipped', reason: 'description has no title-info' };
+ }
+
+ const indent = resolveIndent(descriptionXml, children[0]);
+ const childIndent = indent + indent;
+ const newline = descriptionXml.includes('\r\n') ? '\r\n' : '\n';
+
+ // ── title-info ─────────────────────────────────────────────────────────────
+ const titleInfoChildren = scanChildElements(descriptionXml, titleInfo.contentStart, titleInfo.contentEnd);
+ const titleInfoGroups = groupByName(titleInfoChildren);
+ const slots = new Map();
+ for (const name of TITLE_INFO_ORDER) {
+ slots.set(
+ name,
+ (titleInfoGroups.get(name) ?? []).map((child) => reindent(elementText(descriptionXml, child), childIndent)),
+ );
+ }
+ const foreignTitleInfoChildren = titleInfoChildren
+ .filter((child) => !TITLE_INFO_ORDER.includes(child.name as (typeof TITLE_INFO_ORDER)[number]))
+ .map((child) => reindent(elementText(descriptionXml, child), childIndent));
+
+ if (writes('title')) {
+ slots.set('book-title', [`${childIndent}${escapeText(payload.title!.trim())}`]);
+ record('title');
+ }
+
+ if (writes('authors')) {
+ const authors = payload.authors!.filter((author) => author?.name?.trim());
+ if (authors.length > 0) {
+ slots.set(
+ 'author',
+ authors.map((author) => buildAuthorElement(author, childIndent)),
+ );
+ record('authors');
+ }
+ }
+
+ if (writes('genres')) {
+ const genres = payload.genres!.map((genre) => genre.trim()).filter(Boolean);
+ if (genres.length > 0) {
+ slots.set(
+ 'genre',
+ genres.map((genre) => `${childIndent}${escapeText(genre)}`),
+ );
+ record('genres');
+ }
+ }
+
+ if (writes('description')) {
+ const annotation = buildAnnotationElement(payload.description!, childIndent);
+ if (annotation) {
+ slots.set('annotation', [annotation]);
+ record('description');
+ }
+ }
+
+ if (writes('tags')) {
+ const tags = payload.tags!.map((tag) => tag.trim()).filter(Boolean);
+ if (tags.length > 0) {
+ slots.set('keywords', [`${childIndent}${escapeText(tags.join(', '))}`]);
+ record('tags');
+ }
+ }
+
+ if (writes('language')) {
+ slots.set('lang', [`${childIndent}${escapeText(payload.language!.trim())}`]);
+ record('language');
+ }
+
+ const isoDate = writes('publishedDate') && ISO_DATE_RE.test(payload.publishedDate!.trim()) ? payload.publishedDate!.trim() : null;
+ const publishedYear = resolvePublishedYear(payload, mask);
+ const yearFromPayload = mask.has('publishedYear') && typeof payload.publishedYear === 'number' && Number.isFinite(payload.publishedYear);
+ if (isoDate) {
+ slots.set('date', [`${childIndent}${escapeText(isoDate)}`]);
+ record('publishedDate');
+ } else if (publishedYear !== null) {
+ slots.set('date', [`${childIndent}${escapeText(publishedYear)}`]);
+ }
+ if (publishedYear !== null) {
+ record(yearFromPayload ? 'publishedYear' : 'publishedDate');
+ }
+
+ if (writes('seriesName')) {
+ const seriesName = payload.seriesName!.trim();
+ const index = payload.seriesIndex;
+ const writeIndex = mask.has('seriesIndex') && typeof index === 'number' && Number.isFinite(index);
+ const numberAttribute = writeIndex ? ` number="${escapeAttribute(String(index))}"` : '';
+ slots.set('sequence', [`${childIndent}`]);
+ record('seriesName');
+ if (writeIndex) record('seriesIndex');
+ }
+
+ if (options.coverEntryId) {
+ const href = `#${escapeAttribute(options.coverEntryId)}`;
+ slots.set('coverpage', [`${childIndent}`]);
+ }
+
+ const titleInfoBody = TITLE_INFO_ORDER.flatMap((name) => slots.get(name) ?? []).concat(foreignTitleInfoChildren);
+ const newTitleInfo = [`${indent}${startTagOf(descriptionXml, titleInfo)}`, ...titleInfoBody, `${indent}`].join(newline);
+
+ // ── publish-info ───────────────────────────────────────────────────────────
+ const publishInfo = grouped.get('publish-info')?.[0];
+ let newPublishInfo = publishInfo ? reindent(elementText(descriptionXml, publishInfo), indent) : null;
+ if (PUBLISH_INFO_FIELDS.some((field) => writes(field))) {
+ const publishInfoChildren =
+ publishInfo && !publishInfo.selfClosing ? scanChildElements(descriptionXml, publishInfo.contentStart, publishInfo.contentEnd) : [];
+ const publishGroups = groupByName(publishInfoChildren);
+ const publishSlots = new Map();
+ for (const name of PUBLISH_INFO_ORDER) {
+ publishSlots.set(
+ name,
+ (publishGroups.get(name) ?? []).map((child) => reindent(elementText(descriptionXml, child), childIndent)),
+ );
+ }
+
+ if (writes('publisher')) {
+ publishSlots.set('publisher', [`${childIndent}${escapeText(payload.publisher!.trim())}`]);
+ record('publisher');
+ }
+ if (publishedYear !== null) {
+ publishSlots.set('year', [`${childIndent}${escapeText(publishedYear)}`]);
+ }
+ if (writes('isbn13')) {
+ publishSlots.set('isbn', [`${childIndent}${escapeText(payload.isbn13!.trim())}`]);
+ record('isbn13');
+ }
+
+ const publishBody = PUBLISH_INFO_ORDER.flatMap((name) => publishSlots.get(name) ?? []);
+ const publishOpenTag = publishInfo && !publishInfo.selfClosing ? startTagOf(descriptionXml, publishInfo) : '';
+ newPublishInfo = [`${indent}${publishOpenTag}`, ...publishBody, `${indent}`].join(newline);
+ }
+
+ // ── custom-info ────────────────────────────────────────────────────────────
+ const foreignCustomInfo = (grouped.get('custom-info') ?? [])
+ .map((child) => elementText(descriptionXml, child))
+ .filter((raw) => !raw.includes(`info-type="${CUSTOM_INFO_PREFIX}`) && !raw.includes(`info-type='${CUSTOM_INFO_PREFIX}`))
+ .map((raw) => reindent(raw, indent));
+
+ const managedCustomInfo: string[] = [];
+ for (const field of CUSTOM_INFO_FIELDS) {
+ if (!writes(field)) continue;
+ managedCustomInfo.push(customInfoElement(field, payload[field] as string | number, indent));
+ record(field);
+ }
+ for (const entry of payload.customMetadata ?? []) {
+ if (entry.value === null || entry.value === undefined) continue;
+ managedCustomInfo.push(customInfoElement(`custom:${entry.key}`, String(entry.value), indent));
+ }
+
+ // ── reassemble ─────────────────────────────────────────────────────────────
+ const blocks: string[] = [];
+ for (const name of DESCRIPTION_ORDER) {
+ if (name === 'title-info') {
+ blocks.push(newTitleInfo);
+ } else if (name === 'publish-info') {
+ if (newPublishInfo) blocks.push(newPublishInfo);
+ } else if (name === 'custom-info') {
+ blocks.push(...foreignCustomInfo, ...managedCustomInfo);
+ } else {
+ blocks.push(...(grouped.get(name) ?? []).map((child) => reindent(elementText(descriptionXml, child), indent)));
+ }
+ }
+ blocks.push(
+ ...children
+ .filter((child) => !DESCRIPTION_ORDER.includes(child.name as (typeof DESCRIPTION_ORDER)[number]))
+ .map((child) => reindent(elementText(descriptionXml, child), indent)),
+ );
+
+ const openTag = descriptionXml.slice(0, openTagEnd + 1);
+ const closeTag = descriptionXml.slice(closeTagStart);
+ const descriptionOut = [openTag, ...blocks, closeTag].join(newline);
+
+ return { status: 'built', descriptionXml: descriptionOut, fieldsWritten, coverEntryId: options.coverEntryId ?? null };
+}
+
+function customInfoElement(infoType: string, value: string | number, indent: string): string {
+ return `${indent}${escapeText(value)}`;
+}
+
+function resolvePublishedYear(payload: BookWritePayload, mask: Set): number | null {
+ if (mask.has('publishedYear') && typeof payload.publishedYear === 'number' && Number.isFinite(payload.publishedYear)) {
+ return payload.publishedYear;
+ }
+ if (mask.has('publishedDate') && payload.publishedDate) {
+ const year = Number.parseInt(payload.publishedDate.slice(0, 4), 10);
+ if (Number.isFinite(year)) return year;
+ }
+ return null;
+}
+
+/** Reads the binary id referenced by , when the document has one. */
+export function readCoverEntryId(descriptionXml: string, xlinkPrefix: string): string | null {
+ const titleInfo = locateElement(descriptionXml, 'title-info');
+ if (!titleInfo || titleInfo.selfClosing) return null;
+
+ const coverpage = locateElement(descriptionXml, 'coverpage', titleInfo.contentStart);
+ if (!coverpage || coverpage.end > titleInfo.contentEnd) return null;
+
+ const image = descriptionXml.slice(coverpage.start, coverpage.end).match(/]*>/)?.[0];
+ if (!image) return null;
+
+ const href =
+ image.match(new RegExp(`(?:^|\\s)${xlinkPrefix}:href\\s*=\\s*(?:"([^"]*)"|'([^']*)')`, 'i')) ??
+ image.match(/(?:^|\s)(?:[\w-]+:)?href\s*=\s*(?:"([^"]*)"|'([^']*)')/i);
+ const value = href?.[1] ?? href?.[2] ?? null;
+ if (!value) return null;
+
+ return value.startsWith('#') ? value.slice(1) : value;
+}
diff --git a/server/src/modules/file-write/formats/fb2/fb2-element-scanner.test.ts b/server/src/modules/file-write/formats/fb2/fb2-element-scanner.test.ts
new file mode 100644
index 000000000..4e11b7ba9
--- /dev/null
+++ b/server/src/modules/file-write/formats/fb2/fb2-element-scanner.test.ts
@@ -0,0 +1,166 @@
+import {
+ Fb2StructureError,
+ elementText,
+ findElementClose,
+ findTagEnd,
+ locateElement,
+ readAttribute,
+ scanChildElements,
+ startTagOf,
+} from './fb2-element-scanner';
+
+function childNames(xml: string, parent: string): string[] {
+ const element = locateElement(xml, parent);
+ if (!element) throw new Error(`missing <${parent}>`);
+ return scanChildElements(xml, element.contentStart, element.contentEnd).map((child) => child.name);
+}
+
+describe('findTagEnd', () => {
+ it('finds the end of a simple tag', () => {
+ const xml = 'x';
+ expect(findTagEnd(xml, 0)).toBe(11);
+ });
+
+ it('ignores a > inside a double-quoted attribute value', () => {
+ const xml = '';
+ expect(findTagEnd(xml, 0)).toBe(xml.length - 1);
+ });
+
+ it('ignores a > inside a single-quoted attribute value', () => {
+ const xml = "";
+ expect(findTagEnd(xml, 0)).toBe(xml.length - 1);
+ });
+
+ it('throws when the tag never closes', () => {
+ expect(() => findTagEnd(' {
+ it('matches the outer close when the same element nests', () => {
+ const xml = '';
+ const close = findElementClose(xml, 'sequence', xml.indexOf('>') + 1);
+ expect(xml.slice(close)).toBe('');
+ });
+
+ it('does not count self-closing tags as nesting', () => {
+ const xml = '';
+ const close = findElementClose(xml, 'annotation', xml.indexOf('>') + 1);
+ expect(xml.slice(close)).toBe('');
+ });
+
+ it('tolerates whitespace inside the closing tag', () => {
+ const xml = 'en';
+ expect(findElementClose(xml, 'lang', 6)).toBe(8);
+ });
+
+ it('ignores markup that only appears inside a comment', () => {
+ const xml = 'real
';
+ const close = findElementClose(xml, 'annotation', xml.indexOf('>') + 1);
+ expect(xml.slice(close)).toBe('');
+ });
+
+ it('ignores markup that only appears inside CDATA', () => {
+ const xml = ']]>';
+ const close = findElementClose(xml, 'program-used', xml.indexOf('>') + 1);
+ expect(xml.slice(close)).toBe('');
+ });
+
+ it('throws when the element never closes', () => {
+ expect(() => findElementClose('', 'description', 13)).toThrow(/unclosed /);
+ });
+
+ it('throws on an unterminated comment', () => {
+ expect(() => findElementClose('';
+ expect(childNames(xml, 'description')).toEqual(['title-info', 'document-info']);
+ });
+
+ it('does not descend into grandchildren', () => {
+ const xml = 'sf';
+ expect(childNames(xml, 'description')).toEqual(['title-info']);
+ });
+
+ it('reports spans that slice back to the original text', () => {
+ const xml = 'sfT & U';
+ const parent = locateElement(xml, 'title-info')!;
+ const children = scanChildElements(xml, parent.contentStart, parent.contentEnd);
+ expect(elementText(xml, children[1])).toBe('T & U');
+ expect(xml.slice(children[1].contentStart, children[1].contentEnd)).toBe('T & U');
+ });
+
+ it('stops at the parent closing tag', () => {
+ const xml = '';
+ expect(childNames(xml, 'description')).toEqual(['title-info']);
+ });
+});
+
+describe('locateElement', () => {
+ it('does not match an element whose name only shares a prefix', () => {
+ const xml = '';
+ const found = locateElement(xml, 'description')!;
+ expect(found.start).toBe(xml.indexOf(''));
+ });
+
+ it('handles a self-closing element', () => {
+ const found = locateElement('', 'image')!;
+ expect(found.selfClosing).toBe(true);
+ expect(found.contentStart).toBe(-1);
+ });
+
+ it('returns null when the element is absent', () => {
+ expect(locateElement('', 'publish-info')).toBeNull();
+ });
+
+ it('searches from the requested offset', () => {
+ const xml = 'enru';
+ const second = locateElement(xml, 'lang', 15)!;
+ expect(xml.slice(second.contentStart, second.contentEnd)).toBe('ru');
+ });
+});
+
+describe('readAttribute', () => {
+ it('reads double and single quoted values', () => {
+ expect(readAttribute('', 'l:href')).toBe('#img_0');
+ expect(readAttribute("", 'l:href')).toBe('#img_0');
+ });
+
+ it('is case insensitive on the attribute name', () => {
+ expect(readAttribute('', 'id')).toBe('cover.jpg');
+ });
+
+ it('does not confuse an attribute whose name ends with the requested one', () => {
+ expect(readAttribute('', 'id')).toBe('b');
+ });
+
+ it('returns null for a missing attribute', () => {
+ expect(readAttribute('', 'content-type')).toBeNull();
+ });
+
+ it('reads an empty value', () => {
+ expect(readAttribute('', 'name')).toBe('');
+ });
+});
+
+describe('startTagOf', () => {
+ it('returns only the opening tag with its attributes', () => {
+ const xml = 'sf';
+ const element = locateElement(xml, 'title-info')!;
+ expect(startTagOf(xml, element)).toBe('');
+ });
+});
diff --git a/server/src/modules/file-write/formats/fb2/fb2-element-scanner.ts b/server/src/modules/file-write/formats/fb2/fb2-element-scanner.ts
new file mode 100644
index 000000000..72c530d75
--- /dev/null
+++ b/server/src/modules/file-write/formats/fb2/fb2-element-scanner.ts
@@ -0,0 +1,171 @@
+/**
+ * Minimal element scanner for FB2 metadata blocks.
+ *
+ * The FB2 is rewritten by splicing raw text, so the scanner
+ * reports byte-accurate spans instead of building a document tree. Comments,
+ * CDATA sections and processing instructions are skipped rather than parsed,
+ * and quoted attribute values may contain '>' without ending a tag.
+ */
+
+export class Fb2StructureError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = 'Fb2StructureError';
+ }
+}
+
+export type Fb2Element = {
+ name: string;
+ /** Offset of '<' that opens the element. */
+ start: number;
+ /** Offset just past the element's closing tag. */
+ end: number;
+ /** Offset of the first content character, or -1 when self-closing. */
+ contentStart: number;
+ /** Offset of '<' that opens the closing tag, or -1 when self-closing. */
+ contentEnd: number;
+ selfClosing: boolean;
+};
+
+const COMMENT_OPEN = '';
+const CDATA_OPEN = '';
+const PI_OPEN = '';
+const PI_CLOSE = '?>';
+
+/** Returns the offset of the '>' that closes the tag starting at tagStart. */
+export function findTagEnd(text: string, tagStart: number): number {
+ let quote: string | null = null;
+ for (let i = tagStart + 1; i < text.length; i++) {
+ const char = text[i];
+ if (quote) {
+ if (char === quote) quote = null;
+ continue;
+ }
+ if (char === '"' || char === "'") quote = char;
+ else if (char === '>') return i;
+ }
+ throw new Fb2StructureError(`unterminated tag at offset ${tagStart}`);
+}
+
+function skipNonElement(text: string, at: number): number | null {
+ if (text.startsWith(COMMENT_OPEN, at)) {
+ const close = text.indexOf(COMMENT_CLOSE, at + COMMENT_OPEN.length);
+ if (close < 0) throw new Fb2StructureError('unterminated comment');
+ return close + COMMENT_CLOSE.length;
+ }
+ if (text.startsWith(CDATA_OPEN, at)) {
+ const close = text.indexOf(CDATA_CLOSE, at + CDATA_OPEN.length);
+ if (close < 0) throw new Fb2StructureError('unterminated CDATA section');
+ return close + CDATA_CLOSE.length;
+ }
+ if (text.startsWith(PI_OPEN, at)) {
+ const close = text.indexOf(PI_CLOSE, at + PI_OPEN.length);
+ if (close < 0) throw new Fb2StructureError('unterminated processing instruction');
+ return close + PI_CLOSE.length;
+ }
+ return null;
+}
+
+function tagNameAt(text: string, tagStart: number): string {
+ const rest = text.slice(tagStart + 1);
+ const boundary = /[\s/>]/.exec(rest)?.index ?? -1;
+ return boundary < 0 ? '' : rest.slice(0, boundary);
+}
+
+/** Returns the offset of '<' opening the tag that closes the element named `name`. */
+export function findElementClose(text: string, name: string, contentStart: number): number {
+ let depth = 1;
+ let cursor = contentStart;
+ for (;;) {
+ const open = text.indexOf('<', cursor);
+ if (open < 0) throw new Fb2StructureError(`unclosed <${name}>`);
+
+ const skipped = skipNonElement(text, open);
+ if (skipped !== null) {
+ cursor = skipped;
+ continue;
+ }
+
+ const tagEnd = findTagEnd(text, open);
+ if (text.startsWith('', open)) {
+ if (text.slice(open + 2, tagEnd).trim() === name) {
+ depth--;
+ if (depth === 0) return open;
+ }
+ } else if (tagNameAt(text, open) === name && text[tagEnd - 1] !== '/') {
+ depth++;
+ }
+ cursor = tagEnd + 1;
+ }
+}
+
+/** Lists the element children between `from` and `to`, in document order. */
+export function scanChildElements(text: string, from: number, to: number): Fb2Element[] {
+ const children: Fb2Element[] = [];
+ let cursor = from;
+
+ while (cursor < to) {
+ const open = text.indexOf('<', cursor);
+ if (open < 0 || open >= to) break;
+
+ const skipped = skipNonElement(text, open);
+ if (skipped !== null) {
+ cursor = skipped;
+ continue;
+ }
+ if (text.startsWith('', open)) break;
+
+ const name = tagNameAt(text, open);
+ if (!name) throw new Fb2StructureError(`malformed tag at offset ${open}`);
+
+ const tagEnd = findTagEnd(text, open);
+ if (text[tagEnd - 1] === '/') {
+ children.push({ name, start: open, end: tagEnd + 1, contentStart: -1, contentEnd: -1, selfClosing: true });
+ cursor = tagEnd + 1;
+ continue;
+ }
+
+ const contentStart = tagEnd + 1;
+ const contentEnd = findElementClose(text, name, contentStart);
+ const end = findTagEnd(text, contentEnd) + 1;
+ children.push({ name, start: open, end, contentStart, contentEnd, selfClosing: false });
+ cursor = end;
+ }
+
+ return children;
+}
+
+/** Locates the first element named `name` at or after `from`. */
+export function locateElement(text: string, name: string, from = 0): Fb2Element | null {
+ const pattern = new RegExp(`<${name}(?=[\\s/>])`);
+ const match = pattern.exec(text.slice(from));
+ if (!match) return null;
+
+ const start = from + match.index;
+ const tagEnd = findTagEnd(text, start);
+ if (text[tagEnd - 1] === '/') {
+ return { name, start, end: tagEnd + 1, contentStart: -1, contentEnd: -1, selfClosing: true };
+ }
+
+ const contentStart = tagEnd + 1;
+ const contentEnd = findElementClose(text, name, contentStart);
+ return { name, start, end: findTagEnd(text, contentEnd) + 1, contentStart, contentEnd, selfClosing: false };
+}
+
+/** Reads an attribute value from a start tag, tolerating either quote style. */
+export function readAttribute(startTag: string, attributeName: string): string | null {
+ const escaped = attributeName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+ const match = startTag.match(new RegExp(`(?:^|\\s)${escaped}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`, 'i'));
+ if (!match) return null;
+ return match[1] ?? match[2] ?? null;
+}
+
+export function elementText(text: string, element: Fb2Element): string {
+ return text.slice(element.start, element.end);
+}
+
+export function startTagOf(text: string, element: Fb2Element): string {
+ return text.slice(element.start, findTagEnd(text, element.start) + 1);
+}
diff --git a/server/src/modules/file-write/formats/fb2/fb2-format-writer.test.ts b/server/src/modules/file-write/formats/fb2/fb2-format-writer.test.ts
new file mode 100644
index 000000000..dc4871d7b
--- /dev/null
+++ b/server/src/modules/file-write/formats/fb2/fb2-format-writer.test.ts
@@ -0,0 +1,100 @@
+import { mkdtemp, readFile, rm, writeFile } from 'fs/promises';
+import { tmpdir } from 'os';
+import { join } from 'path';
+
+import { FB2_BOOK_FILE_WRITE_FIELDS } from '@bookorbit/types';
+import type { BookWritePayloadKey } from '../../interfaces/book-write-payload.interface';
+import { Fb2FormatWriter } from './fb2-format-writer';
+
+const FULL_MASK = new Set(FB2_BOOK_FILE_WRITE_FIELDS);
+
+const CONTENT = [
+ '',
+ '',
+ '',
+ ' ',
+ ' antique',
+ ' Old Title',
+ ' en',
+ ' ',
+ '',
+ '',
+ '',
+ '',
+].join('\n');
+
+let testRoot: string;
+let writer: Fb2FormatWriter;
+
+beforeEach(async () => {
+ testRoot = await mkdtemp(join(tmpdir(), 'bookorbit-fb2-writer-'));
+ writer = new Fb2FormatWriter();
+});
+
+afterEach(async () => {
+ await rm(testRoot, { recursive: true, force: true });
+});
+
+async function makeFile(content = CONTENT): Promise {
+ const path = join(testRoot, 'book.fb2');
+ await writeFile(path, content, 'utf8');
+ return path;
+}
+
+describe('Fb2FormatWriter', () => {
+ it('registers itself for the fb2 format', () => {
+ expect(writer.format).toBe('fb2');
+ });
+
+ it('returns success with the fields it wrote', async () => {
+ const path = await makeFile();
+
+ const result = await writer.write(path, { title: 'New Title', language: 'de' }, { fieldMask: FULL_MASK, dryRun: false });
+
+ expect(result.status).toBe('success');
+ expect(result.fieldsWritten).toEqual(['title', 'language']);
+ expect(result.durationMs).toBeGreaterThanOrEqual(0);
+ expect(await readFile(path, 'utf8')).toContain('New Title');
+ });
+
+ it('reports a skip reason and the fields a real write would touch on dry-run', async () => {
+ const path = await makeFile();
+ const before = await readFile(path, 'utf8');
+
+ const result = await writer.write(path, { title: 'New Title' }, { fieldMask: FULL_MASK, dryRun: true });
+
+ expect(result).toMatchObject({ status: 'skipped', reason: 'dry-run', fieldsWritten: ['title'] });
+ expect(await readFile(path, 'utf8')).toBe(before);
+ });
+
+ it('skips a document it cannot write and leaves it unchanged', async () => {
+ const path = await makeFile('\n');
+ const before = await readFile(path, 'utf8');
+
+ const result = await writer.write(path, { title: 'New Title' }, { fieldMask: FULL_MASK, dryRun: false });
+
+ expect(result).toMatchObject({ status: 'skipped', reason: 'no description block found', fieldsWritten: [] });
+ expect(await readFile(path, 'utf8')).toBe(before);
+ });
+
+ it('propagates errors for a missing file so the service can log a failure', async () => {
+ await expect(writer.write(join(testRoot, 'nope.fb2'), { title: 'T' }, { fieldMask: FULL_MASK, dryRun: false })).rejects.toMatchObject({
+ code: 'ENOENT',
+ });
+ });
+
+ it('honours the field mask handed down by the service', async () => {
+ const path = await makeFile();
+
+ const result = await writer.write(
+ path,
+ { title: 'New Title', language: 'de' },
+ { fieldMask: new Set(['language']), dryRun: false },
+ );
+
+ expect(result.fieldsWritten).toEqual(['language']);
+ const updated = await readFile(path, 'utf8');
+ expect(updated).toContain('Old Title');
+ expect(updated).toContain('de');
+ });
+});
diff --git a/server/src/modules/file-write/formats/fb2/fb2-format-writer.ts b/server/src/modules/file-write/formats/fb2/fb2-format-writer.ts
new file mode 100644
index 000000000..2989ffea8
--- /dev/null
+++ b/server/src/modules/file-write/formats/fb2/fb2-format-writer.ts
@@ -0,0 +1,24 @@
+import { Injectable } from '@nestjs/common';
+
+import type { WriteResult } from '@bookorbit/types';
+import { FORMAT_FB2 } from '../../file-write.constants';
+import type { BookWritePayload } from '../../interfaces/book-write-payload.interface';
+import type { FormatWriteOptions } from '../../interfaces/format-write-options.interface';
+import type { FormatWriter } from '../../interfaces/format-writer.interface';
+import { writeFb2Metadata } from './fb2-write-core';
+
+@Injectable()
+export class Fb2FormatWriter implements FormatWriter {
+ readonly format = FORMAT_FB2;
+
+ async write(filePath: string, payload: BookWritePayload, options: FormatWriteOptions): Promise {
+ const start = Date.now();
+ const outcome = await writeFb2Metadata(filePath, payload, options.fieldMask, { dryRun: options.dryRun });
+
+ if (outcome.status === 'skipped') {
+ return { status: 'skipped', reason: outcome.reason, fieldsWritten: outcome.fieldsWritten, durationMs: Date.now() - start };
+ }
+
+ return { status: 'success', fieldsWritten: outcome.fieldsWritten, durationMs: Date.now() - start };
+ }
+}
diff --git a/server/src/modules/file-write/formats/fb2/fb2-write-core.test.ts b/server/src/modules/file-write/formats/fb2/fb2-write-core.test.ts
new file mode 100644
index 000000000..14eb17312
--- /dev/null
+++ b/server/src/modules/file-write/formats/fb2/fb2-write-core.test.ts
@@ -0,0 +1,525 @@
+import { mkdtemp, readdir, readFile, rm, writeFile } from 'fs/promises';
+import { tmpdir } from 'os';
+import { join } from 'path';
+
+import { XMLValidator } from 'fast-xml-parser';
+import iconv from 'iconv-lite';
+import sharp from 'sharp';
+
+import { FB2_BOOK_FILE_WRITE_FIELDS } from '@bookorbit/types';
+import type { BookWritePayload, BookWritePayloadKey } from '../../interfaces/book-write-payload.interface';
+import { writeFb2Metadata } from './fb2-write-core';
+
+const FULL_MASK = new Set(FB2_BOOK_FILE_WRITE_FIELDS);
+const NBSP = ' ';
+
+let testRoot: string;
+let jpegCover: Buffer;
+let pngCover: Buffer;
+
+beforeAll(async () => {
+ jpegCover = await sharp({ create: { width: 12, height: 18, channels: 3, background: { r: 200, g: 40, b: 90 } } })
+ .jpeg()
+ .toBuffer();
+ pngCover = await sharp({ create: { width: 8, height: 8, channels: 3, background: { r: 10, g: 20, b: 30 } } })
+ .png()
+ .toBuffer();
+});
+
+beforeEach(async () => {
+ testRoot = await mkdtemp(join(tmpdir(), 'bookorbit-fb2-write-'));
+});
+
+afterEach(async () => {
+ await rm(testRoot, { recursive: true, force: true });
+});
+
+type FileOptions = {
+ declaration?: string;
+ rootAttributes?: string;
+ titleInfo?: string;
+ extraDescription?: string;
+ body?: string;
+ binaries?: string;
+ encoding?: string;
+ newline?: '\n' | '\r\n';
+};
+
+const DEFAULT_TITLE_INFO = [
+ ' antique',
+ ' OldAuthor',
+ ' Old Title',
+ ' en',
+].join('\n');
+
+/** Body text carrying the characters a naive XML round-trip is known to damage. */
+const DEFAULT_BODY = [
+ '',
+ ' ',
+ ` Text with${NBSP}a non-breaking space and an & entity.
`,
+ ' ',
+ ' Second paragraph with runs of spaces.
',
+ ' ',
+ '',
+].join('\n');
+
+function fb2Content(options: FileOptions = {}): string {
+ const declaration = options.declaration ?? '';
+ const rootAttributes = options.rootAttributes ?? 'xmlns="http://www.gribuser.ru/xml/fictionbook/2.0" xmlns:l="http://www.w3.org/1999/xlink"';
+ const parts = [
+ declaration,
+ ``,
+ '',
+ ' ',
+ options.titleInfo ?? DEFAULT_TITLE_INFO,
+ ' ',
+ ' doc-11.0',
+ options.extraDescription ?? '',
+ '',
+ options.body ?? DEFAULT_BODY,
+ options.binaries ?? '',
+ '',
+ '',
+ ].filter((part) => part !== '');
+ const content = parts.join('\n');
+ return options.newline === '\r\n' ? content.replace(/\n/g, '\r\n') : content;
+}
+
+async function makeFile(content: string, encoding = 'utf8'): Promise {
+ const path = join(testRoot, 'book.fb2');
+ await writeFile(path, encoding === 'utf8' ? Buffer.from(content, 'utf8') : iconv.encode(content, encoding));
+ return path;
+}
+
+async function write(path: string, payload: BookWritePayload, mask: Set = FULL_MASK, dryRun = false) {
+ return writeFb2Metadata(path, payload, mask, { dryRun });
+}
+
+function bodyRegion(text: string): string {
+ return text.slice(text.indexOf('') + ''.length);
+}
+
+function binaryMap(text: string): Map {
+ const map = new Map();
+ for (const match of text.matchAll(/]*)>([\s\S]*?)<\/binary>/g)) {
+ const id = match[1]!.match(/\bid\s*=\s*"([^"]*)"/)?.[1] ?? '(none)';
+ map.set(id, match[2]!);
+ }
+ return map;
+}
+
+function expectWellFormed(text: string): void {
+ const result = XMLValidator.validate(text, { allowBooleanAttributes: true });
+ expect(result === true ? true : JSON.stringify(result)).toBe(true);
+}
+
+describe('writeFb2Metadata byte preservation', () => {
+ it('leaves every byte outside the description untouched', async () => {
+ const original = fb2Content();
+ const path = await makeFile(original);
+ const originalBytes = await readFile(path);
+
+ const result = await write(path, { title: 'New Title', language: 'de' });
+ expect(result.status).toBe('success');
+
+ const updated = await readFile(path);
+ const originalText = originalBytes.toString('utf8');
+ const updatedText = updated.toString('utf8');
+ const headLength = originalText.indexOf('');
+
+ expect(updated.subarray(0, headLength).equals(originalBytes.subarray(0, headLength))).toBe(true);
+ const originalTail = originalBytes.subarray(originalText.indexOf('') + ''.length);
+ const updatedTail = updated.subarray(updatedText.indexOf('') + ''.length);
+ expect(updatedTail.equals(originalTail)).toBe(true);
+ });
+
+ it('preserves non-breaking spaces, entities and empty-line tags in the body', async () => {
+ const path = await makeFile(fb2Content());
+ const before = bodyRegion(await readFile(path, 'utf8'));
+
+ await write(path, { title: 'New Title', description: 'Fresh annotation.' });
+
+ const after = bodyRegion(await readFile(path, 'utf8'));
+ expect(after).toBe(before);
+ expect(after).toContain(NBSP);
+ expect(after).toContain('&');
+ expect(after).toContain('');
+ expect(after).toContain('with runs of spaces');
+ });
+
+ it('preserves crlf line endings in the body', async () => {
+ const path = await makeFile(fb2Content({ newline: '\r\n' }));
+ const before = await readFile(path, 'utf8');
+
+ await write(path, { title: 'New Title' });
+
+ const after = await readFile(path, 'utf8');
+ expect(bodyRegion(after)).toBe(bodyRegion(before));
+ expect(bodyRegion(after)).toContain('\r\n');
+ const rebuiltDescription = after.slice(after.indexOf(''), after.indexOf(''));
+ expect(rebuiltDescription).toContain('\r\n');
+ expect(rebuiltDescription.replace(/\r\n/g, '')).not.toContain('\n');
+ });
+
+ it('preserves a utf-8 BOM', async () => {
+ const path = join(testRoot, 'bom.fb2');
+ await writeFile(path, Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from(fb2Content(), 'utf8')]));
+
+ const result = await write(path, { title: 'New Title' });
+ expect(result.status).toBe('success');
+
+ const updated = await readFile(path);
+ expect(updated.subarray(0, 3).equals(Buffer.from([0xef, 0xbb, 0xbf]))).toBe(true);
+ expect(updated.toString('utf8')).toContain('New Title');
+ });
+
+ it('keeps multibyte characters intact when computing byte offsets', async () => {
+ const body = `Мастер и Маргарита ${'ü'.repeat(50)}
`;
+ const path = await makeFile(fb2Content({ body }));
+ const before = bodyRegion(await readFile(path, 'utf8'));
+
+ await write(path, { title: 'Новое название' });
+
+ const after = await readFile(path, 'utf8');
+ expect(bodyRegion(after)).toBe(before);
+ expect(after).toContain('Новое название');
+ expectWellFormed(after);
+ });
+
+ it('produces a well-formed document', async () => {
+ const path = await makeFile(fb2Content());
+ await write(path, {
+ title: 'Title & "Quotes" ',
+ authors: [{ name: 'A B', sortName: 'B, A' }],
+ description: 'One.\n\nTwo.',
+ genres: ['Fantasy & Epic'],
+ tags: ['a', 'b'],
+ seriesName: 'S & S',
+ seriesIndex: 2,
+ publisher: 'P & P',
+ isbn13: '9780000000001',
+ publishedDate: '2001-02-03',
+ rating: 4,
+ });
+ expectWellFormed(await readFile(path, 'utf8'));
+ });
+
+ it('leaves no temporary files behind', async () => {
+ const path = await makeFile(fb2Content());
+ await write(path, { title: 'New Title' });
+ expect(await readdir(testRoot)).toEqual(['book.fb2']);
+ });
+});
+
+describe('writeFb2Metadata idempotency', () => {
+ it('writing the same payload twice produces identical bytes', async () => {
+ const payload: BookWritePayload = {
+ title: 'Stable Title',
+ authors: [{ name: 'Cat Bruno', sortName: 'Bruno, Cat' }],
+ genres: ['Fantasy'],
+ language: 'en',
+ seriesName: 'Series',
+ seriesIndex: 2,
+ publishedDate: '2018-03-14',
+ publisher: 'Pub',
+ rating: 5,
+ };
+ const path = await makeFile(fb2Content());
+
+ await write(path, payload);
+ const first = await readFile(path);
+ await write(path, payload);
+ const second = await readFile(path);
+
+ expect(second.equals(first)).toBe(true);
+ });
+
+ it('writing the same cover twice produces identical bytes', async () => {
+ const path = await makeFile(
+ fb2Content({
+ titleInfo: `${DEFAULT_TITLE_INFO}\n `,
+ binaries: 'QUJD',
+ }),
+ );
+
+ await write(path, { title: 'T', coverBytes: jpegCover });
+ const first = await readFile(path);
+ await write(path, { title: 'T', coverBytes: jpegCover });
+
+ expect((await readFile(path)).equals(first)).toBe(true);
+ });
+});
+
+describe('writeFb2Metadata cover handling', () => {
+ it('replaces the referenced cover binary and leaves the others alone', async () => {
+ const binaries = [
+ 'SU1HMQ==',
+ 'T0xEQ09WRVI=',
+ 'SU1HMg==',
+ ].join('\n');
+ const path = await makeFile(
+ fb2Content({ titleInfo: `${DEFAULT_TITLE_INFO}\n `, binaries }),
+ );
+
+ const result = await write(path, { coverBytes: jpegCover });
+ expect(result.status).toBe('success');
+ expect(result.fieldsWritten).toContain('coverBytes');
+
+ const updated = await readFile(path, 'utf8');
+ const map = binaryMap(updated);
+ expect(map.size).toBe(3);
+ expect(map.get('img_1')).toBe('SU1HMQ==');
+ expect(map.get('img_2')).toBe('SU1HMg==');
+ expect(Buffer.from(map.get('cover.jpg')!.replace(/\s+/g, ''), 'base64').equals(jpegCover)).toBe(true);
+ expectWellFormed(updated);
+ });
+
+ it('injects a coverpage and binary when the document has neither', async () => {
+ const path = await makeFile(fb2Content({ binaries: 'SU1HMQ==' }));
+
+ const result = await write(path, { coverBytes: jpegCover });
+ expect(result.status).toBe('success');
+
+ const updated = await readFile(path, 'utf8');
+ expect(updated).toContain('');
+ const map = binaryMap(updated);
+ expect(map.size).toBe(2);
+ expect(Buffer.from(map.get('bookorbit-cover.jpg')!.replace(/\s+/g, ''), 'base64').equals(jpegCover)).toBe(true);
+ expect(updated.indexOf('bookorbit-cover.jpg', updated.indexOf(''))).toBeLessThan(updated.lastIndexOf(''));
+ expectWellFormed(updated);
+ });
+
+ it('injects the missing binary when a coverpage points at nothing', async () => {
+ const path = await makeFile(fb2Content({ titleInfo: `${DEFAULT_TITLE_INFO}\n ` }));
+
+ await write(path, { coverBytes: jpegCover });
+
+ const updated = await readFile(path, 'utf8');
+ expect(updated).toContain('');
+ expect(Buffer.from(binaryMap(updated).get('missing.jpg')!.replace(/\s+/g, ''), 'base64').equals(jpegCover)).toBe(true);
+ expectWellFormed(updated);
+ });
+
+ it('uses the document xlink prefix when injecting a coverpage', async () => {
+ const path = await makeFile(
+ fb2Content({ rootAttributes: 'xmlns="http://www.gribuser.ru/xml/fictionbook/2.0" xmlns:xlink="http://www.w3.org/1999/xlink"' }),
+ );
+
+ await write(path, { coverBytes: jpegCover });
+
+ expect(await readFile(path, 'utf8')).toContain('');
+ });
+
+ it('records the cover media type from the image bytes', async () => {
+ const path = await makeFile(fb2Content());
+
+ await write(path, { coverBytes: pngCover });
+
+ const updated = await readFile(path, 'utf8');
+ expect(updated).toContain('');
+ });
+
+ it('does not touch the cover when coverBytes is masked out', async () => {
+ const binaries = 'T0xEQ09WRVI=';
+ const path = await makeFile(
+ fb2Content({ titleInfo: `${DEFAULT_TITLE_INFO}\n `, binaries }),
+ );
+
+ const result = await write(path, { title: 'T', coverBytes: jpegCover }, new Set(['title']));
+
+ expect(result.fieldsWritten).toEqual(['title']);
+ expect(binaryMap(await readFile(path, 'utf8')).get('cover.jpg')).toBe('T0xEQ09WRVI=');
+ });
+
+ it('replaces a cover that sits behind a large body without disturbing it', async () => {
+ const body = `${'filler '.repeat(60_000)}
`;
+ const binaries = `${'QUJD'.repeat(20_000)}`;
+ const path = await makeFile(
+ fb2Content({ body, titleInfo: `${DEFAULT_TITLE_INFO}\n `, binaries }),
+ );
+ const before = bodyRegion(await readFile(path, 'utf8'));
+
+ const result = await write(path, { title: 'Big Book', coverBytes: jpegCover });
+ expect(result.status).toBe('success');
+
+ const updated = await readFile(path, 'utf8');
+ expect(bodyRegion(updated)).toBe(before);
+ expect(Buffer.from(binaryMap(updated).get('cover.jpg')!.replace(/\s+/g, ''), 'base64').equals(jpegCover)).toBe(true);
+ });
+});
+
+describe('writeFb2Metadata encodings', () => {
+ it('writes a windows-1251 file without changing its encoding or body bytes', async () => {
+ const content = fb2Content({
+ declaration: '',
+ titleInfo: ' prose_classic\n Мастер\n ru',
+ body: 'В белом плаще с кровавым подбоем.
',
+ });
+ const path = await makeFile(content, 'win1251');
+ const originalBytes = await readFile(path);
+
+ const result = await write(path, { title: 'Мастер и Маргарита', seriesName: 'Русская классика', seriesIndex: 1 });
+ expect(result.status).toBe('success');
+
+ const updatedBytes = await readFile(path);
+ const decoded = iconv.decode(updatedBytes, 'win1251');
+ expect(decoded).toContain('encoding="windows-1251"');
+ expect(decoded).toContain('Мастер и Маргарита');
+ expect(decoded).toContain('');
+ expect(decoded).toContain('В белом плаще с кровавым подбоем.');
+
+ // Single-byte encoding, so a latin1 view gives byte-accurate offsets.
+ const tailAfterDescription = (buffer: Buffer): Buffer =>
+ buffer.subarray(buffer.toString('latin1').indexOf('') + ''.length);
+ expect(tailAfterDescription(updatedBytes).equals(tailAfterDescription(originalBytes))).toBe(true);
+ expect(iconv.encode(decoded, 'win1251').equals(updatedBytes)).toBe(true);
+ });
+
+ it('writes a koi8-r file in its declared encoding', async () => {
+ const content = fb2Content({
+ declaration: '',
+ titleInfo: ' sf\n Старое',
+ body: '',
+ });
+ const path = await makeFile(content, 'koi8-r');
+
+ const result = await write(path, { title: 'Новое' });
+ expect(result.status).toBe('success');
+
+ const decoded = iconv.decode(await readFile(path), 'koi8-r');
+ expect(decoded).toContain('Новое');
+ expect(decoded).toContain('Текст');
+ });
+
+ it('skips utf-16 files instead of corrupting them', async () => {
+ const path = join(testRoot, 'utf16.fb2');
+ const content = fb2Content({ declaration: '' });
+ await writeFile(path, Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from(content, 'utf16le')]));
+ const before = await readFile(path);
+
+ const result = await write(path, { title: 'New Title' });
+
+ expect(result).toMatchObject({ status: 'skipped' });
+ expect(result.status === 'skipped' && result.reason).toMatch(/unsupported encoding/);
+ expect((await readFile(path)).equals(before)).toBe(true);
+ });
+
+ it('skips a file whose declared encoding is unknown', async () => {
+ const path = await makeFile(fb2Content({ declaration: '' }));
+ const before = await readFile(path);
+
+ const result = await write(path, { title: 'New Title' });
+
+ expect(result).toMatchObject({ status: 'skipped' });
+ expect(result.status === 'skipped' && result.reason).toContain('made-up-codec-9000');
+ expect((await readFile(path)).equals(before)).toBe(true);
+ });
+});
+
+describe('writeFb2Metadata refusals', () => {
+ it('reports dry-run without touching the file', async () => {
+ const path = await makeFile(fb2Content());
+ const before = await readFile(path);
+
+ const result = await write(path, { title: 'New Title', coverBytes: jpegCover }, FULL_MASK, true);
+
+ expect(result).toMatchObject({ status: 'skipped', reason: 'dry-run' });
+ expect(result.fieldsWritten).toEqual(expect.arrayContaining(['title', 'coverBytes']));
+ expect((await readFile(path)).equals(before)).toBe(true);
+ });
+
+ it('skips when the payload has nothing FB2 can write', async () => {
+ const path = await makeFile(fb2Content());
+ const before = await readFile(path);
+
+ const result = await write(path, { comicIssueNumber: '3' });
+
+ expect(result).toMatchObject({ status: 'skipped', reason: 'no metadata to write' });
+ expect((await readFile(path)).equals(before)).toBe(true);
+ });
+
+ it('skips a file with no description block', async () => {
+ const path = await makeFile('\n');
+ const before = await readFile(path);
+
+ const result = await write(path, { title: 'New Title' });
+
+ expect(result).toMatchObject({ status: 'skipped', reason: 'no description block found' });
+ expect((await readFile(path)).equals(before)).toBe(true);
+ });
+
+ it('skips a file whose body precedes the description', async () => {
+ const path = await makeFile(
+ '\nT',
+ );
+ const before = await readFile(path);
+
+ const result = await write(path, { title: 'New Title' });
+
+ expect(result).toMatchObject({ status: 'skipped', reason: 'body precedes description' });
+ expect((await readFile(path)).equals(before)).toBe(true);
+ });
+
+ it('skips a description without title-info', async () => {
+ const path = await makeFile(
+ '\n1',
+ );
+
+ const result = await write(path, { title: 'New Title' });
+
+ expect(result).toMatchObject({ status: 'skipped', reason: 'description has no title-info' });
+ });
+
+ it('skips a truncated file instead of writing a broken one', async () => {
+ const path = await makeFile('\nT');
+ const before = await readFile(path);
+
+ const result = await write(path, { title: 'New Title' });
+
+ expect(result).toMatchObject({ status: 'skipped' });
+ expect(result.status === 'skipped' && result.reason).toMatch(/unclosed/);
+ expect((await readFile(path)).equals(before)).toBe(true);
+ });
+
+ it('is not fooled by an escaped description tag inside the body', async () => {
+ const body = 'The <description> element holds metadata.
';
+ const path = await makeFile(fb2Content({ body }));
+
+ const result = await write(path, { title: 'New Title' });
+
+ expect(result.status).toBe('success');
+ const updated = await readFile(path, 'utf8');
+ expect(updated).toContain('The <description> element holds metadata.');
+ expectWellFormed(updated);
+ });
+});
+
+describe('writeFb2Metadata large metadata blocks', () => {
+ it('writes a description larger than the initial read window', async () => {
+ const hugeAnnotation = `${'x'.repeat(400 * 1024)}
`;
+ const path = await makeFile(fb2Content({ titleInfo: `${DEFAULT_TITLE_INFO}\n ${hugeAnnotation}` }));
+ const before = bodyRegion(await readFile(path, 'utf8'));
+
+ const result = await write(path, { title: 'New Title' });
+
+ expect(result.status).toBe('success');
+ const updated = await readFile(path, 'utf8');
+ expect(updated).toContain('New Title');
+ expect(updated).toContain('x'.repeat(400 * 1024));
+ expect(bodyRegion(updated)).toBe(before);
+ });
+
+ it('writes metadata into a multi-megabyte file without altering the body', async () => {
+ const body = `${'Chapter text goes here.
'.repeat(120_000)}`;
+ const path = await makeFile(fb2Content({ body }));
+ const before = bodyRegion(await readFile(path, 'utf8'));
+ expect(Buffer.byteLength(before)).toBeGreaterThan(3 * 1024 * 1024);
+
+ const result = await write(path, { title: 'New Title', authors: [{ name: 'A B', sortName: 'B, A' }] });
+
+ expect(result.status).toBe('success');
+ const updated = await readFile(path, 'utf8');
+ expect(bodyRegion(updated)).toBe(before);
+ expect(updated).toContain('New Title');
+ });
+});
diff --git a/server/src/modules/file-write/formats/fb2/fb2-write-core.ts b/server/src/modules/file-write/formats/fb2/fb2-write-core.ts
new file mode 100644
index 000000000..271b5dc16
--- /dev/null
+++ b/server/src/modules/file-write/formats/fb2/fb2-write-core.ts
@@ -0,0 +1,206 @@
+import { createReadStream, createWriteStream } from 'fs';
+import { open, unlink } from 'fs/promises';
+import type { FileHandle } from 'fs/promises';
+import { dirname, join } from 'path';
+import { randomUUID } from 'crypto';
+import { pipeline } from 'stream/promises';
+
+import { decodeFb2, detectFb2Encoding, encodeFb2, isFb2EncodingSupported, type Fb2Encoding } from '../../../../common/utils/fb2-encoding.utils';
+import type { BookWritePayload, BookWritePayloadKey } from '../../interfaces/book-write-payload.interface';
+import { replaceFileAtomically } from '../shared/atomic-file-replace';
+import { inspectCoverImage } from '../shared/cover-image';
+import { buildBinaryElement, findBinarySpan, findRootCloseOffset } from './fb2-binary-locator';
+import { buildFb2Description, readCoverEntryId } from './fb2-description-builder';
+import { Fb2StructureError, findElementClose, findTagEnd } from './fb2-element-scanner';
+
+/**
+ * Metadata lives in the element at the very start of an FB2 file,
+ * so the writer only decodes a leading window instead of the whole book.
+ */
+const HEAD_WINDOW_BYTES = 256 * 1024;
+const MAX_HEAD_WINDOW_BYTES = 8 * 1024 * 1024;
+const DEFAULT_COVER_ENTRY_ID = 'bookorbit-cover';
+const STREAM_CHUNK_BYTES = 1024 * 1024;
+
+export type Fb2WriteOutcome =
+ { status: 'success'; fieldsWritten: BookWritePayloadKey[] } | { status: 'skipped'; reason: string; fieldsWritten: BookWritePayloadKey[] };
+
+type SourceRange = { kind: 'range'; start: number; end: number };
+type LiteralBytes = { kind: 'bytes'; bytes: Buffer };
+type OutputPart = SourceRange | LiteralBytes;
+
+type DescriptionLocation = {
+ startByte: number;
+ endByte: number;
+ descriptionXml: string;
+ xlinkPrefix: string;
+};
+
+export async function writeFb2Metadata(
+ filePath: string,
+ payload: BookWritePayload,
+ fieldMask: Set,
+ options: { dryRun: boolean },
+): Promise {
+ const handle = await open(filePath, 'r');
+ try {
+ const fileSize = (await handle.stat()).size;
+ const encoding = await readEncoding(handle, fileSize);
+ if (!isFb2EncodingSupported(encoding)) {
+ return { status: 'skipped', reason: `unsupported encoding: ${encoding.declared ?? encoding.name}`, fieldsWritten: [] };
+ }
+
+ let location: DescriptionLocation | null;
+ try {
+ location = await locateDescription(handle, fileSize, encoding);
+ } catch (error) {
+ if (error instanceof Fb2StructureError) {
+ return { status: 'skipped', reason: error.message, fieldsWritten: [] };
+ }
+ throw error;
+ }
+ if (!location) {
+ return { status: 'skipped', reason: 'no description block found', fieldsWritten: [] };
+ }
+
+ const writesCover = Boolean(payload.coverBytes?.length) && fieldMask.has('coverBytes');
+ const existingCoverEntryId = writesCover ? readCoverEntryId(location.descriptionXml, location.xlinkPrefix) : null;
+ const coverImage = writesCover ? await inspectCoverImage(payload.coverBytes!) : null;
+ const coverEntryId = writesCover ? (existingCoverEntryId ?? `${DEFAULT_COVER_ENTRY_ID}.${coverImage!.extension}`) : null;
+
+ const built = buildFb2Description(location.descriptionXml, payload, fieldMask, { xlinkPrefix: location.xlinkPrefix, coverEntryId });
+ if (built.status === 'skipped') {
+ return { status: 'skipped', reason: built.reason, fieldsWritten: [] };
+ }
+
+ const fieldsWritten = [...built.fieldsWritten];
+ if (writesCover) fieldsWritten.push('coverBytes');
+
+ if (fieldsWritten.length === 0) {
+ return { status: 'skipped', reason: 'no metadata to write', fieldsWritten: [] };
+ }
+ if (options.dryRun) {
+ return { status: 'skipped', reason: 'dry-run', fieldsWritten };
+ }
+
+ const parts: OutputPart[] = [
+ { kind: 'range', start: 0, end: location.startByte },
+ { kind: 'bytes', bytes: encodeFb2(built.descriptionXml, encoding) },
+ ];
+
+ if (writesCover) {
+ const binaryXml = buildBinaryElement(coverEntryId!, coverImage!.mediaType, payload.coverBytes!);
+ const existingSpan = await findBinarySpan(handle, coverEntryId!, location.endByte, fileSize);
+ if (existingSpan) {
+ parts.push({ kind: 'range', start: location.endByte, end: existingSpan.start });
+ parts.push({ kind: 'bytes', bytes: encodeFb2(binaryXml, encoding) });
+ parts.push({ kind: 'range', start: existingSpan.end, end: fileSize });
+ } else {
+ const rootCloseAt = await findRootCloseOffset(handle, fileSize);
+ if (rootCloseAt === null) {
+ return { status: 'skipped', reason: 'no closing FictionBook tag found', fieldsWritten: [] };
+ }
+ parts.push({ kind: 'range', start: location.endByte, end: rootCloseAt });
+ parts.push({ kind: 'bytes', bytes: encodeFb2(`${binaryXml}\n`, encoding) });
+ parts.push({ kind: 'range', start: rootCloseAt, end: fileSize });
+ }
+ } else {
+ parts.push({ kind: 'range', start: location.endByte, end: fileSize });
+ }
+
+ await writeParts(filePath, parts);
+ return { status: 'success', fieldsWritten };
+ } finally {
+ await handle.close();
+ }
+}
+
+async function readEncoding(handle: FileHandle, fileSize: number): Promise {
+ const length = Math.min(1024, Math.max(fileSize, 1));
+ const buffer = Buffer.alloc(length);
+ const { bytesRead } = await handle.read(buffer, 0, length, 0);
+ return detectFb2Encoding(buffer.subarray(0, bytesRead));
+}
+
+/**
+ * Reads a leading window and resolves the byte span of . The window
+ * grows when the block is unusually large (for example a very long annotation)
+ * and gives up rather than buffering an entire book.
+ */
+async function locateDescription(handle: FileHandle, fileSize: number, encoding: Fb2Encoding): Promise {
+ for (let windowBytes = HEAD_WINDOW_BYTES; ; windowBytes *= 4) {
+ const length = Math.min(windowBytes, fileSize);
+ const buffer = Buffer.alloc(Math.max(length, 1));
+ const { bytesRead } = await handle.read(buffer, 0, length, 0);
+ const headText = decodeFb2(buffer.subarray(0, bytesRead), encoding);
+
+ const descriptionAt = headText.search(/]/);
+ if (descriptionAt < 0) {
+ if (length >= fileSize || windowBytes >= MAX_HEAD_WINDOW_BYTES) return null;
+ continue;
+ }
+
+ const bodyAt = headText.search(/]/);
+ if (bodyAt >= 0 && bodyAt < descriptionAt) {
+ throw new Fb2StructureError('body precedes description');
+ }
+
+ let endOffset: number;
+ try {
+ const contentStart = findTagEnd(headText, descriptionAt) + 1;
+ endOffset = findTagEnd(headText, findElementClose(headText, 'description', contentStart)) + 1;
+ } catch (error) {
+ const exhausted = length >= fileSize || windowBytes >= MAX_HEAD_WINDOW_BYTES;
+ if (exhausted) throw error;
+ continue;
+ }
+
+ const xlinkPrefix = headText.match(/xmlns:([\w.-]+)\s*=\s*["']http:\/\/www\.w3\.org\/1999\/xlink["']/)?.[1] ?? 'l';
+
+ return {
+ startByte: encodeFb2(headText.slice(0, descriptionAt), encoding).length,
+ endByte: encodeFb2(headText.slice(0, endOffset), encoding).length,
+ descriptionXml: headText.slice(descriptionAt, endOffset),
+ xlinkPrefix,
+ };
+ }
+}
+
+/** Streams the assembled output to a sibling temp file, then swaps it in atomically. */
+async function writeParts(filePath: string, parts: OutputPart[]): Promise {
+ const tempPath = join(dirname(filePath), `.fb2-write-${randomUUID()}`);
+ const output = createWriteStream(tempPath);
+
+ try {
+ for (const part of parts) {
+ if (part.kind === 'bytes') {
+ await writeBuffer(output, part.bytes);
+ continue;
+ }
+ if (part.end <= part.start) continue;
+ await pipeline(createReadStream(filePath, { start: part.start, end: part.end - 1, highWaterMark: STREAM_CHUNK_BYTES }), output, {
+ end: false,
+ });
+ }
+ await closeStream(output);
+ } catch (error) {
+ output.destroy();
+ await unlink(tempPath).catch(() => {});
+ throw error;
+ }
+
+ await replaceFileAtomically(tempPath, filePath);
+}
+
+function writeBuffer(output: ReturnType, bytes: Buffer): Promise {
+ return new Promise((resolve, reject) => {
+ output.write(bytes, (error) => (error ? reject(error) : resolve()));
+ });
+}
+
+function closeStream(output: ReturnType): Promise {
+ return new Promise((resolve, reject) => {
+ output.end(() => resolve());
+ output.once('error', reject);
+ });
+}
diff --git a/server/src/modules/file-write/formats/fb2/fb2-write-roundtrip.test.ts b/server/src/modules/file-write/formats/fb2/fb2-write-roundtrip.test.ts
new file mode 100644
index 000000000..44d2f6d67
--- /dev/null
+++ b/server/src/modules/file-write/formats/fb2/fb2-write-roundtrip.test.ts
@@ -0,0 +1,189 @@
+import { mkdtemp, readFile, rm, writeFile } from 'fs/promises';
+import { tmpdir } from 'os';
+import { join } from 'path';
+
+import iconv from 'iconv-lite';
+import sharp from 'sharp';
+
+import { FB2_BOOK_FILE_WRITE_FIELDS } from '@bookorbit/types';
+import { Fb2FormatExtractor } from '../../../metadata/extractors/fb2-format.extractor';
+import type { BookWritePayload, BookWritePayloadKey } from '../../interfaces/book-write-payload.interface';
+import { Fb2FormatWriter } from './fb2-format-writer';
+
+const FULL_MASK = new Set(FB2_BOOK_FILE_WRITE_FIELDS);
+
+const SOURCE = [
+ '',
+ '',
+ '',
+ ' ',
+ ' antique',
+ ' OldAuthor',
+ ' Old Title',
+ ' en',
+ ' ',
+ ' doc-1',
+ ' 1901',
+ '',
+ '',
+ '',
+ '',
+].join('\n');
+
+let testRoot: string;
+let writer: Fb2FormatWriter;
+let extractor: Fb2FormatExtractor;
+let cover: Buffer;
+
+beforeAll(async () => {
+ cover = await sharp({ create: { width: 10, height: 15, channels: 3, background: { r: 5, g: 100, b: 200 } } })
+ .jpeg()
+ .toBuffer();
+});
+
+beforeEach(async () => {
+ testRoot = await mkdtemp(join(tmpdir(), 'bookorbit-fb2-roundtrip-'));
+ writer = new Fb2FormatWriter();
+ extractor = new Fb2FormatExtractor();
+});
+
+afterEach(async () => {
+ await rm(testRoot, { recursive: true, force: true });
+});
+
+async function writeAndRead(payload: BookWritePayload, content = SOURCE, encoding?: string) {
+ const path = join(testRoot, 'book.fb2');
+ await writeFile(path, encoding ? iconv.encode(content, encoding) : Buffer.from(content, 'utf8'));
+
+ const result = await writer.write(path, payload, { fieldMask: FULL_MASK, dryRun: false });
+ expect(result.status).toBe('success');
+
+ const parsed = await extractor.extract(path);
+ if (!parsed) throw new Error('extractor returned null for a file it just wrote');
+ return { parsed, result, path };
+}
+
+describe('FB2 write then extract round-trip', () => {
+ it('round-trips every field FB2 advertises as writable', async () => {
+ const payload: BookWritePayload = {
+ title: 'Nemesis & the "Sword" ',
+ subtitle: 'A Tale of Ünïcode',
+ description: 'First paragraph.\n\nSecond paragraph.',
+ publisher: 'Painted Quill Press & Sons',
+ publishedDate: '2018-03-14',
+ publishedYear: 2018,
+ language: 'ru',
+ pageCount: 412,
+ seriesName: 'Pathway of the Chosen',
+ seriesIndex: 2,
+ isbn13: '9781729419007',
+ isbn10: '1729419001',
+ rating: 4.5,
+ authors: [
+ { name: 'Cat Bruno', sortName: 'Bruno, Cat' },
+ { name: 'James David Victor', sortName: 'Victor, James David' },
+ { name: 'Prince', sortName: null },
+ ],
+ genres: ['Fantasy', 'Epic & Heroic'],
+ tags: ['favourite', 'to-reread'],
+ goodreadsId: '12345',
+ amazonId: 'B07XYZ',
+ hardcoverId: 'hc-9',
+ googleBooksId: 'gb-1',
+ openLibraryId: 'OL1W',
+ koboId: 'kobo-1',
+ itunesId: 'it-1',
+ coverBytes: cover,
+ };
+
+ const { parsed } = await writeAndRead(payload);
+
+ expect(parsed.title).toBe(payload.title);
+ expect(parsed.subtitle).toBe(payload.subtitle);
+ expect(parsed.description).toContain('First paragraph.');
+ expect(parsed.description).toContain('Second paragraph.');
+ expect(parsed.publisher).toBe(payload.publisher);
+ expect(parsed.publishedDate).toBe('2018-03-14');
+ expect(parsed.publishedYear).toBe(2018);
+ expect(parsed.language).toBe('ru');
+ expect(parsed.pageCount).toBe(412);
+ expect(parsed.seriesName).toBe('Pathway of the Chosen');
+ expect(parsed.seriesIndex).toBe(2);
+ expect(parsed.isbn13).toBe('9781729419007');
+ expect(parsed.isbn10).toBe('1729419001');
+ expect(parsed.rating).toBe(4.5);
+ expect(parsed.authors.map((author) => author.name)).toEqual(['Cat Bruno', 'James David Victor', 'Prince']);
+ expect(parsed.genres).toEqual(['Fantasy', 'Epic & Heroic']);
+ expect(parsed.tags).toEqual(['favourite', 'to-reread']);
+ expect(parsed.goodreadsId).toBe('12345');
+ expect(parsed.amazonId).toBe('B07XYZ');
+ expect(parsed.hardcoverId).toBe('hc-9');
+ expect(parsed.googleBooksId).toBe('gb-1');
+ expect(parsed.openLibraryId).toBe('OL1W');
+ expect(parsed.koboId).toBe('kobo-1');
+ expect(parsed.itunesId).toBe('it-1');
+ expect(parsed.cover?.equals(cover)).toBe(true);
+ });
+
+ it('round-trips a fractional series index', async () => {
+ const { parsed } = await writeAndRead({ seriesName: 'Series', seriesIndex: 2.5 });
+ expect(parsed.seriesIndex).toBe(2.5);
+ });
+
+ it('round-trips cyrillic metadata in a windows-1251 file', async () => {
+ const source = SOURCE.replace('encoding="UTF-8"', 'encoding="windows-1251"').replace('Chapter one.
', 'В белом плаще.
');
+ const { parsed, path } = await writeAndRead(
+ { title: 'Мастер и Маргарита', authors: [{ name: 'Михаил Булгаков', sortName: 'Булгаков, Михаил' }], seriesName: 'Классика' },
+ source,
+ 'win1251',
+ );
+
+ expect(parsed.title).toBe('Мастер и Маргарита');
+ expect(parsed.authors[0]?.name).toBe('Михаил Булгаков');
+ expect(parsed.seriesName).toBe('Классика');
+ expect(iconv.decode(await readFile(path), 'win1251')).toContain('В белом плаще.');
+ });
+
+ it('round-trips a cover injected into a file that had none', async () => {
+ const { parsed } = await writeAndRead({ coverBytes: cover });
+ expect(parsed.cover?.equals(cover)).toBe(true);
+ });
+
+ it('leaves untouched fields readable after a partial write', async () => {
+ const path = join(testRoot, 'book.fb2');
+ await writeFile(path, SOURCE, 'utf8');
+
+ await writer.write(path, { title: 'Only Title Changed' }, { fieldMask: new Set(['title']), dryRun: false });
+
+ const parsed = await extractor.extract(path);
+ expect(parsed?.title).toBe('Only Title Changed');
+ expect(parsed?.language).toBe('en');
+ expect(parsed?.publishedYear).toBe(1901);
+ expect(parsed?.authors.map((author) => author.name)).toEqual(['Old Author']);
+ expect(parsed?.genres).toEqual(['antique']);
+ });
+
+ it('re-extracts identical metadata after a second identical write, so scans do not churn', async () => {
+ const payload: BookWritePayload = {
+ title: 'Stable',
+ authors: [{ name: 'A B', sortName: 'B, A' }],
+ genres: ['Fantasy'],
+ tags: ['x'],
+ language: 'en',
+ publishedDate: '2010-05-06',
+ publisher: 'Pub',
+ seriesName: 'S',
+ seriesIndex: 1,
+ rating: 3,
+ coverBytes: cover,
+ };
+ const { parsed: first, path } = await writeAndRead(payload);
+ const bytesAfterFirst = await readFile(path);
+
+ await writer.write(path, payload, { fieldMask: FULL_MASK, dryRun: false });
+ const second = await extractor.extract(path);
+
+ expect((await readFile(path)).equals(bytesAfterFirst)).toBe(true);
+ expect(second).toEqual(first);
+ });
+});
diff --git a/server/src/modules/file-write/formats/shared/cover-image.ts b/server/src/modules/file-write/formats/shared/cover-image.ts
new file mode 100644
index 000000000..bb4a7591b
--- /dev/null
+++ b/server/src/modules/file-write/formats/shared/cover-image.ts
@@ -0,0 +1,34 @@
+import sharp from 'sharp';
+
+const FORMAT_DETAILS = {
+ jpeg: { mediaType: 'image/jpeg', extension: 'jpg' },
+ png: { mediaType: 'image/png', extension: 'png' },
+ gif: { mediaType: 'image/gif', extension: 'gif' },
+ webp: { mediaType: 'image/webp', extension: 'webp' },
+ tiff: { mediaType: 'image/tiff', extension: 'tiff' },
+ svg: { mediaType: 'image/svg+xml', extension: 'svg' },
+ jp2: { mediaType: 'image/jp2', extension: 'jp2' },
+ jxl: { mediaType: 'image/jxl', extension: 'jxl' },
+} as const;
+
+export interface CoverImageInfo {
+ mediaType: string;
+ extension: string;
+}
+
+export async function inspectCoverImage(bytes: Buffer): Promise {
+ const metadata = await sharp(bytes, { failOn: 'error' }).metadata();
+ const knownFormat = metadata.format ? FORMAT_DETAILS[metadata.format as keyof typeof FORMAT_DETAILS] : undefined;
+ if (knownFormat) return knownFormat;
+
+ if (metadata.format === 'heif') {
+ return metadata.compression === 'av1' ? { mediaType: 'image/avif', extension: 'avif' } : { mediaType: 'image/heic', extension: 'heic' };
+ }
+
+ if (isBitmap(bytes)) return { mediaType: 'image/bmp', extension: 'bmp' };
+ throw new Error(`Unsupported cover image format: ${metadata.format || 'unknown'}`);
+}
+
+function isBitmap(bytes: Buffer): boolean {
+ return bytes.length >= 2 && bytes[0] === 0x42 && bytes[1] === 0x4d;
+}
diff --git a/server/src/modules/library/dto/create-library.dto.ts b/server/src/modules/library/dto/create-library.dto.ts
index 3d353d5f6..51a66b615 100644
--- a/server/src/modules/library/dto/create-library.dto.ts
+++ b/server/src/modules/library/dto/create-library.dto.ts
@@ -135,6 +135,16 @@ export class CreateLibraryDto {
@Max(LIBRARY_FILE_WRITE_MAX_SIZE_MB_MAX)
fileWriteEpubMaxFileSizeMb?: number;
+ @IsOptional()
+ @IsBoolean()
+ fileWriteFb2Enabled?: boolean;
+
+ @IsOptional()
+ @IsInt()
+ @Min(LIBRARY_FILE_WRITE_MAX_SIZE_MB_MIN)
+ @Max(LIBRARY_FILE_WRITE_MAX_SIZE_MB_MAX)
+ fileWriteFb2MaxFileSizeMb?: number;
+
@IsOptional()
@IsBoolean()
fileWritePdfEnabled?: boolean;
diff --git a/server/src/modules/library/dto/update-library.dto.ts b/server/src/modules/library/dto/update-library.dto.ts
index 0d56b084e..48221cd6f 100644
--- a/server/src/modules/library/dto/update-library.dto.ts
+++ b/server/src/modules/library/dto/update-library.dto.ts
@@ -136,6 +136,16 @@ export class UpdateLibraryDto {
@Max(LIBRARY_FILE_WRITE_MAX_SIZE_MB_MAX)
fileWriteEpubMaxFileSizeMb?: number;
+ @IsOptional()
+ @IsBoolean()
+ fileWriteFb2Enabled?: boolean;
+
+ @IsOptional()
+ @IsInt()
+ @Min(LIBRARY_FILE_WRITE_MAX_SIZE_MB_MIN)
+ @Max(LIBRARY_FILE_WRITE_MAX_SIZE_MB_MAX)
+ fileWriteFb2MaxFileSizeMb?: number;
+
@IsOptional()
@IsBoolean()
fileWritePdfEnabled?: boolean;
diff --git a/server/src/modules/library/library.service.ts b/server/src/modules/library/library.service.ts
index 38e699ae4..c47f54fc6 100644
--- a/server/src/modules/library/library.service.ts
+++ b/server/src/modules/library/library.service.ts
@@ -129,6 +129,8 @@ export class LibraryService {
fileWriteWriteCover: dto.fileWriteWriteCover ?? true,
fileWriteEpubEnabled: dto.fileWriteEpubEnabled ?? true,
fileWriteEpubMaxFileSizeMb: dto.fileWriteEpubMaxFileSizeMb ?? 100,
+ fileWriteFb2Enabled: dto.fileWriteFb2Enabled ?? false,
+ fileWriteFb2MaxFileSizeMb: dto.fileWriteFb2MaxFileSizeMb ?? 100,
fileWritePdfEnabled: dto.fileWritePdfEnabled ?? true,
fileWritePdfMaxFileSizeMb: dto.fileWritePdfMaxFileSizeMb ?? 100,
fileWriteCbxEnabled: dto.fileWriteCbxEnabled ?? false,
diff --git a/server/src/modules/metadata/extractors/fb2-format.extractor.ts b/server/src/modules/metadata/extractors/fb2-format.extractor.ts
index 21f16cf0e..f2ed21d50 100644
--- a/server/src/modules/metadata/extractors/fb2-format.extractor.ts
+++ b/server/src/modules/metadata/extractors/fb2-format.extractor.ts
@@ -2,19 +2,46 @@ import { extractFb2Cover } from '../lib/cover-fb2';
import { parseFb2File } from '../lib/fb2-parser';
import type { FormatExtractor, ParsedBookData } from './format-extractor.interface';
+function toNumber(value: string | undefined): number | null {
+ if (!value) return null;
+ const parsed = Number.parseFloat(value);
+ return Number.isFinite(parsed) ? parsed : null;
+}
+
export class Fb2FormatExtractor implements FormatExtractor {
async extract(absolutePath: string): Promise {
const [fb2, cover] = await Promise.all([parseFb2File(absolutePath), extractFb2Cover(absolutePath).catch(() => null)]);
if (!fb2) return null;
+
+ const custom = fb2.custom;
return {
title: fb2.title,
+ subtitle: custom.subtitle ?? null,
description: fb2.description,
+ publisher: fb2.publisher,
+ publishedDate: fb2.publishedDate,
publishedYear: fb2.publishedYear,
language: fb2.language,
seriesName: fb2.seriesName,
seriesIndex: fb2.seriesIndex,
authors: fb2.authors,
genres: fb2.genres,
+ tags: fb2.tags,
+ isbn13: fb2.isbn13,
+ isbn10: custom.isbn10 ?? null,
+ rating: toNumber(custom.rating),
+ pageCount: toNumber(custom.pageCount),
+ googleBooksId: custom.googleBooksId ?? null,
+ goodreadsId: custom.goodreadsId ?? null,
+ amazonId: custom.amazonId ?? null,
+ hardcoverId: custom.hardcoverId ?? null,
+ hardcoverEditionId: custom.hardcoverEditionId ?? null,
+ openLibraryId: custom.openLibraryId ?? null,
+ ranobedbId: custom.ranobedbId ?? null,
+ koboId: custom.koboId ?? null,
+ lubimyczytacId: custom.lubimyczytacId ?? null,
+ aladinId: custom.aladinId ?? null,
+ itunesId: custom.itunesId ?? null,
cover: cover ?? null,
};
}
diff --git a/server/src/modules/metadata/extractors/format-extractors.test.ts b/server/src/modules/metadata/extractors/format-extractors.test.ts
index 37acd04af..cfd1a00b4 100644
--- a/server/src/modules/metadata/extractors/format-extractors.test.ts
+++ b/server/src/modules/metadata/extractors/format-extractors.test.ts
@@ -286,12 +286,17 @@ describe('metadata format extractors', () => {
mockParseFb2File.mockResolvedValue({
title: 'Roadside Picnic',
description: 'Sci-fi classic',
+ publishedDate: null,
publishedYear: 1972,
language: 'ru',
seriesName: null,
seriesIndex: null,
authors: [{ name: 'Arkady Strugatsky', sortName: null }],
genres: ['Science Fiction'],
+ tags: [],
+ publisher: null,
+ isbn13: null,
+ custom: {},
});
mockExtractFb2Cover.mockRejectedValue(new Error('missing binary'));
@@ -304,6 +309,65 @@ describe('metadata format extractors', () => {
);
});
+ it('fb2 extractor maps namespaced custom-info onto book fields', async () => {
+ mockParseFb2File.mockResolvedValue({
+ title: 'Nemesis',
+ description: null,
+ publishedDate: '2018-03-14',
+ publishedYear: 2018,
+ language: 'en',
+ seriesName: 'Pathway',
+ seriesIndex: 2,
+ authors: [],
+ genres: [],
+ tags: ['favourite'],
+ publisher: 'Painted Quill Press',
+ isbn13: '9781729419007',
+ custom: { subtitle: 'A Tale', rating: '4.5', pageCount: '412', goodreadsId: '12345', isbn10: '1729419001' },
+ });
+ mockExtractFb2Cover.mockResolvedValue(Buffer.from('cover'));
+
+ const result = await new Fb2FormatExtractor().extract('/books/test.fb2');
+
+ expect(result).toEqual(
+ expect.objectContaining({
+ subtitle: 'A Tale',
+ rating: 4.5,
+ pageCount: 412,
+ goodreadsId: '12345',
+ isbn10: '1729419001',
+ isbn13: '9781729419007',
+ publisher: 'Painted Quill Press',
+ publishedDate: '2018-03-14',
+ tags: ['favourite'],
+ }),
+ );
+ });
+
+ it('fb2 extractor leaves numeric custom-info null when the stored value is not a number', async () => {
+ mockParseFb2File.mockResolvedValue({
+ title: 'Nemesis',
+ description: null,
+ publishedDate: null,
+ publishedYear: null,
+ language: null,
+ seriesName: null,
+ seriesIndex: null,
+ authors: [],
+ genres: [],
+ tags: [],
+ publisher: null,
+ isbn13: null,
+ custom: { rating: 'not-a-number', pageCount: '' },
+ });
+ mockExtractFb2Cover.mockResolvedValue(null);
+
+ const result = await new Fb2FormatExtractor().extract('/books/test.fb2');
+
+ expect(result?.rating).toBeNull();
+ expect(result?.pageCount).toBeNull();
+ });
+
it('comic extractor uses filename fallback when embedded metadata is missing', async () => {
mockExtractCbzMetadata.mockResolvedValue(null);
mockExtractCbzCover.mockResolvedValue(Buffer.from('cover'));
diff --git a/server/src/modules/metadata/lib/cover-fb2.test.ts b/server/src/modules/metadata/lib/cover-fb2.test.ts
index cdccf9685..a8f0f1cb6 100644
--- a/server/src/modules/metadata/lib/cover-fb2.test.ts
+++ b/server/src/modules/metadata/lib/cover-fb2.test.ts
@@ -20,18 +20,18 @@ describe('extractFb2Cover', () => {
AQIDBA==
`;
- mockReadFile.mockResolvedValue(xml as unknown as Awaited>);
+ mockReadFile.mockResolvedValue(Buffer.from(xml, 'utf8') as unknown as Awaited>);
await expect(extractFb2Cover('/book.fb2')).resolves.toEqual(Buffer.from([1, 2, 3, 4]));
});
it('returns null when coverpage or referenced binary is missing', async () => {
- mockReadFile.mockResolvedValue('' as any);
+ mockReadFile.mockResolvedValue(Buffer.from('') as any);
await expect(extractFb2Cover('/book.fb2')).resolves.toBeNull();
});
it('returns null on malformed XML', async () => {
- mockReadFile.mockResolvedValue(' {
try {
- const xml = await readFile(absolutePath, 'utf-8');
+ const { text: xml } = decodeFb2Document(await readFile(absolutePath));
const doc = parser.parse(xml) as Record;
const fb = (doc['FictionBook'] ?? doc['fictionbook']) as Record | undefined;
diff --git a/server/src/modules/metadata/lib/fb2-parser.test.ts b/server/src/modules/metadata/lib/fb2-parser.test.ts
index 60e13c178..79037558a 100644
--- a/server/src/modules/metadata/lib/fb2-parser.test.ts
+++ b/server/src/modules/metadata/lib/fb2-parser.test.ts
@@ -1,10 +1,16 @@
vi.mock('fs/promises');
import { readFile } from 'fs/promises';
+import iconv from 'iconv-lite';
+
import { parseFb2File } from './fb2-parser';
const mockReadFile = readFile as MockedFunction;
+function fb2Buffer(titleInfo: string, publishInfo = '', description = ''): Buffer {
+ return Buffer.from(makeFb2(titleInfo, publishInfo, description), 'utf8');
+}
+
function makeFb2(titleInfo: string, publishInfo = '', description = ''): string {
return `
@@ -25,14 +31,14 @@ beforeEach(() => {
describe('parseFb2File', () => {
describe('title', () => {
it('extracts book title', async () => {
- mockReadFile.mockResolvedValue(makeFb2('War and Peace') as unknown as Buffer);
+ mockReadFile.mockResolvedValue(fb2Buffer('War and Peace'));
const r = await parseFb2File('/book.fb2');
expect(r?.title).toBe('War and Peace');
});
it('returns null title when book-title element is absent (other fields present)', async () => {
// title-info with only a lang tag — titleInfo is truthy but book-title is missing
- mockReadFile.mockResolvedValue(makeFb2('en') as unknown as Buffer);
+ mockReadFile.mockResolvedValue(fb2Buffer('en'));
const r = await parseFb2File('/book.fb2');
expect(r?.title).toBeNull();
});
@@ -41,12 +47,12 @@ describe('parseFb2File', () => {
describe('authors', () => {
it('constructs author name from first + last name', async () => {
mockReadFile.mockResolvedValue(
- makeFb2(`
+ fb2Buffer(`
Leo
Tolstoy
- `) as unknown as Buffer,
+ `),
);
const r = await parseFb2File('/book.fb2');
expect(r?.authors).toHaveLength(1);
@@ -56,13 +62,13 @@ describe('parseFb2File', () => {
it('includes middle name in display name', async () => {
mockReadFile.mockResolvedValue(
- makeFb2(`
+ fb2Buffer(`
John
Ronald Reuel
Tolkien
- `) as unknown as Buffer,
+ `),
);
const r = await parseFb2File('/book.fb2');
expect(r?.authors[0].name).toBe('John Ronald Reuel Tolkien');
@@ -72,11 +78,11 @@ describe('parseFb2File', () => {
it('falls back to nickname when no name parts', async () => {
mockReadFile.mockResolvedValue(
- makeFb2(`
+ fb2Buffer(`
Voltaire
- `) as unknown as Buffer,
+ `),
);
const r = await parseFb2File('/book.fb2');
expect(r?.authors[0].name).toBe('Voltaire');
@@ -85,17 +91,17 @@ describe('parseFb2File', () => {
it('parses multiple authors', async () => {
mockReadFile.mockResolvedValue(
- makeFb2(`
+ fb2Buffer(`
AuthorOne
AuthorTwo
- `) as unknown as Buffer,
+ `),
);
const r = await parseFb2File('/book.fb2');
expect(r?.authors).toHaveLength(2);
});
it('returns empty authors array when no author elements', async () => {
- mockReadFile.mockResolvedValue(makeFb2('Anon') as unknown as Buffer);
+ mockReadFile.mockResolvedValue(fb2Buffer('Anon'));
const r = await parseFb2File('/book.fb2');
expect(r?.authors).toHaveLength(0);
});
@@ -103,13 +109,13 @@ describe('parseFb2File', () => {
describe('genres', () => {
it('extracts single genre', async () => {
- mockReadFile.mockResolvedValue(makeFb2('sci-fi') as unknown as Buffer);
+ mockReadFile.mockResolvedValue(fb2Buffer('sci-fi'));
const r = await parseFb2File('/book.fb2');
expect(r?.genres).toContain('sci-fi');
});
it('extracts multiple genres', async () => {
- mockReadFile.mockResolvedValue(makeFb2('sci-fiadventure') as unknown as Buffer);
+ mockReadFile.mockResolvedValue(fb2Buffer('sci-fiadventure'));
const r = await parseFb2File('/book.fb2');
expect(r?.genres).toEqual(['sci-fi', 'adventure']);
});
@@ -117,26 +123,26 @@ describe('parseFb2File', () => {
describe('language', () => {
it('extracts language code', async () => {
- mockReadFile.mockResolvedValue(makeFb2('ru') as unknown as Buffer);
+ mockReadFile.mockResolvedValue(fb2Buffer('ru'));
expect((await parseFb2File('/book.fb2'))?.language).toBe('ru');
});
});
describe('series', () => {
it('extracts series name and index from sequence element', async () => {
- mockReadFile.mockResolvedValue(makeFb2('') as unknown as Buffer);
+ mockReadFile.mockResolvedValue(fb2Buffer(''));
const r = await parseFb2File('/book.fb2');
expect(r?.seriesName).toBe('The Dark Tower');
expect(r?.seriesIndex).toBe(1);
});
it('parses float series index', async () => {
- mockReadFile.mockResolvedValue(makeFb2('') as unknown as Buffer);
+ mockReadFile.mockResolvedValue(fb2Buffer(''));
expect((await parseFb2File('/book.fb2'))?.seriesIndex).toBe(1.5);
});
it('returns null seriesName when no sequence element', async () => {
- mockReadFile.mockResolvedValue(makeFb2('Standalone') as unknown as Buffer);
+ mockReadFile.mockResolvedValue(fb2Buffer('Standalone'));
const r = await parseFb2File('/book.fb2');
expect(r?.seriesName).toBeNull();
expect(r?.seriesIndex).toBeNull();
@@ -145,40 +151,40 @@ describe('parseFb2File', () => {
describe('publishedYear', () => {
it('extracts year from publish-info/year', async () => {
- mockReadFile.mockResolvedValue(makeFb2('Book', '1869') as unknown as Buffer);
+ mockReadFile.mockResolvedValue(fb2Buffer('Book', '1869'));
expect((await parseFb2File('/book.fb2'))?.publishedYear).toBe(1869);
});
it('rejects years outside 1000-2200 range', async () => {
- mockReadFile.mockResolvedValue(makeFb2('Book', '900') as unknown as Buffer);
+ mockReadFile.mockResolvedValue(fb2Buffer('Book', '900'));
expect((await parseFb2File('/book.fb2'))?.publishedYear).toBeNull();
});
it('rejects years above 2200', async () => {
- mockReadFile.mockResolvedValue(makeFb2('Book', '2500') as unknown as Buffer);
+ mockReadFile.mockResolvedValue(fb2Buffer('Book', '2500'));
expect((await parseFb2File('/book.fb2'))?.publishedYear).toBeNull();
});
it('falls back to title-info/date when publish-info/year absent', async () => {
- mockReadFile.mockResolvedValue(makeFb2('Book1984') as unknown as Buffer);
+ mockReadFile.mockResolvedValue(fb2Buffer('Book1984'));
expect((await parseFb2File('/book.fb2'))?.publishedYear).toBe(1984);
});
it('parses year from title-info/date value attribute', async () => {
- mockReadFile.mockResolvedValue(makeFb2('Book') as unknown as Buffer);
+ mockReadFile.mockResolvedValue(fb2Buffer('Book'));
expect((await parseFb2File('/book.fb2'))?.publishedYear).toBe(1984);
});
});
describe('description (annotation)', () => {
it('extracts plain string annotation', async () => {
- mockReadFile.mockResolvedValue(makeFb2('Plain description text.') as unknown as Buffer);
+ mockReadFile.mockResolvedValue(fb2Buffer('Plain description text.'));
expect((await parseFb2File('/book.fb2'))?.description).toBe('Plain description text.');
});
it('extracts text from structured annotation with paragraph tags', async () => {
// Bug regression: previously JSON.stringify was used, producing {"p":"text"}
- mockReadFile.mockResolvedValue(makeFb2('Description with paragraph.
') as unknown as Buffer);
+ mockReadFile.mockResolvedValue(fb2Buffer('Description with paragraph.
'));
const r = await parseFb2File('/book.fb2');
expect(r?.description).not.toContain('{');
expect(r?.description).not.toContain('"p":');
@@ -186,11 +192,147 @@ describe('parseFb2File', () => {
});
it('returns null description when annotation absent', async () => {
- mockReadFile.mockResolvedValue(makeFb2('No Desc') as unknown as Buffer);
+ mockReadFile.mockResolvedValue(fb2Buffer('No Desc'));
expect((await parseFb2File('/book.fb2'))?.description).toBeNull();
});
});
+ describe('publishedDate', () => {
+ it('prefers a full date in title-info over a bare publish-info year', async () => {
+ mockReadFile.mockResolvedValue(fb2Buffer('2018-03-14', '2018'));
+ const r = await parseFb2File('/book.fb2');
+ expect(r?.publishedDate).toBe('2018-03-14');
+ expect(r?.publishedYear).toBe(2018);
+ });
+
+ it('keeps the publish-info year when the years disagree', async () => {
+ mockReadFile.mockResolvedValue(fb2Buffer('2018-03-14', '1975'));
+ const r = await parseFb2File('/book.fb2');
+ expect(r?.publishedDate).toBe('2018-03-14');
+ expect(r?.publishedYear).toBe(1975);
+ });
+
+ it('returns a null date when only a year is recorded', async () => {
+ mockReadFile.mockResolvedValue(fb2Buffer('Book', '1999'));
+ const r = await parseFb2File('/book.fb2');
+ expect(r?.publishedDate).toBeNull();
+ expect(r?.publishedYear).toBe(1999);
+ });
+
+ it('reads a full date from title-info when publish-info is absent', async () => {
+ mockReadFile.mockResolvedValue(fb2Buffer('2001-02-03'));
+ expect((await parseFb2File('/book.fb2'))?.publishedDate).toBe('2001-02-03');
+ });
+ });
+
+ describe('publish-info fields', () => {
+ it('extracts publisher and isbn', async () => {
+ mockReadFile.mockResolvedValue(fb2Buffer('Book', 'Kensington Books9781729419007'));
+ const r = await parseFb2File('/book.fb2');
+ expect(r?.publisher).toBe('Kensington Books');
+ expect(r?.isbn13).toBe('9781729419007');
+ });
+
+ it('returns nulls when publish-info is absent', async () => {
+ mockReadFile.mockResolvedValue(fb2Buffer('Book'));
+ const r = await parseFb2File('/book.fb2');
+ expect(r?.publisher).toBeNull();
+ expect(r?.isbn13).toBeNull();
+ });
+ });
+
+ describe('tags (keywords)', () => {
+ it('splits keywords on commas', async () => {
+ mockReadFile.mockResolvedValue(fb2Buffer('favourite, to-reread , sci-fi'));
+ expect((await parseFb2File('/book.fb2'))?.tags).toEqual(['favourite', 'to-reread', 'sci-fi']);
+ });
+
+ it('returns an empty list when keywords are absent', async () => {
+ mockReadFile.mockResolvedValue(fb2Buffer('Book'));
+ expect((await parseFb2File('/book.fb2'))?.tags).toEqual([]);
+ });
+ });
+
+ describe('custom-info', () => {
+ function withCustomInfo(entries: string): Buffer {
+ return Buffer.from(
+ `
+
+
+ Book
+ ${entries}
+
+`,
+ 'utf8',
+ );
+ }
+
+ it('reads namespaced fields written by BookOrbit', async () => {
+ mockReadFile.mockResolvedValue(
+ withCustomInfo(
+ [
+ 'A Subtitle',
+ '4.5',
+ '412',
+ '12345',
+ '1729419001',
+ ].join('\n '),
+ ),
+ );
+ const r = await parseFb2File('/book.fb2');
+ expect(r?.custom).toEqual({
+ subtitle: 'A Subtitle',
+ rating: '4.5',
+ pageCount: '412',
+ goodreadsId: '12345',
+ isbn10: '1729419001',
+ });
+ });
+
+ it('ignores custom-info written by other tools', async () => {
+ mockReadFile.mockResolvedValue(withCustomInfo('abc-123'));
+ expect((await parseFb2File('/book.fb2'))?.custom).toEqual({});
+ });
+
+ it('ignores namespaced keys it does not recognise', async () => {
+ mockReadFile.mockResolvedValue(withCustomInfo('x'));
+ expect((await parseFb2File('/book.fb2'))?.custom).toEqual({});
+ });
+
+ it('returns an empty object when there is no custom-info', async () => {
+ mockReadFile.mockResolvedValue(fb2Buffer('Book'));
+ expect((await parseFb2File('/book.fb2'))?.custom).toEqual({});
+ });
+ });
+
+ describe('encodings', () => {
+ it('decodes a windows-1251 document using its declared encoding', async () => {
+ const xml = `
+
+
+ МихаилБулгаков
+ Мастер и Маргарита
+
+`;
+ mockReadFile.mockResolvedValue(iconv.encode(xml, 'win1251'));
+ const r = await parseFb2File('/book.fb2');
+ expect(r?.title).toBe('Мастер и Маргарита');
+ expect(r?.authors[0]?.name).toBe('Михаил Булгаков');
+ });
+
+ it('decodes a koi8-r document', async () => {
+ const xml = `
+Пикник`;
+ mockReadFile.mockResolvedValue(iconv.encode(xml, 'koi8-r'));
+ expect((await parseFb2File('/book.fb2'))?.title).toBe('Пикник');
+ });
+
+ it('still reads utf-8 documents', async () => {
+ mockReadFile.mockResolvedValue(fb2Buffer('Über den Bergen'));
+ expect((await parseFb2File('/book.fb2'))?.title).toBe('Über den Bergen');
+ });
+ });
+
describe('error handling', () => {
it('returns null when file read throws', async () => {
mockReadFile.mockRejectedValue(new Error('ENOENT'));
@@ -198,12 +340,12 @@ describe('parseFb2File', () => {
});
it('returns null when XML has no FictionBook root', async () => {
- mockReadFile.mockResolvedValue('' as unknown as Buffer);
+ mockReadFile.mockResolvedValue(Buffer.from(''));
expect(await parseFb2File('/bad.fb2')).toBeNull();
});
it('returns null when title-info is missing', async () => {
- mockReadFile.mockResolvedValue(`` as unknown as Buffer);
+ mockReadFile.mockResolvedValue(Buffer.from(''));
expect(await parseFb2File('/bad.fb2')).toBeNull();
});
});
diff --git a/server/src/modules/metadata/lib/fb2-parser.ts b/server/src/modules/metadata/lib/fb2-parser.ts
index 7acbaedfa..88e5bb174 100644
--- a/server/src/modules/metadata/lib/fb2-parser.ts
+++ b/server/src/modules/metadata/lib/fb2-parser.ts
@@ -1,6 +1,8 @@
import { readFile } from 'fs/promises';
import { XMLParser } from 'fast-xml-parser';
+import { BOOKORBIT_NS_PREFIX } from '../../../common/bookorbit-ns';
+import { decodeFb2Document } from '../../../common/utils/fb2-encoding.utils';
import { parsePublishedDateKey } from '../../../common/utils/published-date.utils';
const parser = new XMLParser({
@@ -86,6 +88,27 @@ function parseFb2DateNode(val: unknown): string | null {
return null;
}
+/** Fields BookOrbit writes into . */
+const CUSTOM_INFO_FIELDS = [
+ 'subtitle',
+ 'pageCount',
+ 'rating',
+ 'isbn10',
+ 'googleBooksId',
+ 'goodreadsId',
+ 'amazonId',
+ 'hardcoverId',
+ 'hardcoverEditionId',
+ 'openLibraryId',
+ 'ranobedbId',
+ 'koboId',
+ 'lubimyczytacId',
+ 'aladinId',
+ 'itunesId',
+] as const;
+
+export type Fb2CustomInfoField = (typeof CUSTOM_INFO_FIELDS)[number];
+
export interface Fb2Metadata {
title: string | null;
description: string | null;
@@ -96,11 +119,36 @@ export interface Fb2Metadata {
seriesIndex: number | null;
authors: { name: string; sortName: string | null }[];
genres: string[];
+ tags: string[];
+ publisher: string | null;
+ isbn13: string | null;
+ custom: Partial>;
+}
+
+function parseCustomInfo(description: Record | undefined): Partial> {
+ const custom: Partial> = {};
+ if (!description) return custom;
+
+ for (const entry of toArray(description['custom-info'])) {
+ if (typeof entry !== 'object' || entry === null) continue;
+ const infoType = text((entry as Record)['@_info-type']);
+ if (!infoType?.startsWith(`${BOOKORBIT_NS_PREFIX}:`)) continue;
+
+ const field = infoType.slice(BOOKORBIT_NS_PREFIX.length + 1) as Fb2CustomInfoField;
+ if (!CUSTOM_INFO_FIELDS.includes(field)) continue;
+
+ const value = text(entry);
+ if (value) custom[field] = value;
+ }
+
+ return custom;
}
export async function parseFb2File(absolutePath: string): Promise {
try {
- const xml = await readFile(absolutePath, 'utf-8');
+ // FB2 is commonly published in windows-1251 or koi8-r, so honour the
+ // encoding declared in the file instead of assuming UTF-8.
+ const { text: xml } = decodeFb2Document(await readFile(absolutePath));
const doc = parser.parse(xml) as Record;
const fb = (doc['FictionBook'] ?? doc['fictionbook']) as Record | undefined;
@@ -151,12 +199,13 @@ export async function parseFb2File(absolutePath: string): Promise or
- let publishedYear: number | null = null;
+ // usually carries only a year, so a full date in
+ // wins when it is available.
const publishInfo = description?.['publish-info'] as Record | undefined;
- const yearRaw = publishInfo?.['year'] ?? titleInfo['date'];
- const publishedDate = parseFb2DateNode(yearRaw);
- publishedYear = parseFb2YearNode(yearRaw);
+ const titleInfoDate = titleInfo['date'];
+ const publishInfoYear = publishInfo?.['year'];
+ const publishedDate = parseFb2DateNode(titleInfoDate) ?? parseFb2DateNode(publishInfoYear);
+ const publishedYear = parseFb2YearNode(publishInfoYear) ?? parseFb2YearNode(titleInfoDate);
// Annotation (description)
let annotationDescription: string | null = null;
@@ -166,7 +215,29 @@ export async function parseFb2File(absolutePath: string): Promise tag.trim())
+ .filter(Boolean)
+ : [];
+
+ return {
+ title,
+ description: annotationDescription,
+ language,
+ publishedDate,
+ publishedYear,
+ seriesName,
+ seriesIndex,
+ authors,
+ genres,
+ tags,
+ publisher: text(publishInfo?.['publisher']),
+ isbn13: text(publishInfo?.['isbn']),
+ custom: parseCustomInfo(description),
+ };
} catch {
return null;
}
diff --git a/server/test/e2e/metadata-write/metadata-write-fixture-builder.ts b/server/test/e2e/metadata-write/metadata-write-fixture-builder.ts
index bf72ab7a2..da74708b8 100644
--- a/server/test/e2e/metadata-write/metadata-write-fixture-builder.ts
+++ b/server/test/e2e/metadata-write/metadata-write-fixture-builder.ts
@@ -26,6 +26,12 @@ interface EpubFixtureInput {
uid?: string;
}
+interface Fb2FixtureInput {
+ title?: string;
+ language?: string;
+ bodyText?: string;
+}
+
interface ComicFixtureInput {
title?: string;
author?: string;
@@ -116,6 +122,31 @@ export async function createEpubFixture(rootPath: string, relativePath: string,
return absolutePath;
}
+export async function createFb2Fixture(rootPath: string, relativePath: string, input: Fb2FixtureInput = {}): Promise {
+ const title = input.title ?? 'Fixture FB2 Title';
+ const language = input.language ?? 'en';
+ const bodyText = input.bodyText ?? 'fixture body text';
+
+ const xml = [
+ '',
+ '',
+ '',
+ ' ',
+ ' antique',
+ ' FixtureAuthor',
+ ` ${escapeXml(title)}`,
+ ` ${escapeXml(language)}`,
+ ' ',
+ ' fixture-doc',
+ '',
+ ``,
+ '',
+ '',
+ ].join('\n');
+
+ return writeFixtureFile(rootPath, relativePath, Buffer.from(xml, 'utf8'));
+}
+
export async function createCbzFixture(rootPath: string, relativePath: string, input: ComicFixtureInput = {}): Promise {
assertRelativePath(relativePath);
const absolutePath = join(rootPath, relativePath);
diff --git a/server/test/e2e/metadata-write/metadata-write-harness.ts b/server/test/e2e/metadata-write/metadata-write-harness.ts
index d9dca0f96..87332595b 100644
--- a/server/test/e2e/metadata-write/metadata-write-harness.ts
+++ b/server/test/e2e/metadata-write/metadata-write-harness.ts
@@ -65,6 +65,8 @@ export type LibraryFileWritePatch = {
fileWriteWriteCover?: boolean;
fileWriteEpubEnabled?: boolean;
fileWriteEpubMaxFileSizeMb?: number;
+ fileWriteFb2Enabled?: boolean;
+ fileWriteFb2MaxFileSizeMb?: number;
fileWritePdfEnabled?: boolean;
fileWritePdfMaxFileSizeMb?: number;
fileWriteCbxEnabled?: boolean;
@@ -132,6 +134,8 @@ export async function createLibraryWithFolder(
fileWriteEnabled?: boolean;
fileWriteWriteCover?: boolean;
fileWriteEpubEnabled?: boolean;
+ fileWriteFb2Enabled?: boolean;
+ fileWriteFb2MaxFileSizeMb?: number;
fileWritePdfEnabled?: boolean;
fileWriteCbxEnabled?: boolean;
fileWriteCbxMaxFileSizeMb?: number;
@@ -155,6 +159,8 @@ export async function createLibraryWithFolder(
fileWriteEnabled: options.fileWriteEnabled ?? false,
fileWriteWriteCover: options.fileWriteWriteCover ?? true,
fileWriteEpubEnabled: options.fileWriteEpubEnabled ?? true,
+ fileWriteFb2Enabled: options.fileWriteFb2Enabled ?? false,
+ fileWriteFb2MaxFileSizeMb: options.fileWriteFb2MaxFileSizeMb ?? 100,
fileWritePdfEnabled: options.fileWritePdfEnabled ?? true,
fileWriteCbxEnabled: options.fileWriteCbxEnabled ?? false,
fileWriteCbxMaxFileSizeMb: options.fileWriteCbxMaxFileSizeMb ?? 500,
@@ -184,6 +190,8 @@ export async function setLibraryFileWriteSettings(db: Db, libraryId: number, pat
if (patch.fileWriteWriteCover !== undefined) updateValues.fileWriteWriteCover = patch.fileWriteWriteCover;
if (patch.fileWriteEpubEnabled !== undefined) updateValues.fileWriteEpubEnabled = patch.fileWriteEpubEnabled;
if (patch.fileWriteEpubMaxFileSizeMb !== undefined) updateValues.fileWriteEpubMaxFileSizeMb = patch.fileWriteEpubMaxFileSizeMb;
+ if (patch.fileWriteFb2Enabled !== undefined) updateValues.fileWriteFb2Enabled = patch.fileWriteFb2Enabled;
+ if (patch.fileWriteFb2MaxFileSizeMb !== undefined) updateValues.fileWriteFb2MaxFileSizeMb = patch.fileWriteFb2MaxFileSizeMb;
if (patch.fileWritePdfEnabled !== undefined) updateValues.fileWritePdfEnabled = patch.fileWritePdfEnabled;
if (patch.fileWritePdfMaxFileSizeMb !== undefined) updateValues.fileWritePdfMaxFileSizeMb = patch.fileWritePdfMaxFileSizeMb;
if (patch.fileWriteCbxEnabled !== undefined) updateValues.fileWriteCbxEnabled = patch.fileWriteCbxEnabled;
diff --git a/server/test/metadata-write.e2e-spec.ts b/server/test/metadata-write.e2e-spec.ts
index 7b93a3eab..c94e32ac6 100644
--- a/server/test/metadata-write.e2e-spec.ts
+++ b/server/test/metadata-write.e2e-spec.ts
@@ -1,5 +1,5 @@
import { randomUUID } from 'crypto';
-import { mkdir, writeFile } from 'fs/promises';
+import { mkdir, readFile, writeFile } from 'fs/promises';
import { dirname, join } from 'path';
import { eq } from 'drizzle-orm';
@@ -10,6 +10,7 @@ import { BookService, type MetadataUpdateFailpoint } from '../src/modules/book/b
import { FileWriteService } from '../src/modules/file-write/file-write.service';
import { extractCb7Metadata, extractCbzMetadata } from '../src/modules/metadata/lib/cbz-metadata';
import { extractEpubMetadata } from '../src/modules/metadata/lib/epub';
+import { parseFb2File } from '../src/modules/metadata/lib/fb2-parser';
import { parsePdfFile } from '../src/modules/metadata/lib/pdf-parser';
import {
authHeader,
@@ -30,6 +31,7 @@ import {
createCb7Fixture,
createCbzFixture,
createEpubFixture,
+ createFb2Fixture,
createPdfFixture,
writeFixtureFile,
} from './e2e/metadata-write/metadata-write-fixture-builder';
@@ -1328,6 +1330,74 @@ describe('Metadata write operations (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, ()
expect(disabledLog).toBeNull();
});
+ it('writes metadata into an fb2 book without touching its body text', async () => {
+ const library = await createLibraryWithFolder(context, {
+ mode: 'book_per_file',
+ fileWriteEnabled: true,
+ fileWriteFb2Enabled: true,
+ });
+ await createFb2Fixture(library.folderPath, 'per-format/book.fb2', { title: 'FB2 Seed', bodyText: 'untouched chapter text' });
+ await triggerAndWaitForLibraryScan(context, library.libraryId);
+ const book = await locateBookFileByRelPath(context, library.libraryId, 'per-format/book.fb2');
+ const originalBytes = await readFile(book.absolutePath);
+
+ const patchResponse = await context.app.inject({
+ method: 'PATCH',
+ url: `/api/v1/books/${book.bookId}/metadata`,
+ headers: authHeader(context.adminToken),
+ payload: { title: 'FB2 Written Title', seriesName: 'FB2 Series', seriesIndex: 2 },
+ });
+ expect(patchResponse.statusCode).toBe(200);
+
+ const log = await waitForWriteLogEntry(context.db, book.bookId, { triggeredBy: 'auto', status: 'success' });
+ expect(log.format).toBe('fb2');
+ expect(log.fieldsWritten).toEqual(expect.arrayContaining(['title', 'seriesName', 'seriesIndex']));
+
+ const updated = await readFile(book.absolutePath, 'utf8');
+ expect(updated).toContain('FB2 Written Title');
+ expect(updated).toContain('');
+
+ const bodyOf = (text: string) => text.slice(text.indexOf('') + ''.length);
+ expect(bodyOf(updated)).toBe(bodyOf(originalBytes.toString('utf8')));
+ expect(updated).toContain('untouched chapter text');
+
+ const reparsed = await parseFb2File(book.absolutePath);
+ expect(reparsed?.title).toBe('FB2 Written Title');
+ expect(reparsed?.seriesName).toBe('FB2 Series');
+ expect(reparsed?.seriesIndex).toBe(2);
+ });
+
+ it('skips fb2 book when the fb2 format is disabled for the library', async () => {
+ const library = await createLibraryWithFolder(context, {
+ mode: 'book_per_file',
+ fileWriteEnabled: true,
+ fileWriteFb2Enabled: false,
+ });
+ await createFb2Fixture(library.folderPath, 'per-format/disabled.fb2', { title: 'FB2 Disabled' });
+ await triggerAndWaitForLibraryScan(context, library.libraryId);
+ const book = await locateBookFileByRelPath(context, library.libraryId, 'per-format/disabled.fb2');
+ const originalBytes = await readFile(book.absolutePath);
+
+ await setLibraryFileWriteSettings(context.db, library.libraryId, { fileWriteEnabled: true, fileWriteFb2Enabled: false });
+
+ const syncResponse = await context.app.inject({
+ method: 'POST',
+ url: `/api/v1/libraries/${library.libraryId}/write-metadata-to-files`,
+ headers: authHeader(context.adminToken),
+ });
+ expect(syncResponse.statusCode).toBe(200);
+
+ const doneEvent = parseSseEvents(syncResponse.body).find(
+ (event): event is Extract => 'done' in event && event.done === true,
+ );
+ expect(doneEvent).toMatchObject({ processed: 1, succeeded: 0, failed: 0, skipped: 1 });
+
+ const logEntry = await getLatestWriteLogEntry(context.db, book.bookId, 'sync');
+ expect(logEntry?.status).toBe('skipped');
+ expect(logEntry?.errorMessage).toBe('format disabled');
+ expect((await readFile(book.absolutePath)).equals(originalBytes)).toBe(true);
+ });
+
it('skips cbz book when cbx format is disabled for the library', async () => {
const library = await createLibraryWithFolder(context, {
mode: 'book_per_file',