;
+ describeSearch(params: P): CacheKeyDescriptor | null;
+ describeLookup(providerId: string, params?: P): CacheKeyDescriptor | null;
+}
+
+export interface CacheableProvider {
+ readonly cache?: ProviderCacheSpec
;
+}
diff --git a/server/src/common/provider-cache/provider-call-context.test.ts b/server/src/common/provider-cache/provider-call-context.test.ts
new file mode 100644
index 000000000..b2b18064f
--- /dev/null
+++ b/server/src/common/provider-cache/provider-call-context.test.ts
@@ -0,0 +1,83 @@
+import {
+ currentProviderCallRecord,
+ markProviderDegraded,
+ ProviderCallRecord,
+ providerRequestKey,
+ recordAuxiliaryProviderRequest,
+ recordProviderRequest,
+ recordProviderRequestError,
+ runWithProviderCallRecord,
+} from './provider-call-context';
+
+describe('provider call context', () => {
+ it('keeps only the final outcome of a retried request', () => {
+ const record = new ProviderCallRecord();
+ record.recordRequest('GET a', { kind: 'status', status: 503 });
+ record.recordRequest('GET a', { kind: 'status', status: 200 });
+
+ expect(record.mainRequests).toBe(1);
+ expect(record.outboundRequests).toBe(2);
+ expect(record.unsuccessfulRequests()).toEqual([]);
+ });
+
+ it('treats declared statuses as answers and everything else as failures', () => {
+ const record = new ProviderCallRecord();
+ record.recordRequest('GET a', { kind: 'status', status: 404 });
+ record.recordRequest('GET b', { kind: 'error', errorClass: 'TypeError' });
+ record.recordRequest('GET c', { kind: 'aborted' });
+
+ expect(record.unsuccessfulRequests([404])).toHaveLength(2);
+ expect(record.unsuccessfulRequests()).toHaveLength(3);
+ });
+
+ it('counts a status the provider accepts for that request as a normal answer', () => {
+ const record = new ProviderCallRecord();
+ record.recordRequest('GET chapters', { kind: 'status', status: 404, accepted: true });
+
+ expect(record.unsuccessfulRequests()).toEqual([]);
+ });
+
+ it('distinguishes requests by method and body', () => {
+ expect(providerRequestKey('https://x.test/graphql', { method: 'post', body: '{"q":1}' })).not.toBe(
+ providerRequestKey('https://x.test/graphql', { method: 'POST', body: '{"q":2}' }),
+ );
+ });
+
+ it('records aborts and transport errors separately', () => {
+ const record = new ProviderCallRecord();
+ const abort = Object.assign(new Error('aborted'), { name: 'AbortError' });
+ return runWithProviderCallRecord(record, () => {
+ recordProviderRequestError('GET a', abort);
+ recordProviderRequestError('GET b', new TypeError('fetch failed'));
+ return Promise.resolve();
+ }).then(() => {
+ expect(record.unsuccessfulRequests()).toEqual([{ kind: 'aborted' }, { kind: 'error', errorClass: 'TypeError' }]);
+ });
+ });
+
+ it('isolates concurrent operations and ignores calls outside any operation', async () => {
+ const first = new ProviderCallRecord();
+ const second = new ProviderCallRecord();
+ recordProviderRequest('GET outside', { kind: 'status', status: 200 });
+
+ await Promise.all([
+ runWithProviderCallRecord(first, async () => {
+ await new Promise((resolve) => setTimeout(resolve, 5));
+ recordProviderRequest('GET first', { kind: 'status', status: 200 });
+ }),
+ runWithProviderCallRecord(second, async () => {
+ markProviderDegraded('challenge');
+ recordAuxiliaryProviderRequest(false);
+ expect(currentProviderCallRecord()).toBe(second);
+ await Promise.resolve();
+ }),
+ ]);
+
+ expect(first.mainRequests).toBe(1);
+ expect(first.degradationReasons).toEqual([]);
+ expect(second.mainRequests).toBe(0);
+ expect(second.degradationReasons).toEqual(['challenge']);
+ expect(second.auxiliaryAllDefinitive).toBe(false);
+ expect(currentProviderCallRecord()).toBeUndefined();
+ });
+});
diff --git a/server/src/common/provider-cache/provider-call-context.ts b/server/src/common/provider-cache/provider-call-context.ts
new file mode 100644
index 000000000..662889b80
--- /dev/null
+++ b/server/src/common/provider-cache/provider-call-context.ts
@@ -0,0 +1,134 @@
+import { AsyncLocalStorage } from 'node:async_hooks';
+
+/** `accepted` marks a status the provider treats as a normal answer for that request (e.g. 404 for optional data). */
+export type ProviderRequestOutcome =
+ { kind: 'status'; status: number; accepted?: boolean } | { kind: 'error'; errorClass: string } | { kind: 'aborted' };
+
+/**
+ * Everything a provider did on the network during one live operation, plus anything the provider
+ * itself reported about the quality of its answer. The metadata cache only stores an answer when
+ * this record shows it was complete; a provider returning `[]` or `null` is otherwise
+ * indistinguishable from one that swallowed a failure.
+ */
+export class ProviderCallRecord {
+ private readonly finalOutcomes = new Map();
+ private auxiliaryRequestCount = 0;
+ private indefiniteAuxiliaryCount = 0;
+ private readonly failures: string[] = [];
+ private readonly degradations: string[] = [];
+ private truncatedFlag = false;
+ private attemptCount = 0;
+
+ /**
+ * Records a request's outcome. A later attempt for the same request replaces an earlier one, so
+ * a retried 503 that then succeeds counts as a success.
+ */
+ recordRequest(requestKey: string, outcome: ProviderRequestOutcome): void {
+ this.attemptCount += 1;
+ this.finalOutcomes.set(requestKey, outcome);
+ }
+
+ /** A side request (e.g. a cover placeholder check) that informs the answer but does not produce it. */
+ recordAuxiliary(definitive: boolean): void {
+ this.auxiliaryRequestCount += 1;
+ if (!definitive) this.indefiniteAuxiliaryCount += 1;
+ }
+
+ markFailed(reason: string): void {
+ this.failures.push(reason);
+ }
+
+ markDegraded(reason: string): void {
+ this.degradations.push(reason);
+ }
+
+ markTruncated(): void {
+ this.truncatedFlag = true;
+ }
+
+ /** Distinct main requests. */
+ get mainRequests(): number {
+ return this.finalOutcomes.size;
+ }
+
+ /** Every outbound attempt, main and auxiliary, for pacing decisions. */
+ get outboundRequests(): number {
+ return this.attemptCount + this.auxiliaryRequestCount;
+ }
+
+ get auxiliaryAllDefinitive(): boolean {
+ return this.indefiniteAuxiliaryCount === 0;
+ }
+
+ get failureReasons(): readonly string[] {
+ return this.failures;
+ }
+
+ get degradationReasons(): readonly string[] {
+ return this.degradations;
+ }
+
+ get truncated(): boolean {
+ return this.truncatedFlag;
+ }
+
+ /** Requests whose final outcome was not a success, ignoring statuses the caller treats as answers. */
+ unsuccessfulRequests(authoritativeStatuses: readonly number[] = []): ProviderRequestOutcome[] {
+ const unsuccessful: ProviderRequestOutcome[] = [];
+ for (const outcome of this.finalOutcomes.values()) {
+ if (outcome.kind !== 'status') {
+ unsuccessful.push(outcome);
+ continue;
+ }
+ const ok = (outcome.status >= 200 && outcome.status < 300) || outcome.accepted === true;
+ if (!ok && !authoritativeStatuses.includes(outcome.status)) unsuccessful.push(outcome);
+ }
+ return unsuccessful;
+ }
+}
+
+const storage = new AsyncLocalStorage();
+
+export function runWithProviderCallRecord(record: ProviderCallRecord, fn: () => Promise): Promise {
+ return storage.run(record, fn);
+}
+
+export function currentProviderCallRecord(): ProviderCallRecord | undefined {
+ return storage.getStore();
+}
+
+/** Identifies one logical request so retries of it collapse into their final outcome. */
+export function providerRequestKey(url: string | URL, init?: { method?: string; body?: unknown }): string {
+ const method = (init?.method ?? 'GET').toUpperCase();
+ const body = typeof init?.body === 'string' ? init.body : '';
+ return `${method} ${url.toString()} ${body}`;
+}
+
+export function recordProviderRequest(requestKey: string, outcome: ProviderRequestOutcome): void {
+ storage.getStore()?.recordRequest(requestKey, outcome);
+}
+
+export function recordProviderRequestError(requestKey: string, error: unknown): void {
+ const name = error instanceof Error ? error.name : 'UnknownError';
+ const outcome: ProviderRequestOutcome = name === 'AbortError' ? { kind: 'aborted' } : { kind: 'error', errorClass: name };
+ recordProviderRequest(requestKey, outcome);
+}
+
+export function recordAuxiliaryProviderRequest(definitive: boolean): void {
+ storage.getStore()?.recordAuxiliary(definitive);
+}
+
+/** The provider got a response it could not use (malformed body, an API error inside a 200). */
+export function markProviderFailed(reason: string): void {
+ storage.getStore()?.markFailed(reason);
+}
+
+/** The provider answered, but with less than a complete result (challenge page, skipped detail). */
+export function markProviderDegraded(reason: string): void {
+ storage.getStore()?.markDegraded(reason);
+}
+
+/** The provider stopped early on its own search budget. */
+export function markProviderTruncated(): void {
+ storage.getStore()?.markTruncated();
+}
diff --git a/server/src/db/migrations/0107_add_provider_metadata_cache.sql b/server/src/db/migrations/0107_add_provider_metadata_cache.sql
new file mode 100644
index 000000000..8c81d6bf4
--- /dev/null
+++ b/server/src/db/migrations/0107_add_provider_metadata_cache.sql
@@ -0,0 +1,28 @@
+CREATE TABLE "provider_metadata_cache" (
+ "id" serial PRIMARY KEY NOT NULL,
+ "key_hash" char(64) NOT NULL,
+ "namespace" varchar(16) NOT NULL,
+ "provider" varchar(64) NOT NULL,
+ "operation" varchar(16) NOT NULL,
+ "status" varchar(16) NOT NULL,
+ "schema_version" integer NOT NULL,
+ "provider_version" integer NOT NULL,
+ "config_fingerprint" char(16) NOT NULL,
+ "max_candidates" integer,
+ "covers_validated" boolean DEFAULT false NOT NULL,
+ "payload" text NOT NULL,
+ "payload_bytes" integer NOT NULL,
+ "fetched_at" timestamp with time zone NOT NULL,
+ "last_accessed_at" timestamp with time zone NOT NULL,
+ "upstream_ms" integer DEFAULT 0 NOT NULL,
+ CONSTRAINT "provider_metadata_cache_key_hash_unique" UNIQUE("key_hash"),
+ CONSTRAINT "provider_metadata_cache_namespace_chk" CHECK ("provider_metadata_cache"."namespace" in ('book', 'author')),
+ CONSTRAINT "provider_metadata_cache_operation_chk" CHECK ("provider_metadata_cache"."operation" in ('search', 'lookup')),
+ CONSTRAINT "provider_metadata_cache_status_chk" CHECK ("provider_metadata_cache"."status" in ('results', 'empty', 'found', 'not_found')),
+ CONSTRAINT "provider_metadata_cache_payload_bytes_nonnegative_chk" CHECK ("provider_metadata_cache"."payload_bytes" >= 0)
+);
+--> statement-breakpoint
+ALTER TABLE "author_enrichment_queue" ADD COLUMN "ignore_cache" boolean DEFAULT false NOT NULL;--> statement-breakpoint
+ALTER TABLE "book_metadata_fetch_queue" ADD COLUMN "ignore_cache" boolean DEFAULT false NOT NULL;--> statement-breakpoint
+CREATE INDEX "provider_metadata_cache_ns_provider_fetched_idx" ON "provider_metadata_cache" USING btree ("namespace","provider","fetched_at");--> statement-breakpoint
+CREATE INDEX "provider_metadata_cache_last_accessed_idx" ON "provider_metadata_cache" USING btree ("last_accessed_at");
\ No newline at end of file
diff --git a/server/src/db/migrations/meta/0107_snapshot.json b/server/src/db/migrations/meta/0107_snapshot.json
new file mode 100644
index 000000000..ce3d3c7eb
--- /dev/null
+++ b/server/src/db/migrations/meta/0107_snapshot.json
@@ -0,0 +1,22991 @@
+{
+ "id": "070abc4a-1ab1-4209-9bab-156862efd4dc",
+ "prevId": "7dbd0aa9-fd8f-4586-9ee2-a8430487f6f8",
+ "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.auth_sessions": {
+ "name": "auth_sessions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token_version": {
+ "name": "token_version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "authentication_method": {
+ "name": "authentication_method",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "client_kind": {
+ "name": "client_kind",
+ "type": "varchar(10)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'web'"
+ },
+ "device_label": {
+ "name": "device_label",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": 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
+ },
+ "revoked_at": {
+ "name": "revoked_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "auth_sessions_user_id_idx": {
+ "name": "auth_sessions_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "auth_sessions_expires_at_idx": {
+ "name": "auth_sessions_expires_at_idx",
+ "columns": [
+ {
+ "expression": "expires_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "auth_sessions_user_id_users_id_fk": {
+ "name": "auth_sessions_user_id_users_id_fk",
+ "tableFrom": "auth_sessions",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "auth_sessions_client_kind_chk": {
+ "name": "auth_sessions_client_kind_chk",
+ "value": "\"auth_sessions\".\"client_kind\" in ('web', 'native')"
+ },
+ "auth_sessions_authentication_method_chk": {
+ "name": "auth_sessions_authentication_method_chk",
+ "value": "\"auth_sessions\".\"authentication_method\" in ('password', 'oidc', 'magic_link', 'setup', 'legacy')"
+ }
+ },
+ "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
+ },
+ "session_id": {
+ "name": "session_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "token_hash": {
+ "name": "token_hash",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "authentication_method": {
+ "name": "authentication_method",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'legacy'"
+ },
+ "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_session_id_idx": {
+ "name": "refresh_tokens_session_id_idx",
+ "columns": [
+ {
+ "expression": "session_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"
+ },
+ "refresh_tokens_session_id_auth_sessions_id_fk": {
+ "name": "refresh_tokens_session_id_auth_sessions_id_fk",
+ "tableFrom": "refresh_tokens",
+ "tableTo": "auth_sessions",
+ "columnsFrom": ["session_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\""
+ },
+ "refresh_tokens_authentication_method_chk": {
+ "name": "refresh_tokens_authentication_method_chk",
+ "value": "\"refresh_tokens\".\"authentication_method\" in ('password', 'oidc', 'magic_link', 'setup', 'legacy')"
+ }
+ },
+ "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
+ },
+ "see_own_requested_books": {
+ "name": "see_own_requested_books",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 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
+ },
+ "type": {
+ "name": "type",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'books'"
+ },
+ "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
+ },
+ "watch_local_folders": {
+ "name": "watch_local_folders",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "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'"
+ },
+ "added_at_source": {
+ "name": "added_at_source",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'imported'"
+ },
+ "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": "double precision",
+ "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_write_all_files": {
+ "name": "file_write_all_files",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "file_write_read_along_enabled": {
+ "name": "file_write_read_along_enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "file_write_read_along_max_file_size_mb": {
+ "name": "file_write_read_along_max_file_size_mb",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1000
+ },
+ "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
+ },
+ "koreader_hash_revision": {
+ "name": "koreader_hash_revision",
+ "type": "bigint",
+ "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": {
+ "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_type_chk": {
+ "name": "libraries_type_chk",
+ "value": "\"libraries\".\"type\" in ('books', 'podcasts')"
+ },
+ "libraries_organization_mode_chk": {
+ "name": "libraries_organization_mode_chk",
+ "value": "\"libraries\".\"organization_mode\" in ('book_per_folder', 'book_per_file')"
+ },
+ "libraries_added_at_source_chk": {
+ "name": "libraries_added_at_source_chk",
+ "value": "\"libraries\".\"added_at_source\" in ('imported', 'file_modified', 'file_created')"
+ },
+ "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
+ },
+ "scan_state_version": {
+ "name": "scan_state_version",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "role": {
+ "name": "role",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'downloads'"
+ },
+ "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": {
+ "library_folders_scan_state_version_nonnegative_chk": {
+ "name": "library_folders_scan_state_version_nonnegative_chk",
+ "value": "\"library_folders\".\"scan_state_version\" >= 0"
+ },
+ "library_folders_role_chk": {
+ "name": "library_folders_role_chk",
+ "value": "\"library_folders\".\"role\" in ('downloads', 'local')"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.book_files": {
+ "name": "book_files",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "public_id": {
+ "name": "public_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "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
+ },
+ "is_fixed_layout": {
+ "name": "is_fixed_layout",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "media_overlay_available": {
+ "name": "media_overlay_available",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "media_overlay_duration_seconds": {
+ "name": "media_overlay_duration_seconds",
+ "type": "double precision",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "media_overlay_checked_at": {
+ "name": "media_overlay_checked_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": {
+ "book_files_public_id_uidx": {
+ "name": "book_files_public_id_uidx",
+ "columns": [
+ {
+ "expression": "public_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "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_change_timestamp_idx": {
+ "name": "book_files_change_timestamp_idx",
+ "columns": [
+ {
+ "expression": "greatest(\"created_at\", \"updated_at\") desc",
+ "asc": true,
+ "isExpression": 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_media_overlay_available_idx": {
+ "name": "book_files_media_overlay_available_idx",
+ "columns": [
+ {
+ "expression": "media_overlay_available",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "\"book_files\".\"media_overlay_available\" = true",
+ "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"
+ },
+ "book_files_media_overlay_duration_seconds_nonnegative_chk": {
+ "name": "book_files_media_overlay_duration_seconds_nonnegative_chk",
+ "value": "\"book_files\".\"media_overlay_duration_seconds\" is null or \"book_files\".\"media_overlay_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_id_idx": {
+ "name": "books_library_status_id_idx",
+ "columns": [
+ {
+ "expression": "library_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "id",
+ "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_covers": {
+ "name": "book_covers",
+ "schema": "",
+ "columns": {
+ "book_id": {
+ "name": "book_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "medium": {
+ "name": "medium",
+ "type": "varchar(5)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source": {
+ "name": "source",
+ "type": "varchar(9)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "origin": {
+ "name": "origin",
+ "type": "varchar(12)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "width": {
+ "name": "width",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "height": {
+ "name": "height",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "dormant_since": {
+ "name": "dormant_since",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "book_covers_book_id_books_id_fk": {
+ "name": "book_covers_book_id_books_id_fk",
+ "tableFrom": "book_covers",
+ "tableTo": "books",
+ "columnsFrom": ["book_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "book_covers_book_id_medium_pk": {
+ "name": "book_covers_book_id_medium_pk",
+ "columns": ["book_id", "medium"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "book_covers_medium_chk": {
+ "name": "book_covers_medium_chk",
+ "value": "\"book_covers\".\"medium\" in ('ebook', 'audio')"
+ },
+ "book_covers_source_chk": {
+ "name": "book_covers_source_chk",
+ "value": "\"book_covers\".\"source\" in ('extracted', 'custom')"
+ },
+ "book_covers_origin_chk": {
+ "name": "book_covers_origin_chk",
+ "value": "\"book_covers\".\"origin\" in ('embedded', 'folder_image', 'opf', 'provider', 'dock', 'upload', 'legacy')"
+ },
+ "book_covers_width_positive_chk": {
+ "name": "book_covers_width_positive_chk",
+ "value": "\"book_covers\".\"width\" is null or \"book_covers\".\"width\" > 0"
+ },
+ "book_covers_height_positive_chk": {
+ "name": "book_covers_height_positive_chk",
+ "value": "\"book_covers\".\"height\" is null or \"book_covers\".\"height\" > 0"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.book_duplicate_dismissals": {
+ "name": "book_duplicate_dismissals",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "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
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "book_duplicate_dismissals_user_created_idx": {
+ "name": "book_duplicate_dismissals_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": {}
+ }
+ },
+ "foreignKeys": {
+ "book_duplicate_dismissals_user_id_users_id_fk": {
+ "name": "book_duplicate_dismissals_user_id_users_id_fk",
+ "tableFrom": "book_duplicate_dismissals",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "book_duplicate_dismissals_book_id_a_books_id_fk": {
+ "name": "book_duplicate_dismissals_book_id_a_books_id_fk",
+ "tableFrom": "book_duplicate_dismissals",
+ "tableTo": "books",
+ "columnsFrom": ["book_id_a"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "book_duplicate_dismissals_book_id_b_books_id_fk": {
+ "name": "book_duplicate_dismissals_book_id_b_books_id_fk",
+ "tableFrom": "book_duplicate_dismissals",
+ "tableTo": "books",
+ "columnsFrom": ["book_id_b"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "book_duplicate_dismissals_user_id_book_id_a_book_id_b_pk": {
+ "name": "book_duplicate_dismissals_user_id_book_id_a_book_id_b_pk",
+ "columns": ["user_id", "book_id_a", "book_id_b"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "book_duplicate_dismissals_order_chk": {
+ "name": "book_duplicate_dismissals_order_chk",
+ "value": "\"book_duplicate_dismissals\".\"book_id_a\" < \"book_duplicate_dismissals\".\"book_id_b\""
+ }
+ },
+ "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
+ },
+ "confidence": {
+ "name": "confidence",
+ "type": "smallint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "member_bytes_total": {
+ "name": "member_bytes_total",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "member_bytes_max": {
+ "name": "member_bytes_max",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ }
+ },
+ "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": {}
+ },
+ "book_duplicate_groups_scan_reclaimable_idx": {
+ "name": "book_duplicate_groups_scan_reclaimable_idx",
+ "columns": [
+ {
+ "expression": "scan_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "(\"member_bytes_total\" - \"member_bytes_max\") desc",
+ "asc": true,
+ "isExpression": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "book_duplicate_groups_scan_confidence_idx": {
+ "name": "book_duplicate_groups_scan_confidence_idx",
+ "columns": [
+ {
+ "expression": "scan_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "confidence",
+ "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": {
+ "book_duplicate_groups_confidence_chk": {
+ "name": "book_duplicate_groups_confidence_chk",
+ "value": "\"book_duplicate_groups\".\"confidence\" between 1 and 4"
+ }
+ },
+ "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
+ },
+ "total_extra_copies": {
+ "name": "total_extra_copies",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "total_reclaimable_bytes": {
+ "name": "total_reclaimable_bytes",
+ "type": "bigint",
+ "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.book_move_jobs": {
+ "name": "book_move_jobs",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "started_by": {
+ "name": "started_by",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "target_library_id": {
+ "name": "target_library_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "target_folder_id": {
+ "name": "target_folder_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_library_ids": {
+ "name": "source_library_ids",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "status": {
+ "name": "status",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'running'"
+ },
+ "total_books": {
+ "name": "total_books",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "succeeded": {
+ "name": "succeeded",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "merged": {
+ "name": "merged",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "failed": {
+ "name": "failed",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "skipped": {
+ "name": "skipped",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "finished_at": {
+ "name": "finished_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "book_move_jobs_status_idx": {
+ "name": "book_move_jobs_status_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "book_move_jobs_started_at_idx": {
+ "name": "book_move_jobs_started_at_idx",
+ "columns": [
+ {
+ "expression": "\"started_at\" desc",
+ "asc": true,
+ "isExpression": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "book_move_jobs_started_by_users_id_fk": {
+ "name": "book_move_jobs_started_by_users_id_fk",
+ "tableFrom": "book_move_jobs",
+ "tableTo": "users",
+ "columnsFrom": ["started_by"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "book_move_jobs_target_library_id_libraries_id_fk": {
+ "name": "book_move_jobs_target_library_id_libraries_id_fk",
+ "tableFrom": "book_move_jobs",
+ "tableTo": "libraries",
+ "columnsFrom": ["target_library_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "book_move_jobs_target_folder_id_library_folders_id_fk": {
+ "name": "book_move_jobs_target_folder_id_library_folders_id_fk",
+ "tableFrom": "book_move_jobs",
+ "tableTo": "library_folders",
+ "columnsFrom": ["target_folder_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "book_move_jobs_status_chk": {
+ "name": "book_move_jobs_status_chk",
+ "value": "\"book_move_jobs\".\"status\" in ('running', 'completed', 'failed', 'interrupted')"
+ },
+ "book_move_jobs_counts_nonnegative_chk": {
+ "name": "book_move_jobs_counts_nonnegative_chk",
+ "value": "\"book_move_jobs\".\"total_books\" >= 0 and \"book_move_jobs\".\"succeeded\" >= 0 and \"book_move_jobs\".\"merged\" >= 0 and \"book_move_jobs\".\"failed\" >= 0 and \"book_move_jobs\".\"skipped\" >= 0"
+ }
+ },
+ "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()"
+ },
+ "position": {
+ "name": "position",
+ "type": "bigserial",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "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": {}
+ },
+ "collection_books_collection_position_idx": {
+ "name": "collection_books_collection_position_idx",
+ "columns": [
+ {
+ "expression": "collection_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "position",
+ "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.collection_podcasts": {
+ "name": "collection_podcasts",
+ "schema": "",
+ "columns": {
+ "collection_id": {
+ "name": "collection_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "podcast_id": {
+ "name": "podcast_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_podcasts_podcast_id_idx": {
+ "name": "collection_podcasts_podcast_id_idx",
+ "columns": [
+ {
+ "expression": "podcast_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "collection_podcasts_collection_id_collections_id_fk": {
+ "name": "collection_podcasts_collection_id_collections_id_fk",
+ "tableFrom": "collection_podcasts",
+ "tableTo": "collections",
+ "columnsFrom": ["collection_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "collection_podcasts_podcast_id_podcasts_id_fk": {
+ "name": "collection_podcasts_podcast_id_podcasts_id_fk",
+ "tableFrom": "collection_podcasts",
+ "tableTo": "podcasts",
+ "columnsFrom": ["podcast_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "collection_podcasts_collection_id_podcast_id_pk": {
+ "name": "collection_podcasts_collection_id_podcast_id_pk",
+ "columns": ["collection_id", "podcast_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
+ },
+ "media_type": {
+ "name": "media_type",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'books'"
+ },
+ "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
+ },
+ "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": {
+ "collections_user_media_type_name_uidx": {
+ "name": "collections_user_media_type_name_uidx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "media_type",
+ "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": {}
+ },
+ "collections_public_display_name_idx": {
+ "name": "collections_public_display_name_idx",
+ "columns": [
+ {
+ "expression": "is_public",
+ "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": {
+ "collections_media_type_chk": {
+ "name": "collections_media_type_chk",
+ "value": "\"collections\".\"media_type\" in ('books', 'podcasts')"
+ },
+ "collections_kobo_books_only_chk": {
+ "name": "collections_kobo_books_only_chk",
+ "value": "\"collections\".\"sync_to_kobo\" = false or \"collections\".\"media_type\" = 'books'"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.smart_scope_kobo_subscriptions": {
+ "name": "smart_scope_kobo_subscriptions",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "smart_scope_id": {
+ "name": "smart_scope_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "smart_scope_kobo_subscriptions_smart_scope_id_idx": {
+ "name": "smart_scope_kobo_subscriptions_smart_scope_id_idx",
+ "columns": [
+ {
+ "expression": "smart_scope_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "smart_scope_kobo_subscriptions_user_id_users_id_fk": {
+ "name": "smart_scope_kobo_subscriptions_user_id_users_id_fk",
+ "tableFrom": "smart_scope_kobo_subscriptions",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "smart_scope_kobo_subscriptions_smart_scope_id_smart_scopes_id_fk": {
+ "name": "smart_scope_kobo_subscriptions_smart_scope_id_smart_scopes_id_fk",
+ "tableFrom": "smart_scope_kobo_subscriptions",
+ "tableTo": "smart_scopes",
+ "columnsFrom": ["smart_scope_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "smart_scope_kobo_subscriptions_user_id_smart_scope_id_pk": {
+ "name": "smart_scope_kobo_subscriptions_user_id_smart_scope_id_pk",
+ "columns": ["user_id", "smart_scope_id"]
+ }
+ },
+ "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
+ },
+ "media_type": {
+ "name": "media_type",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'books'"
+ },
+ "library_id": {
+ "name": "library_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "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": {
+ "smart_scopes_user_media_type_idx": {
+ "name": "smart_scopes_user_media_type_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "media_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "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"
+ },
+ "smart_scopes_library_id_libraries_id_fk": {
+ "name": "smart_scopes_library_id_libraries_id_fk",
+ "tableFrom": "smart_scopes",
+ "tableTo": "libraries",
+ "columnsFrom": ["library_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "smart_scopes_user_id_media_type_name_unique": {
+ "name": "smart_scopes_user_id_media_type_name_unique",
+ "nullsNotDistinct": false,
+ "columns": ["user_id", "media_type", "name"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {
+ "smart_scopes_media_type_chk": {
+ "name": "smart_scopes_media_type_chk",
+ "value": "\"smart_scopes\".\"media_type\" in ('books', 'podcasts')"
+ },
+ "smart_scopes_library_scope_chk": {
+ "name": "smart_scopes_library_scope_chk",
+ "value": "(\"smart_scopes\".\"media_type\" = 'podcasts') = (\"smart_scopes\".\"library_id\" is not null)"
+ },
+ "smart_scopes_kobo_books_only_chk": {
+ "name": "smart_scopes_kobo_books_only_chk",
+ "value": "\"smart_scopes\".\"sync_to_kobo\" = false or \"smart_scopes\".\"media_type\" = 'books'"
+ }
+ },
+ "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
+ },
+ "expected_book_count": {
+ "name": "expected_book_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "expected_book_count_source": {
+ "name": "expected_book_count_source",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "expected_book_count_updated_at": {
+ "name": "expected_book_count_updated_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": {
+ "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": {
+ "book_series_expected_book_count_range_chk": {
+ "name": "book_series_expected_book_count_range_chk",
+ "value": "\"book_series\".\"expected_book_count\" IS NULL OR (\"book_series\".\"expected_book_count\" >= 1 AND \"book_series\".\"expected_book_count\" <= 10000)"
+ }
+ },
+ "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": "varchar(20)",
+ "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": "(CASE WHEN \"series_index\" IS NULL THEN NULL ELSE ARRAY[split_part(\"series_index\", '.', 1)::numeric, CASE WHEN strpos(\"series_index\", '.') = 0 THEN -1::numeric ELSE split_part(\"series_index\", '.', 2)::numeric END] END)",
+ "asc": true,
+ "isExpression": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "\"series_index\" COLLATE \"C\"",
+ "asc": true,
+ "isExpression": 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"
+ },
+ "book_series_memberships_series_index_format_chk": {
+ "name": "book_series_memberships_series_index_format_chk",
+ "value": "\"book_series_memberships\".\"series_index\" is null or \"book_series_memberships\".\"series_index\" ~ '^[0-9]+([.][0-9]+)?$'"
+ }
+ },
+ "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
+ },
+ "ignore_cache": {
+ "name": "ignore_cache",
+ "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": {
+ "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
+ },
+ "birth_date": {
+ "name": "birth_date",
+ "type": "date",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "birth_year": {
+ "name": "birth_year",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "death_date": {
+ "name": "death_date",
+ "type": "date",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "death_year": {
+ "name": "death_year",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "website": {
+ "name": "website",
+ "type": "varchar(2048)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "genres": {
+ "name": "genres",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "influences": {
+ "name": "influences",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "metadata_provider": {
+ "name": "metadata_provider",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "metadata_provider_id": {
+ "name": "metadata_provider_id",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": 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": {}
+ },
+ "book_community_ratings_count_book_idx": {
+ "name": "book_community_ratings_count_book_idx",
+ "columns": [
+ {
+ "expression": "rating_count",
+ "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_count_book_idx": {
+ "name": "book_community_ratings_provider_count_book_idx",
+ "columns": [
+ {
+ "expression": "provider",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "rating_count",
+ "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": "varchar(20)",
+ "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[]"
+ },
+ "cover_updated_at": {
+ "name": "cover_updated_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": {
+ "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_subtitle_unaccent_trgm_idx": {
+ "name": "bm_subtitle_unaccent_trgm_idx",
+ "columns": [
+ {
+ "expression": "public.bookorbit_unaccent(\"subtitle\") gin_trgm_ops",
+ "asc": true,
+ "isExpression": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "gin",
+ "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": "(CASE WHEN \"series_index\" IS NULL THEN NULL ELSE ARRAY[split_part(\"series_index\", '.', 1)::numeric, CASE WHEN strpos(\"series_index\", '.') = 0 THEN -1::numeric ELSE split_part(\"series_index\", '.', 2)::numeric END] END)",
+ "asc": true,
+ "isExpression": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "\"series_index\" COLLATE \"C\"",
+ "asc": true,
+ "isExpression": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "book_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "bm_series_index_sort_idx": {
+ "name": "bm_series_index_sort_idx",
+ "columns": [
+ {
+ "expression": "(CASE WHEN \"series_index\" IS NULL THEN NULL ELSE ARRAY[split_part(\"series_index\", '.', 1)::numeric, CASE WHEN strpos(\"series_index\", '.') = 0 THEN -1::numeric ELSE split_part(\"series_index\", '.', 2)::numeric END] END)",
+ "asc": true,
+ "isExpression": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "\"series_index\" COLLATE \"C\"",
+ "asc": true,
+ "isExpression": 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": "(CASE WHEN \"series_index\" IS NULL THEN NULL ELSE ARRAY[split_part(\"series_index\", '.', 1)::numeric, CASE WHEN strpos(\"series_index\", '.') = 0 THEN -1::numeric ELSE split_part(\"series_index\", '.', 2)::numeric END] END)",
+ "asc": true,
+ "isExpression": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "\"series_index\" COLLATE \"C\"",
+ "asc": true,
+ "isExpression": 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_series_index_format_chk": {
+ "name": "book_metadata_series_index_format_chk",
+ "value": "\"book_metadata\".\"series_index\" is null or \"book_metadata\".\"series_index\" ~ '^[0-9]+([.][0-9]+)?$'"
+ },
+ "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
+ },
+ "ignore_cache": {
+ "name": "ignore_cache",
+ "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": {
+ "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": {}
+ },
+ "scan_jobs_library_started_at_idx": {
+ "name": "scan_jobs_library_started_at_idx",
+ "columns": [
+ {
+ "expression": "library_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "\"started_at\" desc",
+ "asc": true,
+ "isExpression": 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
+ },
+ "source_created_at": {
+ "name": "source_created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "starred_at": {
+ "name": "starred_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": {
+ "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": {}
+ },
+ "annotations_user_created_active_idx": {
+ "name": "annotations_user_created_active_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "coalesce(\"source_created_at\", \"created_at\") desc",
+ "asc": true,
+ "isExpression": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "id",
+ "isExpression": false,
+ "asc": false,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "\"annotations\".\"deleted_at\" is null",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "annotations_user_book_created_active_idx": {
+ "name": "annotations_user_book_created_active_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "book_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "coalesce(\"source_created_at\", \"created_at\") desc",
+ "asc": true,
+ "isExpression": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "id",
+ "isExpression": false,
+ "asc": false,
+ "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
+ },
+ "revision": {
+ "name": "revision",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "captured_at": {
+ "name": "captured_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "operation_id": {
+ "name": "operation_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "manifest_revision": {
+ "name": "manifest_revision",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "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"
+ },
+ "audiobook_progress_revision_positive_chk": {
+ "name": "audiobook_progress_revision_positive_chk",
+ "value": "\"audiobook_progress\".\"revision\" >= 1"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.book_journal_entries": {
+ "name": "book_journal_entries",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "client_id": {
+ "name": "client_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "book_id": {
+ "name": "book_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "body": {
+ "name": "body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "quote": {
+ "name": "quote",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "chapter_title": {
+ "name": "chapter_title",
+ "type": "varchar(500)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "position_percent": {
+ "name": "position_percent",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cfi": {
+ "name": "cfi",
+ "type": "varchar(2000)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "position_seconds": {
+ "name": "position_seconds",
+ "type": "real",
+ "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": {
+ "book_journal_entries_user_client_id_uidx": {
+ "name": "book_journal_entries_user_client_id_uidx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "client_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "book_journal_entries_user_book_created_active_idx": {
+ "name": "book_journal_entries_user_book_created_active_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "book_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "\"book_journal_entries\".\"deleted_at\" is null",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "book_journal_entries_book_id_idx": {
+ "name": "book_journal_entries_book_id_idx",
+ "columns": [
+ {
+ "expression": "book_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "book_journal_entries_user_id_users_id_fk": {
+ "name": "book_journal_entries_user_id_users_id_fk",
+ "tableFrom": "book_journal_entries",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "book_journal_entries_book_id_books_id_fk": {
+ "name": "book_journal_entries_book_id_books_id_fk",
+ "tableFrom": "book_journal_entries",
+ "tableTo": "books",
+ "columnsFrom": ["book_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "book_journal_entries_body_length_chk": {
+ "name": "book_journal_entries_body_length_chk",
+ "value": "char_length(\"book_journal_entries\".\"body\") between 1 and 10000"
+ },
+ "book_journal_entries_quote_length_chk": {
+ "name": "book_journal_entries_quote_length_chk",
+ "value": "\"book_journal_entries\".\"quote\" is null or char_length(\"book_journal_entries\".\"quote\") <= 5000"
+ },
+ "book_journal_entries_position_percent_range_chk": {
+ "name": "book_journal_entries_position_percent_range_chk",
+ "value": "\"book_journal_entries\".\"position_percent\" is null or (\"book_journal_entries\".\"position_percent\" >= 0 and \"book_journal_entries\".\"position_percent\" <= 100)"
+ },
+ "book_journal_entries_position_seconds_nonnegative_chk": {
+ "name": "book_journal_entries_position_seconds_nonnegative_chk",
+ "value": "\"book_journal_entries\".\"position_seconds\" is null or \"book_journal_entries\".\"position_seconds\" >= 0"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.bookmarks": {
+ "name": "bookmarks",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "client_id": {
+ "name": "client_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "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
+ },
+ "note": {
+ "name": "note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "chapter_id": {
+ "name": "chapter_id",
+ "type": "varchar(80)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "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_client_id_uidx": {
+ "name": "bookmarks_user_book_client_id_uidx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "book_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "client_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "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 and \"bookmarks\".\"deleted_at\" 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
+ },
+ "media_overlay_fragment": {
+ "name": "media_overlay_fragment",
+ "type": "varchar(4096)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "media_overlay_section_index": {
+ "name": "media_overlay_section_index",
+ "type": "integer",
+ "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
+ },
+ "narration_percentage": {
+ "name": "narration_percentage",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "narration_updated_at": {
+ "name": "narration_updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "text_updated_at": {
+ "name": "text_updated_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()"
+ },
+ "last_read_at": {
+ "name": "last_read_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_last_read_at_idx": {
+ "name": "reading_progress_user_last_read_at_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "last_read_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_media_overlay_section_index_nonnegative_chk": {
+ "name": "reading_progress_media_overlay_section_index_nonnegative_chk",
+ "value": "\"reading_progress\".\"media_overlay_section_index\" is null or \"reading_progress\".\"media_overlay_section_index\" >= 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)"
+ },
+ "reading_progress_narration_percentage_range_chk": {
+ "name": "reading_progress_narration_percentage_range_chk",
+ "value": "\"reading_progress\".\"narration_percentage\" is null or (\"reading_progress\".\"narration_percentage\" >= 0 and \"reading_progress\".\"narration_percentage\" <= 100)"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.reading_session_sync_cursors": {
+ "name": "reading_session_sync_cursors",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "book_id": {
+ "name": "book_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source": {
+ "name": "source",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_device_key": {
+ "name": "source_device_key",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "counter": {
+ "name": "counter",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "generation": {
+ "name": "generation",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "last_modified": {
+ "name": "last_modified",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "rssc_book_id_idx": {
+ "name": "rssc_book_id_idx",
+ "columns": [
+ {
+ "expression": "book_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "reading_session_sync_cursors_user_id_users_id_fk": {
+ "name": "reading_session_sync_cursors_user_id_users_id_fk",
+ "tableFrom": "reading_session_sync_cursors",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "reading_session_sync_cursors_book_id_books_id_fk": {
+ "name": "reading_session_sync_cursors_book_id_books_id_fk",
+ "tableFrom": "reading_session_sync_cursors",
+ "tableTo": "books",
+ "columnsFrom": ["book_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "reading_session_sync_cursors_user_id_book_id_source_source_device_key_pk": {
+ "name": "reading_session_sync_cursors_user_id_book_id_source_source_device_key_pk",
+ "columns": ["user_id", "book_id", "source", "source_device_key"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "rssc_counter_nonnegative_chk": {
+ "name": "rssc_counter_nonnegative_chk",
+ "value": "\"reading_session_sync_cursors\".\"counter\" >= 0"
+ },
+ "rssc_generation_nonnegative_chk": {
+ "name": "rssc_generation_nonnegative_chk",
+ "value": "\"reading_session_sync_cursors\".\"generation\" >= 0"
+ }
+ },
+ "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
+ },
+ "source_device_key": {
+ "name": "source_device_key",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "session_type": {
+ "name": "session_type",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'read'"
+ },
+ "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": {}
+ },
+ "rs_user_book_source_device_started_idx": {
+ "name": "rs_user_book_source_device_started_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "book_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "source",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "source_device_key",
+ "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', 'ios', 'watchos', 'android', '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_read_aloud_sync_settings": {
+ "name": "user_book_read_aloud_sync_settings",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "book_id": {
+ "name": "book_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "mode": {
+ "name": "mode",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'auto'"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "ubrass_user_id_idx": {
+ "name": "ubrass_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "user_book_read_aloud_sync_settings_user_id_users_id_fk": {
+ "name": "user_book_read_aloud_sync_settings_user_id_users_id_fk",
+ "tableFrom": "user_book_read_aloud_sync_settings",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "user_book_read_aloud_sync_settings_book_id_books_id_fk": {
+ "name": "user_book_read_aloud_sync_settings_book_id_books_id_fk",
+ "tableFrom": "user_book_read_aloud_sync_settings",
+ "tableTo": "books",
+ "columnsFrom": ["book_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "user_book_read_aloud_sync_settings_user_id_book_id_pk": {
+ "name": "user_book_read_aloud_sync_settings_user_id_book_id_pk",
+ "columns": ["user_id", "book_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "user_book_read_aloud_sync_settings_mode_chk": {
+ "name": "user_book_read_aloud_sync_settings_mode_chk",
+ "value": "\"user_book_read_aloud_sync_settings\".\"mode\" in ('auto', 'disabled')"
+ }
+ },
+ "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
+ },
+ "session_id": {
+ "name": "session_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "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_session_id_idx": {
+ "name": "oidc_sessions_session_id_idx",
+ "columns": [
+ {
+ "expression": "session_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "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_session_id_auth_sessions_id_fk": {
+ "name": "oidc_sessions_session_id_auth_sessions_id_fk",
+ "tableFrom": "oidc_sessions",
+ "tableTo": "auth_sessions",
+ "columnsFrom": ["session_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
+ },
+ "store_sync": {
+ "name": "store_sync",
+ "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
+ },
+ "unit_directory": {
+ "name": "unit_directory",
+ "type": "text",
+ "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
+ },
+ "auto_finalize_suppressed": {
+ "name": "auto_finalize_suppressed",
+ "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": {
+ "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"]
+ },
+ "book_dock_files_unit_directory_unique": {
+ "name": "book_dock_files_unit_directory_unique",
+ "nullsNotDistinct": false,
+ "columns": ["unit_directory"]
+ }
+ },
+ "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.book_dock_unit_files": {
+ "name": "book_dock_unit_files",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "dock_file_id": {
+ "name": "dock_file_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "absolute_path": {
+ "name": "absolute_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "file_name": {
+ "name": "file_name",
+ "type": "varchar(500)",
+ "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
+ },
+ "role": {
+ "name": "role",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'content'"
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "book_dock_unit_files_dock_file_id_sort_order_idx": {
+ "name": "book_dock_unit_files_dock_file_id_sort_order_idx",
+ "columns": [
+ {
+ "expression": "dock_file_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "sort_order",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "book_dock_unit_files_dock_file_id_book_dock_files_id_fk": {
+ "name": "book_dock_unit_files_dock_file_id_book_dock_files_id_fk",
+ "tableFrom": "book_dock_unit_files",
+ "tableTo": "book_dock_files",
+ "columnsFrom": ["dock_file_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "book_dock_unit_files_absolute_path_unique": {
+ "name": "book_dock_unit_files_absolute_path_unique",
+ "nullsNotDistinct": false,
+ "columns": ["absolute_path"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {
+ "book_dock_unit_files_role_chk": {
+ "name": "book_dock_unit_files_role_chk",
+ "value": "\"book_dock_unit_files\".\"role\" in ('content', 'cover', 'metadata', 'supplement')"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.book_request_dedupe_aliases": {
+ "name": "book_request_dedupe_aliases",
+ "schema": "",
+ "columns": {
+ "request_id": {
+ "name": "request_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "dedupe_key": {
+ "name": "dedupe_key",
+ "type": "varchar(500)",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "book_request_dedupe_aliases_key_idx": {
+ "name": "book_request_dedupe_aliases_key_idx",
+ "columns": [
+ {
+ "expression": "dedupe_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "book_request_dedupe_aliases_request_id_book_requests_id_fk": {
+ "name": "book_request_dedupe_aliases_request_id_book_requests_id_fk",
+ "tableFrom": "book_request_dedupe_aliases",
+ "tableTo": "book_requests",
+ "columnsFrom": ["request_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "book_request_dedupe_aliases_request_id_dedupe_key_pk": {
+ "name": "book_request_dedupe_aliases_request_id_dedupe_key_pk",
+ "columns": ["request_id", "dedupe_key"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.book_request_dismissals": {
+ "name": "book_request_dismissals",
+ "schema": "",
+ "columns": {
+ "request_id": {
+ "name": "request_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "book_request_dismissals_user_id_idx": {
+ "name": "book_request_dismissals_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "book_request_dismissals_request_id_book_requests_id_fk": {
+ "name": "book_request_dismissals_request_id_book_requests_id_fk",
+ "tableFrom": "book_request_dismissals",
+ "tableTo": "book_requests",
+ "columnsFrom": ["request_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "book_request_dismissals_user_id_users_id_fk": {
+ "name": "book_request_dismissals_user_id_users_id_fk",
+ "tableFrom": "book_request_dismissals",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "book_request_dismissals_request_id_user_id_pk": {
+ "name": "book_request_dismissals_request_id_user_id_pk",
+ "columns": ["request_id", "user_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.book_request_subscribers": {
+ "name": "book_request_subscribers",
+ "schema": "",
+ "columns": {
+ "request_id": {
+ "name": "request_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "book_request_subscribers_user_id_idx": {
+ "name": "book_request_subscribers_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "book_request_subscribers_request_id_book_requests_id_fk": {
+ "name": "book_request_subscribers_request_id_book_requests_id_fk",
+ "tableFrom": "book_request_subscribers",
+ "tableTo": "book_requests",
+ "columnsFrom": ["request_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "book_request_subscribers_user_id_users_id_fk": {
+ "name": "book_request_subscribers_user_id_users_id_fk",
+ "tableFrom": "book_request_subscribers",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "book_request_subscribers_request_id_user_id_pk": {
+ "name": "book_request_subscribers_request_id_user_id_pk",
+ "columns": ["request_id", "user_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.book_requests": {
+ "name": "book_requests",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "media_kind": {
+ "name": "media_kind",
+ "type": "book_request_media_kind",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "title": {
+ "name": "title",
+ "type": "varchar(500)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "subtitle": {
+ "name": "subtitle",
+ "type": "varchar(500)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "authors": {
+ "name": "authors",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "series_name": {
+ "name": "series_name",
+ "type": "varchar(500)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "series_index": {
+ "name": "series_index",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "isbn10": {
+ "name": "isbn10",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "isbn13": {
+ "name": "isbn13",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "published_year": {
+ "name": "published_year",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "language": {
+ "name": "language",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cover_url": {
+ "name": "cover_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "provider_key": {
+ "name": "provider_key",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "metadata_sources": {
+ "name": "metadata_sources",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "preferred_formats": {
+ "name": "preferred_formats",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "note": {
+ "name": "note",
+ "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
+ },
+ "created_by_user_id": {
+ "name": "created_by_user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "decided_by_user_id": {
+ "name": "decided_by_user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "decided_at": {
+ "name": "decided_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "decision_note": {
+ "name": "decision_note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "matched_book_id": {
+ "name": "matched_book_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "book_dock_file_id": {
+ "name": "book_dock_file_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status_reason": {
+ "name": "status_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "failure_code": {
+ "name": "failure_code",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "failure_meta": {
+ "name": "failure_meta",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "self_serve": {
+ "name": "self_serve",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "fulfiller_user_id": {
+ "name": "fulfiller_user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "companion_request_id": {
+ "name": "companion_request_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "target_book_id": {
+ "name": "target_book_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "dedupe_key": {
+ "name": "dedupe_key",
+ "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_requests_user_status_idx": {
+ "name": "book_requests_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": {}
+ },
+ "book_requests_status_created_at_idx": {
+ "name": "book_requests_status_created_at_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "book_requests_target_library_id_idx": {
+ "name": "book_requests_target_library_id_idx",
+ "columns": [
+ {
+ "expression": "target_library_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "book_requests_created_at_idx": {
+ "name": "book_requests_created_at_idx",
+ "columns": [
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "book_requests_title_idx": {
+ "name": "book_requests_title_idx",
+ "columns": [
+ {
+ "expression": "title",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "book_requests_matched_book_user_idx": {
+ "name": "book_requests_matched_book_user_idx",
+ "columns": [
+ {
+ "expression": "matched_book_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "\"book_requests\".\"matched_book_id\" is not null",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "book_requests_companion_request_id_idx": {
+ "name": "book_requests_companion_request_id_idx",
+ "columns": [
+ {
+ "expression": "companion_request_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "\"book_requests\".\"companion_request_id\" is not null",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "book_requests_target_book_id_idx": {
+ "name": "book_requests_target_book_id_idx",
+ "columns": [
+ {
+ "expression": "target_book_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "\"book_requests\".\"target_book_id\" is not null",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "book_requests_self_serve_idx": {
+ "name": "book_requests_self_serve_idx",
+ "columns": [
+ {
+ "expression": "self_serve",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "\"book_requests\".\"self_serve\"",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "book_requests_active_dedupe_key_uidx": {
+ "name": "book_requests_active_dedupe_key_uidx",
+ "columns": [
+ {
+ "expression": "dedupe_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"book_requests\".\"status\" in ('pending', 'approved', 'searching', 'grabbed', 'downloading', 'importing', 'needs_review')",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "book_requests_user_id_users_id_fk": {
+ "name": "book_requests_user_id_users_id_fk",
+ "tableFrom": "book_requests",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "book_requests_target_library_id_libraries_id_fk": {
+ "name": "book_requests_target_library_id_libraries_id_fk",
+ "tableFrom": "book_requests",
+ "tableTo": "libraries",
+ "columnsFrom": ["target_library_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "book_requests_target_folder_id_library_folders_id_fk": {
+ "name": "book_requests_target_folder_id_library_folders_id_fk",
+ "tableFrom": "book_requests",
+ "tableTo": "library_folders",
+ "columnsFrom": ["target_folder_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "book_requests_created_by_user_id_users_id_fk": {
+ "name": "book_requests_created_by_user_id_users_id_fk",
+ "tableFrom": "book_requests",
+ "tableTo": "users",
+ "columnsFrom": ["created_by_user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "book_requests_decided_by_user_id_users_id_fk": {
+ "name": "book_requests_decided_by_user_id_users_id_fk",
+ "tableFrom": "book_requests",
+ "tableTo": "users",
+ "columnsFrom": ["decided_by_user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "book_requests_matched_book_id_books_id_fk": {
+ "name": "book_requests_matched_book_id_books_id_fk",
+ "tableFrom": "book_requests",
+ "tableTo": "books",
+ "columnsFrom": ["matched_book_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "book_requests_book_dock_file_id_book_dock_files_id_fk": {
+ "name": "book_requests_book_dock_file_id_book_dock_files_id_fk",
+ "tableFrom": "book_requests",
+ "tableTo": "book_dock_files",
+ "columnsFrom": ["book_dock_file_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "book_requests_fulfiller_user_id_users_id_fk": {
+ "name": "book_requests_fulfiller_user_id_users_id_fk",
+ "tableFrom": "book_requests",
+ "tableTo": "users",
+ "columnsFrom": ["fulfiller_user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "book_requests_companion_request_id_book_requests_id_fk": {
+ "name": "book_requests_companion_request_id_book_requests_id_fk",
+ "tableFrom": "book_requests",
+ "tableTo": "book_requests",
+ "columnsFrom": ["companion_request_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "book_requests_target_book_id_books_id_fk": {
+ "name": "book_requests_target_book_id_books_id_fk",
+ "tableFrom": "book_requests",
+ "tableTo": "books",
+ "columnsFrom": ["target_book_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "book_requests_status_chk": {
+ "name": "book_requests_status_chk",
+ "value": "\"book_requests\".\"status\" in ('pending', 'approved', 'rejected', 'cancelled', 'searching', 'grabbed', 'downloading', 'importing', 'needs_review', 'available', 'failed')"
+ },
+ "book_requests_series_index_nonnegative_chk": {
+ "name": "book_requests_series_index_nonnegative_chk",
+ "value": "\"book_requests\".\"series_index\" is null or \"book_requests\".\"series_index\" >= 0"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.book_request_downloads": {
+ "name": "book_request_downloads",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "request_id": {
+ "name": "request_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "download_client_id": {
+ "name": "download_client_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "indexer_id": {
+ "name": "indexer_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "source": {
+ "name": "source",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "automated": {
+ "name": "automated",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "release_title": {
+ "name": "release_title",
+ "type": "varchar(500)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "release_guid": {
+ "name": "release_guid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "release_size_bytes": {
+ "name": "release_size_bytes",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "release_seeders": {
+ "name": "release_seeders",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "release_format": {
+ "name": "release_format",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "freeleech": {
+ "name": "freeleech",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "client_key": {
+ "name": "client_key",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'queued'"
+ },
+ "progress_percent": {
+ "name": "progress_percent",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "downloaded_bytes": {
+ "name": "downloaded_bytes",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "total_bytes": {
+ "name": "total_bytes",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "direct_url": {
+ "name": "direct_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "direct_file_name": {
+ "name": "direct_file_name",
+ "type": "varchar(500)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "direct_etag": {
+ "name": "direct_etag",
+ "type": "varchar(1024)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "direct_last_modified": {
+ "name": "direct_last_modified",
+ "type": "varchar(200)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "content_path": {
+ "name": "content_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "local_path": {
+ "name": "local_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "book_dock_file_id": {
+ "name": "book_dock_file_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "release_units": {
+ "name": "release_units",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "error_message": {
+ "name": "error_message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "grabbed_at": {
+ "name": "grabbed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_progress_at": {
+ "name": "last_progress_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "completed_at": {
+ "name": "completed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "imported_at": {
+ "name": "imported_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": {
+ "book_request_downloads_status_idx": {
+ "name": "book_request_downloads_status_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "book_request_downloads_request_latest_idx": {
+ "name": "book_request_downloads_request_latest_idx",
+ "columns": [
+ {
+ "expression": "request_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "id",
+ "isExpression": false,
+ "asc": false,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "book_request_downloads_book_dock_file_id_idx": {
+ "name": "book_request_downloads_book_dock_file_id_idx",
+ "columns": [
+ {
+ "expression": "book_dock_file_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "book_request_downloads_active_hash_uidx": {
+ "name": "book_request_downloads_active_hash_uidx",
+ "columns": [
+ {
+ "expression": "coalesce(\"download_client_id\", 0)",
+ "asc": true,
+ "isExpression": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "client_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"book_request_downloads\".\"status\" in ('queued', 'downloading', 'completed', 'importing', 'needs_review')",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "book_request_downloads_request_id_book_requests_id_fk": {
+ "name": "book_request_downloads_request_id_book_requests_id_fk",
+ "tableFrom": "book_request_downloads",
+ "tableTo": "book_requests",
+ "columnsFrom": ["request_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "book_request_downloads_download_client_id_download_clients_id_fk": {
+ "name": "book_request_downloads_download_client_id_download_clients_id_fk",
+ "tableFrom": "book_request_downloads",
+ "tableTo": "download_clients",
+ "columnsFrom": ["download_client_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "book_request_downloads_indexer_id_request_indexers_id_fk": {
+ "name": "book_request_downloads_indexer_id_request_indexers_id_fk",
+ "tableFrom": "book_request_downloads",
+ "tableTo": "request_indexers",
+ "columnsFrom": ["indexer_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "book_request_downloads_book_dock_file_id_book_dock_files_id_fk": {
+ "name": "book_request_downloads_book_dock_file_id_book_dock_files_id_fk",
+ "tableFrom": "book_request_downloads",
+ "tableTo": "book_dock_files",
+ "columnsFrom": ["book_dock_file_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "book_request_downloads_source_chk": {
+ "name": "book_request_downloads_source_chk",
+ "value": "\"book_request_downloads\".\"source\" in ('magnet', 'torrent_file', 'direct_url', 'nzb_file')"
+ },
+ "book_request_downloads_status_chk": {
+ "name": "book_request_downloads_status_chk",
+ "value": "\"book_request_downloads\".\"status\" in ('queued', 'downloading', 'completed', 'importing', 'needs_review', 'imported', 'failed')"
+ },
+ "book_request_downloads_progress_range_chk": {
+ "name": "book_request_downloads_progress_range_chk",
+ "value": "\"book_request_downloads\".\"progress_percent\" >= 0 and \"book_request_downloads\".\"progress_percent\" <= 100"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.download_client_path_mappings": {
+ "name": "download_client_path_mappings",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "download_client_id": {
+ "name": "download_client_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "remote_path": {
+ "name": "remote_path",
+ "type": "varchar(4096)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "local_path": {
+ "name": "local_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": {
+ "download_client_path_mappings_client_remote_uidx": {
+ "name": "download_client_path_mappings_client_remote_uidx",
+ "columns": [
+ {
+ "expression": "download_client_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "remote_path",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "download_client_path_mappings_download_client_id_download_clients_id_fk": {
+ "name": "download_client_path_mappings_download_client_id_download_clients_id_fk",
+ "tableFrom": "download_client_path_mappings",
+ "tableTo": "download_clients",
+ "columnsFrom": ["download_client_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.download_clients": {
+ "name": "download_clients",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "color": {
+ "name": "color",
+ "type": "varchar(16)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "adapter_type": {
+ "name": "adapter_type",
+ "type": "varchar(30)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "priority": {
+ "name": "priority",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "base_url": {
+ "name": "base_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "username": {
+ "name": "username",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "credentials_enc": {
+ "name": "credentials_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "category": {
+ "name": "category",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'bookorbit'"
+ },
+ "use_hardlinks": {
+ "name": "use_hardlinks",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "allow_private_address": {
+ "name": "allow_private_address",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "settings": {
+ "name": "settings",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_tested_at": {
+ "name": "last_tested_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_test_ok": {
+ "name": "last_test_ok",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_error_message": {
+ "name": "last_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": {
+ "download_clients_name_lower_uidx": {
+ "name": "download_clients_name_lower_uidx",
+ "columns": [
+ {
+ "expression": "lower(\"name\")",
+ "asc": true,
+ "isExpression": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "download_clients_enabled_priority_idx": {
+ "name": "download_clients_enabled_priority_idx",
+ "columns": [
+ {
+ "expression": "enabled",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "priority",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "download_clients_adapter_type_chk": {
+ "name": "download_clients_adapter_type_chk",
+ "value": "\"download_clients\".\"adapter_type\" in ('qbittorrent', 'transmission', 'deluge', 'nzbget', 'sabnzbd')"
+ },
+ "download_clients_priority_range_chk": {
+ "name": "download_clients_priority_range_chk",
+ "value": "\"download_clients\".\"priority\" >= 1 and \"download_clients\".\"priority\" <= 100"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.request_indexers": {
+ "name": "request_indexers",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "color": {
+ "name": "color",
+ "type": "varchar(16)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "adapter_type": {
+ "name": "adapter_type",
+ "type": "varchar(30)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "manager_id": {
+ "name": "manager_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "manager_external_id": {
+ "name": "manager_external_id",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "manager_available": {
+ "name": "manager_available",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "manager_last_seen_at": {
+ "name": "manager_last_seen_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "manager_metadata": {
+ "name": "manager_metadata",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "base_url": {
+ "name": "base_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "credentials_enc": {
+ "name": "credentials_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "allow_private_address": {
+ "name": "allow_private_address",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "apply_tracker_seed_goals": {
+ "name": "apply_tracker_seed_goals",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "seed_ratio_goal": {
+ "name": "seed_ratio_goal",
+ "type": "double precision",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "seed_time_minutes": {
+ "name": "seed_time_minutes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "categories": {
+ "name": "categories",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "disabled_media_kinds": {
+ "name": "disabled_media_kinds",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "isbn_search_disabled": {
+ "name": "isbn_search_disabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "settings": {
+ "name": "settings",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "network_profile": {
+ "name": "network_profile",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_tested_at": {
+ "name": "last_tested_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_test_ok": {
+ "name": "last_test_ok",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_error_message": {
+ "name": "last_error_message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_search_at": {
+ "name": "last_search_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_search_ok": {
+ "name": "last_search_ok",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_search_error": {
+ "name": "last_search_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "search_failure_streak": {
+ "name": "search_failure_streak",
+ "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": {
+ "request_indexers_name_lower_uidx": {
+ "name": "request_indexers_name_lower_uidx",
+ "columns": [
+ {
+ "expression": "lower(\"name\")",
+ "asc": true,
+ "isExpression": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "request_indexers_manager_external_uidx": {
+ "name": "request_indexers_manager_external_uidx",
+ "columns": [
+ {
+ "expression": "manager_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "manager_external_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "request_indexers_enabled_idx": {
+ "name": "request_indexers_enabled_idx",
+ "columns": [
+ {
+ "expression": "enabled",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "request_indexers_manager_idx": {
+ "name": "request_indexers_manager_idx",
+ "columns": [
+ {
+ "expression": "manager_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "request_indexers_manager_id_request_indexer_managers_id_fk": {
+ "name": "request_indexers_manager_id_request_indexer_managers_id_fk",
+ "tableFrom": "request_indexers",
+ "tableTo": "request_indexer_managers",
+ "columnsFrom": ["manager_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "request_indexers_adapter_type_chk": {
+ "name": "request_indexers_adapter_type_chk",
+ "value": "\"request_indexers\".\"adapter_type\" ~ '^[a-z0-9][a-z0-9-]{0,29}$'"
+ },
+ "request_indexers_manager_fields_chk": {
+ "name": "request_indexers_manager_fields_chk",
+ "value": "(\"request_indexers\".\"manager_id\" is null and \"request_indexers\".\"manager_external_id\" is null and \"request_indexers\".\"manager_metadata\" is null) or (\"request_indexers\".\"manager_id\" is not null and \"request_indexers\".\"manager_external_id\" is not null and \"request_indexers\".\"manager_metadata\" is not null and \"request_indexers\".\"credentials_enc\" is null and \"request_indexers\".\"adapter_type\" in ('torznab', 'newznab'))"
+ },
+ "request_indexers_seed_ratio_goal_chk": {
+ "name": "request_indexers_seed_ratio_goal_chk",
+ "value": "\"request_indexers\".\"seed_ratio_goal\" is null or (\"request_indexers\".\"seed_ratio_goal\" > 0 and \"request_indexers\".\"seed_ratio_goal\" < 'Infinity'::double precision)"
+ },
+ "request_indexers_seed_time_minutes_chk": {
+ "name": "request_indexers_seed_time_minutes_chk",
+ "value": "\"request_indexers\".\"seed_time_minutes\" is null or \"request_indexers\".\"seed_time_minutes\" > 0"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.request_indexer_managers": {
+ "name": "request_indexer_managers",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "color": {
+ "name": "color",
+ "type": "varchar(16)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "type": {
+ "name": "type",
+ "type": "varchar(30)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "base_url": {
+ "name": "base_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "credentials_enc": {
+ "name": "credentials_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "allow_private_address": {
+ "name": "allow_private_address",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "sync_new_indexers": {
+ "name": "sync_new_indexers",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "per_indexer_timeout_seconds": {
+ "name": "per_indexer_timeout_seconds",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 20
+ },
+ "overall_search_budget_seconds": {
+ "name": "overall_search_budget_seconds",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 60
+ },
+ "auto_expand_categories": {
+ "name": "auto_expand_categories",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "inherit_seed_limits": {
+ "name": "inherit_seed_limits",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "network_profile": {
+ "name": "network_profile",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "version": {
+ "name": "version",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_tested_at": {
+ "name": "last_tested_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_test_ok": {
+ "name": "last_test_ok",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_error_message": {
+ "name": "last_error_message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_synced_at": {
+ "name": "last_synced_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_sync_ok": {
+ "name": "last_sync_ok",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_sync_error": {
+ "name": "last_sync_error",
+ "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": {
+ "request_indexer_managers_name_lower_uidx": {
+ "name": "request_indexer_managers_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": {
+ "request_indexer_managers_type_chk": {
+ "name": "request_indexer_managers_type_chk",
+ "value": "\"request_indexer_managers\".\"type\" in ('prowlarr')"
+ },
+ "request_indexer_managers_timeout_chk": {
+ "name": "request_indexer_managers_timeout_chk",
+ "value": "\"request_indexer_managers\".\"per_indexer_timeout_seconds\" between 5 and 180"
+ },
+ "request_indexer_managers_budget_chk": {
+ "name": "request_indexer_managers_budget_chk",
+ "value": "\"request_indexer_managers\".\"overall_search_budget_seconds\" between 5 and 300"
+ }
+ },
+ "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
+ },
+ "group_key": {
+ "name": "group_key",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "count": {
+ "name": "count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "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": {}
+ },
+ "notifications_user_updated_at_idx": {
+ "name": "notifications_user_updated_at_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "updated_at",
+ "isExpression": false,
+ "asc": false,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "notifications_updated_at_idx": {
+ "name": "notifications_updated_at_idx",
+ "columns": [
+ {
+ "expression": "updated_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "notifications_user_unread_group_key_idx": {
+ "name": "notifications_user_unread_group_key_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "group_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"notifications\".\"read\" = false and \"notifications\".\"group_key\" is not null",
+ "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.server_fonts": {
+ "name": "server_fonts",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "uploaded_by": {
+ "name": "uploaded_by",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "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'"
+ },
+ "weight_min": {
+ "name": "weight_min",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "weight_max": {
+ "name": "weight_max",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "instances": {
+ "name": "instances",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "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": {
+ "sf_hash_uidx": {
+ "name": "sf_hash_uidx",
+ "columns": [
+ {
+ "expression": "file_hash",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "sf_family_weight_style_uidx": {
+ "name": "sf_family_weight_style_uidx",
+ "columns": [
+ {
+ "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": {
+ "server_fonts_uploaded_by_users_id_fk": {
+ "name": "server_fonts_uploaded_by_users_id_fk",
+ "tableFrom": "server_fonts",
+ "tableTo": "users",
+ "columnsFrom": ["uploaded_by"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "server_fonts_format_chk": {
+ "name": "server_fonts_format_chk",
+ "value": "\"server_fonts\".\"format\" in ('ttf', 'otf', 'woff', 'woff2')"
+ },
+ "server_fonts_weight_chk": {
+ "name": "server_fonts_weight_chk",
+ "value": "\"server_fonts\".\"weight\" >= 1 and \"server_fonts\".\"weight\" <= 1000"
+ },
+ "server_fonts_style_chk": {
+ "name": "server_fonts_style_chk",
+ "value": "\"server_fonts\".\"style\" in ('normal', 'italic')"
+ },
+ "server_fonts_weight_range_chk": {
+ "name": "server_fonts_weight_range_chk",
+ "value": "(\"server_fonts\".\"weight_min\" is null) = (\"server_fonts\".\"weight_max\" is null) and (\"server_fonts\".\"weight_min\" is null or (\"server_fonts\".\"weight_min\" >= 1 and \"server_fonts\".\"weight_max\" <= 1000 and \"server_fonts\".\"weight_min\" < \"server_fonts\".\"weight_max\"))"
+ }
+ },
+ "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'"
+ },
+ "weight_min": {
+ "name": "weight_min",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "weight_max": {
+ "name": "weight_max",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "instances": {
+ "name": "instances",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "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\" >= 1 and \"user_fonts\".\"weight\" <= 1000"
+ },
+ "user_fonts_style_chk": {
+ "name": "user_fonts_style_chk",
+ "value": "\"user_fonts\".\"style\" in ('normal', 'italic')"
+ },
+ "user_fonts_weight_range_chk": {
+ "name": "user_fonts_weight_range_chk",
+ "value": "(\"user_fonts\".\"weight_min\" is null) = (\"user_fonts\".\"weight_max\" is null) and (\"user_fonts\".\"weight_min\" is null or (\"user_fonts\".\"weight_min\" >= 1 and \"user_fonts\".\"weight_max\" <= 1000 and \"user_fonts\".\"weight_min\" < \"user_fonts\".\"weight_max\"))"
+ }
+ },
+ "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', 'kobo_download')"
+ }
+ },
+ "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_retirements": {
+ "name": "koreader_device_retirements",
+ "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
+ },
+ "retired_at": {
+ "name": "retired_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "koreader_device_retirements_user_id_idx": {
+ "name": "koreader_device_retirements_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "koreader_device_retirements_user_id_users_id_fk": {
+ "name": "koreader_device_retirements_user_id_users_id_fk",
+ "tableFrom": "koreader_device_retirements",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "koreader_device_retirements_user_id_device_id_pk": {
+ "name": "koreader_device_retirements_user_id_device_id_pk",
+ "columns": ["user_id", "device_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "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_progress_reset_devices": {
+ "name": "koreader_progress_reset_devices",
+ "schema": "",
+ "columns": {
+ "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
+ },
+ "converged_at": {
+ "name": "converged_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "koreader_progress_reset_devices_reset_fk": {
+ "name": "koreader_progress_reset_devices_reset_fk",
+ "tableFrom": "koreader_progress_reset_devices",
+ "tableTo": "koreader_progress_resets",
+ "columnsFrom": ["user_id", "book_file_id"],
+ "columnsTo": ["user_id", "book_file_id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "koreader_progress_reset_devices_user_id_book_file_id_device_id_pk": {
+ "name": "koreader_progress_reset_devices_user_id_book_file_id_device_id_pk",
+ "columns": ["user_id", "book_file_id", "device_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.koreader_progress_resets": {
+ "name": "koreader_progress_resets",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "book_file_id": {
+ "name": "book_file_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "reset_at": {
+ "name": "reset_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "koreader_progress_resets_book_file_id_idx": {
+ "name": "koreader_progress_resets_book_file_id_idx",
+ "columns": [
+ {
+ "expression": "book_file_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "koreader_progress_resets_user_id_users_id_fk": {
+ "name": "koreader_progress_resets_user_id_users_id_fk",
+ "tableFrom": "koreader_progress_resets",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "koreader_progress_resets_book_file_id_book_files_id_fk": {
+ "name": "koreader_progress_resets_book_file_id_book_files_id_fk",
+ "tableFrom": "koreader_progress_resets",
+ "tableTo": "book_files",
+ "columnsFrom": ["book_file_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "koreader_progress_resets_user_id_book_file_id_pk": {
+ "name": "koreader_progress_resets_user_id_book_file_id_pk",
+ "columns": ["user_id", "book_file_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "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
+ },
+ "manual_link_requested": {
+ "name": "manual_link_requested",
+ "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
+ },
+ "celebration_claim_id": {
+ "name": "celebration_claim_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "celebration_claimed_at": {
+ "name": "celebration_claimed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "celebrated_at": {
+ "name": "celebrated_at",
+ "type": "timestamp with time zone",
+ "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": {}
+ },
+ "user_achievements_pending_celebration_idx": {
+ "name": "user_achievements_pending_celebration_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "celebrated_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "awarded_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "user_achievements_celebration_claim_uidx": {
+ "name": "user_achievements_celebration_claim_uidx",
+ "columns": [
+ {
+ "expression": "celebration_claim_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "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
+ },
+ "public.tts_book_preferences": {
+ "name": "tts_book_preferences",
+ "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
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "voice_id": {
+ "name": "voice_id",
+ "type": "varchar(200)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "speed": {
+ "name": "speed",
+ "type": "real",
+ "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": {
+ "tts_book_prefs_user_book_uidx": {
+ "name": "tts_book_prefs_user_book_uidx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "book_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "tts_book_preferences_user_id_users_id_fk": {
+ "name": "tts_book_preferences_user_id_users_id_fk",
+ "tableFrom": "tts_book_preferences",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "tts_book_preferences_book_id_books_id_fk": {
+ "name": "tts_book_preferences_book_id_books_id_fk",
+ "tableFrom": "tts_book_preferences",
+ "tableTo": "books",
+ "columnsFrom": ["book_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "tts_book_preferences_provider_id_tts_providers_id_fk": {
+ "name": "tts_book_preferences_provider_id_tts_providers_id_fk",
+ "tableFrom": "tts_book_preferences",
+ "tableTo": "tts_providers",
+ "columnsFrom": ["provider_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.tts_providers": {
+ "name": "tts_providers",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "varchar(30)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "base_url": {
+ "name": "base_url",
+ "type": "varchar(500)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "api_key": {
+ "name": "api_key",
+ "type": "varchar(500)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "default_model": {
+ "name": "default_model",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "static_voices": {
+ "name": "static_voices",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "supports_voice_discovery": {
+ "name": "supports_voice_discovery",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 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": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.tts_reading_position": {
+ "name": "tts_reading_position",
+ "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
+ },
+ "cfi": {
+ "name": "cfi",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "chapter_index": {
+ "name": "chapter_index",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "tts_reading_pos_user_file_uidx": {
+ "name": "tts_reading_pos_user_file_uidx",
+ "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": {}
+ }
+ },
+ "foreignKeys": {
+ "tts_reading_position_user_id_users_id_fk": {
+ "name": "tts_reading_position_user_id_users_id_fk",
+ "tableFrom": "tts_reading_position",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "tts_reading_position_book_file_id_book_files_id_fk": {
+ "name": "tts_reading_position_book_file_id_book_files_id_fk",
+ "tableFrom": "tts_reading_position",
+ "tableTo": "book_files",
+ "columnsFrom": ["book_file_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.tts_user_preferences": {
+ "name": "tts_user_preferences",
+ "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
+ },
+ "voice_id": {
+ "name": "voice_id",
+ "type": "varchar(200)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "speed": {
+ "name": "speed",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "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": {
+ "tts_user_prefs_user_uidx": {
+ "name": "tts_user_prefs_user_uidx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "tts_user_preferences_user_id_users_id_fk": {
+ "name": "tts_user_preferences_user_id_users_id_fk",
+ "tableFrom": "tts_user_preferences",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "tts_user_preferences_provider_id_tts_providers_id_fk": {
+ "name": "tts_user_preferences_provider_id_tts_providers_id_fk",
+ "tableFrom": "tts_user_preferences",
+ "tableTo": "tts_providers",
+ "columnsFrom": ["provider_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.upload_sessions": {
+ "name": "upload_sessions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "idempotency_key": {
+ "name": "idempotency_key",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "target_kind": {
+ "name": "target_kind",
+ "type": "varchar(30)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "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
+ },
+ "target_book_id": {
+ "name": "target_book_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "filename": {
+ "name": "filename",
+ "type": "varchar(500)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "content_type": {
+ "name": "content_type",
+ "type": "varchar(200)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "size_bytes": {
+ "name": "size_bytes",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "received_bytes": {
+ "name": "received_bytes",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "staging_path": {
+ "name": "staging_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'receiving'"
+ },
+ "expected_sha256": {
+ "name": "expected_sha256",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "error_code": {
+ "name": "error_code",
+ "type": "varchar(80)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "error_message": {
+ "name": "error_message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "result_book_id": {
+ "name": "result_book_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "result_book_dock_file_id": {
+ "name": "result_book_dock_file_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "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()"
+ },
+ "updated_at": {
+ "name": "updated_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": {
+ "upload_sessions_user_idempotency_uidx": {
+ "name": "upload_sessions_user_idempotency_uidx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "idempotency_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "upload_sessions_user_created_idx": {
+ "name": "upload_sessions_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": {}
+ },
+ "upload_sessions_status_expires_idx": {
+ "name": "upload_sessions_status_expires_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "expires_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "upload_sessions_user_id_users_id_fk": {
+ "name": "upload_sessions_user_id_users_id_fk",
+ "tableFrom": "upload_sessions",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "upload_sessions_target_library_id_libraries_id_fk": {
+ "name": "upload_sessions_target_library_id_libraries_id_fk",
+ "tableFrom": "upload_sessions",
+ "tableTo": "libraries",
+ "columnsFrom": ["target_library_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "upload_sessions_target_folder_id_library_folders_id_fk": {
+ "name": "upload_sessions_target_folder_id_library_folders_id_fk",
+ "tableFrom": "upload_sessions",
+ "tableTo": "library_folders",
+ "columnsFrom": ["target_folder_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "upload_sessions_target_book_id_books_id_fk": {
+ "name": "upload_sessions_target_book_id_books_id_fk",
+ "tableFrom": "upload_sessions",
+ "tableTo": "books",
+ "columnsFrom": ["target_book_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "upload_sessions_result_book_id_books_id_fk": {
+ "name": "upload_sessions_result_book_id_books_id_fk",
+ "tableFrom": "upload_sessions",
+ "tableTo": "books",
+ "columnsFrom": ["result_book_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "upload_sessions_staging_path_unique": {
+ "name": "upload_sessions_staging_path_unique",
+ "nullsNotDistinct": false,
+ "columns": ["staging_path"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {
+ "upload_sessions_target_kind_chk": {
+ "name": "upload_sessions_target_kind_chk",
+ "value": "\"upload_sessions\".\"target_kind\" in ('library', 'existing_book', 'book_dock')"
+ },
+ "upload_sessions_status_chk": {
+ "name": "upload_sessions_status_chk",
+ "value": "\"upload_sessions\".\"status\" in ('receiving', 'processing', 'completed', 'failed', 'cancelled', 'expired')"
+ },
+ "upload_sessions_size_positive_chk": {
+ "name": "upload_sessions_size_positive_chk",
+ "value": "\"upload_sessions\".\"size_bytes\" > 0"
+ },
+ "upload_sessions_received_range_chk": {
+ "name": "upload_sessions_received_range_chk",
+ "value": "\"upload_sessions\".\"received_bytes\" >= 0 and \"upload_sessions\".\"received_bytes\" <= \"upload_sessions\".\"size_bytes\""
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.podcast_bookmarks": {
+ "name": "podcast_bookmarks",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "episode_id": {
+ "name": "episode_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "position_seconds": {
+ "name": "position_seconds",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "varchar(500)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "note": {
+ "name": "note",
+ "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": {
+ "podcast_bookmarks_user_episode_idx": {
+ "name": "podcast_bookmarks_user_episode_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "episode_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "podcast_bookmarks_user_episode_position_idx": {
+ "name": "podcast_bookmarks_user_episode_position_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "episode_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "position_seconds",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "podcast_bookmarks_user_id_users_id_fk": {
+ "name": "podcast_bookmarks_user_id_users_id_fk",
+ "tableFrom": "podcast_bookmarks",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "podcast_bookmarks_episode_id_podcast_episodes_id_fk": {
+ "name": "podcast_bookmarks_episode_id_podcast_episodes_id_fk",
+ "tableFrom": "podcast_bookmarks",
+ "tableTo": "podcast_episodes",
+ "columnsFrom": ["episode_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "podcast_bookmarks_position_nonnegative_chk": {
+ "name": "podcast_bookmarks_position_nonnegative_chk",
+ "value": "\"podcast_bookmarks\".\"position_seconds\" >= 0"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.podcast_episode_media": {
+ "name": "podcast_episode_media",
+ "schema": "",
+ "columns": {
+ "episode_id": {
+ "name": "episode_id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'remote'"
+ },
+ "local_path": {
+ "name": "local_path",
+ "type": "varchar(4096)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "file_name": {
+ "name": "file_name",
+ "type": "varchar(1000)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "format": {
+ "name": "format",
+ "type": "varchar(30)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "mime_type": {
+ "name": "mime_type",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "size_bytes": {
+ "name": "size_bytes",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "checksum": {
+ "name": "checksum",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_error": {
+ "name": "last_error",
+ "type": "varchar(2000)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "downloaded_at": {
+ "name": "downloaded_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_remote_validated_at": {
+ "name": "last_remote_validated_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": {
+ "podcast_episode_media_status_idx": {
+ "name": "podcast_episode_media_status_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "podcast_episode_media_downloaded_idx": {
+ "name": "podcast_episode_media_downloaded_idx",
+ "columns": [
+ {
+ "expression": "downloaded_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "podcast_episode_media_episode_id_podcast_episodes_id_fk": {
+ "name": "podcast_episode_media_episode_id_podcast_episodes_id_fk",
+ "tableFrom": "podcast_episode_media",
+ "tableTo": "podcast_episodes",
+ "columnsFrom": ["episode_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "podcast_episode_media_status_chk": {
+ "name": "podcast_episode_media_status_chk",
+ "value": "\"podcast_episode_media\".\"status\" in ('remote', 'queued', 'downloading', 'local', 'failed', 'unavailable')"
+ },
+ "podcast_episode_media_size_nonnegative_chk": {
+ "name": "podcast_episode_media_size_nonnegative_chk",
+ "value": "\"podcast_episode_media\".\"size_bytes\" is null or \"podcast_episode_media\".\"size_bytes\" >= 0"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.podcast_episodes": {
+ "name": "podcast_episodes",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "podcast_id": {
+ "name": "podcast_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "identity_hash": {
+ "name": "identity_hash",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "origin": {
+ "name": "origin",
+ "type": "varchar(10)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'feed'"
+ },
+ "guid": {
+ "name": "guid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "title": {
+ "name": "title",
+ "type": "varchar(2000)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "subtitle": {
+ "name": "subtitle",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "published_at": {
+ "name": "published_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "season": {
+ "name": "season",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "episode": {
+ "name": "episode",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "episode_type": {
+ "name": "episode_type",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "duration_seconds": {
+ "name": "duration_seconds",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "explicit": {
+ "name": "explicit",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "enclosure_url_encrypted": {
+ "name": "enclosure_url_encrypted",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "enclosure_url_hash": {
+ "name": "enclosure_url_hash",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "enclosure_type": {
+ "name": "enclosure_type",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "enclosure_size_bytes": {
+ "name": "enclosure_size_bytes",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "chapters": {
+ "name": "chapters",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "transcripts": {
+ "name": "transcripts",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "locked_fields": {
+ "name": "locked_fields",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "in_feed": {
+ "name": "in_feed",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "last_seen_at": {
+ "name": "last_seen_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "unavailable_at": {
+ "name": "unavailable_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": {
+ "podcast_episodes_podcast_identity_uidx": {
+ "name": "podcast_episodes_podcast_identity_uidx",
+ "columns": [
+ {
+ "expression": "podcast_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "identity_hash",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "podcast_episodes_podcast_id_idx": {
+ "name": "podcast_episodes_podcast_id_idx",
+ "columns": [
+ {
+ "expression": "podcast_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "podcast_episodes_podcast_created_idx": {
+ "name": "podcast_episodes_podcast_created_idx",
+ "columns": [
+ {
+ "expression": "podcast_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "podcast_episodes_podcast_published_id_idx": {
+ "name": "podcast_episodes_podcast_published_id_idx",
+ "columns": [
+ {
+ "expression": "podcast_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "\"published_at\" desc nulls last",
+ "asc": true,
+ "isExpression": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "\"id\" desc",
+ "asc": true,
+ "isExpression": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "podcast_episodes_title_trgm_idx": {
+ "name": "podcast_episodes_title_trgm_idx",
+ "columns": [
+ {
+ "expression": "title",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "gin_trgm_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "gin",
+ "with": {}
+ },
+ "podcast_episodes_duration_idx": {
+ "name": "podcast_episodes_duration_idx",
+ "columns": [
+ {
+ "expression": "duration_seconds",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "podcast_episodes_enclosure_hash_idx": {
+ "name": "podcast_episodes_enclosure_hash_idx",
+ "columns": [
+ {
+ "expression": "enclosure_url_hash",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "podcast_episodes_in_feed_idx": {
+ "name": "podcast_episodes_in_feed_idx",
+ "columns": [
+ {
+ "expression": "podcast_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "in_feed",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "podcast_episodes_podcast_id_podcasts_id_fk": {
+ "name": "podcast_episodes_podcast_id_podcasts_id_fk",
+ "tableFrom": "podcast_episodes",
+ "tableTo": "podcasts",
+ "columnsFrom": ["podcast_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "podcast_episodes_origin_chk": {
+ "name": "podcast_episodes_origin_chk",
+ "value": "\"podcast_episodes\".\"origin\" in ('feed', 'local')"
+ },
+ "podcast_episodes_feed_origin_requires_enclosure_chk": {
+ "name": "podcast_episodes_feed_origin_requires_enclosure_chk",
+ "value": "\"podcast_episodes\".\"origin\" = 'local' or \"podcast_episodes\".\"enclosure_url_hash\" is not null"
+ },
+ "podcast_episodes_duration_nonnegative_chk": {
+ "name": "podcast_episodes_duration_nonnegative_chk",
+ "value": "\"podcast_episodes\".\"duration_seconds\" is null or \"podcast_episodes\".\"duration_seconds\" >= 0"
+ },
+ "podcast_episodes_size_nonnegative_chk": {
+ "name": "podcast_episodes_size_nonnegative_chk",
+ "value": "\"podcast_episodes\".\"enclosure_size_bytes\" is null or \"podcast_episodes\".\"enclosure_size_bytes\" >= 0"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.podcast_feed_aliases": {
+ "name": "podcast_feed_aliases",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "podcast_id": {
+ "name": "podcast_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "url_hash": {
+ "name": "url_hash",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "url_encrypted": {
+ "name": "url_encrypted",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "podcast_feed_aliases_podcast_hash_uidx": {
+ "name": "podcast_feed_aliases_podcast_hash_uidx",
+ "columns": [
+ {
+ "expression": "podcast_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "url_hash",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "podcast_feed_aliases_hash_idx": {
+ "name": "podcast_feed_aliases_hash_idx",
+ "columns": [
+ {
+ "expression": "url_hash",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "podcast_feed_aliases_podcast_id_podcasts_id_fk": {
+ "name": "podcast_feed_aliases_podcast_id_podcasts_id_fk",
+ "tableFrom": "podcast_feed_aliases",
+ "tableTo": "podcasts",
+ "columnsFrom": ["podcast_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.podcast_jobs": {
+ "name": "podcast_jobs",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "varchar(30)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "dedupe_key": {
+ "name": "dedupe_key",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "library_id": {
+ "name": "library_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "podcast_id": {
+ "name": "podcast_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "episode_id": {
+ "name": "episode_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "requested_by_user_id": {
+ "name": "requested_by_user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "download_batch_id": {
+ "name": "download_batch_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'queued'"
+ },
+ "payload": {
+ "name": "payload",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "progress_current": {
+ "name": "progress_current",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "progress_total": {
+ "name": "progress_total",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "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()"
+ },
+ "lease_until": {
+ "name": "lease_until",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_error": {
+ "name": "last_error",
+ "type": "varchar(2000)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cancel_requested": {
+ "name": "cancel_requested",
+ "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": {
+ "podcast_jobs_active_dedupe_uidx": {
+ "name": "podcast_jobs_active_dedupe_uidx",
+ "columns": [
+ {
+ "expression": "dedupe_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"podcast_jobs\".\"status\" in ('queued', 'processing')",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "podcast_jobs_due_idx": {
+ "name": "podcast_jobs_due_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "next_attempt_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "podcast_jobs_library_status_idx": {
+ "name": "podcast_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": {}
+ },
+ "podcast_jobs_status_updated_idx": {
+ "name": "podcast_jobs_status_updated_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "updated_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "podcast_jobs_download_batch_idx": {
+ "name": "podcast_jobs_download_batch_idx",
+ "columns": [
+ {
+ "expression": "requested_by_user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "download_batch_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "\"podcast_jobs\".\"type\" = 'download' and \"podcast_jobs\".\"download_batch_id\" is not null",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "podcast_jobs_active_podcast_type_idx": {
+ "name": "podcast_jobs_active_podcast_type_idx",
+ "columns": [
+ {
+ "expression": "podcast_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "requested_by_user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "\"podcast_jobs\".\"status\" in ('queued', 'processing') and \"podcast_jobs\".\"podcast_id\" is not null",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "podcast_jobs_active_episode_type_idx": {
+ "name": "podcast_jobs_active_episode_type_idx",
+ "columns": [
+ {
+ "expression": "episode_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "\"podcast_jobs\".\"status\" in ('queued', 'processing') and \"podcast_jobs\".\"episode_id\" is not null",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "podcast_jobs_library_id_libraries_id_fk": {
+ "name": "podcast_jobs_library_id_libraries_id_fk",
+ "tableFrom": "podcast_jobs",
+ "tableTo": "libraries",
+ "columnsFrom": ["library_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "podcast_jobs_podcast_id_podcasts_id_fk": {
+ "name": "podcast_jobs_podcast_id_podcasts_id_fk",
+ "tableFrom": "podcast_jobs",
+ "tableTo": "podcasts",
+ "columnsFrom": ["podcast_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "podcast_jobs_episode_id_podcast_episodes_id_fk": {
+ "name": "podcast_jobs_episode_id_podcast_episodes_id_fk",
+ "tableFrom": "podcast_jobs",
+ "tableTo": "podcast_episodes",
+ "columnsFrom": ["episode_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "podcast_jobs_requested_by_user_id_users_id_fk": {
+ "name": "podcast_jobs_requested_by_user_id_users_id_fk",
+ "tableFrom": "podcast_jobs",
+ "tableTo": "users",
+ "columnsFrom": ["requested_by_user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "podcast_jobs_type_chk": {
+ "name": "podcast_jobs_type_chk",
+ "value": "\"podcast_jobs\".\"type\" in ('refresh', 'reparse', 'download', 'retention', 'purge', 'opml_import', 'import_scan', 'local_import', 'merge', 'digest', 'file_cleanup')"
+ },
+ "podcast_jobs_status_chk": {
+ "name": "podcast_jobs_status_chk",
+ "value": "\"podcast_jobs\".\"status\" in ('queued', 'processing', 'completed', 'failed', 'cancelled')"
+ },
+ "podcast_jobs_attempt_nonnegative_chk": {
+ "name": "podcast_jobs_attempt_nonnegative_chk",
+ "value": "\"podcast_jobs\".\"attempt_count\" >= 0"
+ },
+ "podcast_jobs_progress_nonnegative_chk": {
+ "name": "podcast_jobs_progress_nonnegative_chk",
+ "value": "\"podcast_jobs\".\"progress_current\" >= 0 and (\"podcast_jobs\".\"progress_total\" is null or \"podcast_jobs\".\"progress_total\" >= 0)"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.podcast_library_settings": {
+ "name": "podcast_library_settings",
+ "schema": "",
+ "columns": {
+ "library_id": {
+ "name": "library_id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "storage_quota_bytes": {
+ "name": "storage_quota_bytes",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "107374182400"
+ },
+ "minimum_free_space_bytes": {
+ "name": "minimum_free_space_bytes",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "5368709120"
+ },
+ "default_refresh_interval_minutes": {
+ "name": "default_refresh_interval_minutes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 60
+ },
+ "completion_remaining_seconds": {
+ "name": "completion_remaining_seconds",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 60
+ },
+ "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": {
+ "podcast_library_settings_library_id_libraries_id_fk": {
+ "name": "podcast_library_settings_library_id_libraries_id_fk",
+ "tableFrom": "podcast_library_settings",
+ "tableTo": "libraries",
+ "columnsFrom": ["library_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "podcast_library_storage_quota_nonnegative_chk": {
+ "name": "podcast_library_storage_quota_nonnegative_chk",
+ "value": "\"podcast_library_settings\".\"storage_quota_bytes\" >= 0"
+ },
+ "podcast_library_free_space_nonnegative_chk": {
+ "name": "podcast_library_free_space_nonnegative_chk",
+ "value": "\"podcast_library_settings\".\"minimum_free_space_bytes\" >= 0"
+ },
+ "podcast_library_refresh_positive_chk": {
+ "name": "podcast_library_refresh_positive_chk",
+ "value": "\"podcast_library_settings\".\"default_refresh_interval_minutes\" > 0"
+ },
+ "podcast_library_completion_nonnegative_chk": {
+ "name": "podcast_library_completion_nonnegative_chk",
+ "value": "\"podcast_library_settings\".\"completion_remaining_seconds\" >= 0"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.podcast_listening_sessions": {
+ "name": "podcast_listening_sessions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "episode_id": {
+ "name": "episode_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "session_id": {
+ "name": "session_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "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
+ },
+ "end_position_seconds": {
+ "name": "end_position_seconds",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "podcast_sessions_user_session_uidx": {
+ "name": "podcast_sessions_user_session_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": {}
+ },
+ "podcast_sessions_user_started_idx": {
+ "name": "podcast_sessions_user_started_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "\"started_at\" desc",
+ "asc": true,
+ "isExpression": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "podcast_sessions_episode_started_idx": {
+ "name": "podcast_sessions_episode_started_idx",
+ "columns": [
+ {
+ "expression": "episode_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "\"started_at\" desc",
+ "asc": true,
+ "isExpression": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "podcast_listening_sessions_user_id_users_id_fk": {
+ "name": "podcast_listening_sessions_user_id_users_id_fk",
+ "tableFrom": "podcast_listening_sessions",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "podcast_listening_sessions_episode_id_podcast_episodes_id_fk": {
+ "name": "podcast_listening_sessions_episode_id_podcast_episodes_id_fk",
+ "tableFrom": "podcast_listening_sessions",
+ "tableTo": "podcast_episodes",
+ "columnsFrom": ["episode_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "podcast_sessions_duration_nonnegative_chk": {
+ "name": "podcast_sessions_duration_nonnegative_chk",
+ "value": "\"podcast_listening_sessions\".\"duration_seconds\" >= 0"
+ },
+ "podcast_sessions_position_nonnegative_chk": {
+ "name": "podcast_sessions_position_nonnegative_chk",
+ "value": "\"podcast_listening_sessions\".\"end_position_seconds\" >= 0"
+ },
+ "podcast_sessions_time_order_chk": {
+ "name": "podcast_sessions_time_order_chk",
+ "value": "\"podcast_listening_sessions\".\"ended_at\" >= \"podcast_listening_sessions\".\"started_at\""
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.podcasts": {
+ "name": "podcasts",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "library_id": {
+ "name": "library_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "varchar(1000)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "author": {
+ "name": "author",
+ "type": "varchar(1000)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "origin": {
+ "name": "origin",
+ "type": "varchar(10)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'feed'"
+ },
+ "feed_url_encrypted": {
+ "name": "feed_url_encrypted",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "feed_url_hash": {
+ "name": "feed_url_hash",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "local_folder_path": {
+ "name": "local_folder_path",
+ "type": "varchar(4096)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "image_url_encrypted": {
+ "name": "image_url_encrypted",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "site_url": {
+ "name": "site_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "language": {
+ "name": "language",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "podcast_type": {
+ "name": "podcast_type",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "explicit": {
+ "name": "explicit",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "categories": {
+ "name": "categories",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "acquisition_policy": {
+ "name": "acquisition_policy",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'remote_only'"
+ },
+ "auto_download_limit": {
+ "name": "auto_download_limit",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "auto_download_window_days": {
+ "name": "auto_download_window_days",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "download_cleanup": {
+ "name": "download_cleanup",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'keep'"
+ },
+ "download_cleanup_delay_hours": {
+ "name": "download_cleanup_delay_hours",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 24
+ },
+ "refresh_interval_minutes": {
+ "name": "refresh_interval_minutes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 60
+ },
+ "next_refresh_at": {
+ "name": "next_refresh_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "last_refresh_at": {
+ "name": "last_refresh_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_refresh_success_at": {
+ "name": "last_refresh_success_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "consecutive_failures": {
+ "name": "consecutive_failures",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "last_error": {
+ "name": "last_error",
+ "type": "varchar(2000)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_http_status": {
+ "name": "last_http_status",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "etag": {
+ "name": "etag",
+ "type": "varchar(1000)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_modified": {
+ "name": "last_modified",
+ "type": "varchar(1000)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "locked_fields": {
+ "name": "locked_fields",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "custom_artwork_at": {
+ "name": "custom_artwork_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "feed_snapshot_at": {
+ "name": "feed_snapshot_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "archived_at": {
+ "name": "archived_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "missing_at": {
+ "name": "missing_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": {
+ "podcasts_library_feed_hash_uidx": {
+ "name": "podcasts_library_feed_hash_uidx",
+ "columns": [
+ {
+ "expression": "library_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "feed_url_hash",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"podcasts\".\"feed_url_hash\" is not null",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "podcasts_library_local_folder_uidx": {
+ "name": "podcasts_library_local_folder_uidx",
+ "columns": [
+ {
+ "expression": "library_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "local_folder_path",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"podcasts\".\"local_folder_path\" is not null",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "podcasts_library_archived_title_idx": {
+ "name": "podcasts_library_archived_title_idx",
+ "columns": [
+ {
+ "expression": "library_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "archived_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "title",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "podcasts_title_trgm_idx": {
+ "name": "podcasts_title_trgm_idx",
+ "columns": [
+ {
+ "expression": "title",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "gin_trgm_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "gin",
+ "with": {}
+ },
+ "podcasts_author_trgm_idx": {
+ "name": "podcasts_author_trgm_idx",
+ "columns": [
+ {
+ "expression": "author",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "gin_trgm_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "gin",
+ "with": {}
+ },
+ "podcasts_due_refresh_idx": {
+ "name": "podcasts_due_refresh_idx",
+ "columns": [
+ {
+ "expression": "next_refresh_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "\"podcasts\".\"archived_at\" is null and \"podcasts\".\"origin\" = 'feed'",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "podcasts_library_cleanup_idx": {
+ "name": "podcasts_library_cleanup_idx",
+ "columns": [
+ {
+ "expression": "library_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "\"podcasts\".\"download_cleanup\" <> 'keep'",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "podcasts_library_id_libraries_id_fk": {
+ "name": "podcasts_library_id_libraries_id_fk",
+ "tableFrom": "podcasts",
+ "tableTo": "libraries",
+ "columnsFrom": ["library_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "podcasts_origin_chk": {
+ "name": "podcasts_origin_chk",
+ "value": "\"podcasts\".\"origin\" in ('feed', 'local')"
+ },
+ "podcasts_feed_origin_requires_feed_chk": {
+ "name": "podcasts_feed_origin_requires_feed_chk",
+ "value": "\"podcasts\".\"origin\" = 'local' or \"podcasts\".\"feed_url_hash\" is not null"
+ },
+ "podcasts_acquisition_policy_chk": {
+ "name": "podcasts_acquisition_policy_chk",
+ "value": "\"podcasts\".\"acquisition_policy\" in ('remote_only', 'manual', 'newest', 'window')"
+ },
+ "podcasts_download_cleanup_chk": {
+ "name": "podcasts_download_cleanup_chk",
+ "value": "\"podcasts\".\"download_cleanup\" in ('keep', 'after_finished')"
+ },
+ "podcasts_download_cleanup_delay_chk": {
+ "name": "podcasts_download_cleanup_delay_chk",
+ "value": "\"podcasts\".\"download_cleanup_delay_hours\" >= 0"
+ },
+ "podcasts_refresh_interval_positive_chk": {
+ "name": "podcasts_refresh_interval_positive_chk",
+ "value": "\"podcasts\".\"refresh_interval_minutes\" > 0"
+ },
+ "podcasts_failures_nonnegative_chk": {
+ "name": "podcasts_failures_nonnegative_chk",
+ "value": "\"podcasts\".\"consecutive_failures\" >= 0"
+ },
+ "podcasts_auto_download_limit_chk": {
+ "name": "podcasts_auto_download_limit_chk",
+ "value": "\"podcasts\".\"auto_download_limit\" is null or \"podcasts\".\"auto_download_limit\" > 0"
+ },
+ "podcasts_auto_download_window_chk": {
+ "name": "podcasts_auto_download_window_chk",
+ "value": "\"podcasts\".\"auto_download_window_days\" is null or \"podcasts\".\"auto_download_window_days\" > 0"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.user_podcast_episode_state": {
+ "name": "user_podcast_episode_state",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "episode_id": {
+ "name": "episode_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "position_seconds": {
+ "name": "position_seconds",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "progress_percent": {
+ "name": "progress_percent",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "finished": {
+ "name": "finished",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "finished_at": {
+ "name": "finished_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "pinned": {
+ "name": "pinned",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "last_listened_at": {
+ "name": "last_listened_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": {
+ "user_podcast_episode_state_user_finished_idx": {
+ "name": "user_podcast_episode_state_user_finished_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "finished",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "\"updated_at\" desc",
+ "asc": true,
+ "isExpression": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "user_podcast_episode_state_episode_active_idx": {
+ "name": "user_podcast_episode_state_episode_active_idx",
+ "columns": [
+ {
+ "expression": "episode_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "finished",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "position_seconds",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "user_podcast_episode_state_user_id_users_id_fk": {
+ "name": "user_podcast_episode_state_user_id_users_id_fk",
+ "tableFrom": "user_podcast_episode_state",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "user_podcast_episode_state_episode_id_podcast_episodes_id_fk": {
+ "name": "user_podcast_episode_state_episode_id_podcast_episodes_id_fk",
+ "tableFrom": "user_podcast_episode_state",
+ "tableTo": "podcast_episodes",
+ "columnsFrom": ["episode_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "user_podcast_episode_state_user_id_episode_id_pk": {
+ "name": "user_podcast_episode_state_user_id_episode_id_pk",
+ "columns": ["user_id", "episode_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "user_podcast_episode_state_position_chk": {
+ "name": "user_podcast_episode_state_position_chk",
+ "value": "\"user_podcast_episode_state\".\"position_seconds\" >= 0"
+ },
+ "user_podcast_episode_state_progress_chk": {
+ "name": "user_podcast_episode_state_progress_chk",
+ "value": "\"user_podcast_episode_state\".\"progress_percent\" >= 0 and \"user_podcast_episode_state\".\"progress_percent\" <= 100"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.user_podcast_follows": {
+ "name": "user_podcast_follows",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "podcast_id": {
+ "name": "podcast_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "notification_mode": {
+ "name": "notification_mode",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'off'"
+ },
+ "last_notified_at": {
+ "name": "last_notified_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()"
+ }
+ },
+ "indexes": {
+ "user_podcast_follows_podcast_idx": {
+ "name": "user_podcast_follows_podcast_idx",
+ "columns": [
+ {
+ "expression": "podcast_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "user_podcast_follows_due_digest_idx": {
+ "name": "user_podcast_follows_due_digest_idx",
+ "columns": [
+ {
+ "expression": "notification_mode",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "last_notified_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "podcast_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "\"user_podcast_follows\".\"notification_mode\" in ('daily', 'weekly')",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "user_podcast_follows_user_id_users_id_fk": {
+ "name": "user_podcast_follows_user_id_users_id_fk",
+ "tableFrom": "user_podcast_follows",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "user_podcast_follows_podcast_id_podcasts_id_fk": {
+ "name": "user_podcast_follows_podcast_id_podcasts_id_fk",
+ "tableFrom": "user_podcast_follows",
+ "tableTo": "podcasts",
+ "columnsFrom": ["podcast_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "user_podcast_follows_user_id_podcast_id_pk": {
+ "name": "user_podcast_follows_user_id_podcast_id_pk",
+ "columns": ["user_id", "podcast_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "user_podcast_follows_notification_chk": {
+ "name": "user_podcast_follows_notification_chk",
+ "value": "\"user_podcast_follows\".\"notification_mode\" in ('off', 'immediate', 'daily', 'weekly')"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.user_podcast_queue": {
+ "name": "user_podcast_queue",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "episode_id": {
+ "name": "episode_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "position": {
+ "name": "position",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "user_podcast_queue_user_position_uidx": {
+ "name": "user_podcast_queue_user_position_uidx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "position",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "user_podcast_queue_user_id_users_id_fk": {
+ "name": "user_podcast_queue_user_id_users_id_fk",
+ "tableFrom": "user_podcast_queue",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "user_podcast_queue_episode_id_podcast_episodes_id_fk": {
+ "name": "user_podcast_queue_episode_id_podcast_episodes_id_fk",
+ "tableFrom": "user_podcast_queue",
+ "tableTo": "podcast_episodes",
+ "columnsFrom": ["episode_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "user_podcast_queue_user_id_episode_id_pk": {
+ "name": "user_podcast_queue_user_id_episode_id_pk",
+ "columns": ["user_id", "episode_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "user_podcast_queue_position_nonnegative_chk": {
+ "name": "user_podcast_queue_position_nonnegative_chk",
+ "value": "\"user_podcast_queue\".\"position\" >= 0"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.provider_metadata_cache": {
+ "name": "provider_metadata_cache",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "key_hash": {
+ "name": "key_hash",
+ "type": "char(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "namespace": {
+ "name": "namespace",
+ "type": "varchar(16)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider": {
+ "name": "provider",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "operation": {
+ "name": "operation",
+ "type": "varchar(16)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "varchar(16)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "schema_version": {
+ "name": "schema_version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_version": {
+ "name": "provider_version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "config_fingerprint": {
+ "name": "config_fingerprint",
+ "type": "char(16)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "max_candidates": {
+ "name": "max_candidates",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "covers_validated": {
+ "name": "covers_validated",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "payload": {
+ "name": "payload",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "payload_bytes": {
+ "name": "payload_bytes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "fetched_at": {
+ "name": "fetched_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "last_accessed_at": {
+ "name": "last_accessed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "upstream_ms": {
+ "name": "upstream_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ }
+ },
+ "indexes": {
+ "provider_metadata_cache_ns_provider_fetched_idx": {
+ "name": "provider_metadata_cache_ns_provider_fetched_idx",
+ "columns": [
+ {
+ "expression": "namespace",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "provider",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "fetched_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "provider_metadata_cache_last_accessed_idx": {
+ "name": "provider_metadata_cache_last_accessed_idx",
+ "columns": [
+ {
+ "expression": "last_accessed_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "provider_metadata_cache_key_hash_unique": {
+ "name": "provider_metadata_cache_key_hash_unique",
+ "nullsNotDistinct": false,
+ "columns": ["key_hash"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {
+ "provider_metadata_cache_namespace_chk": {
+ "name": "provider_metadata_cache_namespace_chk",
+ "value": "\"provider_metadata_cache\".\"namespace\" in ('book', 'author')"
+ },
+ "provider_metadata_cache_operation_chk": {
+ "name": "provider_metadata_cache_operation_chk",
+ "value": "\"provider_metadata_cache\".\"operation\" in ('search', 'lookup')"
+ },
+ "provider_metadata_cache_status_chk": {
+ "name": "provider_metadata_cache_status_chk",
+ "value": "\"provider_metadata_cache\".\"status\" in ('results', 'empty', 'found', 'not_found')"
+ },
+ "provider_metadata_cache_payload_bytes_nonnegative_chk": {
+ "name": "provider_metadata_cache_payload_bytes_nonnegative_chk",
+ "value": "\"provider_metadata_cache\".\"payload_bytes\" >= 0"
+ }
+ },
+ "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"]
+ },
+ "public.book_request_media_kind": {
+ "name": "book_request_media_kind",
+ "schema": "public",
+ "values": ["ebook", "audiobook", "comic"]
+ }
+ },
+ "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 6adac129c..4a0c055b0 100644
--- a/server/src/db/migrations/meta/_journal.json
+++ b/server/src/db/migrations/meta/_journal.json
@@ -750,6 +750,13 @@
"when": 1791429899868,
"tag": "0106_add_book_request_pairs",
"breakpoints": true
+ },
+ {
+ "idx": 107,
+ "version": "7",
+ "when": 1791658522588,
+ "tag": "0107_add_provider_metadata_cache",
+ "breakpoints": true
}
]
}
diff --git a/server/src/db/schema/index.ts b/server/src/db/schema/index.ts
index 9d41defaf..375bba715 100644
--- a/server/src/db/schema/index.ts
+++ b/server/src/db/schema/index.ts
@@ -43,3 +43,4 @@ export * from './user-preferences';
export * from './tts';
export * from './upload-sessions';
export * from './podcasts';
+export * from './metadata-cache';
diff --git a/server/src/db/schema/metadata-cache.ts b/server/src/db/schema/metadata-cache.ts
new file mode 100644
index 000000000..a1158dec0
--- /dev/null
+++ b/server/src/db/schema/metadata-cache.ts
@@ -0,0 +1,42 @@
+import { sql } from 'drizzle-orm';
+import { boolean, char, check, index, integer, pgTable, serial, text, timestamp, varchar } from 'drizzle-orm/pg-core';
+
+/**
+ * Answers from external metadata providers, shared by every user. Holds public catalog data only:
+ * no user ids and no raw search terms, which exist here only inside the key hash.
+ *
+ * `payload` is a JSON string rather than jsonb: scraped text can carry NUL characters and unpaired
+ * surrogates that jsonb rejects, and nothing ever queries inside the payload.
+ */
+export const providerMetadataCache = pgTable(
+ 'provider_metadata_cache',
+ {
+ id: serial('id').primaryKey(),
+ keyHash: char('key_hash', { length: 64 }).notNull().unique(),
+ namespace: varchar('namespace', { length: 16 }).notNull(),
+ provider: varchar('provider', { length: 64 }).notNull(),
+ operation: varchar('operation', { length: 16 }).notNull(),
+ status: varchar('status', { length: 16 }).notNull(),
+ schemaVersion: integer('schema_version').notNull(),
+ providerVersion: integer('provider_version').notNull(),
+ configFingerprint: char('config_fingerprint', { length: 16 }).notNull(),
+ maxCandidates: integer('max_candidates'),
+ coversValidated: boolean('covers_validated').notNull().default(false),
+ payload: text('payload').notNull(),
+ payloadBytes: integer('payload_bytes').notNull(),
+ fetchedAt: timestamp('fetched_at', { withTimezone: true }).notNull(),
+ lastAccessedAt: timestamp('last_accessed_at', { withTimezone: true }).notNull(),
+ upstreamMs: integer('upstream_ms').notNull().default(0),
+ },
+ (t) => [
+ index('provider_metadata_cache_ns_provider_fetched_idx').on(t.namespace, t.provider, t.fetchedAt),
+ index('provider_metadata_cache_last_accessed_idx').on(t.lastAccessedAt),
+ check('provider_metadata_cache_namespace_chk', sql`${t.namespace} in ('book', 'author')`),
+ check('provider_metadata_cache_operation_chk', sql`${t.operation} in ('search', 'lookup')`),
+ check('provider_metadata_cache_status_chk', sql`${t.status} in ('results', 'empty', 'found', 'not_found')`),
+ check('provider_metadata_cache_payload_bytes_nonnegative_chk', sql`${t.payloadBytes} >= 0`),
+ ],
+);
+
+export type ProviderMetadataCacheRow = typeof providerMetadataCache.$inferSelect;
+export type NewProviderMetadataCacheRow = typeof providerMetadataCache.$inferInsert;
diff --git a/server/src/db/schema/metadata.ts b/server/src/db/schema/metadata.ts
index 0d7feae78..c8f8c9b7b 100644
--- a/server/src/db/schema/metadata.ts
+++ b/server/src/db/schema/metadata.ts
@@ -222,6 +222,8 @@ export const authorEnrichmentQueue = pgTable(
lastSuccessAt: timestamp('last_success_at', { withTimezone: true }),
lastError: text('last_error'),
lastHttpStatus: integer('last_http_status'),
+ /** Set by a run that asked to bypass cached provider answers; survives requeues. */
+ ignoreCache: boolean('ignore_cache').notNull().default(false),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true })
.notNull()
@@ -322,6 +324,8 @@ export const bookMetadataFetchQueue = pgTable(
lastAttemptAt: timestamp('last_attempt_at', { withTimezone: true }),
lastError: text('last_error'),
lastHttpStatus: integer('last_http_status'),
+ /** Set by a run that asked to bypass cached provider answers; survives requeues. */
+ ignoreCache: boolean('ignore_cache').notNull().default(false),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true })
.notNull()
diff --git a/server/src/modules/authors/author-cache-directives.test.ts b/server/src/modules/authors/author-cache-directives.test.ts
new file mode 100644
index 000000000..5e322714f
--- /dev/null
+++ b/server/src/modules/authors/author-cache-directives.test.ts
@@ -0,0 +1,35 @@
+import {
+ backgroundEnrichmentCacheDirective,
+ bulkRefreshCacheDirective,
+ CACHE_ONLY_ITEM_YIELD_MS,
+ interactiveCacheDirective,
+ SINGLE_REFRESH_CACHE_DIRECTIVE,
+} from './author-cache-directives';
+
+describe('author cache directives', () => {
+ it('lets interactive requests choose between cached and live answers, always with fallback', () => {
+ expect(interactiveCacheDirective()).toEqual({ policy: 'default', allowFallback: true });
+ expect(interactiveCacheDirective('default')).toEqual({ policy: 'default', allowFallback: true });
+ expect(interactiveCacheDirective('refresh')).toEqual({ policy: 'refresh', allowFallback: true });
+ });
+
+ it('always refreshes a single author live with fallback', () => {
+ expect(SINGLE_REFRESH_CACHE_DIRECTIVE).toEqual({ policy: 'refresh', allowFallback: true });
+ });
+
+ it('uses the cache for bulk refresh unless told to ignore it, always with fallback', () => {
+ expect(bulkRefreshCacheDirective(false)).toEqual({ policy: 'default', allowFallback: true });
+ expect(bulkRefreshCacheDirective(true)).toEqual({ policy: 'refresh', allowFallback: true });
+ });
+
+ it('only falls back on the final background attempt', () => {
+ expect(backgroundEnrichmentCacheDirective(false, false)).toEqual({ policy: 'default', allowFallback: false });
+ expect(backgroundEnrichmentCacheDirective(false, true)).toEqual({ policy: 'default', allowFallback: true });
+ expect(backgroundEnrichmentCacheDirective(true, false)).toEqual({ policy: 'refresh', allowFallback: false });
+ expect(backgroundEnrichmentCacheDirective(true, true)).toEqual({ policy: 'refresh', allowFallback: true });
+ });
+
+ it('yields briefly between cache-only items', () => {
+ expect(CACHE_ONLY_ITEM_YIELD_MS).toBe(100);
+ });
+});
diff --git a/server/src/modules/authors/author-cache-directives.ts b/server/src/modules/authors/author-cache-directives.ts
new file mode 100644
index 000000000..a566706ee
--- /dev/null
+++ b/server/src/modules/authors/author-cache-directives.ts
@@ -0,0 +1,26 @@
+import type { MetadataCacheRequestPolicy } from '@bookorbit/types';
+
+import type { CacheDirective } from '../../common/provider-cache/provider-cache-spec';
+
+/** What remains of the pause between items when an item never left the server. */
+export const CACHE_ONLY_ITEM_YIELD_MS = 100;
+
+/** Interactive search, stream and lookup: cached answers unless the client asks to go live. */
+export function interactiveCacheDirective(policy?: MetadataCacheRequestPolicy): CacheDirective {
+ return { policy: policy ?? 'default', allowFallback: true };
+}
+
+/** An explicit single-author refresh is always live, falling back to a saved answer for a provider that fails. */
+export const SINGLE_REFRESH_CACHE_DIRECTIVE: CacheDirective = { policy: 'refresh', allowFallback: true };
+
+export function bulkRefreshCacheDirective(ignoreCache: boolean): CacheDirective {
+ return { policy: ignoreCache ? 'refresh' : 'default', allowFallback: true };
+}
+
+/**
+ * Background enrichment requeues transient failures with backoff, so a saved answer only stands in
+ * for a failing provider on the last attempt; earlier attempts fail and retry as they always have.
+ */
+export function backgroundEnrichmentCacheDirective(ignoreCache: boolean, finalAttempt: boolean): CacheDirective {
+ return { policy: ignoreCache ? 'refresh' : 'default', allowFallback: finalAttempt };
+}
diff --git a/server/src/modules/authors/author-enrichment-executor.service.test.ts b/server/src/modules/authors/author-enrichment-executor.service.test.ts
index 083be4e92..6cfd437fd 100644
--- a/server/src/modules/authors/author-enrichment-executor.service.test.ts
+++ b/server/src/modules/authors/author-enrichment-executor.service.test.ts
@@ -73,6 +73,8 @@ describe('AuthorEnrichmentExecutorService', () => {
[AuthorMetadataProviderKey.AUDNEXUS, AUDNEXUS],
]),
failures: [],
+ cacheServed: [],
+ outboundRequests: 4,
});
});
@@ -121,6 +123,8 @@ describe('AuthorEnrichmentExecutorService', () => {
[AuthorMetadataProviderKey.GOODREADS, GOODREADS],
]),
failures: [],
+ cacheServed: [],
+ outboundRequests: 4,
});
const preferences = prefs({
description: { providers: [AuthorMetadataProviderKey.AUDNEXUS, AuthorMetadataProviderKey.GOODREADS] },
@@ -205,7 +209,7 @@ describe('AuthorEnrichmentExecutorService', () => {
});
it('reports no_match when no provider returned a candidate', async () => {
- metadataFetch.collectByProvider.mockResolvedValue({ candidates: new Map(), failures: [] });
+ metadataFetch.collectByProvider.mockResolvedValue({ candidates: new Map(), failures: [], cacheServed: [], outboundRequests: 2 });
const result = await service.execute({ authorId: 5, preferences: prefs() });
@@ -216,6 +220,8 @@ describe('AuthorEnrichmentExecutorService', () => {
metadataFetch.collectByProvider.mockResolvedValue({
candidates: new Map(),
failures: [{ provider: AuthorMetadataProviderKey.GOODREADS, message: 'boom', httpStatus: 429, retryAfterMs: 1000, transient: true }],
+ cacheServed: [],
+ outboundRequests: 1,
});
const result = await service.execute({ authorId: 5, preferences: prefs() });
@@ -238,4 +244,87 @@ describe('AuthorEnrichmentExecutorService', () => {
expect(result).toMatchObject({ kind: 'done', imageUpdated: true });
expect(authorsRepo.updateAuthorById).toHaveBeenCalledWith(5, expect.objectContaining({ hasPhoto: true }));
});
+
+ describe('metadata cache', () => {
+ const CACHED = { provider: AuthorMetadataProviderKey.GOODREADS, source: 'cache', stale: false, cachedAt: '2026-09-01T00:00:00.000Z' };
+
+ it('stays live without fallback when no directive is given', async () => {
+ await service.execute({ authorId: 5, preferences: prefs() });
+
+ expect(metadataFetch.collectByProvider).toHaveBeenCalledWith(expect.objectContaining({ name: 'Jane Doe' }), expect.any(Array), {
+ policy: 'refresh',
+ allowFallback: false,
+ });
+ });
+
+ it('passes the caller directive to the metadata fetch', async () => {
+ await service.execute({ authorId: 5, preferences: prefs() }, { policy: 'default', allowFallback: true });
+
+ expect(metadataFetch.collectByProvider).toHaveBeenCalledWith(expect.anything(), expect.anything(), { policy: 'default', allowFallback: true });
+ });
+
+ it('reports provider requests plus portrait downloads and the cache provenance', async () => {
+ metadataFetch.collectByProvider.mockResolvedValue({
+ candidates: new Map([[AuthorMetadataProviderKey.GOODREADS, GOODREADS]]),
+ failures: [],
+ cacheServed: [CACHED],
+ outboundRequests: 0,
+ });
+
+ const result = await service.execute({ authorId: 5, preferences: prefs() });
+
+ expect(imageStorage.saveFromUrl).toHaveBeenCalledTimes(1);
+ expect(result).toMatchObject({ kind: 'done', outboundRequests: 1, cacheServed: [CACHED] });
+ });
+
+ it('reports zero outbound requests when the cache answered and no portrait was needed', async () => {
+ authorsRepo.findByIdForEnrichment.mockResolvedValue({ id: 5, name: 'Jane Doe', description: 'Existing bio', hasPhoto: true, bookCount: 3 });
+ metadataFetch.collectByProvider.mockResolvedValue({
+ candidates: new Map([[AuthorMetadataProviderKey.GOODREADS, GOODREADS]]),
+ failures: [],
+ cacheServed: [CACHED],
+ outboundRequests: 0,
+ });
+
+ const result = await service.execute({ authorId: 5, preferences: prefs() });
+
+ expect(imageStorage.saveFromUrl).not.toHaveBeenCalled();
+ expect(result).toMatchObject({ kind: 'done', outboundRequests: 0 });
+ });
+
+ it('counts every portrait download attempt, including ones that stored nothing or failed', async () => {
+ imageStorage.saveFromUrl.mockResolvedValueOnce(false).mockRejectedValueOnce(new AuthorImageStorageError('down', { transient: true }));
+
+ const result = await service.execute({
+ authorId: 5,
+ preferences: prefs({ photo: { providers: [AuthorMetadataProviderKey.AUDNEXUS, AuthorMetadataProviderKey.GOODREADS] } }),
+ });
+
+ expect(result).toMatchObject({ kind: 'failed', outboundRequests: 6 });
+ });
+
+ it('reports no outbound requests for authors skipped before any fetch', async () => {
+ authorsRepo.findByIdForEnrichment.mockResolvedValue(null);
+
+ await expect(service.execute({ authorId: 5, preferences: prefs() })).resolves.toMatchObject({ outboundRequests: 0, cacheServed: [] });
+ });
+
+ it('carries provenance on no-match and failure results', async () => {
+ metadataFetch.collectByProvider.mockResolvedValueOnce({ candidates: new Map(), failures: [], cacheServed: [CACHED], outboundRequests: 0 });
+ await expect(service.execute({ authorId: 5, preferences: prefs() })).resolves.toMatchObject({
+ kind: 'skipped',
+ reason: 'no_match',
+ outboundRequests: 0,
+ cacheServed: [CACHED],
+ });
+
+ metadataFetch.collectByProvider.mockResolvedValueOnce({
+ candidates: new Map(),
+ failures: [{ provider: AuthorMetadataProviderKey.GOODREADS, message: 'boom', httpStatus: 503, retryAfterMs: null, transient: true }],
+ cacheServed: [],
+ outboundRequests: 2,
+ });
+ await expect(service.execute({ authorId: 5, preferences: prefs() })).resolves.toMatchObject({ kind: 'failed', outboundRequests: 2 });
+ });
+ });
});
diff --git a/server/src/modules/authors/author-enrichment-executor.service.ts b/server/src/modules/authors/author-enrichment-executor.service.ts
index 43236e5ad..ddccc8d7e 100644
--- a/server/src/modules/authors/author-enrichment-executor.service.ts
+++ b/server/src/modules/authors/author-enrichment-executor.service.ts
@@ -7,13 +7,22 @@ import {
AuthorMetadataPreferences,
AuthorMetadataProviderKey,
MergeStrategy,
+ MetadataCacheServedProvider,
} from '@bookorbit/types';
+import type { CacheDirective } from '../../common/provider-cache/provider-cache-spec';
import { AuthorMetadataFetchService } from './metadata/author-metadata-fetch.service';
import { AuthorImageStorageError, AuthorImageStorageService } from './author-image-storage.service';
import { AuthorEnrichmentRow, AuthorsRepository } from './authors.repository';
-export type AuthorEnrichmentExecutionResult =
+/** How an execution used the network and the metadata cache, for provenance and pacing. */
+export type AuthorEnrichmentProvenance = {
+ /** Provider requests plus portrait download attempts; 0 when the cache answered everything. */
+ outboundRequests: number;
+ cacheServed: MetadataCacheServedProvider[];
+};
+
+type AuthorEnrichmentOutcome =
| {
kind: 'done';
provider: string | null;
@@ -23,7 +32,7 @@ export type AuthorEnrichmentExecutionResult =
}
| {
kind: 'skipped';
- reason: 'author_not_found' | 'orphaned' | 'provider_disabled' | 'no_match';
+ reason: SkipReason;
provider: string | null;
descriptionUpdated: false;
imageUpdated: false;
@@ -41,8 +50,17 @@ export type AuthorEnrichmentExecutionResult =
fieldsUpdated: [];
};
+export type AuthorEnrichmentExecutionResult = AuthorEnrichmentOutcome & AuthorEnrichmentProvenance;
+
type AuthorUpdate = Parameters[1];
+type SkipReason = 'author_not_found' | 'orphaned' | 'provider_disabled' | 'no_match';
+
+/** Without a directive an execution behaves as it did before the cache: always live, no fallback. */
+const LIVE_DIRECTIVE: CacheDirective = { policy: 'refresh', allowFallback: false };
+
+const NO_PROVENANCE: AuthorEnrichmentProvenance = { outboundRequests: 0, cacheServed: [] };
+
@Injectable()
export class AuthorEnrichmentExecutorService {
constructor(
@@ -51,7 +69,10 @@ export class AuthorEnrichmentExecutorService {
private readonly authorImageStorage: AuthorImageStorageService,
) {}
- async execute(params: { authorId: number; preferences: AuthorMetadataPreferences }): Promise {
+ async execute(
+ params: { authorId: number; preferences: AuthorMetadataPreferences },
+ directive: CacheDirective = LIVE_DIRECTIVE,
+ ): Promise {
const author = await this.authorsRepo.findByIdForEnrichment(params.authorId);
if (!author) return this.skipped('author_not_found');
if (author.bookCount <= 0) return this.skipped('orphaned');
@@ -59,14 +80,16 @@ export class AuthorEnrichmentExecutorService {
const providerOrder = this.providersInUse(params.preferences);
if (providerOrder.length === 0) return this.skipped('provider_disabled');
- const { candidates, failures } = await this.authorMetadataFetchService.collectByProvider(
+ const { candidates, failures, outboundRequests, cacheServed } = await this.authorMetadataFetchService.collectByProvider(
{ name: author.name, region: 'us', limit: 1 },
providerOrder,
+ directive,
);
+ const provenance: AuthorEnrichmentProvenance = { outboundRequests, cacheServed };
if (candidates.size === 0) {
const failure = failures[0];
- if (!failure) return this.skipped('no_match');
+ if (!failure) return this.skipped('no_match', provenance);
return {
kind: 'failed',
message: failure.message,
@@ -77,18 +100,21 @@ export class AuthorEnrichmentExecutorService {
descriptionUpdated: false,
imageUpdated: false,
fieldsUpdated: [],
+ ...provenance,
};
}
- return this.applyCandidates(author, params.preferences, candidates);
+ return this.applyCandidates(author, params.preferences, candidates, provenance);
}
private async applyCandidates(
author: AuthorEnrichmentRow,
preferences: AuthorMetadataPreferences,
candidates: Map,
+ provenance: AuthorEnrichmentProvenance,
): Promise {
const update: AuthorUpdate = {};
+ const downloads = { attempts: 0 };
const fieldsUpdated: AuthorMetadataField[] = [];
const winners = new Map();
let imageUpdated = false;
@@ -98,9 +124,9 @@ export class AuthorEnrichmentExecutorService {
if (!preference?.enabled) continue;
if (field === 'photo') {
- const photo = await this.applyPhoto(author, preference, candidates);
+ const photo = await this.applyPhoto(author, preference, candidates, downloads);
if (photo) {
- if (photo.kind === 'failed') return photo.result;
+ if (photo.kind === 'failed') return { ...photo.result, ...this.withDownloads(provenance, downloads.attempts) };
imageUpdated = true;
fieldsUpdated.push('photo');
winners.set('photo', photo.provider);
@@ -133,9 +159,14 @@ export class AuthorEnrichmentExecutorService {
descriptionUpdated: fieldsUpdated.includes('description'),
imageUpdated,
fieldsUpdated,
+ ...this.withDownloads(provenance, downloads.attempts),
};
}
+ private withDownloads(provenance: AuthorEnrichmentProvenance, downloads: number): AuthorEnrichmentProvenance {
+ return { outboundRequests: provenance.outboundRequests + downloads, cacheServed: provenance.cacheServed };
+ }
+
private resolveField(
field: Exclude,
author: AuthorEnrichmentRow,
@@ -203,13 +234,18 @@ export class AuthorEnrichmentExecutorService {
author: AuthorEnrichmentRow,
preference: AuthorFieldPreference,
candidates: Map,
- ): Promise<{ kind: 'stored'; provider: AuthorMetadataProviderKey } | { kind: 'failed'; result: AuthorEnrichmentExecutionResult } | null> {
+ downloads: { attempts: number },
+ ): Promise<
+ { kind: 'stored'; provider: AuthorMetadataProviderKey } | { kind: 'failed'; result: Extract } | null
+ > {
if (preference.mergeStrategy === 'fillMissing' && author.hasPhoto) return null;
for (const provider of preference.providers) {
const imageUrl = candidates.get(provider)?.imageUrl;
if (!imageUrl) continue;
+ // Counted per attempt rather than per request: a cached candidate still costs a download.
+ downloads.attempts += 1;
try {
const stored = await this.authorImageStorage.saveFromUrl(author.id, imageUrl);
if (!stored) continue;
@@ -272,7 +308,7 @@ export class AuthorEnrichmentExecutorService {
return ordered;
}
- private skipped(reason: 'author_not_found' | 'orphaned' | 'provider_disabled' | 'no_match'): AuthorEnrichmentExecutionResult {
- return { kind: 'skipped', reason, provider: null, descriptionUpdated: false, imageUpdated: false, fieldsUpdated: [] };
+ private skipped(reason: SkipReason, provenance: AuthorEnrichmentProvenance = NO_PROVENANCE): AuthorEnrichmentExecutionResult {
+ return { kind: 'skipped', reason, provider: null, descriptionUpdated: false, imageUpdated: false, fieldsUpdated: [], ...provenance };
}
}
diff --git a/server/src/modules/authors/author-enrichment-orchestrator.service.test.ts b/server/src/modules/authors/author-enrichment-orchestrator.service.test.ts
index 2da505743..4c07daddf 100644
--- a/server/src/modules/authors/author-enrichment-orchestrator.service.test.ts
+++ b/server/src/modules/authors/author-enrichment-orchestrator.service.test.ts
@@ -85,6 +85,8 @@ describe('AuthorEnrichmentOrchestratorService', () => {
provider: 'audnexus',
descriptionUpdated: true,
imageUpdated: false,
+ outboundRequests: 1,
+ cacheServed: [],
});
metadataPreferences.getPreferences.mockResolvedValue(new AuthorMetadataPreferenceResolver().getDefaultPreferences());
@@ -127,14 +129,139 @@ describe('AuthorEnrichmentOrchestratorService', () => {
await (service as any).pollOnce();
expect(queueRepo.markProcessing).toHaveBeenCalledWith(22);
- expect(executor.execute).toHaveBeenCalledWith({
- authorId: 22,
- preferences: expect.objectContaining({ fields: expect.any(Object) }),
- });
+ expect(executor.execute).toHaveBeenCalledWith(
+ {
+ authorId: 22,
+ preferences: expect.objectContaining({ fields: expect.any(Object) }),
+ },
+ { policy: 'default', allowFallback: false },
+ );
expect(queueRepo.markDone).toHaveBeenCalledWith(22, false);
expect(queueRepo.markFailed).not.toHaveBeenCalled();
});
+ describe('metadata cache directive', () => {
+ it('uses the cache without fallback before the final attempt', async () => {
+ await (service as any).processOne(10, 0);
+ await (service as any).processOne(11, 4);
+
+ expect(executor.execute).toHaveBeenNthCalledWith(1, expect.anything(), { policy: 'default', allowFallback: false });
+ expect(executor.execute).toHaveBeenNthCalledWith(2, expect.anything(), { policy: 'default', allowFallback: false });
+ });
+
+ it('allows a saved answer to stand in for a failing provider only on the final attempt', async () => {
+ await (service as any).processOne(12, 5);
+
+ expect(executor.execute).toHaveBeenCalledWith(expect.anything(), { policy: 'default', allowFallback: true });
+ });
+
+ it('still requeues a transient failure before the final attempt', async () => {
+ executor.execute.mockResolvedValue({
+ kind: 'failed',
+ message: 'provider unavailable',
+ provider: 'audnexus',
+ httpStatus: 503,
+ retryAfterMs: null,
+ transient: true,
+ descriptionUpdated: false,
+ imageUpdated: false,
+ outboundRequests: 1,
+ cacheServed: [],
+ });
+
+ await (service as any).processOne(13, 4);
+
+ expect(queueRepo.markFailed).toHaveBeenCalledWith(expect.objectContaining({ authorId: 13, nextAttemptAt: expect.any(Date) }));
+ });
+
+ it('goes live for rows queued with ignore_cache', async () => {
+ queueRepo.fetchDue.mockResolvedValue([{ authorId: 23, attemptCount: 0, authorName: null, reason: 'manual_backfill', ignoreCache: true }]);
+ vi.spyOn(service as any, 'randomDelay').mockResolvedValue(undefined);
+
+ await (service as any).pollOnce();
+
+ expect(executor.execute).toHaveBeenCalledWith(expect.objectContaining({ authorId: 23 }), { policy: 'refresh', allowFallback: false });
+ });
+
+ it('keeps fallback on the final attempt of an ignore_cache row', async () => {
+ await (service as any).processOne(24, 5, null, AUTHOR_ENRICHMENT_REASONS.MANUAL_BACKFILL, true);
+
+ expect(executor.execute).toHaveBeenCalledWith(expect.anything(), { policy: 'refresh', allowFallback: true });
+ });
+ });
+
+ describe('pacing', () => {
+ it('skips the provider delay when an item made no outbound requests', async () => {
+ queueRepo.fetchDue.mockResolvedValue([{ authorId: 30, attemptCount: 0, authorName: null, reason: 'manual_backfill', ignoreCache: false }]);
+ executor.execute.mockResolvedValue({
+ kind: 'done',
+ provider: 'audnexus',
+ descriptionUpdated: true,
+ imageUpdated: false,
+ outboundRequests: 0,
+ cacheServed: [{ provider: 'audnexus', source: 'cache', stale: false, cachedAt: '2026-09-01T00:00:00.000Z' }],
+ });
+ const randomDelay = vi.spyOn(service as any, 'randomDelay').mockResolvedValue(undefined);
+ const cacheOnlyYield = vi.spyOn(service as any, 'cacheOnlyYield').mockResolvedValue(undefined);
+
+ await (service as any).pollOnce();
+
+ expect(cacheOnlyYield).toHaveBeenCalledTimes(1);
+ expect(randomDelay).not.toHaveBeenCalled();
+ });
+
+ it('keeps the provider delay when an item went out', async () => {
+ queueRepo.fetchDue.mockResolvedValue([{ authorId: 31, attemptCount: 0, authorName: null, reason: 'manual_backfill', ignoreCache: false }]);
+ executor.execute.mockResolvedValue({
+ kind: 'skipped',
+ reason: 'no_match',
+ provider: null,
+ descriptionUpdated: false,
+ imageUpdated: false,
+ outboundRequests: 2,
+ cacheServed: [],
+ });
+ const randomDelay = vi.spyOn(service as any, 'randomDelay').mockResolvedValue(undefined);
+ const cacheOnlyYield = vi.spyOn(service as any, 'cacheOnlyYield').mockResolvedValue(undefined);
+
+ await (service as any).pollOnce();
+
+ expect(randomDelay).toHaveBeenCalledTimes(1);
+ expect(cacheOnlyYield).not.toHaveBeenCalled();
+ });
+
+ it('yields for about 100 ms instead of the 1-3 s provider delay', async () => {
+ vi.useFakeTimers();
+ try {
+ let resolved = false;
+ void (service as any).cacheOnlyYield().then(() => {
+ resolved = true;
+ });
+ await vi.advanceTimersByTimeAsync(99);
+ expect(resolved).toBe(false);
+ await vi.advanceTimersByTimeAsync(1);
+ expect(resolved).toBe(true);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+ });
+
+ it('backfills pass ignoreCache through to the queue', async () => {
+ queueRepo.enqueueEligibleLinkedAuthors.mockResolvedValue(2);
+ queueRepo.enqueueAllLinkedAuthors.mockResolvedValue(3);
+
+ await service.backfillLinkedAuthors({ ignoreCache: true });
+ await service.backfillAllLinkedAuthors({ ignoreCache: true });
+ await service.backfillLinkedAuthors();
+
+ expect(queueRepo.enqueueEligibleLinkedAuthors).toHaveBeenNthCalledWith(1, AUTHOR_ENRICHMENT_REASONS.MANUAL_BACKFILL, expect.any(Object), {
+ ignoreCache: true,
+ });
+ expect(queueRepo.enqueueAllLinkedAuthors).toHaveBeenCalledWith(AUTHOR_ENRICHMENT_REASONS.MANUAL_BACKFILL_ALL, { ignoreCache: true });
+ expect(queueRepo.enqueueEligibleLinkedAuthors).toHaveBeenNthCalledWith(2, AUTHOR_ENRICHMENT_REASONS.MANUAL_BACKFILL, expect.any(Object), {});
+ });
+
it('marks rate-limited failures for retry with delayed nextAttemptAt', async () => {
session.addToTotal(1);
executor.execute.mockResolvedValue({
diff --git a/server/src/modules/authors/author-enrichment-orchestrator.service.ts b/server/src/modules/authors/author-enrichment-orchestrator.service.ts
index 18b9f321a..cb0fe84cc 100644
--- a/server/src/modules/authors/author-enrichment-orchestrator.service.ts
+++ b/server/src/modules/authors/author-enrichment-orchestrator.service.ts
@@ -5,12 +5,13 @@ import { NotificationType, Permission } from '@bookorbit/types';
import { NotificationService } from '../notification/notification.service';
import { METADATA_AUTHORS_REPLACED, MetadataAuthorsReplacedEvent, MetadataEventsService } from '../metadata/metadata-events.service';
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
+import { backgroundEnrichmentCacheDirective, CACHE_ONLY_ITEM_YIELD_MS } from './author-cache-directives';
import { AuthorEnrichmentConfigService } from './author-enrichment-config.service';
import { AuthorMetadataPreferencesService } from './author-metadata-preferences.service';
import { AuthorEnrichmentExecutorService } from './author-enrichment-executor.service';
import { AuthorEnrichmentGateway } from './author-enrichment.gateway';
import { AUTHOR_ENRICHMENT_REASONS, AuthorEnrichmentReason } from './author-enrichment-reasons';
-import { AuthorEnrichmentRepository } from './author-enrichment.repository';
+import { AuthorEnrichmentEnqueueOptions, AuthorEnrichmentRepository } from './author-enrichment.repository';
import { AuthorEnrichmentSessionService } from './author-enrichment-session.service';
const POLL_INTERVAL_MS = 4_000;
@@ -95,10 +96,10 @@ export class AuthorEnrichmentOrchestratorService implements OnApplicationBootstr
return queued;
}
- async backfillLinkedAuthors(): Promise {
+ async backfillLinkedAuthors(options: AuthorEnrichmentEnqueueOptions = {}): Promise {
const config = await this.enrichmentConfig.getConfig();
await this.finalizeCompletedSession();
- const queued = await this.queueRepo.enqueueEligibleLinkedAuthors(AUTHOR_ENRICHMENT_REASONS.MANUAL_BACKFILL, config.conditions);
+ const queued = await this.queueRepo.enqueueEligibleLinkedAuthors(AUTHOR_ENRICHMENT_REASONS.MANUAL_BACKFILL, config.conditions, options);
if (queued > 0) {
this.session.addToTotal(queued);
await this.unpauseIfNeeded();
@@ -108,9 +109,9 @@ export class AuthorEnrichmentOrchestratorService implements OnApplicationBootstr
return queued;
}
- async backfillAllLinkedAuthors(): Promise {
+ async backfillAllLinkedAuthors(options: AuthorEnrichmentEnqueueOptions = {}): Promise {
await this.finalizeCompletedSession();
- const queued = await this.queueRepo.enqueueAllLinkedAuthors(AUTHOR_ENRICHMENT_REASONS.MANUAL_BACKFILL_ALL);
+ const queued = await this.queueRepo.enqueueAllLinkedAuthors(AUTHOR_ENRICHMENT_REASONS.MANUAL_BACKFILL_ALL, options);
if (queued > 0) {
this.session.addToTotal(queued);
await this.unpauseIfNeeded();
@@ -167,8 +168,14 @@ export class AuthorEnrichmentOrchestratorService implements OnApplicationBootstr
const dueRows = await this.queueRepo.fetchDue(BATCH_SIZE);
for (const row of dueRows) {
- await this.processOne(row.authorId, row.attemptCount, row.authorName, row.reason as AuthorEnrichmentReason);
- await this.randomDelay();
+ const outboundRequests = await this.processOne(
+ row.authorId,
+ row.attemptCount,
+ row.authorName,
+ row.reason as AuthorEnrichmentReason,
+ row.ignoreCache,
+ );
+ await (outboundRequests === 0 ? this.cacheOnlyYield() : this.randomDelay());
}
} catch (error) {
const errorClass = error instanceof Error ? error.name : 'Error';
@@ -219,14 +226,16 @@ export class AuthorEnrichmentOrchestratorService implements OnApplicationBootstr
.catch(() => {});
}
+ /** Returns the outbound requests the item made, so the poll loop can skip pacing for cache-only work. */
private async processOne(
authorId: number,
previousAttemptCount: number,
authorName: string | null = null,
reason: AuthorEnrichmentReason = AUTHOR_ENRICHMENT_REASONS.UNKNOWN,
- ): Promise {
+ ignoreCache = false,
+ ): Promise {
const claimed = await this.queueRepo.markProcessing(authorId);
- if (!claimed) return;
+ if (!claimed) return 0;
const trackSession = reason !== AUTHOR_ENRICHMENT_REASONS.METADATA_REPLACE;
if (trackSession) {
this.session.setCurrentItemName(authorName);
@@ -235,11 +244,14 @@ export class AuthorEnrichmentOrchestratorService implements OnApplicationBootstr
const preferences = await this.metadataPreferences.getPreferences();
- const result = await this.executor.execute({ authorId, preferences });
+ const attemptNumber = previousAttemptCount + 1;
+ const directive = backgroundEnrichmentCacheDirective(ignoreCache, attemptNumber >= MAX_ATTEMPTS);
+ const result = await this.executor.execute({ authorId, preferences }, directive);
+ const outboundRequests = result.outboundRequests;
if (result.kind === 'done') {
this.logger.debug(
- `[author.enrichment.process] [end] authorId=${authorId} outcome=done provider=${result.provider ?? 'none'} descriptionUpdated=${result.descriptionUpdated} imageUpdated=${result.imageUpdated} - author enrichment processed`,
+ `[author.enrichment.process] [end] authorId=${authorId} ignoreCache=${ignoreCache} outcome=done provider=${result.provider ?? 'none'} descriptionUpdated=${result.descriptionUpdated} imageUpdated=${result.imageUpdated} outboundRequests=${outboundRequests} - author enrichment processed`,
);
await this.queueRepo.markDone(authorId, result.imageUpdated);
if (trackSession) {
@@ -247,12 +259,12 @@ export class AuthorEnrichmentOrchestratorService implements OnApplicationBootstr
this.session.setCurrentItemName(null);
await this.emitStatusSnapshot();
}
- return;
+ return outboundRequests;
}
if (result.kind === 'skipped') {
this.logger.debug(
- `[author.enrichment.process] [end] authorId=${authorId} outcome=skipped reason=${result.reason} - author enrichment processed`,
+ `[author.enrichment.process] [end] authorId=${authorId} ignoreCache=${ignoreCache} outcome=skipped reason=${result.reason} outboundRequests=${outboundRequests} - author enrichment processed`,
);
await this.queueRepo.markDone(authorId, false);
if (trackSession) {
@@ -260,10 +272,9 @@ export class AuthorEnrichmentOrchestratorService implements OnApplicationBootstr
this.session.setCurrentItemName(null);
await this.emitStatusSnapshot();
}
- return;
+ return outboundRequests;
}
- const attemptNumber = previousAttemptCount + 1;
const finalFailure = attemptNumber >= MAX_ATTEMPTS || !result.transient;
const nextAttemptAt = finalFailure ? null : this.computeNextAttemptAt(attemptNumber, result.retryAfterMs);
@@ -289,6 +300,7 @@ export class AuthorEnrichmentOrchestratorService implements OnApplicationBootstr
this.session.setCurrentItemName(null);
await this.emitStatusSnapshot();
}
+ return outboundRequests;
}
private computeNextAttemptAt(attemptNumber: number, retryAfterMs: number | null): Date {
@@ -307,6 +319,10 @@ export class AuthorEnrichmentOrchestratorService implements OnApplicationBootstr
return new Promise((resolve) => setTimeout(resolve, ms));
}
+ private cacheOnlyYield(): Promise {
+ return new Promise((resolve) => setTimeout(resolve, CACHE_ONLY_ITEM_YIELD_MS));
+ }
+
private async emitStatusSnapshot(): Promise {
if (!this.gateway) return;
const summary = await this.queueRepo.getStatusSummary();
diff --git a/server/src/modules/authors/author-enrichment.repository.test.ts b/server/src/modules/authors/author-enrichment.repository.test.ts
index 16254c870..65211d9ae 100644
--- a/server/src/modules/authors/author-enrichment.repository.test.ts
+++ b/server/src/modules/authors/author-enrichment.repository.test.ts
@@ -105,7 +105,7 @@ describe('AuthorEnrichmentRepository', () => {
insertBuilder.returning.mockResolvedValueOnce([{ authorId: 1 }, { authorId: 2 }]).mockResolvedValueOnce([{ authorId: 3 }]);
const repo = new AuthorEnrichmentRepository(db as never);
- const count = await repo.upsertSchedule([1, 2, 3], 'metadata_replace', 2);
+ const count = await repo.upsertSchedule([1, 2, 3], 'metadata_replace', {}, 2);
expect(count).toBe(3);
expect(db.insert).toHaveBeenCalledTimes(2);
@@ -118,6 +118,110 @@ describe('AuthorEnrichmentRepository', () => {
]);
});
+ describe('ignore_cache', () => {
+ it('leaves the flag untouched when the enqueue does not ask to ignore the cache', async () => {
+ const { db, insertBuilder } = makeDb();
+ const repo = new AuthorEnrichmentRepository(db as never);
+
+ await repo.upsertSchedule([3, 4], AUTHOR_ENRICHMENT_REASONS.MANUAL_BACKFILL);
+
+ const [rows] = insertBuilder.values.mock.calls[0]!;
+ expect(rows.every((row: Record) => !('ignoreCache' in row))).toBe(true);
+ expect(insertBuilder.onConflictDoUpdate.mock.calls[0]![0].set).not.toHaveProperty('ignoreCache');
+ expect(db.update).not.toHaveBeenCalled();
+ });
+
+ it('sets the flag on inserted rows, reactivated failures and rows already queued', async () => {
+ const { db, insertBuilder, updateBuilder } = makeDb();
+ insertBuilder.returning.mockResolvedValueOnce([{ authorId: 3 }]);
+ const repo = new AuthorEnrichmentRepository(db as never);
+
+ const queued = await repo.upsertSchedule([3, 4], AUTHOR_ENRICHMENT_REASONS.MANUAL_BACKFILL, { ignoreCache: true });
+
+ expect(queued).toBe(1);
+ expect(insertBuilder.values).toHaveBeenCalledWith([
+ expect.objectContaining({ authorId: 3, ignoreCache: true }),
+ expect.objectContaining({ authorId: 4, ignoreCache: true }),
+ ]);
+ expect(insertBuilder.onConflictDoUpdate).toHaveBeenCalledWith(
+ expect.objectContaining({
+ set: expect.objectContaining({ ignoreCache: true }),
+ setWhere: { op: 'eq', left: authorEnrichmentQueue.status, right: 'failed' },
+ }),
+ );
+ expect(db.update).toHaveBeenCalledWith(authorEnrichmentQueue);
+ expect(updateBuilder.set).toHaveBeenCalledWith(expect.objectContaining({ ignoreCache: true }));
+ expect(updateBuilder.where).toHaveBeenCalledWith({
+ op: 'and',
+ clauses: [
+ { op: 'inArray', left: authorEnrichmentQueue.authorId, right: [3, 4] },
+ { op: 'eq', left: authorEnrichmentQueue.ignoreCache, right: false },
+ ],
+ });
+ });
+
+ it('only ever sets the flag, never clears it', async () => {
+ const { db, insertBuilder, updateBuilder } = makeDb();
+ const repo = new AuthorEnrichmentRepository(db as never);
+
+ await repo.upsertSchedule([5], AUTHOR_ENRICHMENT_REASONS.MANUAL_BACKFILL, { ignoreCache: true });
+ await repo.upsertSchedule([5], AUTHOR_ENRICHMENT_REASONS.MANUAL_BACKFILL, { ignoreCache: false });
+
+ const writes = [
+ ...insertBuilder.values.mock.calls.flatMap(([rows]) => rows as Record[]),
+ ...insertBuilder.onConflictDoUpdate.mock.calls.map(([config]) => config.set as Record),
+ ...updateBuilder.set.mock.calls.map(([values]) => values as Record),
+ ];
+ expect(writes.filter((values) => 'ignoreCache' in values).every((values) => values.ignoreCache === true)).toBe(true);
+ });
+
+ it('passes the flag through both backfill enqueues', async () => {
+ const { db, selectBuilder } = makeDb();
+ selectBuilder.limit
+ .mockResolvedValueOnce([{ authorId: 7 }])
+ .mockResolvedValueOnce([])
+ .mockResolvedValueOnce([{ authorId: 8 }])
+ .mockResolvedValueOnce([]);
+ const repo = new AuthorEnrichmentRepository(db as never);
+ const upsert = vi.spyOn(repo, 'upsertSchedule').mockResolvedValue(1);
+
+ await repo.enqueueEligibleLinkedAuthors(
+ AUTHOR_ENRICHMENT_REASONS.MANUAL_BACKFILL,
+ { neverEnriched: true, missingBio: false, missingPhoto: false },
+ { ignoreCache: true },
+ );
+ await repo.enqueueAllLinkedAuthors(AUTHOR_ENRICHMENT_REASONS.MANUAL_BACKFILL_ALL, { ignoreCache: true });
+
+ expect(upsert).toHaveBeenNthCalledWith(1, [7], AUTHOR_ENRICHMENT_REASONS.MANUAL_BACKFILL, { ignoreCache: true }, 1000);
+ expect(upsert).toHaveBeenNthCalledWith(2, [8], AUTHOR_ENRICHMENT_REASONS.MANUAL_BACKFILL_ALL, { ignoreCache: true }, 1000);
+ });
+
+ it('preserves the flag across retries and requeues', async () => {
+ const { db, updateBuilder } = makeDb();
+ const repo = new AuthorEnrichmentRepository(db as never);
+
+ await repo.markFailed({ authorId: 9, error: 'boom', httpStatus: 503, nextAttemptAt: new Date(), rateLimited: false });
+ await repo.requeueFailed();
+ await repo.recoverStuckProcessing(new Date());
+ await repo.resetAllProcessingOnBoot();
+
+ expect(updateBuilder.set).toHaveBeenCalledTimes(4);
+ for (const [values] of updateBuilder.set.mock.calls) expect(values).not.toHaveProperty('ignoreCache');
+ });
+
+ it('resets the flag when a row completes, because the row is removed', async () => {
+ const { db, deleteBuilder, updateBuilder } = makeDb();
+ deleteBuilder.where.mockResolvedValueOnce(undefined);
+ updateBuilder.where.mockResolvedValueOnce(undefined);
+ const repo = new AuthorEnrichmentRepository(db as never);
+
+ await repo.markDone(9, false);
+
+ expect(db.delete).toHaveBeenCalledWith(authorEnrichmentQueue);
+ expect(deleteBuilder.where).toHaveBeenCalledWith({ op: 'eq', left: authorEnrichmentQueue.authorId, right: 9 });
+ });
+ });
+
it('fetchDue filters by active statuses and due time', async () => {
const { db, selectBuilder } = makeDb();
const repo = new AuthorEnrichmentRepository(db as never);
@@ -249,12 +353,13 @@ describe('AuthorEnrichmentRepository', () => {
missingBio: true,
missingPhoto: false,
},
+ {},
2,
);
expect(queued).toBe(3);
- expect(upsert).toHaveBeenNthCalledWith(1, [10, 20], AUTHOR_ENRICHMENT_REASONS.MANUAL_BACKFILL, 2);
- expect(upsert).toHaveBeenNthCalledWith(2, [40], AUTHOR_ENRICHMENT_REASONS.MANUAL_BACKFILL, 2);
+ expect(upsert).toHaveBeenNthCalledWith(1, [10, 20], AUTHOR_ENRICHMENT_REASONS.MANUAL_BACKFILL, {}, 2);
+ expect(upsert).toHaveBeenNthCalledWith(2, [40], AUTHOR_ENRICHMENT_REASONS.MANUAL_BACKFILL, {}, 2);
expect(selectBuilder.limit).toHaveBeenCalledTimes(3);
expect(selectBuilder.limit).toHaveBeenCalledWith(2);
});
@@ -333,9 +438,9 @@ describe('AuthorEnrichmentRepository', () => {
const repo = new AuthorEnrichmentRepository(db as never);
const upsert = vi.spyOn(repo, 'upsertSchedule').mockResolvedValueOnce(2).mockResolvedValueOnce(1);
- await expect(repo.enqueueAllLinkedAuthors(AUTHOR_ENRICHMENT_REASONS.MANUAL_BACKFILL_ALL, 2)).resolves.toBe(3);
- expect(upsert).toHaveBeenNthCalledWith(1, [11, 12], AUTHOR_ENRICHMENT_REASONS.MANUAL_BACKFILL_ALL, 2);
- expect(upsert).toHaveBeenNthCalledWith(2, [15], AUTHOR_ENRICHMENT_REASONS.MANUAL_BACKFILL_ALL, 2);
+ await expect(repo.enqueueAllLinkedAuthors(AUTHOR_ENRICHMENT_REASONS.MANUAL_BACKFILL_ALL, { ignoreCache: true }, 2)).resolves.toBe(3);
+ expect(upsert).toHaveBeenNthCalledWith(1, [11, 12], AUTHOR_ENRICHMENT_REASONS.MANUAL_BACKFILL_ALL, { ignoreCache: true }, 2);
+ expect(upsert).toHaveBeenNthCalledWith(2, [15], AUTHOR_ENRICHMENT_REASONS.MANUAL_BACKFILL_ALL, { ignoreCache: true }, 2);
expect(selectBuilder.limit).toHaveBeenCalledTimes(3);
});
diff --git a/server/src/modules/authors/author-enrichment.repository.ts b/server/src/modules/authors/author-enrichment.repository.ts
index 05b306a63..da95a0824 100644
--- a/server/src/modules/authors/author-enrichment.repository.ts
+++ b/server/src/modules/authors/author-enrichment.repository.ts
@@ -17,11 +17,21 @@ export const AUTHOR_ENRICHMENT_ACTIVE_STATUSES = ['queued', 'rate_limited'] as c
export type AuthorEnrichmentQueueRow = typeof authorEnrichmentQueue.$inferSelect;
+export type AuthorEnrichmentEnqueueOptions = {
+ /** Enrich these authors without saved provider answers. Merged into rows already queued, never cleared by a later enqueue. */
+ ignoreCache?: boolean;
+};
+
@Injectable()
export class AuthorEnrichmentRepository {
constructor(@Inject(DB) private readonly db: Db) {}
- async upsertSchedule(authorIds: number[], reason: AuthorEnrichmentReason, batchSize = DEFAULT_SCHEDULE_BATCH_SIZE): Promise {
+ async upsertSchedule(
+ authorIds: number[],
+ reason: AuthorEnrichmentReason,
+ options: AuthorEnrichmentEnqueueOptions = {},
+ batchSize = DEFAULT_SCHEDULE_BATCH_SIZE,
+ ): Promise {
const uniqueAuthorIds = [...new Set(authorIds)].filter((id) => Number.isInteger(id) && id > 0);
if (uniqueAuthorIds.length === 0) return 0;
@@ -32,13 +42,13 @@ export class AuthorEnrichmentRepository {
let totalTouched = 0;
for (let offset = 0; offset < uniqueAuthorIds.length; offset += resolvedBatchSize) {
const batch = uniqueAuthorIds.slice(offset, offset + resolvedBatchSize);
- totalTouched += await this.upsertScheduleBatch(batch, reason, now);
+ totalTouched += await this.upsertScheduleBatch(batch, reason, now, options.ignoreCache === true);
}
return totalTouched;
}
- private async upsertScheduleBatch(authorIds: number[], reason: AuthorEnrichmentReason, now: Date): Promise {
+ private async upsertScheduleBatch(authorIds: number[], reason: AuthorEnrichmentReason, now: Date, ignoreCache: boolean): Promise {
const touched = await this.db
.insert(authorEnrichmentQueue)
.values(
@@ -48,6 +58,7 @@ export class AuthorEnrichmentRepository {
reason,
attemptCount: 0,
nextAttemptAt: now,
+ ...(ignoreCache ? { ignoreCache: true } : {}),
})),
)
.onConflictDoUpdate({
@@ -58,15 +69,28 @@ export class AuthorEnrichmentRepository {
attemptCount: 0,
nextAttemptAt: now,
updatedAt: now,
+ ...(ignoreCache ? { ignoreCache: true } : {}),
},
setWhere: eq(authorEnrichmentQueue.status, 'failed'),
})
.returning({ authorId: authorEnrichmentQueue.authorId });
+ // Rows still waiting are left as they are by the upsert above; they only gain the flag.
+ if (ignoreCache) {
+ await this.db
+ .update(authorEnrichmentQueue)
+ .set({ ignoreCache: true, updatedAt: now })
+ .where(and(inArray(authorEnrichmentQueue.authorId, authorIds), eq(authorEnrichmentQueue.ignoreCache, false)));
+ }
+
return touched.length;
}
- async enqueueAllLinkedAuthors(reason: AuthorEnrichmentReason, batchSize = DEFAULT_SCHEDULE_BATCH_SIZE): Promise {
+ async enqueueAllLinkedAuthors(
+ reason: AuthorEnrichmentReason,
+ options: AuthorEnrichmentEnqueueOptions = {},
+ batchSize = DEFAULT_SCHEDULE_BATCH_SIZE,
+ ): Promise {
const resolvedBatchSize = resolveBatchSize(batchSize);
if (!resolvedBatchSize) return 0;
@@ -86,6 +110,7 @@ export class AuthorEnrichmentRepository {
totalQueued += await this.upsertSchedule(
rows.map((row) => row.authorId),
reason,
+ options,
resolvedBatchSize,
);
cursorAuthorId = rows[rows.length - 1]!.authorId;
@@ -124,6 +149,7 @@ export class AuthorEnrichmentRepository {
async enqueueEligibleLinkedAuthors(
reason: AuthorEnrichmentReason,
conditions: AuthorEnrichmentConditions,
+ options: AuthorEnrichmentEnqueueOptions = {},
batchSize = DEFAULT_SCHEDULE_BATCH_SIZE,
): Promise {
const eligibility = this.buildEligibilityPredicate(conditions);
@@ -149,6 +175,7 @@ export class AuthorEnrichmentRepository {
totalQueued += await this.upsertSchedule(
rows.map((row) => row.authorId),
reason,
+ options,
resolvedBatchSize,
);
cursorAuthorId = rows[rows.length - 1]!.authorId;
diff --git a/server/src/modules/authors/authors.controller.test.ts b/server/src/modules/authors/authors.controller.test.ts
index 4ed030350..3d00cc80d 100644
--- a/server/src/modules/authors/authors.controller.test.ts
+++ b/server/src/modules/authors/authors.controller.test.ts
@@ -10,6 +10,7 @@ import { AUDITABLE_KEY } from '../../common/decorators/auditable.decorator';
import { FORBIDDEN_PERMISSION_KEY } from '../../common/decorators/forbid-permission.decorator';
import type { RequestUser } from '../../common/types/request-user';
import { AuthorsController } from './authors.controller';
+import { BulkRefreshAuthorsMetadataDto } from './dto/bulk-refresh-authors-metadata.dto';
import { EMPTY_CONTENT_FILTER_RULES } from '@bookorbit/types';
vi.mock('fs', async () => {
@@ -184,6 +185,58 @@ describe('AuthorsController', () => {
expect(raw.write).toHaveBeenNthCalledWith(1, `data: ${JSON.stringify({ authorId: 5, status: 'queued' })}\n\n`);
expect(raw.write).toHaveBeenNthCalledWith(2, `data: ${JSON.stringify({ done: true, queued: 1, processed: 1, failed: 0 })}\n\n`);
expect(raw.end).toHaveBeenCalled();
+ expect(authorsService.bulkRefreshMetadata).toHaveBeenCalledWith([5], user, expect.any(Function), { ignoreCache: false });
+ });
+
+ it('passes ignoreCache from the bulk refresh body', async () => {
+ const { controller, authorsService } = makeController();
+ const { reply } = makeReply();
+ const user = makeUser();
+ authorsService.bulkRefreshMetadata.mockResolvedValue({ processed: 1, failed: 0, updated: 1 });
+
+ await controller.bulkRefreshMetadata({ authorIds: [5], ignoreCache: true }, user, reply);
+
+ expect(authorsService.bulkRefreshMetadata).toHaveBeenCalledWith([5], user, expect.any(Function), { ignoreCache: true });
+ });
+
+ it('accepts the bulk refresh body with an optional ignoreCache only on that endpoint', () => {
+ const [bulkRefreshBody] = Reflect.getMetadata('design:paramtypes', AuthorsController.prototype, 'bulkRefreshMetadata') as unknown[];
+ const [deleteBody] = Reflect.getMetadata('design:paramtypes', AuthorsController.prototype, 'delete') as unknown[];
+
+ expect(bulkRefreshBody).toBe(BulkRefreshAuthorsMetadataDto);
+ expect(deleteBody).not.toBe(BulkRefreshAuthorsMetadataDto);
+ });
+
+ it('streams cache provenance alongside each candidate', async () => {
+ const { controller, authorsService } = makeController();
+ const cached = { provider: 'audnexus', providerId: 'x1', name: 'Jane Doe', cache: { fetchedAt: '2026-09-01T00:00:00.000Z', stale: false } };
+ authorsService.streamMetadata.mockReturnValue(of(cached));
+
+ const events = await new Promise((resolve, reject) => {
+ const rows: unknown[] = [];
+ controller.streamMetadata({ q: 'Jane', cache: 'refresh' }).subscribe({
+ next: (row) => rows.push(row),
+ error: reject,
+ complete: () => resolve(rows),
+ });
+ });
+
+ expect(events).toEqual([{ data: cached }]);
+ expect(authorsService.streamMetadata).toHaveBeenCalledWith({ q: 'Jane', cache: 'refresh' });
+ });
+
+ it('passes the backfill ignoreCache flag and tolerates an absent body', async () => {
+ const { controller, enrichmentOrchestrator } = makeController();
+ enrichmentOrchestrator.backfillLinkedAuthors.mockResolvedValue(2);
+ enrichmentOrchestrator.backfillAllLinkedAuthors.mockResolvedValue(4);
+
+ await expect(controller.enqueueBackfill({ ignoreCache: true })).resolves.toEqual({ queued: 2 });
+ await expect(controller.enqueueBackfillAll({ ignoreCache: true })).resolves.toEqual({ queued: 4 });
+ await expect(controller.enqueueBackfill(undefined)).resolves.toEqual({ queued: 2 });
+ await expect(controller.enqueueBackfillAll({})).resolves.toEqual({ queued: 4 });
+
+ expect(enrichmentOrchestrator.backfillLinkedAuthors.mock.calls).toEqual([[{ ignoreCache: true }], [{ ignoreCache: false }]]);
+ expect(enrichmentOrchestrator.backfillAllLinkedAuthors.mock.calls).toEqual([[{ ignoreCache: true }], [{ ignoreCache: false }]]);
});
it('marks bulk metadata refresh endpoint as demo-restricted', () => {
@@ -311,12 +364,12 @@ describe('AuthorsController', () => {
it('delegates write endpoints for refresh/update/merge/delete', async () => {
const { controller, authorsService } = makeController();
const user = makeUser();
- authorsService.refreshEnrichment.mockResolvedValue({ id: 9 });
+ authorsService.refreshEnrichment.mockResolvedValue({ id: 9, metadataCache: { fallbackProviders: [] } });
authorsService.update.mockResolvedValue({ id: 9, name: 'Updated' });
authorsService.merge.mockResolvedValue({ mergedAuthorIds: [2], affectedBookCount: 3 });
authorsService.delete.mockResolvedValue({ deletedAuthorIds: [2], affectedBookCount: 3 });
- await expect(controller.refreshEnrichment(user, 9)).resolves.toEqual({ id: 9 });
+ await expect(controller.refreshEnrichment(user, 9)).resolves.toEqual({ id: 9, metadataCache: { fallbackProviders: [] } });
await expect(controller.update(user, 9, { name: 'Updated' })).resolves.toEqual({ id: 9, name: 'Updated' });
await expect(controller.merge(user, { targetAuthorId: 9, sourceAuthorIds: [2] })).resolves.toEqual({
mergedAuthorIds: [2],
diff --git a/server/src/modules/authors/authors.controller.ts b/server/src/modules/authors/authors.controller.ts
index 7be59a542..29ab0b16f 100644
--- a/server/src/modules/authors/authors.controller.ts
+++ b/server/src/modules/authors/authors.controller.ts
@@ -33,10 +33,11 @@ import { UpdateAuthorMetadataPreferencesDto } from './dto/update-author-metadata
import { imageContentTypeFromPath } from '../../common/image-content-type';
import type { MultipartRequest } from '../../common/types/multipart-request';
import type { RequestUser } from '../../common/types/request-user';
-import type { AuthorMetadataCandidate } from '@bookorbit/types';
+import type { AuthorMetadataSearchCandidateEvent } from '@bookorbit/types';
import { AuthorEnrichmentConfigService } from './author-enrichment-config.service';
import { AuthorAutoEnrichmentConfigDto } from './dto/author-auto-enrichment-config.dto';
-import { BulkAuthorIdsDto } from './dto/bulk-author-ids.dto';
+import { AuthorEnrichmentBackfillDto } from './dto/author-enrichment-backfill.dto';
+import { BulkRefreshAuthorsMetadataDto } from './dto/bulk-refresh-authors-metadata.dto';
import { DeleteAuthorsDto } from './dto/delete-authors.dto';
import { ListAuthorBooksDto } from './dto/list-author-books.dto';
import { ListAuthorMetadataDto } from './dto/list-author-metadata.dto';
@@ -82,7 +83,7 @@ export class AuthorsController {
@Sse('metadata/stream')
streamMetadata(@Query() dto: ListAuthorMetadataDto): Observable {
- return this.authorsService.streamMetadata(dto).pipe(map((candidate: AuthorMetadataCandidate) => ({ data: candidate })));
+ return this.authorsService.streamMetadata(dto).pipe(map((candidate: AuthorMetadataSearchCandidateEvent) => ({ data: candidate })));
}
@Get('metadata/lookup')
@@ -93,7 +94,7 @@ export class AuthorsController {
@Post('bulk-refresh-metadata')
@RequirePermission(Permission.LibraryEditMetadata)
@ForbidPermission(Permission.DemoRestricted, 'Demo-restricted account cannot perform bulk edits')
- async bulkRefreshMetadata(@Body() dto: BulkAuthorIdsDto, @CurrentUser() user: RequestUser, @Res() reply: FastifyReply) {
+ async bulkRefreshMetadata(@Body() dto: BulkRefreshAuthorsMetadataDto, @CurrentUser() user: RequestUser, @Res() reply: FastifyReply) {
reply.raw.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
@@ -106,9 +107,14 @@ export class AuthorsController {
reply.raw.write(`data: ${JSON.stringify(event)}\n\n`);
};
try {
- const result = await this.authorsService.bulkRefreshMetadata(dto.authorIds, user, (event) => {
- sendEvent(event);
- });
+ const result = await this.authorsService.bulkRefreshMetadata(
+ dto.authorIds,
+ user,
+ (event) => {
+ sendEvent(event);
+ },
+ { ignoreCache: dto.ignoreCache === true },
+ );
sendEvent({ done: true, ...result });
} finally {
if (!reply.raw.destroyed && !reply.raw.writableEnded) {
@@ -155,15 +161,15 @@ export class AuthorsController {
@Post('enrichment/backfill')
@RequirePermission(Permission.ManageMetadataConfig)
- async enqueueBackfill() {
- const queued = await this.enrichmentOrchestrator.backfillLinkedAuthors();
+ async enqueueBackfill(@Body() dto?: AuthorEnrichmentBackfillDto) {
+ const queued = await this.enrichmentOrchestrator.backfillLinkedAuthors({ ignoreCache: dto?.ignoreCache === true });
return { queued };
}
@Post('enrichment/backfill-all')
@RequirePermission(Permission.ManageMetadataConfig)
- async enqueueBackfillAll() {
- const queued = await this.enrichmentOrchestrator.backfillAllLinkedAuthors();
+ async enqueueBackfillAll(@Body() dto?: AuthorEnrichmentBackfillDto) {
+ const queued = await this.enrichmentOrchestrator.backfillAllLinkedAuthors({ ignoreCache: dto?.ignoreCache === true });
return { queued };
}
diff --git a/server/src/modules/authors/authors.module.test.ts b/server/src/modules/authors/authors.module.test.ts
index e9851ba1c..34bf8e52d 100644
--- a/server/src/modules/authors/authors.module.test.ts
+++ b/server/src/modules/authors/authors.module.test.ts
@@ -8,6 +8,7 @@ vi.mock('../metadata/metadata.module', () => ({ MetadataModule: class MetadataMo
import { MODULE_METADATA } from '@nestjs/common/constants';
+import { MetadataCacheModule } from '../metadata-cache/metadata-cache.module';
import { AuthorsController } from './authors.controller';
import { AuthorsModule } from './authors.module';
import { AuthorsRepository } from './authors.repository';
@@ -20,6 +21,7 @@ describe('AuthorsModule', () => {
const providers = Reflect.getMetadata(MODULE_METADATA.PROVIDERS, AuthorsModule) as Array;
expect(controllers).toEqual([AuthorsController]);
+ expect(Reflect.getMetadata(MODULE_METADATA.IMPORTS, AuthorsModule)).toEqual(expect.arrayContaining([MetadataCacheModule]));
expect(providers).toEqual(expect.arrayContaining([AuthorsService, AuthorsRepository]));
expect(providers).toEqual(
expect.arrayContaining([
diff --git a/server/src/modules/authors/authors.module.ts b/server/src/modules/authors/authors.module.ts
index c732993bb..76e3fe89b 100644
--- a/server/src/modules/authors/authors.module.ts
+++ b/server/src/modules/authors/authors.module.ts
@@ -9,6 +9,7 @@ import { BookModule } from '../book/book.module';
import { BookCoverStoreModule } from '../book-cover-store/book-cover-store.module';
import { LibraryModule } from '../library/library.module';
import { MetadataModule } from '../metadata/metadata.module';
+import { MetadataCacheModule } from '../metadata-cache/metadata-cache.module';
import { MetadataScoreModule } from '../metadata-score/metadata-score.module';
import { NotificationModule } from '../notification/notification.module';
import { AuthorImageStorageService } from './author-image-storage.service';
@@ -41,6 +42,7 @@ const AUTHOR_PROVIDER_CLASSES = [AudnexusAuthorMetadataProvider, GoodreadsAuthor
LibraryModule,
AppSettingsModule,
MetadataModule,
+ MetadataCacheModule,
MetadataPreferencesModule,
MetadataScoreModule,
forwardRef(() => NotificationModule),
diff --git a/server/src/modules/authors/authors.service.test.ts b/server/src/modules/authors/authors.service.test.ts
index db67fe63d..4c6545e92 100644
--- a/server/src/modules/authors/authors.service.test.ts
+++ b/server/src/modules/authors/authors.service.test.ts
@@ -238,6 +238,8 @@ describe('AuthorsService', () => {
provider: 'audnexus',
descriptionUpdated: true,
imageUpdated: false,
+ outboundRequests: 2,
+ cacheServed: [],
});
authorsRepo.findById.mockResolvedValue({
id: 20,
@@ -250,11 +252,34 @@ describe('AuthorsService', () => {
const result = await service.refreshEnrichment(reqUser(), 20);
- expect(enrichmentExecutor.execute).toHaveBeenCalledWith({
- authorId: 20,
- preferences: expect.objectContaining({ fields: expect.any(Object) }),
- });
+ expect(enrichmentExecutor.execute).toHaveBeenCalledWith(
+ {
+ authorId: 20,
+ preferences: expect.objectContaining({ fields: expect.any(Object) }),
+ },
+ { policy: 'refresh', allowFallback: true },
+ );
expect(result.description).toBe('Provider description');
+ expect(result).not.toHaveProperty('metadataCache');
+ });
+
+ it('refreshEnrichment reports only the providers that fell back to saved answers', async () => {
+ authorsRepo.findVisibleAuthorIds.mockResolvedValue([25]);
+ authorsRepo.findRelatedLibraryIds.mockResolvedValue([1]);
+ const fallback = { provider: 'goodreads', source: 'fallback', stale: true, cachedAt: '2026-08-01T00:00:00.000Z' };
+ enrichmentExecutor.execute.mockResolvedValue({
+ kind: 'done',
+ provider: 'goodreads',
+ descriptionUpdated: true,
+ imageUpdated: false,
+ outboundRequests: 3,
+ cacheServed: [{ provider: 'audnexus', source: 'cache', stale: false, cachedAt: '2026-09-01T00:00:00.000Z' }, fallback],
+ });
+ authorsRepo.findById.mockResolvedValue({ id: 25, name: 'Jane Doe', sortName: null, description: 'Bio', bookCount: 1, lastAddedAt: null });
+
+ const result = await service.refreshEnrichment(reqUser(), 25);
+
+ expect(result).toEqual(expect.objectContaining({ id: 25, metadataCache: { fallbackProviders: [fallback] } }));
});
it('refreshEnrichment stores fetched author image on disk when provider returns one', async () => {
@@ -265,6 +290,8 @@ describe('AuthorsService', () => {
provider: 'audnexus',
descriptionUpdated: false,
imageUpdated: true,
+ outboundRequests: 2,
+ cacheServed: [],
});
authorsRepo.findById.mockResolvedValue({
id: 21,
@@ -277,10 +304,13 @@ describe('AuthorsService', () => {
await service.refreshEnrichment(reqUser(), 21);
- expect(enrichmentExecutor.execute).toHaveBeenCalledWith({
- authorId: 21,
- preferences: expect.objectContaining({ fields: expect.any(Object) }),
- });
+ expect(enrichmentExecutor.execute).toHaveBeenCalledWith(
+ {
+ authorId: 21,
+ preferences: expect.objectContaining({ fields: expect.any(Object) }),
+ },
+ { policy: 'refresh', allowFallback: true },
+ );
});
it('refreshEnrichment throws when provider fails so callers can surface failure', async () => {
@@ -669,7 +699,28 @@ describe('AuthorsService', () => {
await expect(service.searchMetadata({ q: 'John Smith', region: 'us', limit: 2, providers: ['audnexus'] })).resolves.toEqual([
{ provider: 'audnexus', providerId: 'A1', name: 'John Smith' },
]);
- expect(authorMetadataFetchService.search).toHaveBeenCalledWith({ name: 'John Smith', region: 'us', limit: 2 }, { keys: ['audnexus'] });
+ expect(authorMetadataFetchService.search).toHaveBeenCalledWith(
+ { name: 'John Smith', region: 'us', limit: 2 },
+ { keys: ['audnexus'], directive: { policy: 'default', allowFallback: true } },
+ );
+ });
+
+ it('searchMetadata goes live when the client asks to refresh', async () => {
+ authorMetadataFetchService.search.mockResolvedValue([]);
+
+ await service.searchMetadata({ q: 'John Smith', cache: 'refresh' });
+
+ expect(authorMetadataFetchService.search).toHaveBeenCalledWith(expect.anything(), {
+ keys: undefined,
+ directive: { policy: 'refresh', allowFallback: true },
+ });
+ });
+
+ it('searchMetadata returns cache provenance on candidates unchanged', async () => {
+ const cached = { provider: 'audnexus', providerId: 'A1', name: 'John Smith', cache: { fetchedAt: '2026-09-01T00:00:00.000Z', stale: false } };
+ authorMetadataFetchService.search.mockResolvedValue([cached]);
+
+ await expect(service.searchMetadata({ q: 'John Smith' })).resolves.toEqual([cached]);
});
it('lookupMetadata forwards provider key and id', async () => {
@@ -680,6 +731,14 @@ describe('AuthorsService', () => {
providerId: 'A1',
name: 'John Smith',
});
+ expect(authorMetadataFetchService.lookupById).toHaveBeenCalledWith('audnexus', 'A1', 'ca', {
+ directive: { policy: 'default', allowFallback: true },
+ });
+
+ await service.lookupMetadata({ provider: 'audnexus', id: 'A1', cache: 'refresh' });
+ expect(authorMetadataFetchService.lookupById).toHaveBeenLastCalledWith('audnexus', 'A1', undefined, {
+ directive: { policy: 'refresh', allowFallback: true },
+ });
});
it('streamMetadata proxies through provider stream options', () => {
@@ -689,7 +748,16 @@ describe('AuthorsService', () => {
const stream = service.streamMetadata({ q: 'Jane', region: 'us', limit: 5, providers: ['audnexus'] });
expect(stream).toBe(mockStream);
- expect(authorMetadataFetchService.stream).toHaveBeenCalledWith({ name: 'Jane', region: 'us', limit: 5 }, { keys: ['audnexus'] });
+ expect(authorMetadataFetchService.stream).toHaveBeenCalledWith(
+ { name: 'Jane', region: 'us', limit: 5 },
+ { keys: ['audnexus'], directive: { policy: 'default', allowFallback: true } },
+ );
+
+ service.streamMetadata({ q: 'Jane', cache: 'refresh' });
+ expect(authorMetadataFetchService.stream).toHaveBeenLastCalledWith(expect.anything(), {
+ keys: undefined,
+ directive: { policy: 'refresh', allowFallback: true },
+ });
});
it('update returns current detail when no mutable fields are provided', async () => {
@@ -835,6 +903,73 @@ describe('AuthorsService', () => {
expect(progress).toHaveBeenCalledTimes(2);
});
+ describe('bulkRefreshMetadata metadata cache', () => {
+ function doneResult(outboundRequests: number) {
+ return { kind: 'done', provider: 'audnexus', descriptionUpdated: true, imageUpdated: false, outboundRequests, cacheServed: [] };
+ }
+
+ beforeEach(() => {
+ authorsRepo.findVisibleAuthorIds.mockResolvedValue([1, 2, 3]);
+ authorsRepo.findRelatedLibraryIds.mockResolvedValue([1]);
+ });
+
+ it('uses the cache with fallback by default', async () => {
+ authorsRepo.findVisibleAuthorIds.mockResolvedValue([1]);
+ enrichmentExecutor.execute.mockResolvedValue(doneResult(1));
+ vi.spyOn(service as any, 'sleep').mockResolvedValue(undefined);
+
+ await service.bulkRefreshMetadata([1], reqUser());
+
+ expect(enrichmentExecutor.execute).toHaveBeenCalledWith(expect.objectContaining({ authorId: 1 }), { policy: 'default', allowFallback: true });
+ });
+
+ it('goes live with fallback when asked to ignore the cache', async () => {
+ authorsRepo.findVisibleAuthorIds.mockResolvedValue([1]);
+ enrichmentExecutor.execute.mockResolvedValue(doneResult(1));
+ vi.spyOn(service as any, 'sleep').mockResolvedValue(undefined);
+
+ await service.bulkRefreshMetadata([1], reqUser(), undefined, { ignoreCache: true });
+
+ expect(enrichmentExecutor.execute).toHaveBeenCalledWith(expect.objectContaining({ authorId: 1 }), { policy: 'refresh', allowFallback: true });
+ });
+
+ it('only yields between items that made no outbound requests', async () => {
+ enrichmentExecutor.execute.mockResolvedValueOnce(doneResult(0)).mockResolvedValueOnce(doneResult(3)).mockResolvedValueOnce(doneResult(0));
+ const sleep = vi.spyOn(service as any, 'sleep').mockResolvedValue(undefined);
+
+ await service.bulkRefreshMetadata([1, 2, 3], reqUser());
+
+ expect(sleep).toHaveBeenCalledTimes(2);
+ expect(sleep).toHaveBeenNthCalledWith(1, 100);
+ const providerDelay = sleep.mock.calls[1]![0] as number;
+ expect(providerDelay).toBeGreaterThanOrEqual(250);
+ expect(providerDelay).toBeLessThanOrEqual(1_000);
+ });
+
+ it('keeps the provider delay after a failed item', async () => {
+ authorsRepo.findVisibleAuthorIds.mockResolvedValue([1, 2]);
+ enrichmentExecutor.execute
+ .mockResolvedValueOnce({
+ kind: 'failed',
+ message: 'down',
+ provider: 'audnexus',
+ httpStatus: 503,
+ retryAfterMs: null,
+ transient: true,
+ descriptionUpdated: false,
+ imageUpdated: false,
+ outboundRequests: 0,
+ cacheServed: [],
+ })
+ .mockResolvedValueOnce(doneResult(0));
+ const sleep = vi.spyOn(service as any, 'sleep').mockResolvedValue(undefined);
+
+ await service.bulkRefreshMetadata([1, 2], reqUser());
+
+ expect((sleep.mock.calls[0]![0] as number) >= 250).toBe(true);
+ });
+ });
+
describe('findJumpBuckets', () => {
it('turns letter counts into running offsets the rail can scroll to', async () => {
authorsRepo.findLetterCounts.mockResolvedValue([
diff --git a/server/src/modules/authors/authors.service.ts b/server/src/modules/authors/authors.service.ts
index 8dd08f972..76f34feb9 100644
--- a/server/src/modules/authors/authors.service.ts
+++ b/server/src/modules/authors/authors.service.ts
@@ -4,17 +4,20 @@ import { Observable } from 'rxjs';
import type {
AuthorBooksPage,
AuthorDetail,
- AuthorMetadataCandidate,
AuthorMetadataProviderInfo,
+ AuthorMetadataSearchCandidateEvent,
AuthorSummary,
AuthorsPage,
JumpBucketsResponse,
MergeAuthorsResult,
+ MetadataCacheServedProvider,
+ MetadataCacheUsage,
} from '@bookorbit/types';
import { assembleBookCards, assembleCollapsedBookCards } from '../book/utils/assemble-book-cards';
import { MAX_OFFSET_ROWS, isOffsetWithinLimit } from '../../common/constants/pagination.constants';
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
import { normalizeMetadataText } from '../../common/utils/metadata-text-normalize.utils';
+import type { CacheDirective } from '../../common/provider-cache/provider-cache-spec';
import type { RequestUser } from '../../common/types/request-user';
import { BookReadService } from '../book/book-read.service';
import { BookCoverStore } from '../book-cover-store/book-cover-store.service';
@@ -22,6 +25,12 @@ import { LibraryService } from '../library/library.service';
import { AuthorMetadataPreferencesService } from './author-metadata-preferences.service';
import { MetadataScoreService } from '../metadata-score/metadata-score.service';
import { AuthorImageStorageError, AuthorImageStorageService } from './author-image-storage.service';
+import {
+ bulkRefreshCacheDirective,
+ CACHE_ONLY_ITEM_YIELD_MS,
+ interactiveCacheDirective,
+ SINGLE_REFRESH_CACHE_DIRECTIVE,
+} from './author-cache-directives';
import { AUTHOR_ENRICHMENT_REASONS } from './author-enrichment-reasons';
import { AuthorEnrichmentExecutorService } from './author-enrichment-executor.service';
import { AuthorEnrichmentOrchestratorService } from './author-enrichment-orchestrator.service';
@@ -35,6 +44,17 @@ import { MergeAuthorsDto } from './dto/merge-authors.dto';
import { UpdateAuthorDto } from './dto/update-author.dto';
import { AuthorMetadataFetchService } from './metadata/author-metadata-fetch.service';
+/** A refreshed author, plus the providers that failed live and were answered from saved results. */
+export type AuthorRefreshResult = AuthorDetail & { metadataCache?: MetadataCacheUsage };
+
+type EnrichmentRefreshOutcome = {
+ descriptionUpdated: boolean;
+ imageUpdated: boolean;
+ provider: string | null;
+ outboundRequests: number;
+ cacheServed: MetadataCacheServedProvider[];
+};
+
@Injectable()
export class AuthorsService {
static readonly MAX_AUTHOR_IMAGE_BYTES = 20 * 1024 * 1024;
@@ -230,11 +250,11 @@ export class AuthorsService {
return this.authorMetadataFetchService.listProviders();
}
- async searchMetadata(dto: ListAuthorMetadataDto): Promise {
+ async searchMetadata(dto: ListAuthorMetadataDto): Promise {
const event = 'author.search_metadata';
const startedAt = Date.now();
this.logger.log(
- `[${event}] [start] query=${JSON.stringify(dto.q)} region=${dto.region ?? 'default'} limit=${dto.limit ?? 0} providerCount=${dto.providers?.length ?? 0} - author metadata search started`,
+ `[${event}] [start] query=${JSON.stringify(dto.q)} region=${dto.region ?? 'default'} limit=${dto.limit ?? 0} providerCount=${dto.providers?.length ?? 0} cache=${dto.cache ?? 'default'} - author metadata search started`,
);
try {
const result = await this.authorMetadataFetchService.search(
@@ -243,7 +263,7 @@ export class AuthorsService {
region: dto.region,
limit: dto.limit,
},
- { keys: dto.providers },
+ { keys: dto.providers, directive: interactiveCacheDirective(dto.cache) },
);
this.logger.log(
`[${event}] [end] query=${JSON.stringify(dto.q)} durationMs=${Date.now() - startedAt} resultCount=${result.length} - author metadata search completed`,
@@ -259,14 +279,16 @@ export class AuthorsService {
}
}
- async lookupMetadata(dto: LookupAuthorMetadataDto): Promise {
+ async lookupMetadata(dto: LookupAuthorMetadataDto): Promise {
const event = 'author.lookup_metadata';
const startedAt = Date.now();
this.logger.log(
- `[${event}] [start] provider=${dto.provider} providerId=${JSON.stringify(dto.id)} region=${dto.region ?? 'default'} - author metadata lookup started`,
+ `[${event}] [start] provider=${dto.provider} providerId=${JSON.stringify(dto.id)} region=${dto.region ?? 'default'} cache=${dto.cache ?? 'default'} - author metadata lookup started`,
);
try {
- const result = await this.authorMetadataFetchService.lookupById(dto.provider, dto.id, dto.region);
+ const result = await this.authorMetadataFetchService.lookupById(dto.provider, dto.id, dto.region, {
+ directive: interactiveCacheDirective(dto.cache),
+ });
this.logger.log(
`[${event}] [end] provider=${dto.provider} providerId=${JSON.stringify(dto.id)} durationMs=${Date.now() - startedAt} found=${result != null} - author metadata lookup completed`,
);
@@ -281,14 +303,14 @@ export class AuthorsService {
}
}
- streamMetadata(dto: ListAuthorMetadataDto): Observable {
+ streamMetadata(dto: ListAuthorMetadataDto): Observable {
return this.authorMetadataFetchService.stream(
{
name: dto.q,
region: dto.region,
limit: dto.limit,
},
- { keys: dto.providers },
+ { keys: dto.providers, directive: interactiveCacheDirective(dto.cache) },
);
}
@@ -420,19 +442,20 @@ export class AuthorsService {
}
}
- async refreshEnrichment(user: RequestUser, authorId: number): Promise {
+ async refreshEnrichment(user: RequestUser, authorId: number): Promise {
const event = 'author.refresh_enrichment';
const startedAt = Date.now();
this.logger.log(`[${event}] [start] userId=${user.id} authorId=${authorId} - author enrichment refresh started`);
try {
await this.assertMutationAccess(user, [authorId]);
- const result = await this.refreshEnrichmentInternal(authorId);
+ const result = await this.refreshEnrichmentInternal(authorId, SINGLE_REFRESH_CACHE_DIRECTIVE);
const detail = await this.findOne(user, authorId);
+ const fallbackProviders = result.cacheServed.filter((served) => served.source === 'fallback');
this.logger.log(
- `[${event}] [end] userId=${user.id} authorId=${authorId} durationMs=${Date.now() - startedAt} provider=${result.provider ?? 'none'} descriptionUpdated=${result.descriptionUpdated} imageUpdated=${result.imageUpdated} - author enrichment refresh completed`,
+ `[${event}] [end] userId=${user.id} authorId=${authorId} durationMs=${Date.now() - startedAt} provider=${result.provider ?? 'none'} descriptionUpdated=${result.descriptionUpdated} imageUpdated=${result.imageUpdated} cacheFallbacks=${fallbackProviders.length} - author enrichment refresh completed`,
);
- return detail;
+ return fallbackProviders.length > 0 ? { ...detail, metadataCache: { fallbackProviders } } : detail;
} catch (err) {
const errorClass = err instanceof Error ? err.name : 'Error';
const errorMessage = sanitizeLogValue(err instanceof Error ? err.message : String(err));
@@ -527,10 +550,15 @@ export class AuthorsService {
authorIds: number[],
user: RequestUser,
onProgress?: (event: { authorId: number; updated: boolean; imageUpdated?: boolean; imageUrl?: string | null; error?: string }) => void,
+ options?: { ignoreCache?: boolean },
): Promise<{ processed: number; failed: number; updated: number }> {
const event = 'author.bulk_refresh_metadata';
const startedAt = Date.now();
- this.logger.log(`[${event}] [start] userId=${user.id} count=${authorIds.length} - bulk author metadata refresh started`);
+ const ignoreCache = options?.ignoreCache === true;
+ const directive = bulkRefreshCacheDirective(ignoreCache);
+ this.logger.log(
+ `[${event}] [start] userId=${user.id} count=${authorIds.length} ignoreCache=${ignoreCache} - bulk author metadata refresh started`,
+ );
try {
const uniqueAuthorIds = [...new Set(authorIds)];
if (uniqueAuthorIds.length === 0) {
@@ -553,9 +581,11 @@ export class AuthorsService {
let imageUpdated = false;
let imageUrl: string | null | undefined;
let errorMessage: string | undefined;
+ let outboundRequests: number | undefined;
try {
- const result = await this.refreshEnrichmentInternal(authorId);
+ const result = await this.refreshEnrichmentInternal(authorId, directive);
+ outboundRequests = result.outboundRequests;
didUpdate = result.descriptionUpdated || result.imageUpdated;
imageUpdated = result.imageUpdated;
if (imageUpdated) {
@@ -584,7 +614,7 @@ export class AuthorsService {
}
if (index < uniqueAuthorIds.length - 1) {
- await this.sleep(this.getBulkAudnexusDelayMs());
+ await this.sleep(outboundRequests === 0 ? CACHE_ONLY_ITEM_YIELD_MS : this.getBulkAudnexusDelayMs());
}
}
@@ -694,11 +724,9 @@ export class AuthorsService {
};
}
- private async refreshEnrichmentInternal(
- authorId: number,
- ): Promise<{ descriptionUpdated: boolean; imageUpdated: boolean; provider: string | null }> {
+ private async refreshEnrichmentInternal(authorId: number, directive: CacheDirective): Promise {
const preferences = await this.metadataPreferences.getPreferences();
- const result = await this.enrichmentExecutor.execute({ authorId, preferences });
+ const result = await this.enrichmentExecutor.execute({ authorId, preferences }, directive);
if (result.kind === 'skipped' && result.reason === 'author_not_found') {
throw new NotFoundException('Author not found');
@@ -713,6 +741,8 @@ export class AuthorsService {
descriptionUpdated: result.descriptionUpdated,
imageUpdated: result.imageUpdated,
provider: result.provider,
+ outboundRequests: result.outboundRequests,
+ cacheServed: result.cacheServed,
};
}
diff --git a/server/src/modules/authors/dto/author-enrichment-backfill.dto.ts b/server/src/modules/authors/dto/author-enrichment-backfill.dto.ts
new file mode 100644
index 000000000..38d8535d2
--- /dev/null
+++ b/server/src/modules/authors/dto/author-enrichment-backfill.dto.ts
@@ -0,0 +1,8 @@
+import { IsBoolean, IsOptional } from 'class-validator';
+
+export class AuthorEnrichmentBackfillDto {
+ /** Queue the authors so their enrichment skips saved provider answers. Absent keeps using the cache. */
+ @IsOptional()
+ @IsBoolean()
+ ignoreCache?: boolean;
+}
diff --git a/server/src/modules/authors/dto/authors-dto-validation.test.ts b/server/src/modules/authors/dto/authors-dto-validation.test.ts
index 51085f985..a9c76c7bd 100644
--- a/server/src/modules/authors/dto/authors-dto-validation.test.ts
+++ b/server/src/modules/authors/dto/authors-dto-validation.test.ts
@@ -1,10 +1,13 @@
import 'reflect-metadata';
+import { BadRequestException, ValidationPipe } from '@nestjs/common';
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { AuthorMetadataProviderKey } from '@bookorbit/types';
+import { AuthorEnrichmentBackfillDto } from './author-enrichment-backfill.dto';
import { BulkAuthorIdsDto } from './bulk-author-ids.dto';
+import { BulkRefreshAuthorsMetadataDto } from './bulk-refresh-authors-metadata.dto';
import { DeleteAuthorsDto } from './delete-authors.dto';
import { ListAuthorBooksDto } from './list-author-books.dto';
import { ListAuthorMetadataDto } from './list-author-metadata.dto';
@@ -129,4 +132,42 @@ describe('Authors DTO validation', () => {
expect((await errorsFor(PreviewAuthorEnrichmentCountDto, {})).length).toBeGreaterThan(0);
expect((await errorsFor(PreviewAuthorEnrichmentCountDto, { conditions: { neverEnriched: 'true' } })).length).toBeGreaterThan(0);
});
+
+ describe('metadata cache options', () => {
+ const pipe = new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true });
+ const body = (metatype: new () => T, value: unknown) => pipe.transform(value, { type: 'body', metatype }) as Promise;
+ const query = (metatype: new () => T, value: unknown) => pipe.transform(value, { type: 'query', metatype }) as Promise;
+
+ it('accepts an optional cache policy on metadata search and lookup queries', async () => {
+ expect((await query(ListAuthorMetadataDto, { q: 'Jane' })).cache).toBeUndefined();
+ await expect(query(ListAuthorMetadataDto, { q: 'Jane', cache: 'refresh' })).resolves.toMatchObject({ cache: 'refresh' });
+ await expect(query(ListAuthorMetadataDto, { q: 'Jane', cache: 'default' })).resolves.toMatchObject({ cache: 'default' });
+ await expect(query(LookupAuthorMetadataDto, { provider: 'audnexus', id: 'A1', cache: 'refresh' })).resolves.toMatchObject({ cache: 'refresh' });
+ });
+
+ it('rejects cache policies a client may not choose', async () => {
+ await expect(query(ListAuthorMetadataDto, { q: 'Jane', cache: 'bypass' })).rejects.toBeInstanceOf(BadRequestException);
+ await expect(query(ListAuthorMetadataDto, { q: 'Jane', cache: 'only-if-cached' })).rejects.toBeInstanceOf(BadRequestException);
+ await expect(query(LookupAuthorMetadataDto, { provider: 'audnexus', id: 'A1', cache: 'live' })).rejects.toBeInstanceOf(BadRequestException);
+ });
+
+ it('accepts an optional ignoreCache flag on the bulk refresh body', async () => {
+ await expect(body(BulkRefreshAuthorsMetadataDto, { authorIds: [1, 2] })).resolves.toMatchObject({ authorIds: [1, 2] });
+ await expect(body(BulkRefreshAuthorsMetadataDto, { authorIds: [1], ignoreCache: true })).resolves.toMatchObject({ ignoreCache: true });
+ await expect(body(BulkRefreshAuthorsMetadataDto, { authorIds: [1], ignoreCache: 'yes' })).rejects.toBeInstanceOf(BadRequestException);
+ await expect(body(BulkRefreshAuthorsMetadataDto, { authorIds: [] })).rejects.toBeInstanceOf(BadRequestException);
+ });
+
+ it('keeps rejecting ignoreCache on other bulk author bodies', async () => {
+ await expect(body(BulkAuthorIdsDto, { authorIds: [1], ignoreCache: true })).rejects.toBeInstanceOf(BadRequestException);
+ });
+
+ it('accepts an absent, empty or flagged backfill body', async () => {
+ await expect(body(AuthorEnrichmentBackfillDto, undefined)).resolves.toBeInstanceOf(AuthorEnrichmentBackfillDto);
+ expect((await body(AuthorEnrichmentBackfillDto, {})).ignoreCache).toBeUndefined();
+ await expect(body(AuthorEnrichmentBackfillDto, { ignoreCache: true })).resolves.toMatchObject({ ignoreCache: true });
+ await expect(body(AuthorEnrichmentBackfillDto, { ignoreCache: 1 })).rejects.toBeInstanceOf(BadRequestException);
+ await expect(body(AuthorEnrichmentBackfillDto, { force: true })).rejects.toBeInstanceOf(BadRequestException);
+ });
+ });
});
diff --git a/server/src/modules/authors/dto/bulk-refresh-authors-metadata.dto.ts b/server/src/modules/authors/dto/bulk-refresh-authors-metadata.dto.ts
new file mode 100644
index 000000000..353ed35ac
--- /dev/null
+++ b/server/src/modules/authors/dto/bulk-refresh-authors-metadata.dto.ts
@@ -0,0 +1,10 @@
+import { IsBoolean, IsOptional } from 'class-validator';
+
+import { BulkAuthorIdsDto } from './bulk-author-ids.dto';
+
+export class BulkRefreshAuthorsMetadataDto extends BulkAuthorIdsDto {
+ /** Skip saved provider answers and ask every provider live. Absent keeps using the cache. */
+ @IsOptional()
+ @IsBoolean()
+ ignoreCache?: boolean;
+}
diff --git a/server/src/modules/authors/dto/list-author-metadata.dto.ts b/server/src/modules/authors/dto/list-author-metadata.dto.ts
index b7019d174..73ba170e9 100644
--- a/server/src/modules/authors/dto/list-author-metadata.dto.ts
+++ b/server/src/modules/authors/dto/list-author-metadata.dto.ts
@@ -1,6 +1,6 @@
import { Transform, Type } from 'class-transformer';
-import { IsEnum, IsInt, IsNotEmpty, IsOptional, IsString, Max, Min } from 'class-validator';
-import { AuthorMetadataProviderKey } from '@bookorbit/types';
+import { IsEnum, IsIn, IsInt, IsNotEmpty, IsOptional, IsString, Max, Min } from 'class-validator';
+import { AuthorMetadataProviderKey, METADATA_CACHE_REQUEST_POLICIES, type MetadataCacheRequestPolicy } from '@bookorbit/types';
export class ListAuthorMetadataDto {
@IsString()
@@ -38,4 +38,12 @@ export class ListAuthorMetadataDto {
})
@IsEnum(AuthorMetadataProviderKey, { each: true })
providers?: AuthorMetadataProviderKey[];
+
+ /**
+ * `refresh` skips saved answers and asks every provider live. Clients must only send it to servers
+ * advertising the `metadata-search-cache` feature.
+ */
+ @IsOptional()
+ @IsIn(METADATA_CACHE_REQUEST_POLICIES)
+ cache?: MetadataCacheRequestPolicy;
}
diff --git a/server/src/modules/authors/dto/lookup-author-metadata.dto.ts b/server/src/modules/authors/dto/lookup-author-metadata.dto.ts
index ca6a7ee93..fad83491b 100644
--- a/server/src/modules/authors/dto/lookup-author-metadata.dto.ts
+++ b/server/src/modules/authors/dto/lookup-author-metadata.dto.ts
@@ -1,5 +1,5 @@
-import { IsEnum, IsNotEmpty, IsOptional, IsString } from 'class-validator';
-import { AuthorMetadataProviderKey } from '@bookorbit/types';
+import { IsEnum, IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator';
+import { AuthorMetadataProviderKey, METADATA_CACHE_REQUEST_POLICIES, type MetadataCacheRequestPolicy } from '@bookorbit/types';
export class LookupAuthorMetadataDto {
@IsEnum(AuthorMetadataProviderKey)
@@ -12,4 +12,12 @@ export class LookupAuthorMetadataDto {
@IsOptional()
@IsString()
region?: string;
+
+ /**
+ * `refresh` skips a saved answer and asks the provider live. Clients must only send it to servers
+ * advertising the `metadata-search-cache` feature.
+ */
+ @IsOptional()
+ @IsIn(METADATA_CACHE_REQUEST_POLICIES)
+ cache?: MetadataCacheRequestPolicy;
}
diff --git a/server/src/modules/authors/metadata/author-cache-codecs.test.ts b/server/src/modules/authors/metadata/author-cache-codecs.test.ts
new file mode 100644
index 000000000..15e9215da
--- /dev/null
+++ b/server/src/modules/authors/metadata/author-cache-codecs.test.ts
@@ -0,0 +1,83 @@
+import type { AuthorMetadataCandidate } from '@bookorbit/types';
+
+import { AUTHOR_CANDIDATE_FIELDS, authorLookupCodec, authorSearchCodec } from './author-cache-codecs';
+
+const FULL: AuthorMetadataCandidate = {
+ provider: 'goodreads',
+ providerId: '42',
+ name: 'Jane Doe',
+ description: 'Bio',
+ imageUrl: 'https://img.example/jane.jpg',
+ sourceUrl: 'https://www.goodreads.com/author/show/42',
+ birthDate: '1929-10-21',
+ birthYear: 1929,
+ deathDate: '2018-01-22',
+ deathYear: 2018,
+ website: 'https://jane.example',
+ genres: ['Fantasy'],
+ influences: ['Someone'],
+};
+
+describe('author cache codecs', () => {
+ it('allow-lists every candidate field', () => {
+ expect(Object.keys(AUTHOR_CANDIDATE_FIELDS).sort()).toEqual(Object.keys(FULL).sort());
+ });
+
+ describe('search codec', () => {
+ it('keeps every known field and drops unknown ones', () => {
+ const withExtra = { ...FULL, cache: { fetchedAt: 'x', stale: false }, secret: 'nope' } as AuthorMetadataCandidate;
+
+ expect(authorSearchCodec.sanitize([withExtra])).toEqual([FULL]);
+ });
+
+ it('round-trips through JSON', () => {
+ const stored = JSON.parse(JSON.stringify(authorSearchCodec.sanitize([FULL]))) as unknown;
+
+ expect(authorSearchCodec.parse(stored)).toEqual([FULL]);
+ });
+
+ it('rejects payloads that do not have the candidate shape', () => {
+ expect(authorSearchCodec.parse(null)).toBeUndefined();
+ expect(authorSearchCodec.parse({})).toBeUndefined();
+ expect(authorSearchCodec.parse(['x'])).toBeUndefined();
+ expect(authorSearchCodec.parse([{ provider: 'goodreads', providerId: '42' }])).toBeUndefined();
+ expect(authorSearchCodec.parse([{ provider: 'goodreads', providerId: 42, name: 'Jane' }])).toBeUndefined();
+ expect(authorSearchCodec.parse([{ provider: 1, providerId: '42', name: 'Jane' }])).toBeUndefined();
+ expect(authorSearchCodec.parse([FULL, { provider: 'goodreads' }])).toBeUndefined();
+ });
+
+ it('drops unknown fields while parsing', () => {
+ expect(authorSearchCodec.parse([{ provider: 'audnexus', providerId: 'A1', name: 'Jane', extra: true }])).toEqual([
+ { provider: 'audnexus', providerId: 'A1', name: 'Jane' },
+ ]);
+ });
+
+ it('rejects known fields of the wrong type and reads null as absent', () => {
+ expect(authorSearchCodec.parse([{ ...FULL, genres: 'Fantasy' }])).toBeUndefined();
+ expect(authorSearchCodec.parse([{ ...FULL, birthYear: '1929' }])).toBeUndefined();
+ expect(authorSearchCodec.parse([{ ...FULL, website: null }])?.[0]).not.toHaveProperty('website');
+ });
+
+ it('treats an empty list as empty and slices to the cap', () => {
+ const second = { ...FULL, providerId: '43' };
+
+ expect(authorSearchCodec.isEmpty([])).toBe(true);
+ expect(authorSearchCodec.isEmpty([FULL])).toBe(false);
+ expect(authorSearchCodec.slice([FULL, second], 1)).toEqual([FULL]);
+ expect(authorSearchCodec.publishedDates([FULL])).toEqual([]);
+ });
+ });
+
+ describe('lookup codec', () => {
+ it('handles a found and a missing candidate', () => {
+ expect(authorLookupCodec.isEmpty(null)).toBe(true);
+ expect(authorLookupCodec.isEmpty(FULL)).toBe(false);
+ expect(authorLookupCodec.sanitize(null)).toBeNull();
+ expect(authorLookupCodec.sanitize({ ...FULL, extra: 1 } as AuthorMetadataCandidate)).toEqual(FULL);
+ expect(authorLookupCodec.parse(JSON.parse(JSON.stringify(FULL)))).toEqual(FULL);
+ expect(authorLookupCodec.parse({ provider: 'goodreads' })).toBeUndefined();
+ expect(authorLookupCodec.slice(FULL, 0)).toBe(FULL);
+ expect(authorLookupCodec.publishedDates(FULL)).toEqual([]);
+ });
+ });
+});
diff --git a/server/src/modules/authors/metadata/author-cache-codecs.ts b/server/src/modules/authors/metadata/author-cache-codecs.ts
new file mode 100644
index 000000000..c8fa80b5a
--- /dev/null
+++ b/server/src/modules/authors/metadata/author-cache-codecs.ts
@@ -0,0 +1,52 @@
+import type { AuthorMetadataCandidate } from '@bookorbit/types';
+
+import { parseFields, pickAllowed, type CacheCodec, type CacheFieldKind } from '../../../common/provider-cache/cache-codec';
+
+/**
+ * Every candidate field the cache may store, with its JSON type. Typed as a full record so adding a
+ * field to `AuthorMetadataCandidate` fails to compile until it is listed here, instead of silently
+ * vanishing from cached answers.
+ */
+export const AUTHOR_CANDIDATE_FIELDS: Record = {
+ provider: 'string',
+ providerId: 'string',
+ name: 'string',
+ description: 'string',
+ imageUrl: 'string',
+ sourceUrl: 'string',
+ birthDate: 'string',
+ birthYear: 'number',
+ deathDate: 'string',
+ deathYear: 'number',
+ website: 'string',
+ genres: 'string[]',
+ influences: 'string[]',
+};
+
+function sanitizeCandidate(candidate: AuthorMetadataCandidate): AuthorMetadataCandidate {
+ return pickAllowed(candidate, AUTHOR_CANDIDATE_FIELDS);
+}
+
+function parseCandidate(value: unknown): AuthorMetadataCandidate | null {
+ return parseFields(value, AUTHOR_CANDIDATE_FIELDS, ['provider', 'providerId', 'name']);
+}
+
+export const authorSearchCodec: CacheCodec = {
+ isEmpty: (value) => value.length === 0,
+ sanitize: (value) => value.map(sanitizeCandidate),
+ parse: (payload) => {
+ if (!Array.isArray(payload)) return undefined;
+ const parsed = payload.map(parseCandidate);
+ return parsed.every((candidate): candidate is AuthorMetadataCandidate => candidate !== null) ? parsed : undefined;
+ },
+ slice: (value, max) => value.slice(0, max),
+ publishedDates: () => [],
+};
+
+export const authorLookupCodec: CacheCodec = {
+ isEmpty: (value) => value === null,
+ sanitize: (value) => (value ? sanitizeCandidate(value) : null),
+ parse: (payload) => (payload === null ? null : (parseCandidate(payload) ?? undefined)),
+ slice: (value) => value,
+ publishedDates: () => [],
+};
diff --git a/server/src/modules/authors/metadata/author-metadata-fetch.service.test.ts b/server/src/modules/authors/metadata/author-metadata-fetch.service.test.ts
index a9c144a83..b43459dda 100644
--- a/server/src/modules/authors/metadata/author-metadata-fetch.service.test.ts
+++ b/server/src/modules/authors/metadata/author-metadata-fetch.service.test.ts
@@ -1,7 +1,46 @@
+import { DEFAULT_METADATA_CACHE_CONFIG } from '@bookorbit/types';
import { firstValueFrom, toArray } from 'rxjs';
-import { AuthorMetadataProviderError } from './providers/author-metadata-provider';
+import type { ProviderCacheSpec } from '../../../common/provider-cache/provider-cache-spec';
+import { MetadataCacheAccessTracker, MetadataCacheBudget, MetadataCacheCounterService } from '../../metadata-cache/metadata-cache-state';
+import {
+ ProviderOperationExecutor,
+ type ProviderOperationRequest,
+ type ProviderOperationResult,
+} from '../../metadata-cache/provider-operation-executor.service';
+import { classifyAuthorProviderError } from './author-provider-run';
import { AuthorMetadataFetchService } from './author-metadata-fetch.service';
+import { AudnexusAuthorMetadataProvider } from './providers/audnexus/audnexus.provider';
+import { AuthorMetadataProviderError, type AuthorMetadataSearchParams } from './providers/author-metadata-provider';
+import { authorProviderCacheContext } from './providers/author-provider-cache';
+
+type AnyRequest = ProviderOperationRequest;
+
+/** Behaves like the executor with caching disabled: runs the live call and classifies its failure. */
+function passThroughExecutor() {
+ const execute = vi.fn(async (request: AnyRequest): Promise> => {
+ try {
+ const value = await request.live(request.signal ?? new AbortController().signal);
+ return { value, source: 'live', liveOutcome: 'ok', outboundRequests: 1 };
+ } catch (error) {
+ const failure = request.classifyError(error);
+ return { source: 'none', liveOutcome: failure.outcome, error, retryAfterSeconds: failure.retryAfterSeconds, outboundRequests: 1 };
+ }
+ });
+ const resolveContext = vi.fn(async (spec?: ProviderCacheSpec) => (spec ? spec.context() : null));
+ return { execute, resolveContext };
+}
+
+function cacheSpec(overrides?: Partial>): ProviderCacheSpec {
+ return {
+ version: 1,
+ trustEmpty: false,
+ context: () => Promise.resolve(authorProviderCacheContext(true)),
+ describeSearch: (params) => ({ inputs: { name: params.name, region: params.region } }),
+ describeLookup: (id, params) => ({ inputs: { id, region: params?.region } }),
+ ...overrides,
+ };
+}
describe('AuthorMetadataFetchService', () => {
const registry = {
@@ -10,11 +49,13 @@ describe('AuthorMetadataFetchService', () => {
find: vi.fn(),
};
+ let executor: ReturnType;
let service: AuthorMetadataFetchService;
beforeEach(() => {
vi.resetAllMocks();
- service = new AuthorMetadataFetchService(registry as never);
+ executor = passThroughExecutor();
+ service = new AuthorMetadataFetchService(registry as never, executor as never);
});
it('listProviders maps registry providers for client display', () => {
@@ -53,11 +94,14 @@ describe('AuthorMetadataFetchService', () => {
name: 'Author One',
},
failure: null,
+ cacheServed: [],
+ outboundRequests: 1,
});
expect(provider.search).toHaveBeenCalledWith({
name: 'Author One',
region: 'us',
limit: 8,
+ signal: expect.any(AbortSignal),
});
});
@@ -77,7 +121,7 @@ describe('AuthorMetadataFetchService', () => {
},
]);
- await expect(service.quickSearchDetailed({ name: 'Author One' })).resolves.toEqual({
+ await expect(service.quickSearchDetailed({ name: 'Author One' })).resolves.toMatchObject({
candidate: null,
failure: {
provider: 'audnexus',
@@ -102,7 +146,7 @@ describe('AuthorMetadataFetchService', () => {
},
]);
- await expect(service.quickSearchDetailed({ name: 'Charles Exbrayat' })).resolves.toEqual({
+ await expect(service.quickSearchDetailed({ name: 'Charles Exbrayat' })).resolves.toMatchObject({
candidate: null,
failure: null,
});
@@ -121,7 +165,7 @@ describe('AuthorMetadataFetchService', () => {
},
]);
- await expect(service.quickSearchDetailed({ name: 'George Orwell' })).resolves.toEqual({
+ await expect(service.quickSearchDetailed({ name: 'George Orwell' })).resolves.toMatchObject({
candidate: { provider: 'audnexus', providerId: 'A2', name: 'George Orwell' },
failure: null,
});
@@ -131,6 +175,7 @@ describe('AuthorMetadataFetchService', () => {
registry.select.mockReturnValue([]);
await expect(service.search({ name: 'Nobody' }, { keys: ['audnexus'] })).resolves.toEqual([]);
+ expect(executor.execute).not.toHaveBeenCalled();
});
it('search clamps limit and tolerates provider failures by returning successful batches', async () => {
@@ -197,6 +242,8 @@ describe('AuthorMetadataFetchService', () => {
retryAfterMs: null,
transient: true,
},
+ cacheServed: [],
+ outboundRequests: 2,
});
});
@@ -210,7 +257,7 @@ describe('AuthorMetadataFetchService', () => {
};
registry.select.mockReturnValue([provider]);
- await expect(service.quickSearchDetailed({ name: 'George Orwell' })).resolves.toEqual({
+ await expect(service.quickSearchDetailed({ name: 'George Orwell' })).resolves.toMatchObject({
candidate: null,
failure: null,
});
@@ -226,6 +273,7 @@ describe('AuthorMetadataFetchService', () => {
await expect(service.lookupById('audnexus', 'x1')).resolves.toBeNull();
await expect(service.lookupById('audnexus', 'x2')).resolves.toBeNull();
+ expect(executor.execute).not.toHaveBeenCalled();
});
it('lookupById delegates to identifiable provider', async () => {
@@ -243,6 +291,388 @@ describe('AuthorMetadataFetchService', () => {
providerId: '42',
name: 'Douglas Adams',
});
- expect(provider.lookupById).toHaveBeenCalledWith('42', 'us');
+ expect(provider.lookupById).toHaveBeenCalledWith('42', 'us', expect.any(AbortSignal));
+ });
+
+ it('lookupById swallows provider failures as today', async () => {
+ registry.find.mockReturnValue({
+ key: 'audnexus',
+ label: 'Audnexus',
+ identifiable: true,
+ search: vi.fn(),
+ lookupById: vi.fn().mockRejectedValue(new AuthorMetadataProviderError('boom', { transient: true })),
+ });
+
+ await expect(service.lookupById('audnexus', '42')).resolves.toBeNull();
+ });
+
+ describe('cache integration', () => {
+ function cachedProvider(spec = cacheSpec()) {
+ return {
+ key: 'audnexus',
+ label: 'Audnexus',
+ identifiable: true,
+ cache: spec,
+ search: vi.fn().mockResolvedValue([{ provider: 'audnexus', providerId: 'A1', name: 'Jane Doe' }]),
+ lookupById: vi.fn().mockResolvedValue({ provider: 'audnexus', providerId: 'A1', name: 'Jane Doe', description: 'Bio' }),
+ };
+ }
+
+ it('passes the default directive, the resolved context and the provider spec to the executor', async () => {
+ const provider = cachedProvider();
+ registry.select.mockReturnValue([provider]);
+
+ await service.search({ name: 'Jane Doe' });
+
+ expect(executor.resolveContext).toHaveBeenCalledTimes(1);
+ expect(executor.execute).toHaveBeenCalledWith(
+ expect.objectContaining({
+ namespace: 'author',
+ provider: 'audnexus',
+ operation: 'search',
+ spec: provider.cache,
+ context: authorProviderCacheContext(true),
+ directive: { policy: 'default', allowFallback: true },
+ signal: expect.any(AbortSignal),
+ }),
+ );
+ });
+
+ it('passes the caller directive through every operation of a run', async () => {
+ const provider = cachedProvider();
+ registry.select.mockReturnValue([provider]);
+ const directive = { policy: 'refresh', allowFallback: false } as const;
+
+ await service.quickSearchDetailed({ name: 'Jane Doe' }, { directive });
+
+ const operations = executor.execute.mock.calls.map(([request]) => [request.operation, request.directive]);
+ expect(operations).toEqual([
+ ['search', directive],
+ ['lookup', directive],
+ ]);
+ expect(executor.resolveContext).toHaveBeenCalledTimes(1);
+ });
+
+ it('canonicalizes the name and region for both the key and the provider call', async () => {
+ const provider = cachedProvider();
+ registry.select.mockReturnValue([provider]);
+
+ await service.search({ name: ' Jane \n Doe ', region: ' CA ' });
+
+ const [request] = executor.execute.mock.calls[0]!;
+ expect(request.descriptor).toEqual({ inputs: { name: 'Jane Doe', region: 'ca' } });
+ expect(provider.search).toHaveBeenCalledWith(expect.objectContaining({ name: 'Jane Doe', region: 'ca' }));
+ });
+
+ it('attaches cache provenance to candidates the cache answered', async () => {
+ const provider = cachedProvider();
+ registry.select.mockReturnValue([provider]);
+ executor.execute.mockResolvedValueOnce({
+ value: [{ provider: 'audnexus', providerId: 'A1', name: 'Jane Doe' }],
+ source: 'cache',
+ cachedAt: '2026-09-01T00:00:00.000Z',
+ stale: false,
+ liveOutcome: 'ok',
+ outboundRequests: 0,
+ });
+
+ await expect(service.searchDetailed({ name: 'Jane Doe' })).resolves.toEqual({
+ candidates: [{ provider: 'audnexus', providerId: 'A1', name: 'Jane Doe', cache: { fetchedAt: '2026-09-01T00:00:00.000Z', stale: false } }],
+ cacheServed: [{ provider: 'audnexus', source: 'cache', stale: false, cachedAt: '2026-09-01T00:00:00.000Z' }],
+ outboundRequests: 0,
+ });
+ expect(provider.search).not.toHaveBeenCalled();
+ });
+
+ it('leaves live candidates without a cache marker', async () => {
+ registry.select.mockReturnValue([cachedProvider()]);
+
+ const [candidate] = await service.search({ name: 'Jane Doe' });
+
+ expect(candidate).not.toHaveProperty('cache');
+ });
+
+ it('streams cache provenance with each candidate', async () => {
+ registry.select.mockReturnValue([cachedProvider()]);
+ executor.execute.mockResolvedValueOnce({
+ value: [{ provider: 'audnexus', providerId: 'A1', name: 'Jane Doe' }],
+ source: 'fallback',
+ cachedAt: '2026-08-01T00:00:00.000Z',
+ stale: true,
+ liveOutcome: 'failed',
+ outboundRequests: 1,
+ });
+
+ const emitted = await firstValueFrom(service.stream({ name: 'Jane Doe' }).pipe(toArray()));
+
+ expect(emitted).toEqual([
+ { provider: 'audnexus', providerId: 'A1', name: 'Jane Doe', cache: { fetchedAt: '2026-08-01T00:00:00.000Z', stale: true } },
+ ]);
+ });
+
+ it('attaches cache provenance to a cached lookup', async () => {
+ registry.find.mockReturnValue(cachedProvider());
+ executor.execute.mockResolvedValueOnce({
+ value: { provider: 'audnexus', providerId: 'A1', name: 'Jane Doe' },
+ source: 'cache',
+ cachedAt: '2026-09-01T00:00:00.000Z',
+ stale: false,
+ liveOutcome: 'ok',
+ outboundRequests: 0,
+ });
+
+ await expect(service.lookupById('audnexus', ' A1 ', 'CA', { directive: { policy: 'default', allowFallback: true } })).resolves.toEqual({
+ provider: 'audnexus',
+ providerId: 'A1',
+ name: 'Jane Doe',
+ cache: { fetchedAt: '2026-09-01T00:00:00.000Z', stale: false },
+ });
+ const [request] = executor.execute.mock.calls[0]!;
+ expect(request.descriptor).toEqual({ inputs: { id: 'A1', region: 'ca' } });
+ });
+
+ it('counts a fallback-served provider as a success and reports it', async () => {
+ registry.select.mockReturnValue([cachedProvider()]);
+ executor.execute
+ .mockResolvedValueOnce({
+ value: [{ provider: 'audnexus', providerId: 'A1', name: 'Jane Doe' }],
+ source: 'fallback',
+ cachedAt: '2026-08-02T00:00:00.000Z',
+ stale: false,
+ liveOutcome: 'throttled',
+ error: new AuthorMetadataProviderError('429', { httpStatus: 429, transient: true }),
+ outboundRequests: 1,
+ })
+ .mockResolvedValueOnce({
+ value: { provider: 'audnexus', providerId: 'A1', name: 'Jane Doe', description: 'Bio' },
+ source: 'fallback',
+ cachedAt: '2026-08-01T00:00:00.000Z',
+ stale: true,
+ liveOutcome: 'failed',
+ error: new Error('down'),
+ outboundRequests: 1,
+ });
+
+ const result = await service.quickSearchDetailed({ name: 'Jane Doe' }, { directive: { policy: 'refresh', allowFallback: true } });
+
+ expect(result).toEqual({
+ candidate: { provider: 'audnexus', providerId: 'A1', name: 'Jane Doe', description: 'Bio' },
+ failure: null,
+ cacheServed: [{ provider: 'audnexus', source: 'fallback', stale: true, cachedAt: '2026-08-01T00:00:00.000Z' }],
+ outboundRequests: 2,
+ });
+ });
+
+ it('collectByProvider aggregates provenance and outbound requests across providers', async () => {
+ const audnexus = cachedProvider();
+ const goodreads = { ...cachedProvider(), key: 'goodreads', label: 'Goodreads' };
+ registry.find.mockImplementation((key: string) => (key === 'audnexus' ? audnexus : goodreads));
+ registry.select.mockImplementation((keys: string[]) => (keys[0] === 'audnexus' ? [audnexus] : [goodreads]));
+ executor.execute
+ .mockResolvedValueOnce({
+ value: [{ provider: 'audnexus', providerId: 'A1', name: 'Jane Doe' }],
+ source: 'cache',
+ cachedAt: '2026-09-01T00:00:00.000Z',
+ stale: false,
+ liveOutcome: 'ok',
+ outboundRequests: 0,
+ })
+ .mockResolvedValueOnce({
+ value: { provider: 'audnexus', providerId: 'A1', name: 'Jane Doe' },
+ source: 'cache',
+ cachedAt: '2026-09-02T00:00:00.000Z',
+ stale: false,
+ liveOutcome: 'ok',
+ outboundRequests: 0,
+ })
+ .mockResolvedValueOnce({
+ value: [{ provider: 'goodreads', providerId: 'G1', name: 'Jane Doe' }],
+ source: 'live',
+ liveOutcome: 'ok',
+ outboundRequests: 2,
+ })
+ .mockResolvedValueOnce({
+ value: { provider: 'goodreads', providerId: 'G1', name: 'Jane Doe' },
+ source: 'live',
+ liveOutcome: 'ok',
+ outboundRequests: 1,
+ });
+
+ const result = await service.collectByProvider({ name: 'Jane Doe' }, ['audnexus', 'goodreads'] as never, {
+ policy: 'default',
+ allowFallback: true,
+ });
+
+ expect([...result.candidates.keys()]).toEqual(['audnexus', 'goodreads']);
+ expect(result.failures).toEqual([]);
+ expect(result.cacheServed).toEqual([{ provider: 'audnexus', source: 'cache', stale: false, cachedAt: '2026-09-01T00:00:00.000Z' }]);
+ expect(result.outboundRequests).toBe(3);
+ expect(executor.execute.mock.calls.every(([request]) => request.directive.policy === 'default')).toBe(true);
+ });
+
+ it('reports a timeout from the executor as the transient provider timeout', async () => {
+ const provider = cachedProvider();
+ provider.search.mockReturnValue(new Promise(() => undefined));
+ registry.select.mockReturnValue([provider]);
+ executor.execute.mockResolvedValueOnce({ source: 'none', liveOutcome: 'timeout', outboundRequests: 1 });
+
+ await expect(service.quickSearchDetailed({ name: 'Jane Doe' })).resolves.toMatchObject({
+ candidate: null,
+ failure: { provider: 'audnexus', message: 'Author metadata provider timeout', transient: true },
+ });
+ });
+
+ it('leaves the operation deadline to the executor instead of racing its own timer', async () => {
+ registry.select.mockReturnValue([cachedProvider()]);
+ const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout');
+
+ await service.quickSearchDetailed({ name: 'Jane Doe' });
+
+ expect(setTimeoutSpy).not.toHaveBeenCalled();
+ const signals = executor.execute.mock.calls.map(([request]) => request.signal);
+ expect(signals).toHaveLength(2);
+ expect(signals[0]).not.toBe(signals[1]);
+ setTimeoutSpy.mockRestore();
+ });
+ });
+
+ describe('classifyAuthorProviderError', () => {
+ it('treats a 429 or a retry-after as throttled', () => {
+ expect(classifyAuthorProviderError(new AuthorMetadataProviderError('rate', { httpStatus: 429 }))).toEqual({
+ outcome: 'throttled',
+ retryAfterSeconds: undefined,
+ });
+ expect(classifyAuthorProviderError(new AuthorMetadataProviderError('busy', { httpStatus: 503, retryAfterMs: 1_500 }))).toEqual({
+ outcome: 'throttled',
+ retryAfterSeconds: 2,
+ });
+ });
+
+ it('treats everything else as failed', () => {
+ expect(classifyAuthorProviderError(new AuthorMetadataProviderError('down', { httpStatus: 503, transient: true }))).toEqual({
+ outcome: 'failed',
+ });
+ expect(classifyAuthorProviderError(new Error('boom'))).toEqual({ outcome: 'failed' });
+ expect(classifyAuthorProviderError('weird')).toEqual({ outcome: 'failed' });
+ });
+ });
+
+ describe('with the real executor', () => {
+ const originalFetch = global.fetch;
+
+ function jsonResponse(body: unknown, status = 200) {
+ return {
+ ok: status >= 200 && status < 300,
+ status,
+ headers: { get: vi.fn().mockReturnValue(null) },
+ json: vi.fn().mockResolvedValue(body),
+ };
+ }
+
+ function realExecutor() {
+ const rows = new Map>();
+ const repository = {
+ find: vi.fn((key: string) => Promise.resolve(rows.get(key) ?? null)),
+ upsert: vi.fn((row: Record) => {
+ rows.set(row.keyHash as string, { id: rows.size + 1, ...row });
+ return Promise.resolve(row.payloadBytes as number);
+ }),
+ delete: vi.fn((key: string) => {
+ rows.delete(key);
+ return Promise.resolve();
+ }),
+ };
+ const config = { snapshot: vi.fn().mockResolvedValue({ config: DEFAULT_METADATA_CACHE_CONFIG, markers: [] }) };
+ const instance = new ProviderOperationExecutor(
+ config as never,
+ repository as never,
+ new MetadataCacheCounterService(),
+ new MetadataCacheBudget(),
+ new MetadataCacheAccessTracker(),
+ { requestSweep: vi.fn() } as never,
+ );
+ return { instance, rows };
+ }
+
+ let audnexus: AudnexusAuthorMetadataProvider;
+
+ beforeEach(() => {
+ audnexus = new AudnexusAuthorMetadataProvider();
+ registry.select.mockReturnValue([audnexus]);
+ registry.find.mockReturnValue(audnexus);
+ });
+
+ afterEach(() => {
+ global.fetch = originalFetch;
+ });
+
+ it('answers a repeated search from the cache without going out', async () => {
+ const { instance, rows } = realExecutor();
+ service = new AuthorMetadataFetchService(registry as never, instance);
+ global.fetch = vi.fn().mockResolvedValue(jsonResponse([{ asin: 'B1', name: 'Jane Doe' }])) as never;
+
+ const first = await service.searchDetailed({ name: 'Jane Doe' });
+ const second = await service.searchDetailed({ name: ' Jane Doe ' });
+
+ expect(global.fetch).toHaveBeenCalledTimes(1);
+ expect(rows.size).toBe(1);
+ expect(first.outboundRequests).toBe(1);
+ expect(first.candidates[0]).not.toHaveProperty('cache');
+ expect(second.outboundRequests).toBe(0);
+ expect(second.candidates).toEqual([expect.objectContaining({ providerId: 'B1', cache: { fetchedAt: expect.any(String), stale: false } })]);
+ });
+
+ it('goes live under refresh and falls back to the saved answer when the provider fails', async () => {
+ const { instance } = realExecutor();
+ service = new AuthorMetadataFetchService(registry as never, instance);
+ global.fetch = vi
+ .fn()
+ .mockResolvedValueOnce(jsonResponse([{ asin: 'B1', name: 'Jane Doe' }]))
+ .mockResolvedValueOnce(jsonResponse({ asin: 'B1', name: 'Jane Doe', description: 'Bio' }))
+ .mockRejectedValue(new TypeError('fetch failed')) as never;
+
+ await service.quickSearchDetailed({ name: 'Jane Doe' });
+ const refreshed = await service.quickSearchDetailed({ name: 'Jane Doe' }, { directive: { policy: 'refresh', allowFallback: true } });
+
+ expect(global.fetch).toHaveBeenCalledTimes(4);
+ expect(refreshed.failure).toBeNull();
+ expect(refreshed.candidate).toMatchObject({ providerId: 'B1', description: 'Bio' });
+ expect(refreshed.cacheServed).toEqual([{ provider: 'audnexus', source: 'fallback', stale: false, cachedAt: expect.any(String) }]);
+ });
+
+ it('reports the failure under refresh without fallback', async () => {
+ const { instance } = realExecutor();
+ service = new AuthorMetadataFetchService(registry as never, instance);
+ global.fetch = vi
+ .fn()
+ .mockResolvedValueOnce(jsonResponse([{ asin: 'B1', name: 'Jane Doe' }]))
+ .mockResolvedValueOnce(jsonResponse({ asin: 'B1', name: 'Jane Doe' }))
+ .mockResolvedValue(jsonResponse({ message: 'busy' }, 503)) as never;
+
+ await service.quickSearchDetailed({ name: 'Jane Doe' });
+ const refreshed = await service.quickSearchDetailed({ name: 'Jane Doe' }, { directive: { policy: 'refresh', allowFallback: false } });
+
+ expect(refreshed.candidate).toBeNull();
+ expect(refreshed.failure).toMatchObject({ provider: 'audnexus', httpStatus: 503, transient: true });
+ expect(refreshed.cacheServed).toEqual([]);
+ });
+
+ it('abandons a provider at the operation deadline and aborts its request', async () => {
+ const { instance } = realExecutor();
+ service = new AuthorMetadataFetchService(registry as never, instance);
+ vi.spyOn(service as unknown as { createDeadline: () => AbortSignal }, 'createDeadline').mockImplementation(() => AbortSignal.timeout(20));
+ let requestSignal: AbortSignal | undefined;
+ global.fetch = vi.fn((_url: string, init?: RequestInit) => {
+ requestSignal = init?.signal ?? undefined;
+ return new Promise((_resolve, reject) => {
+ init?.signal?.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError')));
+ });
+ }) as never;
+
+ const result = await service.quickSearchDetailed({ name: 'Jane Doe' });
+
+ expect(result.failure).toMatchObject({ provider: 'audnexus', message: 'Author metadata provider timeout', transient: true });
+ expect(requestSignal?.aborted).toBe(true);
+ });
});
});
diff --git a/server/src/modules/authors/metadata/author-metadata-fetch.service.ts b/server/src/modules/authors/metadata/author-metadata-fetch.service.ts
index 563ba4cb1..494549ebe 100644
--- a/server/src/modules/authors/metadata/author-metadata-fetch.service.ts
+++ b/server/src/modules/authors/metadata/author-metadata-fetch.service.ts
@@ -1,13 +1,35 @@
import { Injectable } from '@nestjs/common';
-import { AuthorMetadataCandidate, AuthorMetadataProviderInfo, AuthorMetadataProviderKey } from '@bookorbit/types';
+import {
+ AuthorMetadataCandidate,
+ AuthorMetadataProviderInfo,
+ AuthorMetadataProviderKey,
+ AuthorMetadataSearchCandidateEvent,
+ MetadataCacheServedProvider,
+ MetadataCandidateCacheInfo,
+} from '@bookorbit/types';
import { from, merge, Observable, switchMap, take } from 'rxjs';
+import { canonicalId, canonicalText } from '../../../common/provider-cache/canonicalize';
+import { DEFAULT_CACHE_DIRECTIVE, type CacheDirective } from '../../../common/provider-cache/provider-cache-spec';
+import { ProviderOperationExecutor } from '../../metadata-cache/provider-operation-executor.service';
+import { AuthorProviderRun } from './author-provider-run';
import { pickBestAuthorNameMatch } from './author-name-match';
import { AuthorMetadataProviderRegistry } from './provider-registry';
-import { AuthorMetadataProviderError, AuthorMetadataSearchParams, isIdentifiableAuthorProvider } from './providers/author-metadata-provider';
+import {
+ AuthorMetadataProvider,
+ AuthorMetadataProviderError,
+ AuthorMetadataSearchParams,
+ isIdentifiableAuthorProvider,
+} from './providers/author-metadata-provider';
+import { canonicalAuthorRegion } from './providers/author-provider-cache';
type SearchOptions = {
keys?: AuthorMetadataProviderKey[];
+ directive?: CacheDirective;
+};
+
+type LookupOptions = {
+ directive?: CacheDirective;
};
export type AuthorMetadataFetchFailure = {
@@ -18,17 +40,41 @@ export type AuthorMetadataFetchFailure = {
transient: boolean;
};
-export type AuthorQuickSearchResult = {
+/** Which providers the cache answered for during one call, and how much live traffic it made. */
+export type AuthorMetadataFetchProvenance = {
+ cacheServed: MetadataCacheServedProvider[];
+ outboundRequests: number;
+};
+
+export type AuthorQuickSearchResult = AuthorMetadataFetchProvenance & {
candidate: AuthorMetadataCandidate | null;
failure: AuthorMetadataFetchFailure | null;
};
+export type AuthorMetadataSearchResult = AuthorMetadataFetchProvenance & {
+ candidates: AuthorMetadataSearchCandidateEvent[];
+};
+
+export type AuthorMetadataLookupResult = AuthorMetadataFetchProvenance & {
+ candidate: AuthorMetadataSearchCandidateEvent | null;
+};
+
+export type AuthorMetadataCollectResult = AuthorMetadataFetchProvenance & {
+ candidates: Map;
+ failures: AuthorMetadataFetchFailure[];
+};
+
+type CanonicalSearch = { name: string; region: string; limit: number };
+
@Injectable()
export class AuthorMetadataFetchService {
private static readonly PROVIDER_TIMEOUT_MS = 15_000;
private static readonly QUICK_SEARCH_LIMIT = 8;
- constructor(private readonly registry: AuthorMetadataProviderRegistry) {}
+ constructor(
+ private readonly registry: AuthorMetadataProviderRegistry,
+ private readonly executor: ProviderOperationExecutor,
+ ) {}
listProviders(): AuthorMetadataProviderInfo[] {
return this.registry.all().map((provider) => ({
@@ -39,87 +85,84 @@ export class AuthorMetadataFetchService {
}));
}
- async search(params: AuthorMetadataSearchParams, options?: SearchOptions): Promise {
- const providers = this.registry.select(options?.keys);
- if (providers.length === 0) return [];
-
- const limit = this.normalizeLimit(params.limit);
- const batches = await Promise.all(
- providers.map((provider) =>
- this.withTimeout(
- provider.search({
- ...params,
- limit,
- }),
- [],
- ),
- ),
- );
-
- return batches.flat().slice(0, limit);
+ async search(params: AuthorMetadataSearchParams, options?: SearchOptions): Promise {
+ return (await this.searchDetailed(params, options)).candidates;
}
- stream(params: AuthorMetadataSearchParams, options?: SearchOptions): Observable {
+ async searchDetailed(params: AuthorMetadataSearchParams, options?: SearchOptions): Promise {
+ const providers = this.registry.select(options?.keys);
+ if (providers.length === 0) return { candidates: [], ...summarize([]) };
+
+ const directive = options?.directive ?? DEFAULT_CACHE_DIRECTIVE;
+ const query = this.canonicalSearch(params, this.normalizeLimit(params.limit));
+ const batches = await Promise.all(
+ providers.map(async (provider) => {
+ const run = await this.startRun(provider, directive);
+ return { run, candidates: await this.searchCandidates(run, query) };
+ }),
+ );
+
+ return {
+ candidates: batches.flatMap((batch) => batch.candidates).slice(0, query.limit),
+ ...summarize(batches.map((batch) => batch.run)),
+ };
+ }
+
+ stream(params: AuthorMetadataSearchParams, options?: SearchOptions): Observable {
const providers = this.registry.select(options?.keys);
if (providers.length === 0) return from([]);
- const limit = this.normalizeLimit(params.limit);
+ const directive = options?.directive ?? DEFAULT_CACHE_DIRECTIVE;
+ const query = this.canonicalSearch(params, this.normalizeLimit(params.limit));
return merge(
...providers.map((provider) =>
- from(
- this.withTimeout(
- provider.search({
- ...params,
- limit,
- }),
- [],
- ),
- ).pipe(switchMap((candidates) => from(candidates.slice(0, limit)))),
+ from(this.startRun(provider, directive).then((run) => this.searchCandidates(run, query))).pipe(
+ switchMap((candidates) => from(candidates.slice(0, query.limit))),
+ ),
),
- ).pipe(take(limit));
+ ).pipe(take(query.limit));
}
async quickSearchDetailed(params: AuthorMetadataSearchParams, options?: SearchOptions): Promise {
const providers = this.registry.select(options?.keys);
- if (providers.length === 0) return { candidate: null, failure: null };
+ if (providers.length === 0) return { candidate: null, failure: null, ...summarize([]) };
- const region = params.region?.trim() || 'us';
+ const directive = options?.directive ?? DEFAULT_CACHE_DIRECTIVE;
+ const query = this.canonicalSearch(params, AuthorMetadataFetchService.QUICK_SEARCH_LIMIT);
+ const runs: AuthorProviderRun[] = [];
let lastFailure: AuthorMetadataFetchFailure | null = null;
for (const provider of providers) {
- const matchesResult = await this.withTimeoutResult(
- provider.search({
- name: params.name,
- region,
- limit: AuthorMetadataFetchService.QUICK_SEARCH_LIMIT,
- }),
- );
- if (!matchesResult.ok) {
- lastFailure = this.toFailure(provider.key, matchesResult.error);
+ const run = await this.startRun(provider, directive);
+ runs.push(run);
+
+ const matches = await run.search(query);
+ if (!matches.ok) {
+ lastFailure = this.toFailure(provider.key, matches.error);
continue;
}
- const bestMatch = pickBestAuthorNameMatch(params.name, matchesResult.value);
+ const bestMatch = pickBestAuthorNameMatch(params.name, matches.value);
if (!bestMatch) continue;
if (!isIdentifiableAuthorProvider(provider)) {
- return { candidate: bestMatch.candidate, failure: null };
+ return { candidate: bestMatch.candidate, failure: null, ...summarize(runs) };
}
- const detailResult = await this.withTimeoutResult(provider.lookupById(bestMatch.candidate.providerId, region));
- if (!detailResult.ok) {
- lastFailure = this.toFailure(provider.key, detailResult.error);
+ const detail = await run.lookup(bestMatch.candidate.providerId, query);
+ if (!detail.ok) {
+ lastFailure = this.toFailure(provider.key, detail.error);
continue;
}
- if (!detailResult.value) continue;
- const detailedMatch = pickBestAuthorNameMatch(params.name, [detailResult.value]);
+ if (!detail.value) continue;
+ const detailedMatch = pickBestAuthorNameMatch(params.name, [detail.value]);
if (!detailedMatch) continue;
- return { candidate: detailedMatch.candidate, failure: null };
+ return { candidate: detailedMatch.candidate, failure: null, ...summarize(runs) };
}
- return { candidate: null, failure: lastFailure };
+ return { candidate: null, failure: lastFailure, ...summarize(runs) };
}
// Per-field preferences need a candidate from every listed provider, not the
@@ -128,24 +171,72 @@ export class AuthorMetadataFetchService {
async collectByProvider(
params: AuthorMetadataSearchParams,
keys: AuthorMetadataProviderKey[],
- ): Promise<{ candidates: Map; failures: AuthorMetadataFetchFailure[] }> {
+ directive: CacheDirective = DEFAULT_CACHE_DIRECTIVE,
+ ): Promise {
const candidates = new Map();
const failures: AuthorMetadataFetchFailure[] = [];
+ const cacheServed: MetadataCacheServedProvider[] = [];
+ let outboundRequests = 0;
for (const key of keys) {
if (!this.registry.find(key)) continue;
- const result = await this.quickSearchDetailed(params, { keys: [key] });
+ const result = await this.quickSearchDetailed(params, { keys: [key], directive });
if (result.candidate) candidates.set(key, result.candidate);
if (result.failure) failures.push(result.failure);
+ cacheServed.push(...result.cacheServed);
+ outboundRequests += result.outboundRequests;
}
- return { candidates, failures };
+ return { candidates, failures, cacheServed, outboundRequests };
}
- async lookupById(key: AuthorMetadataProviderKey, providerId: string, region?: string): Promise {
+ async lookupById(
+ key: AuthorMetadataProviderKey,
+ providerId: string,
+ region?: string,
+ options?: LookupOptions,
+ ): Promise {
+ return (await this.lookupByIdDetailed(key, providerId, region, options)).candidate;
+ }
+
+ async lookupByIdDetailed(
+ key: AuthorMetadataProviderKey,
+ providerId: string,
+ region?: string,
+ options?: LookupOptions,
+ ): Promise {
const provider = this.registry.find(key);
- if (!provider || !isIdentifiableAuthorProvider(provider)) return null;
- return this.withTimeout(provider.lookupById(providerId, region), null);
+ if (!provider || !isIdentifiableAuthorProvider(provider)) return { candidate: null, ...summarize([]) };
+
+ const run = await this.startRun(provider, options?.directive ?? DEFAULT_CACHE_DIRECTIVE);
+ const outcome = await run.lookup(canonicalId(providerId) ?? '', { name: '', region: canonicalAuthorRegion(region) });
+ const candidate = outcome.ok && outcome.value ? withCache(outcome.value, outcome.cache) : null;
+ return { candidate, ...summarize([run]) };
+ }
+
+ /** The per-operation deadline; today's provider timeout, now enforced by the executor. */
+ protected createDeadline(): AbortSignal {
+ return AbortSignal.timeout(AuthorMetadataFetchService.PROVIDER_TIMEOUT_MS);
+ }
+
+ private async startRun(provider: AuthorMetadataProvider, directive: CacheDirective): Promise {
+ const context = await this.executor.resolveContext(provider.cache);
+ return new AuthorProviderRun(provider, directive, context, this.executor, () => this.createDeadline());
+ }
+
+ private async searchCandidates(run: AuthorProviderRun, query: CanonicalSearch): Promise {
+ const outcome = await run.search(query);
+ if (!outcome.ok) return [];
+ return outcome.value.map((candidate) => withCache(candidate, outcome.cache));
+ }
+
+ /** The canonical name and region are what both the cache key and the provider request use. */
+ private canonicalSearch(params: AuthorMetadataSearchParams, limit: number): CanonicalSearch {
+ return {
+ name: canonicalText(params.name) ?? '',
+ region: canonicalAuthorRegion(params.region),
+ limit,
+ };
}
private normalizeLimit(limit?: number): number {
@@ -154,32 +245,6 @@ export class AuthorMetadataFetchService {
return Math.max(1, Math.min(25, Math.floor(numeric)));
}
- private async withTimeout(promise: Promise, fallback: T): Promise {
- const result = await this.withTimeoutResult(promise);
- return result.ok ? result.value : fallback;
- }
-
- private async withTimeoutResult(promise: Promise): Promise<{ ok: true; value: T } | { ok: false; error: unknown }> {
- let timer: NodeJS.Timeout;
- const timeoutPromise = new Promise<{ ok: false; error: unknown }>((resolve) => {
- timer = setTimeout(
- () =>
- resolve({
- ok: false,
- error: new AuthorMetadataProviderError('Author metadata provider timeout', {
- transient: true,
- }),
- }),
- AuthorMetadataFetchService.PROVIDER_TIMEOUT_MS,
- );
- });
- const mainPromise = promise
- .then((value): { ok: true; value: T } => ({ ok: true, value }))
- .catch((error): { ok: false; error: unknown } => ({ ok: false, error }));
-
- return Promise.race([mainPromise, timeoutPromise]).finally(() => clearTimeout(timer!));
- }
-
private toFailure(provider: AuthorMetadataProviderKey, error: unknown): AuthorMetadataFetchFailure {
if (error instanceof AuthorMetadataProviderError) {
return {
@@ -200,3 +265,14 @@ export class AuthorMetadataFetchService {
};
}
}
+
+function withCache(candidate: AuthorMetadataCandidate, cache: MetadataCandidateCacheInfo | undefined): AuthorMetadataSearchCandidateEvent {
+ return cache ? { ...candidate, cache } : candidate;
+}
+
+function summarize(runs: readonly AuthorProviderRun[]): AuthorMetadataFetchProvenance {
+ return {
+ cacheServed: runs.map((run) => run.served).filter((served): served is MetadataCacheServedProvider => served !== null),
+ outboundRequests: runs.reduce((sum, run) => sum + run.outboundRequests, 0),
+ };
+}
diff --git a/server/src/modules/authors/metadata/author-provider-run.ts b/server/src/modules/authors/metadata/author-provider-run.ts
new file mode 100644
index 000000000..3e2cb699e
--- /dev/null
+++ b/server/src/modules/authors/metadata/author-provider-run.ts
@@ -0,0 +1,123 @@
+import type { AuthorMetadataCandidate, MetadataCacheServedProvider, MetadataCandidateCacheInfo } from '@bookorbit/types';
+
+import type { CacheDirective, ProviderCacheContext } from '../../../common/provider-cache/provider-cache-spec';
+import type { LiveFailure, ProviderOperationExecutor, ProviderOperationResult } from '../../metadata-cache/provider-operation-executor.service';
+import { authorLookupCodec, authorSearchCodec } from './author-cache-codecs';
+import {
+ AuthorMetadataProvider,
+ AuthorMetadataProviderError,
+ AuthorMetadataSearchParams,
+ isIdentifiableAuthorProvider,
+} from './providers/author-metadata-provider';
+
+export type AuthorRunOutcome = { ok: true; value: T; cache?: MetadataCandidateCacheInfo } | { ok: false; error: unknown };
+
+export function classifyAuthorProviderError(error: unknown): LiveFailure {
+ if (error instanceof AuthorMetadataProviderError) {
+ const retryAfterMs = error.retryAfterMs ?? 0;
+ if (error.httpStatus === 429 || retryAfterMs > 0) {
+ return { outcome: 'throttled', retryAfterSeconds: retryAfterMs > 0 ? Math.ceil(retryAfterMs / 1000) : undefined };
+ }
+ }
+ return { outcome: 'failed' };
+}
+
+export function authorProviderTimeoutError(): AuthorMetadataProviderError {
+ return new AuthorMetadataProviderError('Author metadata provider timeout', { transient: true });
+}
+
+/**
+ * One provider's share of an author metadata fetch, run through the cache. Each operation gets its
+ * own deadline; the executor owns the timed live call, so a timeout can still be answered from the
+ * cache. It remembers every result so callers can report provenance and pace outbound traffic.
+ */
+export class AuthorProviderRun {
+ private readonly results: ProviderOperationResult[] = [];
+
+ constructor(
+ readonly provider: AuthorMetadataProvider,
+ private readonly directive: CacheDirective,
+ private readonly context: ProviderCacheContext | null,
+ private readonly executor: ProviderOperationExecutor,
+ private readonly deadline: () => AbortSignal,
+ ) {}
+
+ async search(params: AuthorMetadataSearchParams): Promise> {
+ const provider = this.provider;
+ try {
+ const result = await this.executor.execute({
+ namespace: 'author',
+ provider: provider.key,
+ operation: 'search',
+ spec: provider.cache,
+ context: this.context,
+ descriptor: provider.cache?.describeSearch(params) ?? null,
+ directive: this.directive,
+ signal: this.deadline(),
+ codec: authorSearchCodec,
+ live: (signal) => provider.search({ ...params, signal }),
+ classifyError: classifyAuthorProviderError,
+ });
+ return this.record(result);
+ } catch (error) {
+ return { ok: false, error };
+ }
+ }
+
+ async lookup(providerId: string, params: AuthorMetadataSearchParams): Promise> {
+ const provider = this.provider;
+ if (!isIdentifiableAuthorProvider(provider)) return { ok: true, value: null };
+ try {
+ const result = await this.executor.execute({
+ namespace: 'author',
+ provider: provider.key,
+ operation: 'lookup',
+ spec: provider.cache,
+ context: this.context,
+ descriptor: provider.cache?.describeLookup(providerId, params) ?? null,
+ directive: this.directive,
+ signal: this.deadline(),
+ codec: authorLookupCodec,
+ live: (signal) => provider.lookupById(providerId, params.region, signal),
+ classifyError: classifyAuthorProviderError,
+ });
+ return this.record(result);
+ } catch (error) {
+ return { ok: false, error };
+ }
+ }
+
+ get outboundRequests(): number {
+ return this.results.reduce((sum, result) => sum + result.outboundRequests, 0);
+ }
+
+ /** Where the cache answered for this provider, merged across its operations. */
+ get served(): MetadataCacheServedProvider | null {
+ const served = this.results.filter((result) => result.source === 'cache' || result.source === 'fallback');
+ if (served.length === 0) return null;
+ return {
+ provider: this.provider.key,
+ source: served.some((result) => result.source === 'fallback') ? 'fallback' : 'cache',
+ stale: served.some((result) => result.stale === true),
+ cachedAt: served.map((result) => result.cachedAt!).sort()[0]!,
+ };
+ }
+
+ private record(result: ProviderOperationResult): AuthorRunOutcome {
+ this.results.push(result);
+ if (result.value !== undefined) return { ok: true, value: result.value, cache: cacheInfo(result) };
+ if (result.error !== undefined) return { ok: false, error: result.error };
+ return {
+ ok: false,
+ error:
+ result.liveOutcome === 'timeout'
+ ? authorProviderTimeoutError()
+ : new AuthorMetadataProviderError('Author metadata provider returned no answer', { transient: true }),
+ };
+ }
+}
+
+function cacheInfo(result: ProviderOperationResult): MetadataCandidateCacheInfo | undefined {
+ if (result.source !== 'cache' && result.source !== 'fallback') return undefined;
+ return { fetchedAt: result.cachedAt!, stale: result.stale === true };
+}
diff --git a/server/src/modules/authors/metadata/provider-registry.test.ts b/server/src/modules/authors/metadata/provider-registry.test.ts
index e23123a1d..3bdc7d76f 100644
--- a/server/src/modules/authors/metadata/provider-registry.test.ts
+++ b/server/src/modules/authors/metadata/provider-registry.test.ts
@@ -20,7 +20,7 @@ describe('AuthorMetadataProviderRegistry', () => {
];
return {
providers,
- registry: new AuthorMetadataProviderRegistry(providers as never),
+ registry: new AuthorMetadataProviderRegistry(providers as never, { register: vi.fn() } as never),
};
}
@@ -57,4 +57,21 @@ describe('AuthorMetadataProviderRegistry', () => {
expect(registry.find('audnexus' as never)).toEqual(expect.objectContaining({ key: 'audnexus' }));
expect(registry.find('missing' as never)).toBeUndefined();
});
+
+ it('registers every provider and its cache declaration with the metadata cache catalog', () => {
+ const spec = { version: 1, trustEmpty: true };
+ const providers = [
+ { key: 'audnexus', label: 'Audnexus', identifiable: true, cache: spec, search: vi.fn() },
+ { key: 'goodreads', label: 'Goodreads', identifiable: true, search: vi.fn() },
+ ];
+ const catalog = { register: vi.fn() };
+ const registry = new AuthorMetadataProviderRegistry(providers as never, catalog as never);
+
+ registry.onModuleInit();
+
+ expect(catalog.register).toHaveBeenCalledWith('author', [
+ { provider: 'audnexus', label: 'Audnexus', spec },
+ { provider: 'goodreads', label: 'Goodreads', spec: undefined },
+ ]);
+ });
});
diff --git a/server/src/modules/authors/metadata/provider-registry.ts b/server/src/modules/authors/metadata/provider-registry.ts
index 77472eeee..b99eca162 100644
--- a/server/src/modules/authors/metadata/provider-registry.ts
+++ b/server/src/modules/authors/metadata/provider-registry.ts
@@ -1,16 +1,25 @@
-import { BadRequestException, Inject, Injectable } from '@nestjs/common';
+import { BadRequestException, Inject, Injectable, OnModuleInit } from '@nestjs/common';
import { AuthorMetadataProviderKey } from '@bookorbit/types';
+import { MetadataCacheProviderCatalog } from '../../metadata-cache/metadata-cache-provider-catalog';
import { AUTHOR_METADATA_PROVIDERS } from './constants';
import { AuthorMetadataProvider } from './providers/author-metadata-provider';
@Injectable()
-export class AuthorMetadataProviderRegistry {
+export class AuthorMetadataProviderRegistry implements OnModuleInit {
constructor(
@Inject(AUTHOR_METADATA_PROVIDERS)
private readonly providers: AuthorMetadataProvider[],
+ private readonly cacheCatalog: MetadataCacheProviderCatalog,
) {}
+ onModuleInit(): void {
+ this.cacheCatalog.register(
+ 'author',
+ this.providers.map((provider) => ({ provider: provider.key, label: provider.label, spec: provider.cache })),
+ );
+ }
+
all(): AuthorMetadataProvider[] {
return this.providers;
}
diff --git a/server/src/modules/authors/metadata/providers/audnexus/audnexus.provider.test.ts b/server/src/modules/authors/metadata/providers/audnexus/audnexus.provider.test.ts
index 89c2eb8fc..46fb24676 100644
--- a/server/src/modules/authors/metadata/providers/audnexus/audnexus.provider.test.ts
+++ b/server/src/modules/authors/metadata/providers/audnexus/audnexus.provider.test.ts
@@ -1,7 +1,37 @@
import { AuthorMetadataProviderKey } from '@bookorbit/types';
+import { ProviderCallRecord, runWithProviderCallRecord } from '../../../../../common/provider-cache/provider-call-context';
import { AudnexusAuthorMetadataProvider } from './audnexus.provider';
+function jsonResponse(body: unknown, status = 200) {
+ return {
+ ok: status >= 200 && status < 300,
+ status,
+ headers: { get: vi.fn().mockReturnValue(null) },
+ json: vi.fn().mockResolvedValue(body),
+ };
+}
+
+/** Mirrors the cache's verdict on a live call record, minus the trustEmpty and TTL settings. */
+function isCleanRecord(record: ProviderCallRecord): boolean {
+ return (
+ record.mainRequests > 0 &&
+ record.unsuccessfulRequests().length === 0 &&
+ record.failureReasons.length === 0 &&
+ record.degradationReasons.length === 0 &&
+ !record.truncated
+ );
+}
+
+async function settle(run: () => Promise): Promise<{ record: ProviderCallRecord; value?: T; error?: unknown }> {
+ const record = new ProviderCallRecord();
+ try {
+ return { record, value: await runWithProviderCallRecord(record, run) };
+ } catch (error) {
+ return { record, error };
+ }
+}
+
describe('AudnexusAuthorMetadataProvider', () => {
let provider: AudnexusAuthorMetadataProvider;
@@ -93,4 +123,155 @@ describe('AudnexusAuthorMetadataProvider', () => {
transient: true,
});
});
+
+ it('passes the caller deadline into the request', async () => {
+ const controller = new AbortController();
+ global.fetch = vi.fn().mockResolvedValue(jsonResponse([]));
+
+ await provider.search({ name: 'Any', signal: controller.signal });
+ const signal = (global.fetch as ReturnType).mock.calls[0]![1].signal as AbortSignal;
+ expect(signal.aborted).toBe(false);
+ controller.abort();
+ expect(signal.aborted).toBe(true);
+ });
+
+ it('sends the canonical region', async () => {
+ global.fetch = vi.fn().mockResolvedValue(jsonResponse([]));
+
+ await provider.search({ name: 'Any', region: ' UK ' });
+
+ expect(global.fetch).toHaveBeenCalledWith('https://api.audnex.us/authors?name=Any®ion=uk', expect.any(Object));
+ });
+
+ // Empty answers are only cached for a provider whose every failure mode leaves the call record
+ // uncacheable. These five cases are that proof for Audnexus; `trustEmpty` depends on them.
+ describe('empty-answer trust suite', () => {
+ type Operation = (p: AudnexusAuthorMetadataProvider) => Promise;
+ describe.each<[string, Operation]>([
+ ['search', (p) => p.search({ name: 'Jane Doe', region: 'us' })],
+ ['lookup', (p) => p.lookupById('B1', 'us')],
+ ])('%s', (_operation, call) => {
+ it('1. transport failure leaves the record uncacheable', async () => {
+ global.fetch = vi.fn().mockRejectedValue(new TypeError('fetch failed'));
+
+ const { record, error } = await settle(() => call(provider));
+
+ expect(error).toMatchObject({ name: 'AuthorMetadataProviderError', transient: true });
+ expect(record.unsuccessfulRequests()).toEqual([{ kind: 'error', errorClass: 'TypeError' }]);
+ expect(isCleanRecord(record)).toBe(false);
+ });
+
+ it('2. a 200 with malformed JSON leaves the record uncacheable', async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ headers: { get: vi.fn() },
+ json: vi.fn().mockRejectedValue(new SyntaxError('Unexpected token < in JSON')),
+ });
+
+ const { record, error } = await settle(() => call(provider));
+
+ expect(error).toMatchObject({ name: 'AuthorMetadataProviderError' });
+ expect(record.failureReasons).toContain('audnexus_malformed_json');
+ expect(isCleanRecord(record)).toBe(false);
+ });
+
+ it('3. an API error body inside a 200 leaves the record uncacheable', async () => {
+ global.fetch = vi.fn().mockResolvedValue(jsonResponse({ statusCode: 500, error: 'Internal Server Error', message: 'upstream unavailable' }));
+
+ const { record, value } = await settle(() => call(provider));
+
+ expect(value === null || (Array.isArray(value) && value.length === 0)).toBe(true);
+ expect(record.failureReasons.length).toBeGreaterThan(0);
+ expect(isCleanRecord(record)).toBe(false);
+ });
+
+ it('4. a mapper exception leaves the record uncacheable', async () => {
+ const row = { asin: 42, name: 'Jane Doe' };
+ global.fetch = vi.fn().mockResolvedValue(jsonResponse(_operation === 'search' ? [row] : row));
+
+ const { record, error } = await settle(() => call(provider));
+
+ expect(error).toBeInstanceOf(TypeError);
+ expect(record.failureReasons).toContain('audnexus_mapper_error');
+ expect(isCleanRecord(record)).toBe(false);
+ });
+ });
+
+ it('4. a partially unmappable search answer leaves the record uncacheable', async () => {
+ global.fetch = vi.fn().mockResolvedValue(jsonResponse([{ asin: 'A1', name: 'Jane Doe' }, { name: 'No Asin' }]));
+
+ const { record, value } = await settle(() => provider.search({ name: 'Jane Doe' }));
+
+ expect(value).toEqual([expect.objectContaining({ providerId: 'A1' })]);
+ expect(record.degradationReasons).toContain('audnexus_unmapped_row');
+ expect(isCleanRecord(record)).toBe(false);
+ });
+
+ it('5. a genuine empty answer leaves a clean record', async () => {
+ global.fetch = vi.fn().mockResolvedValue(jsonResponse([]));
+
+ const { record, value } = await settle(() => provider.search({ name: 'Nobody Anyone' }));
+
+ expect(value).toEqual([]);
+ expect(record.mainRequests).toBe(1);
+ expect(isCleanRecord(record)).toBe(true);
+ });
+
+ it('a non-ok status leaves the record uncacheable', async () => {
+ global.fetch = vi.fn().mockResolvedValue(jsonResponse({ message: 'not found' }, 404));
+
+ const { record } = await settle(() => provider.lookupById('B404'));
+
+ expect(record.unsuccessfulRequests()).toEqual([{ kind: 'status', status: 404 }]);
+ });
+ });
+
+ describe('cache declaration', () => {
+ it('trusts empty answers', () => {
+ expect(provider.cache.version).toBe(1);
+ expect(provider.cache.trustEmpty).toBe(true);
+ });
+
+ it('is always available with an empty fingerprint', async () => {
+ await expect(provider.cache.context()).resolves.toEqual({ available: true, fingerprint: '{}' });
+ });
+
+ it('keys a search by canonical name, canonical region and the result cap it honours', () => {
+ expect(provider.cache.describeSearch({ name: ' Jane Doe ' })).toEqual({ inputs: { name: 'Jane Doe', region: 'us' }, maxCandidates: 10 });
+ expect(provider.cache.describeSearch({ name: 'Jane Doe', region: ' CA ', limit: 30 })).toEqual({
+ inputs: { name: 'Jane Doe', region: 'ca' },
+ maxCandidates: 25,
+ });
+ expect(provider.cache.describeSearch({ name: 'Jane Doe', region: 'us' })).toEqual(provider.cache.describeSearch({ name: 'Jane Doe' }));
+ expect(provider.cache.describeSearch({ name: '' })).toBeNull();
+ });
+
+ it('ignores inputs that do not change the request', () => {
+ expect(provider.cache.describeSearch({ name: 'Jane Doe', limit: 4, signal: new AbortController().signal })).toEqual(
+ provider.cache.describeSearch({ name: 'Jane Doe', limit: 4 }),
+ );
+ expect(provider.cache.describeLookup('B1', { name: 'Someone Else', region: 'ca', limit: 3 })).toEqual(
+ provider.cache.describeLookup('B1', { name: '', region: 'CA' }),
+ );
+ });
+
+ it('keys a lookup by id and canonical region', () => {
+ expect(provider.cache.describeLookup(' B1 ')).toEqual({ inputs: { id: 'B1', region: 'us' } });
+ expect(provider.cache.describeLookup('B1', { name: '', region: 'DE' })).toEqual({ inputs: { id: 'B1', region: 'de' } });
+ expect(provider.cache.describeLookup(' ')).toBeNull();
+ });
+
+ it('honours the result cap it declares', async () => {
+ global.fetch = vi.fn().mockResolvedValue(
+ jsonResponse([
+ { asin: 'A1', name: 'One' },
+ { asin: 'A2', name: 'Two' },
+ { asin: 'A3', name: 'Three' },
+ ]),
+ );
+
+ await expect(provider.search({ name: 'Author', limit: 2 })).resolves.toHaveLength(2);
+ });
+ });
});
diff --git a/server/src/modules/authors/metadata/providers/audnexus/audnexus.provider.ts b/server/src/modules/authors/metadata/providers/audnexus/audnexus.provider.ts
index f4f1bbd22..2c503061d 100644
--- a/server/src/modules/authors/metadata/providers/audnexus/audnexus.provider.ts
+++ b/server/src/modules/authors/metadata/providers/audnexus/audnexus.provider.ts
@@ -1,11 +1,15 @@
import { Injectable, Logger } from '@nestjs/common';
import { AUTHOR_PROVIDER_SUPPORTED_FIELDS, AuthorMetadataCandidate, AuthorMetadataField, AuthorMetadataProviderKey } from '@bookorbit/types';
+import { canonicalId, canonicalText } from '../../../../../common/provider-cache/canonicalize';
+import { markProviderDegraded, markProviderFailed } from '../../../../../common/provider-cache/provider-call-context';
+import type { ProviderCacheSpec } from '../../../../../common/provider-cache/provider-cache-spec';
+import { authorProviderFetch, authorRequestSignal } from '../author-fetch';
import { AuthorMetadataProviderError, AuthorMetadataSearchParams, IdentifiableAuthorMetadataProvider } from '../author-metadata-provider';
+import { authorProviderCacheContext, canonicalAuthorRegion } from '../author-provider-cache';
import { AudnexusAuthorResponse } from './audnexus.types';
const BASE_URL = 'https://api.audnex.us';
-const DEFAULT_REGION = 'us';
const REQUEST_TIMEOUT_MS = 12_000;
@Injectable()
@@ -17,27 +21,54 @@ export class AudnexusAuthorMetadataProvider implements IdentifiableAuthorMetadat
private readonly logger = new Logger(AudnexusAuthorMetadataProvider.name);
+ // Empty answers are trusted because every way Audnexus can fail (transport, status, malformed or
+ // unexpected body, unmappable rows) leaves the call record uncacheable; see the provider tests.
+ readonly cache: ProviderCacheSpec = {
+ version: 1,
+ trustEmpty: true,
+ context: () => Promise.resolve(authorProviderCacheContext(true)),
+ describeSearch: (params) => {
+ const name = canonicalText(params.name);
+ if (!name) return null;
+ return { inputs: { name, region: canonicalAuthorRegion(params.region) }, maxCandidates: this.normalizeLimit(params.limit) };
+ },
+ describeLookup: (providerId, params) => {
+ const id = canonicalId(providerId);
+ if (!id) return null;
+ return { inputs: { id, region: canonicalAuthorRegion(params?.region) } };
+ },
+ };
+
async search(params: AuthorMetadataSearchParams): Promise {
const name = params.name?.trim();
if (!name) return [];
- const region = params.region?.trim() || DEFAULT_REGION;
+ const region = canonicalAuthorRegion(params.region);
const limit = this.normalizeLimit(params.limit);
- const rows = await this.fetchJson(
+ const body = await this.fetchJson(
`${BASE_URL}/authors?${new URLSearchParams({
name,
region,
}).toString()}`,
+ params.signal,
);
- if (!rows?.length) return [];
+ if (!Array.isArray(body)) {
+ markProviderFailed('audnexus_unexpected_search_body');
+ return [];
+ }
+ const rows = body as AudnexusAuthorResponse[];
+ if (!rows.length) return [];
const seenProviderIds = new Set();
const candidates: AuthorMetadataCandidate[] = [];
for (const row of rows) {
- const candidate = this.toCandidate(row);
- if (!candidate) continue;
+ const candidate = this.mapRow(row);
+ if (!candidate) {
+ markProviderDegraded('audnexus_unmapped_row');
+ continue;
+ }
if (seenProviderIds.has(candidate.providerId)) continue;
seenProviderIds.add(candidate.providerId);
candidates.push(candidate);
@@ -47,16 +78,18 @@ export class AudnexusAuthorMetadataProvider implements IdentifiableAuthorMetadat
return candidates;
}
- async lookupById(providerId: string, region?: string): Promise {
+ async lookupById(providerId: string, region?: string, signal?: AbortSignal): Promise {
const asin = providerId.trim();
if (!asin) return null;
const query = new URLSearchParams({
- region: region?.trim() || DEFAULT_REGION,
+ region: canonicalAuthorRegion(region),
}).toString();
- const row = await this.fetchJson(`${BASE_URL}/authors/${encodeURIComponent(asin)}?${query}`);
- return this.toCandidate(row);
+ const row = await this.fetchJson(`${BASE_URL}/authors/${encodeURIComponent(asin)}?${query}`, signal);
+ const candidate = this.mapRow(row);
+ if (!candidate) markProviderFailed('audnexus_unexpected_lookup_body');
+ return candidate;
}
private normalizeLimit(limit?: number): number {
@@ -65,11 +98,11 @@ export class AudnexusAuthorMetadataProvider implements IdentifiableAuthorMetadat
return Math.max(1, Math.min(25, Math.floor(numeric)));
}
- private async fetchJson(url: string): Promise {
+ private async fetchJson(url: string, signal?: AbortSignal): Promise {
const startedAt = Date.now();
this.logger.log(`[audnexus] [start] method=GET`);
try {
- const res = await fetch(url, { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
+ const res = await authorProviderFetch(url, { signal: authorRequestSignal(REQUEST_TIMEOUT_MS, signal) });
if (!res.ok) {
const retryAfterMs = parseRetryAfterMs(res.headers.get('retry-after'));
const message = `[audnexus] [fail] method=GET status=${res.status} durationMs=${Date.now() - startedAt} message="non-ok response"`;
@@ -80,7 +113,13 @@ export class AudnexusAuthorMetadataProvider implements IdentifiableAuthorMetadat
transient: res.status === 429 || res.status >= 500,
});
}
- const body = (await res.json()) as T;
+ let body: T;
+ try {
+ body = (await res.json()) as T;
+ } catch (error) {
+ markProviderFailed('audnexus_malformed_json');
+ throw error;
+ }
this.logger.log(`[audnexus] [end] method=GET status=${res.status} durationMs=${Date.now() - startedAt}`);
return body;
} catch (error) {
@@ -95,6 +134,16 @@ export class AudnexusAuthorMetadataProvider implements IdentifiableAuthorMetadat
}
}
+ /** A mapping error marks the answer as failed before it propagates, so it can never be cached. */
+ private mapRow(input: AudnexusAuthorResponse | null | undefined): AuthorMetadataCandidate | null {
+ try {
+ return this.toCandidate(input);
+ } catch (error) {
+ markProviderFailed('audnexus_mapper_error');
+ throw error;
+ }
+ }
+
private toCandidate(input: AudnexusAuthorResponse | null | undefined): AuthorMetadataCandidate | null {
if (!input) return null;
diff --git a/server/src/modules/authors/metadata/providers/author-fetch.test.ts b/server/src/modules/authors/metadata/providers/author-fetch.test.ts
new file mode 100644
index 000000000..c78032156
--- /dev/null
+++ b/server/src/modules/authors/metadata/providers/author-fetch.test.ts
@@ -0,0 +1,109 @@
+import { readdirSync, readFileSync, statSync } from 'node:fs';
+import { join, relative } from 'node:path';
+
+import { ProviderCallRecord, runWithProviderCallRecord } from '../../../../common/provider-cache/provider-call-context';
+import { authorProviderFetch, authorRequestSignal } from './author-fetch';
+
+describe('authorProviderFetch', () => {
+ const originalFetch = global.fetch;
+
+ afterEach(() => {
+ global.fetch = originalFetch;
+ });
+
+ it('returns the response and records its status', async () => {
+ const response = { status: 200, ok: true };
+ global.fetch = vi.fn().mockResolvedValue(response) as never;
+ const record = new ProviderCallRecord();
+
+ const result = await runWithProviderCallRecord(record, () => authorProviderFetch('https://example.test/a', { method: 'GET' }));
+
+ expect(result).toBe(response);
+ expect(global.fetch).toHaveBeenCalledWith('https://example.test/a', { method: 'GET' });
+ expect(record.mainRequests).toBe(1);
+ expect(record.unsuccessfulRequests()).toEqual([]);
+ });
+
+ it('records a failed status as unsuccessful without throwing', async () => {
+ global.fetch = vi.fn().mockResolvedValue({ status: 503, ok: false }) as never;
+ const record = new ProviderCallRecord();
+
+ await runWithProviderCallRecord(record, () => authorProviderFetch('https://example.test/a'));
+
+ expect(record.unsuccessfulRequests()).toEqual([{ kind: 'status', status: 503 }]);
+ });
+
+ it('records a transport error and rethrows it', async () => {
+ const error = new TypeError('fetch failed');
+ global.fetch = vi.fn().mockRejectedValue(error) as never;
+ const record = new ProviderCallRecord();
+
+ await expect(runWithProviderCallRecord(record, () => authorProviderFetch('https://example.test/a'))).rejects.toBe(error);
+ expect(record.unsuccessfulRequests()).toEqual([{ kind: 'error', errorClass: 'TypeError' }]);
+ });
+
+ it('records an abort as aborted', async () => {
+ global.fetch = vi.fn().mockRejectedValue(new DOMException('aborted', 'AbortError')) as never;
+ const record = new ProviderCallRecord();
+
+ await expect(runWithProviderCallRecord(record, () => authorProviderFetch('https://example.test/a'))).rejects.toThrow('aborted');
+ expect(record.unsuccessfulRequests()).toEqual([{ kind: 'aborted' }]);
+ });
+
+ it('keeps only the final outcome of a retried request', async () => {
+ global.fetch = vi.fn().mockResolvedValueOnce({ status: 503, ok: false }).mockResolvedValueOnce({ status: 200, ok: true }) as never;
+ const record = new ProviderCallRecord();
+
+ await runWithProviderCallRecord(record, async () => {
+ await authorProviderFetch('https://example.test/a');
+ await authorProviderFetch('https://example.test/a');
+ });
+
+ expect(record.mainRequests).toBe(1);
+ expect(record.outboundRequests).toBe(2);
+ expect(record.unsuccessfulRequests()).toEqual([]);
+ });
+
+ it('works outside a call record', async () => {
+ global.fetch = vi.fn().mockResolvedValue({ status: 200, ok: true }) as never;
+
+ await expect(authorProviderFetch('https://example.test/a')).resolves.toEqual({ status: 200, ok: true });
+ });
+});
+
+describe('authorRequestSignal', () => {
+ it('aborts when the caller aborts before the timeout', () => {
+ const controller = new AbortController();
+ const signal = authorRequestSignal(60_000, controller.signal);
+
+ expect(signal.aborted).toBe(false);
+ controller.abort();
+ expect(signal.aborted).toBe(true);
+ });
+
+ it('falls back to the timeout alone without a caller signal', async () => {
+ const signal = authorRequestSignal(5);
+
+ await new Promise((resolve) => setTimeout(resolve, 20));
+ expect(signal.aborted).toBe(true);
+ });
+});
+
+describe('author provider network guard', () => {
+ function sourceFiles(dir: string): string[] {
+ return readdirSync(dir).flatMap((entry) => {
+ const path = join(dir, entry);
+ if (statSync(path).isDirectory()) return sourceFiles(path);
+ return path.endsWith('.ts') && !path.endsWith('.test.ts') ? [path] : [];
+ });
+ }
+
+ it('routes every provider request through authorProviderFetch', () => {
+ const offenders = sourceFiles(__dirname)
+ .filter((path) => !path.endsWith('author-fetch.ts'))
+ .filter((path) => /(? relative(__dirname, path));
+
+ expect(offenders).toEqual([]);
+ });
+});
diff --git a/server/src/modules/authors/metadata/providers/author-fetch.ts b/server/src/modules/authors/metadata/providers/author-fetch.ts
new file mode 100644
index 000000000..85a8e916d
--- /dev/null
+++ b/server/src/modules/authors/metadata/providers/author-fetch.ts
@@ -0,0 +1,24 @@
+import { providerRequestKey, recordProviderRequest, recordProviderRequestError } from '../../../../common/provider-cache/provider-call-context';
+
+/**
+ * Every author provider HTTP call goes through here, so the metadata cache can see how each request
+ * ended. A provider that skips it could have a failure cached as an empty answer.
+ */
+export async function authorProviderFetch(url: string | URL, init?: RequestInit): Promise {
+ const requestKey = providerRequestKey(url, init);
+ let res: Response;
+ try {
+ res = await fetch(url, init);
+ } catch (error) {
+ recordProviderRequestError(requestKey, error);
+ throw error;
+ }
+ recordProviderRequest(requestKey, { kind: 'status', status: res.status });
+ return res;
+}
+
+/** The per-request timeout, cut short when the caller's deadline aborts first. */
+export function authorRequestSignal(timeoutMs: number, signal?: AbortSignal): AbortSignal {
+ const timeout = AbortSignal.timeout(timeoutMs);
+ return signal ? AbortSignal.any([signal, timeout]) : timeout;
+}
diff --git a/server/src/modules/authors/metadata/providers/author-metadata-provider.ts b/server/src/modules/authors/metadata/providers/author-metadata-provider.ts
index 4ad7dd063..c800a30fe 100644
--- a/server/src/modules/authors/metadata/providers/author-metadata-provider.ts
+++ b/server/src/modules/authors/metadata/providers/author-metadata-provider.ts
@@ -1,9 +1,13 @@
import { AuthorMetadataCandidate, AuthorMetadataField, AuthorMetadataProviderKey } from '@bookorbit/types';
+import type { ProviderCacheSpec } from '../../../../common/provider-cache/provider-cache-spec';
+
export type AuthorMetadataSearchParams = {
name: string;
region?: string;
limit?: number;
+ /** The caller's deadline; every request the provider makes is abandoned once it aborts. */
+ signal?: AbortSignal;
};
export interface AuthorMetadataProvider {
@@ -13,12 +17,14 @@ export interface AuthorMetadataProvider {
/** Fields this provider can actually return. Assigning it to any other field
* would be inert, so preferences and the settings UI both filter on this. */
readonly supportedFields: readonly AuthorMetadataField[];
+ /** Absent for providers that have not opted into the metadata cache. */
+ readonly cache?: ProviderCacheSpec;
search(params: AuthorMetadataSearchParams): Promise;
}
export interface IdentifiableAuthorMetadataProvider extends AuthorMetadataProvider {
readonly identifiable: true;
- lookupById(providerId: string, region?: string): Promise;
+ lookupById(providerId: string, region?: string, signal?: AbortSignal): Promise;
}
export function isIdentifiableAuthorProvider(p: AuthorMetadataProvider): p is IdentifiableAuthorMetadataProvider {
diff --git a/server/src/modules/authors/metadata/providers/author-provider-cache.ts b/server/src/modules/authors/metadata/providers/author-provider-cache.ts
new file mode 100644
index 000000000..4a1ea87f7
--- /dev/null
+++ b/server/src/modules/authors/metadata/providers/author-provider-cache.ts
@@ -0,0 +1,13 @@
+import { canonicalJson, canonicalRegion } from '../../../../common/provider-cache/canonicalize';
+import type { ProviderCacheContext } from '../../../../common/provider-cache/provider-cache-spec';
+
+export const DEFAULT_AUTHOR_REGION = 'us';
+
+export function authorProviderCacheContext(available: boolean, fingerprint: Record = {}): ProviderCacheContext {
+ return { available, fingerprint: canonicalJson(fingerprint) };
+}
+
+/** Region changes what a provider answers, so the key and the request must use the same canonical form. */
+export function canonicalAuthorRegion(region: unknown): string {
+ return canonicalRegion(region, DEFAULT_AUTHOR_REGION);
+}
diff --git a/server/src/modules/authors/metadata/providers/goodreads/goodreads.provider.test.ts b/server/src/modules/authors/metadata/providers/goodreads/goodreads.provider.test.ts
index 911377a39..aaa1174d7 100644
--- a/server/src/modules/authors/metadata/providers/goodreads/goodreads.provider.test.ts
+++ b/server/src/modules/authors/metadata/providers/goodreads/goodreads.provider.test.ts
@@ -1,5 +1,6 @@
import { AuthorMetadataProviderKey } from '@bookorbit/types';
+import { ProviderCallRecord, runWithProviderCallRecord } from '../../../../../common/provider-cache/provider-call-context';
import { ProviderConfigService } from '../../../../metadata-preferences/provider-config.service';
import { AuthorMetadataProviderError } from '../author-metadata-provider';
import { GoodreadsAuthorMetadataProvider } from './goodreads.provider';
@@ -121,4 +122,140 @@ describe('GoodreadsAuthorMetadataProvider', () => {
expect(await provider.search({ name: ' ' })).toEqual([]);
expect(global.fetch).not.toHaveBeenCalled();
});
+
+ it('passes the caller deadline into every request', async () => {
+ const controller = new AbortController();
+ global.fetch = vi.fn().mockResolvedValue(textResponse(AUTHOR_PAGE));
+
+ await provider.lookupById('874602', undefined, controller.signal);
+ const signal = (global.fetch as ReturnType).mock.calls[0]![1].signal as AbortSignal;
+ expect(signal.aborted).toBe(false);
+ controller.abort();
+ expect(signal.aborted).toBe(true);
+ });
+
+ describe('cache hardening markers', () => {
+ async function recorded(run: () => Promise): Promise<{ value: T; record: ProviderCallRecord }> {
+ const record = new ProviderCallRecord();
+ const value = await runWithProviderCallRecord(record, run);
+ return { value, record };
+ }
+
+ beforeEach(() => {
+ vi.spyOn(provider as any, 'sleep').mockResolvedValue(undefined);
+ });
+
+ it('leaves a clean record for a well-formed autocomplete answer', async () => {
+ global.fetch = vi.fn().mockResolvedValue(textResponse(JSON.stringify([{ author: { id: 1, name: 'Jane Doe' } }])));
+
+ const { record } = await recorded(() => provider.search({ name: 'Jane Doe' }));
+
+ expect(record.mainRequests).toBe(1);
+ expect(record.unsuccessfulRequests()).toEqual([]);
+ expect(record.failureReasons).toEqual([]);
+ expect(record.degradationReasons).toEqual([]);
+ });
+
+ it('marks malformed autocomplete JSON as failed and still falls back to the search page', async () => {
+ global.fetch = vi
+ .fn()
+ .mockResolvedValueOnce(textResponse('not json'))
+ .mockResolvedValueOnce(textResponse('Jane Doe'));
+
+ const { value, record } = await recorded(() => provider.search({ name: 'Jane Doe' }));
+
+ expect(value).toEqual([expect.objectContaining({ providerId: '1' })]);
+ expect(record.failureReasons).toContain('goodreads_malformed_autocomplete');
+ });
+
+ it('marks a non-array autocomplete answer as failed', async () => {
+ global.fetch = vi.fn().mockResolvedValueOnce(textResponse('{"error":"oops"}')).mockResolvedValueOnce(textResponse(''));
+
+ const { record } = await recorded(() => provider.search({ name: 'Jane Doe' }));
+
+ expect(record.failureReasons).toContain('goodreads_malformed_autocomplete');
+ });
+
+ it('marks a challenge page served with a 2xx status as degraded', async () => {
+ global.fetch = vi
+ .fn()
+ .mockResolvedValueOnce(textResponse('[]'))
+ .mockResolvedValueOnce(textResponse('', { status: 202 }));
+
+ const { value, record } = await recorded(() => provider.search({ name: 'Jane Doe' }));
+
+ expect(value).toEqual([]);
+ expect(record.degradationReasons).toContain('goodreads_challenge_page');
+ });
+
+ it('marks an unparseable author page as degraded', async () => {
+ global.fetch = vi.fn().mockResolvedValue(textResponse('something else'));
+
+ const { value, record } = await recorded(() => provider.lookupById('874602'));
+
+ expect(value).toBeNull();
+ expect(record.degradationReasons).toContain('goodreads_unparseable_author_page');
+ });
+
+ it('records a failed status as an unsuccessful request', async () => {
+ global.fetch = vi.fn().mockResolvedValue(textResponse('', { ok: false, status: 503 }));
+
+ const record = new ProviderCallRecord();
+ await expect(runWithProviderCallRecord(record, () => provider.lookupById('874602'))).rejects.toBeInstanceOf(AuthorMetadataProviderError);
+
+ expect(record.unsuccessfulRequests()).toEqual([{ kind: 'status', status: 503 }]);
+ });
+ });
+
+ describe('cache declaration', () => {
+ it('never keeps empty answers', () => {
+ expect(provider.cache.version).toBe(1);
+ expect(provider.cache.trustEmpty).toBe(false);
+ });
+
+ it('is available only while the provider is enabled', async () => {
+ await expect(provider.cache.context()).resolves.toEqual({ available: true, fingerprint: '{}' });
+ await expect(new GoodreadsAuthorMetadataProvider(configService(false)).cache.context()).resolves.toEqual({
+ available: false,
+ fingerprint: '{}',
+ });
+ });
+
+ it('keys a search by the canonical name and the result cap it honours', () => {
+ expect(provider.cache.describeSearch({ name: ' Ursula K. Le Guin ' })).toEqual({ inputs: { name: 'Ursula K. Le Guin' }, maxCandidates: 10 });
+ expect(provider.cache.describeSearch({ name: 'Ursula K. Le Guin', limit: 3 })).toEqual({
+ inputs: { name: 'Ursula K. Le Guin' },
+ maxCandidates: 3,
+ });
+ expect(provider.cache.describeSearch({ name: 'Ursula K. Le Guin', limit: 400 })).toEqual({
+ inputs: { name: 'Ursula K. Le Guin' },
+ maxCandidates: 25,
+ });
+ expect(provider.cache.describeSearch({ name: ' ' })).toBeNull();
+ });
+
+ it('ignores inputs that do not change the request', () => {
+ const base = provider.cache.describeSearch({ name: 'Jane Doe', limit: 5 });
+
+ expect(provider.cache.describeSearch({ name: 'Jane Doe', limit: 5, region: 'uk', signal: new AbortController().signal })).toEqual(base);
+ expect(provider.cache.describeLookup('874602', { name: 'x', region: 'ca' })).toEqual(provider.cache.describeLookup(' 874602 '));
+ });
+
+ it('keys a lookup by the provider id', () => {
+ expect(provider.cache.describeLookup(' 874602 ')).toEqual({ inputs: { id: '874602' } });
+ expect(provider.cache.describeLookup(' ')).toBeNull();
+ });
+
+ it('honours the result cap it declares', async () => {
+ global.fetch = vi
+ .fn()
+ .mockResolvedValue(
+ textResponse(
+ JSON.stringify([{ author: { id: 1, name: 'One' } }, { author: { id: 2, name: 'Two' } }, { author: { id: 3, name: 'Three' } }]),
+ ),
+ );
+
+ await expect(provider.search({ name: 'Author', limit: 2 })).resolves.toHaveLength(2);
+ });
+ });
});
diff --git a/server/src/modules/authors/metadata/providers/goodreads/goodreads.provider.ts b/server/src/modules/authors/metadata/providers/goodreads/goodreads.provider.ts
index 8f2f6574d..853f5098a 100644
--- a/server/src/modules/authors/metadata/providers/goodreads/goodreads.provider.ts
+++ b/server/src/modules/authors/metadata/providers/goodreads/goodreads.provider.ts
@@ -1,9 +1,14 @@
import { Injectable, Logger } from '@nestjs/common';
import { AUTHOR_PROVIDER_SUPPORTED_FIELDS, AuthorMetadataCandidate, AuthorMetadataField, AuthorMetadataProviderKey } from '@bookorbit/types';
+import { canonicalId, canonicalText } from '../../../../../common/provider-cache/canonicalize';
+import { markProviderDegraded, markProviderFailed } from '../../../../../common/provider-cache/provider-call-context';
+import type { ProviderCacheSpec } from '../../../../../common/provider-cache/provider-cache-spec';
import { sanitizeLogValue } from '../../../../../common/utils/log-sanitize.utils';
import { ProviderConfigService } from '../../../../metadata-preferences/provider-config.service';
+import { authorProviderFetch, authorRequestSignal } from '../author-fetch';
import { AuthorMetadataProviderError, AuthorMetadataSearchParams, IdentifiableAuthorMetadataProvider } from '../author-metadata-provider';
+import { authorProviderCacheContext } from '../author-provider-cache';
import { authorRefsFromAutocomplete, parseGoodreadsAuthorPage, parseGoodreadsAuthorSearch } from './goodreads-author.scraper';
import { GoodreadsAuthorAutocompleteItem, GoodreadsAuthorRef } from './goodreads.types';
@@ -36,6 +41,22 @@ export class GoodreadsAuthorMetadataProvider implements IdentifiableAuthorMetada
private readonly logger = new Logger(GoodreadsAuthorMetadataProvider.name);
+ // A scraper cannot prove an empty page is a genuine "no results", so empty answers are never kept.
+ readonly cache: ProviderCacheSpec = {
+ version: 1,
+ trustEmpty: false,
+ context: async () => authorProviderCacheContext(await this.isEnabled()),
+ describeSearch: (params) => {
+ const name = canonicalText(params.name);
+ if (!name) return null;
+ return { inputs: { name }, maxCandidates: this.normalizeLimit(params.limit) };
+ },
+ describeLookup: (providerId) => {
+ const id = canonicalId(providerId);
+ return id ? { inputs: { id } } : null;
+ },
+ };
+
constructor(private readonly providerConfig: ProviderConfigService) {}
async search(params: AuthorMetadataSearchParams): Promise {
@@ -50,24 +71,27 @@ export class GoodreadsAuthorMetadataProvider implements IdentifiableAuthorMetada
// resolves most names without touching a rendered page. Authors whose name
// does not surface in book autocomplete (or differs by a middle initial)
// still need the heavier search page.
- let refs = authorRefsFromAutocomplete(await this.fetchAutocomplete(name));
+ let refs = authorRefsFromAutocomplete(await this.fetchAutocomplete(name, params.signal));
if (refs.length === 0) {
await this.sleep(BETWEEN_REQUESTS_MS);
- refs = parseGoodreadsAuthorSearch(await this.fetchSearchPage(name));
+ refs = parseGoodreadsAuthorSearch(await this.fetchSearchPage(name, params.signal));
}
return refs.slice(0, limit).map((ref) => this.toShallowCandidate(ref));
}
- async lookupById(providerId: string): Promise {
+ async lookupById(providerId: string, _region?: string, signal?: AbortSignal): Promise {
if (!(await this.isEnabled())) return null;
const id = providerId.trim();
if (!id) return null;
- const html = await this.fetchText(`${BASE_URL}/author/show/${encodeURIComponent(id)}`, 'lookup', HTML_HEADERS);
+ const html = await this.fetchText(`${BASE_URL}/author/show/${encodeURIComponent(id)}`, 'lookup', HTML_HEADERS, signal);
const parsed = parseGoodreadsAuthorPage(html ?? '');
- if (!parsed) return null;
+ if (!parsed) {
+ markProviderDegraded('goodreads_unparseable_author_page');
+ return null;
+ }
return {
provider: this.key,
@@ -100,28 +124,34 @@ export class GoodreadsAuthorMetadataProvider implements IdentifiableAuthorMetada
return config.goodreads.enabled;
}
- private async fetchAutocomplete(name: string): Promise {
+ private async fetchAutocomplete(name: string, signal?: AbortSignal): Promise {
const url = `${BASE_URL}/book/auto_complete?${new URLSearchParams({ format: 'json', q: name }).toString()}`;
- const body = await this.fetchText(url, 'autocomplete', JSON_HEADERS);
- if (!body) return [];
+ const body = await this.fetchText(url, 'autocomplete', JSON_HEADERS, signal);
+ if (!body) {
+ markProviderFailed('goodreads_malformed_autocomplete');
+ return [];
+ }
try {
const parsed: unknown = JSON.parse(body);
- return Array.isArray(parsed) ? (parsed as GoodreadsAuthorAutocompleteItem[]) : [];
+ if (Array.isArray(parsed)) return parsed as GoodreadsAuthorAutocompleteItem[];
+ markProviderFailed('goodreads_malformed_autocomplete');
+ return [];
} catch {
+ markProviderFailed('goodreads_malformed_autocomplete');
return [];
}
}
- private async fetchSearchPage(name: string): Promise {
+ private async fetchSearchPage(name: string, signal?: AbortSignal): Promise {
const query = new URLSearchParams({ q: name, 'search[field]': 'author' }).toString();
- return (await this.fetchText(`${BASE_URL}/search?${query}`, 'search', HTML_HEADERS)) ?? '';
+ return (await this.fetchText(`${BASE_URL}/search?${query}`, 'search', HTML_HEADERS, signal)) ?? '';
}
- private async fetchText(url: string, op: FetchOp, headers: HeadersInit): Promise {
+ private async fetchText(url: string, op: FetchOp, headers: HeadersInit, signal?: AbortSignal): Promise {
const startedAt = Date.now();
this.logger.log(`[goodreads_author] [start] op=${op} method=GET`);
try {
- const res = await fetch(url, { headers, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
+ const res = await authorProviderFetch(url, { headers, signal: authorRequestSignal(REQUEST_TIMEOUT_MS, signal) });
if (!res.ok) {
const retryAfterMs = parseRetryAfterMs(res.headers.get('retry-after'));
const message = `[goodreads_author] [fail] op=${op} method=GET status=${res.status} durationMs=${Date.now() - startedAt} message="non-ok response"`;
@@ -133,6 +163,7 @@ export class GoodreadsAuthorMetadataProvider implements IdentifiableAuthorMetada
});
}
const body = await res.text();
+ if (isWafChallenge(res.status, body)) markProviderDegraded('goodreads_challenge_page');
this.logger.log(`[goodreads_author] [end] op=${op} method=GET status=${res.status} durationMs=${Date.now() - startedAt}`);
return body;
} catch (error) {
@@ -154,6 +185,11 @@ export class GoodreadsAuthorMetadataProvider implements IdentifiableAuthorMetada
}
}
+// AWS WAF answers a gated page with a JavaScript token challenge, usually under a 202 status.
+function isWafChallenge(status: number, html: string): boolean {
+ return status === 202 || /awsWafCookieDomainList|AwsWafIntegration|id=["']challenge-container["']/.test(html);
+}
+
function parseRetryAfterMs(value: string | null): number | null {
if (!value) return null;
const seconds = Number.parseInt(value, 10);
diff --git a/server/src/modules/book-dock/book-dock-ingest.service.test.ts b/server/src/modules/book-dock/book-dock-ingest.service.test.ts
index 0b7d75d8f..25e224308 100644
--- a/server/src/modules/book-dock/book-dock-ingest.service.test.ts
+++ b/server/src/modules/book-dock/book-dock-ingest.service.test.ts
@@ -537,7 +537,7 @@ describe('BookDockIngestService', () => {
},
{},
undefined,
- { coverMedia: { hasEbook: true, hasAudio: false }, lockedCoverSlots: ['audio'] },
+ { coverMedia: { hasEbook: true, hasAudio: false }, lockedCoverSlots: ['audio'], cacheDirective: { policy: 'default', allowFallback: true } },
);
expect(repo.update).toHaveBeenNthCalledWith(1, 8, { status: 'fetching' });
expect(repo.update).toHaveBeenNthCalledWith(2, 8, { status: 'ready' });
@@ -568,7 +568,7 @@ describe('BookDockIngestService', () => {
},
{ isbn10: undefined, isbn13: '9781401284770' },
undefined,
- { coverMedia: { hasEbook: true, hasAudio: false }, lockedCoverSlots: ['audio'] },
+ { coverMedia: { hasEbook: true, hasAudio: false }, lockedCoverSlots: ['audio'], cacheDirective: { policy: 'default', allowFallback: true } },
);
});
@@ -583,9 +583,25 @@ describe('BookDockIngestService', () => {
expect(metadataFetchPipeline.runWithSources).toHaveBeenCalledWith(expect.objectContaining({ isAudiobook: true }), {}, undefined, {
coverMedia: { hasEbook: false, hasAudio: true },
lockedCoverSlots: ['ebook'],
+ cacheDirective: { policy: 'default', allowFallback: true },
});
});
+ it('asks providers live for a manual refetch, without falling back to saved answers', async () => {
+ const { service, repo, metadataFetchPipeline } = makeService();
+ repo.findById.mockResolvedValue({ id: 8, fileName: 'Dune.epub', status: 'ready', format: 'epub', embeddedMetadata: { title: 'Dune' } });
+ (metadataFetchPipeline as any).runWithSources = vi.fn().mockResolvedValue({ resolved: {}, sources: {} });
+
+ await (service as any).autoFetchMetadataAsync(8, true);
+
+ expect(metadataFetchPipeline.runWithSources).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.anything(),
+ undefined,
+ expect.objectContaining({ cacheDirective: { policy: 'refresh', allowFallback: false } }),
+ );
+ });
+
it('searches by the field rules of the library a docked file is headed for', async () => {
const { service, appSettings, repo, metadataFetchPipeline } = makeService();
appSettings.isBookDockAutoFetchEnabled.mockResolvedValue(true);
@@ -740,6 +756,7 @@ describe('BookDockIngestService', () => {
expect(metadataFetchPipeline.runWithSources).toHaveBeenCalledWith(expect.objectContaining({ isAudiobook: false }), {}, undefined, {
coverMedia: { hasEbook: true, hasAudio: true },
lockedCoverSlots: ['audio'],
+ cacheDirective: { policy: 'default', allowFallback: true },
});
});
diff --git a/server/src/modules/book-dock/book-dock-ingest.service.ts b/server/src/modules/book-dock/book-dock-ingest.service.ts
index 6a7301b91..14f3c2076 100644
--- a/server/src/modules/book-dock/book-dock-ingest.service.ts
+++ b/server/src/modules/book-dock/book-dock-ingest.service.ts
@@ -396,7 +396,13 @@ export class BookDockIngestService implements OnApplicationBootstrap, OnModuleDe
},
libraryId ?? undefined,
// A dock file keeps one cover, its own medium's, so the other slot's search would be thrown away.
- { coverMedia, lockedCoverSlots: [params.isAudiobook ? 'ebook' : 'audio'] },
+ // A manual refetch is live only: nothing on the dock would say a saved answer stood in for a
+ // provider that failed. Automatic ingest may answer from the metadata cache.
+ {
+ coverMedia,
+ lockedCoverSlots: [params.isAudiobook ? 'ebook' : 'audio'],
+ cacheDirective: force ? { policy: 'refresh', allowFallback: false } : { policy: 'default', allowFallback: true },
+ },
);
// A dock file has one medium, so its one cover is that medium's slot.
if (params.isAudiobook) {
diff --git a/server/src/modules/book-metadata-fetch/book-metadata-fetch-orchestrator.service.test.ts b/server/src/modules/book-metadata-fetch/book-metadata-fetch-orchestrator.service.test.ts
index 3b10dc88e..5a75856fb 100644
--- a/server/src/modules/book-metadata-fetch/book-metadata-fetch-orchestrator.service.test.ts
+++ b/server/src/modules/book-metadata-fetch/book-metadata-fetch-orchestrator.service.test.ts
@@ -46,7 +46,7 @@ function makeService(withGateway = true) {
replaceCommunityRatings: vi.fn().mockResolvedValue(undefined),
};
const pipeline = {
- runWithSources: vi.fn().mockResolvedValue({ resolved: {}, providerIds: {} }),
+ runWithSources: vi.fn().mockResolvedValue({ resolved: {}, providerIds: {}, cache: { servedProviders: [], outboundRequests: 0 } }),
};
const metadataService = {
replaceAuthors: vi.fn().mockResolvedValue(undefined),
@@ -157,7 +157,7 @@ describe('BookMetadataFetchOrchestratorService', () => {
queueRepo.scheduleEligibleBooksInBatches.mockResolvedValue(0);
await expect(service.triggerForLibrary(11)).resolves.toBe(0);
- expect(queueRepo.scheduleEligibleBooksInBatches).toHaveBeenCalledWith(baseConfig(false, true), 'manual_trigger', 11, 1000);
+ expect(queueRepo.scheduleEligibleBooksInBatches).toHaveBeenCalledWith(baseConfig(false, true), 'manual_trigger', 11, 1000, false);
expect(configService.recordLibraryRun).toHaveBeenCalledWith(11, 0);
});
@@ -257,7 +257,7 @@ describe('BookMetadataFetchOrchestratorService', () => {
communityRatingRows: [],
});
coverStore.fetchState.mockResolvedValue({ media: { hasEbook: true, hasAudio: true }, filled: { ebook: false, audio: true }, locked: ['ebook'] });
- vi.spyOn(service as any, 'persistResolved').mockResolvedValue(undefined);
+ vi.spyOn(service as any, 'persistResolved').mockResolvedValue(0);
await (service as any).processOne(17, 'Both media');
@@ -265,6 +265,7 @@ describe('BookMetadataFetchOrchestratorService', () => {
expect(pipeline.runWithSources).toHaveBeenCalledWith(expect.any(Object), expect.objectContaining({ cover: null, audioCover: true }), 2, {
coverMedia: { hasEbook: true, hasAudio: true },
lockedCoverSlots: ['ebook'],
+ cacheDirective: { policy: 'default', allowFallback: true },
});
});
@@ -418,16 +419,72 @@ describe('BookMetadataFetchOrchestratorService', () => {
it('pollOnce processes due items and waits random delay', async () => {
const { service, queueRepo } = makeService();
- queueRepo.fetchDue.mockResolvedValue([{ bookId: 44, title: 'Queued Book', reason: 'manual_trigger' }]);
- const processSpy = vi.spyOn(service as any, 'processOne').mockResolvedValue(undefined);
+ queueRepo.fetchDue.mockResolvedValue([{ bookId: 44, title: 'Queued Book', reason: 'manual_trigger', ignoreCache: false }]);
+ const processSpy = vi.spyOn(service as any, 'processOne').mockResolvedValue({ outboundRequests: 3, deferred: false });
const delaySpy = vi.spyOn(service as any, 'randomDelay').mockResolvedValue(undefined);
await (service as any).pollOnce();
- expect(processSpy).toHaveBeenCalledWith(44, 'Queued Book', 'manual_trigger');
+ expect(processSpy).toHaveBeenCalledTimes(1);
+ expect(processSpy).toHaveBeenCalledWith(44, 'Queued Book', 'manual_trigger', false);
expect(delaySpy).toHaveBeenCalledTimes(1);
});
+ it('pollOnce moves straight on to the next book when one was answered entirely from the cache', async () => {
+ const { service, queueRepo } = makeService();
+ queueRepo.fetchDue
+ .mockResolvedValueOnce([{ bookId: 1, title: 'Cached', reason: 'manual_trigger', ignoreCache: false }])
+ .mockResolvedValueOnce([{ bookId: 2, title: 'Live', reason: 'manual_trigger', ignoreCache: true }])
+ .mockResolvedValue([]);
+ const processSpy = vi
+ .spyOn(service as any, 'processOne')
+ .mockResolvedValueOnce({ outboundRequests: 0, deferred: false })
+ .mockResolvedValueOnce({ outboundRequests: 2, deferred: false });
+ const delaySpy = vi.spyOn(service as any, 'randomDelay').mockResolvedValue(undefined);
+
+ await (service as any).pollOnce();
+
+ expect(processSpy).toHaveBeenNthCalledWith(1, 1, 'Cached', 'manual_trigger', false);
+ expect(processSpy).toHaveBeenNthCalledWith(2, 2, 'Live', 'manual_trigger', true);
+ expect(delaySpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('pollOnce does not chain onto a book handed back while it is still importing', async () => {
+ const { service, queueRepo } = makeService();
+ queueRepo.fetchDue.mockResolvedValue([{ bookId: 5, title: 'Importing', reason: 'manual_trigger', ignoreCache: false }]);
+ const processSpy = vi.spyOn(service as any, 'processOne').mockResolvedValue({ outboundRequests: 0, deferred: true });
+ const delaySpy = vi.spyOn(service as any, 'randomDelay').mockResolvedValue(undefined);
+
+ await (service as any).pollOnce();
+
+ expect(processSpy).toHaveBeenCalledTimes(1);
+ expect(delaySpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('processOne asks providers live when the queue row asked to ignore the cache', async () => {
+ const { service, bookReadService, coverStore, pipeline } = makeService();
+ bookReadService.findById.mockResolvedValue({
+ book: { books: { libraryId: 2, status: 'present', primaryFileId: null }, book_metadata: { title: 'Fresh' } },
+ authorRows: [],
+ genreRows: [],
+ narratorRows: [],
+ communityRatingRows: [],
+ fileRows: [],
+ });
+ coverStore.fetchState.mockResolvedValue({ media: { hasEbook: true, hasAudio: false }, filled: { ebook: true, audio: false }, locked: [] });
+ vi.spyOn(service as any, 'persistResolved').mockResolvedValue(1);
+
+ const outcome = await (service as any).processOne(8, 'Fresh', 'manual_trigger', true);
+
+ expect(pipeline.runWithSources).toHaveBeenCalledWith(
+ expect.any(Object),
+ expect.any(Object),
+ 2,
+ expect.objectContaining({ cacheDirective: { policy: 'refresh', allowFallback: true } }),
+ );
+ expect(outcome).toEqual({ outboundRequests: 1, deferred: false });
+ });
+
it('processOne exits early when queue row cannot be claimed', async () => {
const { service, queueRepo, bookReadService } = makeService();
queueRepo.markProcessing.mockResolvedValue(false);
@@ -500,8 +557,8 @@ describe('BookMetadataFetchOrchestratorService', () => {
communityRatingRows: [],
});
comicMetadataRepository.findByBookId.mockResolvedValue({ issueNumber: '28', volumeName: null });
- pipeline.runWithSources.mockResolvedValue({ resolved: {}, providerIds: {} });
- vi.spyOn(service as any, 'persistResolved').mockResolvedValue(undefined);
+ pipeline.runWithSources.mockResolvedValue({ resolved: {}, providerIds: {}, cache: { servedProviders: [], outboundRequests: 0 } });
+ vi.spyOn(service as any, 'persistResolved').mockResolvedValue(0);
await (service as any).processOne(33, 'Akira', 'event_import');
@@ -532,8 +589,8 @@ describe('BookMetadataFetchOrchestratorService', () => {
narratorRows: [],
communityRatingRows: [],
});
- pipeline.runWithSources.mockResolvedValue({ resolved: {}, providerIds: {} });
- vi.spyOn(service as any, 'persistResolved').mockResolvedValue(undefined);
+ pipeline.runWithSources.mockResolvedValue({ resolved: {}, providerIds: {}, cache: { servedProviders: [], outboundRequests: 0 } });
+ vi.spyOn(service as any, 'persistResolved').mockResolvedValue(0);
scoreService.calculateAndSave.mockRejectedValue(new Error('score delayed'));
await (service as any).processOne(90, 'Book');
diff --git a/server/src/modules/book-metadata-fetch/book-metadata-fetch-orchestrator.service.ts b/server/src/modules/book-metadata-fetch/book-metadata-fetch-orchestrator.service.ts
index bd9a3ed01..4f4e98fab 100644
--- a/server/src/modules/book-metadata-fetch/book-metadata-fetch-orchestrator.service.ts
+++ b/server/src/modules/book-metadata-fetch/book-metadata-fetch-orchestrator.service.ts
@@ -24,6 +24,21 @@ import { BookMetadataFetchSessionService } from './book-metadata-fetch-session.s
const POLL_INTERVAL_MS = 4_000;
const BATCH_SIZE = 1;
const SCHEDULE_BATCH_SIZE = 1000;
+// A book answered entirely from the metadata cache made no request, so the next one starts after a
+// short yield instead of the usual pause. The chain is bounded so one poll never runs unchecked.
+const CACHED_CHAIN_MAX_ITEMS = 50;
+const CACHED_CHAIN_MAX_MS = 30_000;
+const CACHED_ITEM_YIELD_MS = 100;
+const STATUS_EMIT_MIN_INTERVAL_MS = 500;
+
+interface ProcessOutcome {
+ /** Provider requests and cover downloads the book caused; 0 when everything came from the cache. */
+ outboundRequests: number;
+ /** The book was handed back to the queue (still importing), so the next poll should not chain onto it. */
+ deferred: boolean;
+}
+
+const NO_OUTBOUND: ProcessOutcome = { outboundRequests: 0, deferred: false };
type BookReadResult = NonNullable>>;
@@ -33,6 +48,8 @@ export class BookMetadataFetchOrchestratorService implements OnApplicationBootst
private pollTimer: ReturnType | null = null;
private running = false;
private paused = false;
+ private lastStatusEmitAt = 0;
+ private pendingStatusEmit: ReturnType | null = null;
constructor(
private readonly queueRepo: BookMetadataFetchQueueRepository,
@@ -65,6 +82,10 @@ export class BookMetadataFetchOrchestratorService implements OnApplicationBootst
clearInterval(this.pollTimer);
this.pollTimer = null;
}
+ if (this.pendingStatusEmit) {
+ clearTimeout(this.pendingStatusEmit);
+ this.pendingStatusEmit = null;
+ }
}
async scheduleImportedBooksIfEligible(libraryId: number, bookIds: readonly number[]): Promise {
@@ -79,9 +100,9 @@ export class BookMetadataFetchOrchestratorService implements OnApplicationBootst
return queued;
}
- async triggerGlobal(): Promise {
+ async triggerGlobal(ignoreCache = false): Promise {
const config = await this.configService.getGlobalConfig();
- const queued = await this.queueRepo.scheduleEligibleBooksInBatches(config, 'manual_trigger', undefined, SCHEDULE_BATCH_SIZE);
+ const queued = await this.queueRepo.scheduleEligibleBooksInBatches(config, 'manual_trigger', undefined, SCHEDULE_BATCH_SIZE, ignoreCache);
await this.unpauseIfNeeded();
if (queued > 0) {
this.session.addToTotal(queued);
@@ -91,9 +112,9 @@ export class BookMetadataFetchOrchestratorService implements OnApplicationBootst
return queued;
}
- async triggerForLibrary(libraryId: number): Promise {
+ async triggerForLibrary(libraryId: number, ignoreCache = false): Promise {
const config = await this.configService.getEffectiveConfig(libraryId);
- const queued = await this.queueRepo.scheduleEligibleBooksInBatches(config, 'manual_trigger', libraryId, SCHEDULE_BATCH_SIZE);
+ const queued = await this.queueRepo.scheduleEligibleBooksInBatches(config, 'manual_trigger', libraryId, SCHEDULE_BATCH_SIZE, ignoreCache);
await this.configService.recordLibraryRun(libraryId, queued);
await this.unpauseIfNeeded();
if (queued > 0) {
@@ -142,11 +163,19 @@ export class BookMetadataFetchOrchestratorService implements OnApplicationBootst
await this.queueRepo.recoverStuckProcessing();
await this.checkAndResetSession();
- if (this.paused) return;
- const dueRows = await this.queueRepo.fetchDue(BATCH_SIZE);
- if (dueRows.length > 0) {
- await this.processOne(dueRows[0].bookId, dueRows[0].title, dueRows[0].reason);
- await this.randomDelay();
+ const chainStartedAt = Date.now();
+ for (let processed = 0; ; processed++) {
+ if (this.paused) return;
+ const dueRows = await this.queueRepo.fetchDue(BATCH_SIZE);
+ if (dueRows.length === 0) return;
+ const row = dueRows[0];
+ const outcome = await this.processOne(row.bookId, row.title, row.reason, row.ignoreCache);
+ if (outcome.outboundRequests > 0 || outcome.deferred) {
+ await this.randomDelay();
+ return;
+ }
+ if (processed + 1 >= CACHED_CHAIN_MAX_ITEMS || Date.now() - chainStartedAt >= CACHED_CHAIN_MAX_MS) return;
+ await new Promise((resolve) => setTimeout(resolve, CACHED_ITEM_YIELD_MS));
}
} catch (error) {
const errorClass = error instanceof Error ? error.name : 'Error';
@@ -159,10 +188,15 @@ export class BookMetadataFetchOrchestratorService implements OnApplicationBootst
}
}
- private async processOne(bookId: number, title: string | null, reason: BookMetadataFetchReason = 'manual_trigger'): Promise {
+ private async processOne(
+ bookId: number,
+ title: string | null,
+ reason: BookMetadataFetchReason = 'manual_trigger',
+ ignoreCache = false,
+ ): Promise {
const startedAt = Date.now();
const claimed = await this.queueRepo.markProcessing(bookId);
- if (!claimed) return;
+ if (!claimed) return NO_OUTBOUND;
this.session.setCurrentItemName(title ?? null);
this.logger.debug(`[book.metadata_fetch] [start] bookId=${bookId} - metadata fetch started`);
await this.emitStatus();
@@ -171,7 +205,7 @@ export class BookMetadataFetchOrchestratorService implements OnApplicationBootst
const found = await this.bookReadService.findById(bookId);
if (!found) {
await this.completeMissingBook(bookId, startedAt);
- return;
+ return NO_OUTBOUND;
}
const { book, authorRows, genreRows, narratorRows, communityRatingRows, fileRows } = found;
@@ -185,7 +219,7 @@ export class BookMetadataFetchOrchestratorService implements OnApplicationBootst
`[book.metadata_fetch] [end] bookId=${bookId} durationMs=${Date.now() - startedAt} outcome=awaiting_import_completion - metadata fetch deferred`,
);
await this.emitStatus();
- return;
+ return { outboundRequests: 0, deferred: true };
}
if (book.books.status !== undefined && book.books.status !== 'present') {
@@ -196,13 +230,13 @@ export class BookMetadataFetchOrchestratorService implements OnApplicationBootst
`[book.metadata_fetch] [end] bookId=${bookId} durationMs=${Date.now() - startedAt} outcome=book_not_present - metadata fetch completed`,
);
await this.emitStatus();
- return;
+ return NO_OUTBOUND;
}
const coverState = await this.coverStore.fetchState(bookId);
if (!coverState) {
await this.completeMissingBook(bookId, startedAt);
- return;
+ return NO_OUTBOUND;
}
const preserveExisting = reason === 'event_import';
if (preserveExisting) {
@@ -215,7 +249,7 @@ export class BookMetadataFetchOrchestratorService implements OnApplicationBootst
`[book.metadata_fetch] [end] bookId=${bookId} durationMs=${Date.now() - startedAt} outcome=event_import_no_longer_eligible - metadata fetch completed`,
);
await this.emitStatus();
- return;
+ return NO_OUTBOUND;
}
}
@@ -260,20 +294,25 @@ export class BookMetadataFetchOrchestratorService implements OnApplicationBootst
existingFields.comicMetadata = await this.comicMetadataRepository.findByBookId(bookId);
}
- const { resolved, providerIds } = await this.pipeline.runWithSources(searchParams, existingFields, libraryId, {
+ const { resolved, providerIds, cache } = await this.pipeline.runWithSources(searchParams, existingFields, libraryId, {
...coverInputs.options,
...(preserveExisting ? { preserveExisting: true } : {}),
+ cacheDirective: { policy: ignoreCache ? 'refresh' : 'default', allowFallback: true },
});
- await this.persistResolved(bookId, resolved, providerIds, authorRows, genreRows, narratorRows);
+ const coverDownloads = await this.persistResolved(bookId, resolved, providerIds, authorRows, genreRows, narratorRows);
await this.scoreService.calculateAndSave(bookId);
- this.logger.debug(`[book.metadata_fetch] [end] bookId=${bookId} durationMs=${Date.now() - startedAt} - metadata fetch completed`);
+ const outboundRequests = cache.outboundRequests + coverDownloads;
+ this.logger.debug(
+ `[book.metadata_fetch] [end] bookId=${bookId} durationMs=${Date.now() - startedAt} ignoreCache=${ignoreCache} cacheServed=${cache.servedProviders.length} outbound=${outboundRequests} - metadata fetch completed`,
+ );
await this.queueRepo.markDone(bookId);
this.session.incrementDone();
this.session.setCurrentItemName(null);
await this.emitStatus();
+ return { outboundRequests, deferred: false };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const httpStatus = extractHttpStatus(error);
@@ -284,6 +323,8 @@ export class BookMetadataFetchOrchestratorService implements OnApplicationBootst
await this.queueRepo.markFailed(bookId, message, httpStatus);
this.session.setCurrentItemName(null);
await this.emitStatus();
+ // The failure may have come mid-request, so the usual pause applies.
+ return { outboundRequests: 1, deferred: false };
}
}
@@ -294,7 +335,7 @@ export class BookMetadataFetchOrchestratorService implements OnApplicationBootst
existingAuthorRows: { name: string }[],
existingGenreRows: { name: string }[],
existingNarratorRows: { name: string }[],
- ): Promise {
+ ): Promise {
const {
resolved: filteredResolved,
providerIds: filteredProviderIds,
@@ -414,16 +455,21 @@ export class BookMetadataFetchOrchestratorService implements OnApplicationBootst
await this.metadataService.upsertComicMetadata(bookId, filteredResolved.comicMetadata);
}
+ // Cover downloads reach provider CDNs too, so they count against the pause between books.
+ let coverDownloads = 0;
if (filteredResolved.coverUrl) {
+ coverDownloads += 1;
await this.metadataService.downloadAndSaveCover(filteredResolved.coverChoices ?? [{ url: filteredResolved.coverUrl }], bookId, 'ebook');
}
if (filteredResolved.audioCoverUrl) {
+ coverDownloads += 1;
await this.metadataService.downloadAndSaveCover(
filteredResolved.audioCoverChoices ?? [{ url: filteredResolved.audioCoverUrl }],
bookId,
'audio',
);
}
+ return coverDownloads;
}
private async completeMissingBook(bookId: number, startedAt: number): Promise {
@@ -533,8 +579,23 @@ export class BookMetadataFetchOrchestratorService implements OnApplicationBootst
}
}
+ /** Coalesced: cached books can complete several times a second, which would flood the socket. */
private async emitStatus(): Promise {
if (!this.gateway) return;
+ const wait = STATUS_EMIT_MIN_INTERVAL_MS - (Date.now() - this.lastStatusEmitAt);
+ if (wait > 0) {
+ this.pendingStatusEmit ??= setTimeout(() => {
+ this.pendingStatusEmit = null;
+ void this.emitStatusNow().catch(() => undefined);
+ }, wait);
+ return;
+ }
+ await this.emitStatusNow();
+ }
+
+ private async emitStatusNow(): Promise {
+ if (!this.gateway) return;
+ this.lastStatusEmitAt = Date.now();
const summary = await this.queueRepo.getStatusSummary();
this.gateway.emitStatus({ ...summary, paused: this.paused, ...this.session.getSnapshot() });
}
diff --git a/server/src/modules/book-metadata-fetch/book-metadata-fetch-queue.repository.test.ts b/server/src/modules/book-metadata-fetch/book-metadata-fetch-queue.repository.test.ts
index 02c88b376..ec0137bcb 100644
--- a/server/src/modules/book-metadata-fetch/book-metadata-fetch-queue.repository.test.ts
+++ b/server/src/modules/book-metadata-fetch/book-metadata-fetch-queue.repository.test.ts
@@ -270,7 +270,7 @@ describe('BookMetadataFetchQueueRepository', () => {
config.conditions.neverFetched.enabled = true;
await expect(repo.scheduleEligibleBooksInBatches(config, 'manual_trigger', undefined, 2)).resolves.toBe(2);
- expect(repo.upsertSchedule).toHaveBeenCalledWith([2, 5], 'manual_trigger');
+ expect(repo.upsertSchedule).toHaveBeenCalledWith([2, 5], 'manual_trigger', false);
});
it('fetchDue short-circuits for non-positive limits', async () => {
diff --git a/server/src/modules/book-metadata-fetch/book-metadata-fetch-queue.repository.ts b/server/src/modules/book-metadata-fetch/book-metadata-fetch-queue.repository.ts
index 57ff00da1..9c59d550e 100644
--- a/server/src/modules/book-metadata-fetch/book-metadata-fetch-queue.repository.ts
+++ b/server/src/modules/book-metadata-fetch/book-metadata-fetch-queue.repository.ts
@@ -39,7 +39,11 @@ const QUEUE_STATUS = {
export class BookMetadataFetchQueueRepository {
constructor(@Inject(DB) private readonly db: Db) {}
- async upsertSchedule(bookIds: number[], reason: BookMetadataFetchReason): Promise {
+ /**
+ * `ignoreCache` is only ever turned on: a book already waiting keeps a request to bypass cached
+ * provider answers even when a later, ordinary schedule touches it.
+ */
+ async upsertSchedule(bookIds: number[], reason: BookMetadataFetchReason, ignoreCache = false): Promise {
const unique = [...new Set(bookIds)].filter((id) => Number.isInteger(id) && id > 0);
if (unique.length === 0) return 0;
@@ -52,6 +56,7 @@ export class BookMetadataFetchQueueRepository {
status: QUEUE_STATUS.QUEUED,
reason,
attemptCount: 0,
+ ignoreCache,
})),
)
.onConflictDoUpdate({
@@ -60,6 +65,7 @@ export class BookMetadataFetchQueueRepository {
status: QUEUE_STATUS.QUEUED,
reason,
attemptCount: 0,
+ ignoreCache: sql`${bookMetadataFetchQueue.ignoreCache} OR ${ignoreCache}`,
updatedAt: now,
},
// Only re-queue failed rows. Already-queued rows are intentionally ignored
@@ -68,16 +74,30 @@ export class BookMetadataFetchQueueRepository {
})
.returning({ bookId: bookMetadataFetchQueue.bookId });
+ if (ignoreCache) {
+ await this.db
+ .update(bookMetadataFetchQueue)
+ .set({ ignoreCache: true, updatedAt: now })
+ .where(
+ and(
+ inArray(bookMetadataFetchQueue.bookId, unique),
+ inArray(bookMetadataFetchQueue.status, [QUEUE_STATUS.QUEUED, QUEUE_STATUS.PROCESSING]),
+ eq(bookMetadataFetchQueue.ignoreCache, false),
+ ),
+ );
+ }
+
return touched.length;
}
- async fetchDue(limit: number): Promise<{ bookId: number; title: string | null; reason: BookMetadataFetchReason }[]> {
+ async fetchDue(limit: number): Promise<{ bookId: number; title: string | null; reason: BookMetadataFetchReason; ignoreCache: boolean }[]> {
if (limit <= 0) return [];
const rows = await this.db
.select({
bookId: bookMetadataFetchQueue.bookId,
title: sql`COALESCE(${bookMetadata.title}, ${books.folderPath})`,
reason: bookMetadataFetchQueue.reason,
+ ignoreCache: bookMetadataFetchQueue.ignoreCache,
})
.from(bookMetadataFetchQueue)
.leftJoin(bookMetadata, eq(bookMetadata.bookId, bookMetadataFetchQueue.bookId))
@@ -208,6 +228,7 @@ export class BookMetadataFetchQueueRepository {
reason: BookMetadataFetchReason,
libraryId?: number,
batchSize = 1000,
+ ignoreCache = false,
): Promise {
const whereClause = this.buildEligibleBooksWhereClause(config, libraryId);
if (!whereClause || batchSize <= 0) return 0;
@@ -229,6 +250,7 @@ export class BookMetadataFetchQueueRepository {
totalQueued += await this.upsertSchedule(
rows.map((row) => row.bookId),
reason,
+ ignoreCache,
);
cursorBookId = rows[rows.length - 1]!.bookId;
}
diff --git a/server/src/modules/book-metadata-fetch/book-metadata-fetch.controller.ts b/server/src/modules/book-metadata-fetch/book-metadata-fetch.controller.ts
index 817ef4e56..10aa5cdba 100644
--- a/server/src/modules/book-metadata-fetch/book-metadata-fetch.controller.ts
+++ b/server/src/modules/book-metadata-fetch/book-metadata-fetch.controller.ts
@@ -7,6 +7,7 @@ import { BookMetadataFetchConfigService } from './book-metadata-fetch-config.ser
import { BookMetadataFetchOrchestratorService } from './book-metadata-fetch-orchestrator.service';
import { BookMetadataFetchQueueRepository } from './book-metadata-fetch-queue.repository';
import { BookMetadataFetchSessionService } from './book-metadata-fetch-session.service';
+import { TriggerBookMetadataFetchRunDto } from './dto/trigger-run.dto';
import { PreviewCountDto, UpdateBookMetadataFetchConfigDto, UpdateLibraryBookMetadataFetchConfigDto } from './dto/update-config.dto';
@Controller('book-metadata-fetch')
@@ -56,16 +57,16 @@ export class BookMetadataFetchController {
@Post('run')
@RequirePermission(Permission.ManageMetadataConfig)
- async triggerGlobal() {
- const queued = await this.orchestrator.triggerGlobal();
+ async triggerGlobal(@Body() dto: TriggerBookMetadataFetchRunDto) {
+ const queued = await this.orchestrator.triggerGlobal(dto?.ignoreCache === true);
return { queued };
}
@Post('run/:libraryId')
@RequirePermission(Permission.ManageMetadataConfig)
@RequireLibraryType('books')
- async triggerForLibrary(@Param('libraryId', ParseIntPipe) libraryId: number) {
- const queued = await this.orchestrator.triggerForLibrary(libraryId);
+ async triggerForLibrary(@Param('libraryId', ParseIntPipe) libraryId: number, @Body() dto: TriggerBookMetadataFetchRunDto) {
+ const queued = await this.orchestrator.triggerForLibrary(libraryId, dto?.ignoreCache === true);
return { queued };
}
diff --git a/server/src/modules/book-metadata-fetch/dto/trigger-run.dto.ts b/server/src/modules/book-metadata-fetch/dto/trigger-run.dto.ts
new file mode 100644
index 000000000..87705064c
--- /dev/null
+++ b/server/src/modules/book-metadata-fetch/dto/trigger-run.dto.ts
@@ -0,0 +1,8 @@
+import { IsBoolean, IsOptional } from 'class-validator';
+
+/** Optional body for a manual run; sent with no body, a run uses cached provider answers. */
+export class TriggerBookMetadataFetchRunDto {
+ @IsOptional()
+ @IsBoolean()
+ ignoreCache?: boolean;
+}
diff --git a/server/src/modules/book/book.controller.ts b/server/src/modules/book/book.controller.ts
index 4cff31a14..54ef35182 100644
--- a/server/src/modules/book/book.controller.ts
+++ b/server/src/modules/book/book.controller.ts
@@ -32,6 +32,7 @@ import { FileWriteService } from '../file-write/file-write.service';
import { BookService } from './book.service';
import { BookQueryPipe } from './pipes/book-query.pipe';
import { BulkBookIdsDto } from './dto/bulk-book-ids.dto';
+import { BulkRefreshMetadataDto } from './dto/bulk-refresh-metadata.dto';
import { BulkSetStatusDto } from './dto/bulk-set-status.dto';
import { BulkSetRatingDto } from './dto/bulk-set-rating.dto';
import { BulkSetMetadataDto } from './dto/bulk-set-metadata.dto';
@@ -161,7 +162,7 @@ export class BookController {
return `Bulk refreshed metadata for ${count} book${count !== 1 ? 's' : ''}`;
},
})
- async bulkRefreshMetadata(@Body() dto: BulkBookIdsDto, @CurrentUser() user: RequestUser, @Res() reply: FastifyReply) {
+ async bulkRefreshMetadata(@Body() dto: BulkRefreshMetadataDto, @CurrentUser() user: RequestUser, @Res() reply: FastifyReply) {
const ids = await this.bookService.resolveSelectionToIds(dto, user);
const stream = this.createSseStream(reply);
try {
@@ -171,7 +172,7 @@ export class BookController {
(event) => {
stream.send(event);
},
- { isCancelled: stream.isClosed },
+ { isCancelled: stream.isClosed, ignoreCache: dto.ignoreCache === true },
);
if (!stream.isClosed()) {
stream.send({ done: true, ...result });
diff --git a/server/src/modules/book/book.service.test.ts b/server/src/modules/book/book.service.test.ts
index b6f1c3f79..8cfceafd5 100644
--- a/server/src/modules/book/book.service.test.ts
+++ b/server/src/modules/book/book.service.test.ts
@@ -403,6 +403,9 @@ function makeBookCard(id: number, overrides?: Record) {
};
}
+const NO_CACHE_USE = { servedProviders: [], outboundRequests: 0 };
+const EXPLICIT_REFRESH = { policy: 'refresh', allowFallback: true };
+
describe('BookService', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -1004,6 +1007,7 @@ describe('BookService', () => {
fileRows: [{ format: 'epub', role: 'primary', mediaOverlayAvailable: false }],
});
pipeline.runWithSources.mockResolvedValue({
+ cache: NO_CACHE_USE,
resolved: { title: 'New Title', isbn10: '0306406152', isbn13: '9780306406157' },
sources: {},
providerIds: {},
@@ -1054,7 +1058,7 @@ describe('BookService', () => {
narrators: [],
},
7,
- { coverMedia: { hasEbook: true, hasAudio: false }, lockedCoverSlots: [] },
+ { coverMedia: { hasEbook: true, hasAudio: false }, lockedCoverSlots: [], cacheDirective: EXPLICIT_REFRESH },
);
expect(updateSpy).not.toHaveBeenCalled();
expect(metadataService.downloadAndSaveCover).not.toHaveBeenCalled();
@@ -1073,6 +1077,7 @@ describe('BookService', () => {
genreRows: [],
});
pipeline.runWithSources.mockResolvedValue({
+ cache: NO_CACHE_USE,
resolved: { title: 'Resolved', authors: ['A'], genres: ['G'], isbn10: '0306406152', isbn13: '9780306406157', coverUrl: 'https://img/c.jpg' },
sources: {},
providerIds: {},
@@ -1119,6 +1124,7 @@ describe('BookService', () => {
{ url: 'https://img/b.jpg', provider: MetadataProviderKey.ITUNES, fit: 'match' as const },
];
pipeline.runWithSources.mockResolvedValue({
+ cache: NO_CACHE_USE,
resolved: { audioCoverUrl: 'https://img/a.jpg', audioCoverChoices },
sources: {},
providerIds: {},
@@ -1135,6 +1141,7 @@ describe('BookService', () => {
expect(pipeline.runWithSources).toHaveBeenCalledWith(expect.any(Object), expect.objectContaining({ cover: null, audioCover: null }), 7, {
coverMedia: { hasEbook: true, hasAudio: true },
lockedCoverSlots: ['ebook'],
+ cacheDirective: EXPLICIT_REFRESH,
});
expect(metadataService.downloadAndSaveCover).toHaveBeenCalledTimes(1);
expect(metadataService.downloadAndSaveCover).toHaveBeenCalledWith(audioCoverChoices, 1, 'audio');
@@ -1167,6 +1174,7 @@ describe('BookService', () => {
fileRows: [],
});
pipeline.runWithSources.mockResolvedValue({
+ cache: NO_CACHE_USE,
resolved: {
coverUrl: 'https://img/e.jpg',
coverChoices: [{ url: 'https://img/e.jpg', provider: MetadataProviderKey.AMAZON, fit: 'match' }],
@@ -1195,6 +1203,7 @@ describe('BookService', () => {
genreRows: [],
});
pipeline.runWithSources.mockResolvedValue({
+ cache: NO_CACHE_USE,
resolved: { title: 'Resolved' },
sources: {},
providerIds: {
@@ -1232,6 +1241,7 @@ describe('BookService', () => {
communityRatingRows: [],
});
pipeline.runWithSources.mockResolvedValue({
+ cache: NO_CACHE_USE,
resolved: {
communityRatings: [{ provider: MetadataProviderKey.HARDCOVER, rating: 4.25, ratingCount: 12345, updatedAt: '2026-06-25T00:00:00.000Z' }],
},
@@ -1263,6 +1273,7 @@ describe('BookService', () => {
genreRows: [],
});
pipeline.runWithSources.mockResolvedValue({
+ cache: NO_CACHE_USE,
resolved: {
title: 'Resolved',
narrators: ['Narrator One'],
@@ -1308,6 +1319,7 @@ describe('BookService', () => {
genreRows: [],
});
pipeline.runWithSources.mockResolvedValue({
+ cache: NO_CACHE_USE,
resolved: {
seriesName: 'Sword of Truth',
seriesIndex: 11,
@@ -1336,6 +1348,35 @@ describe('BookService', () => {
});
});
+ it('refreshMetadata reports providers that fell back to the cache', async () => {
+ const { service, bookRepo, pipeline } = makeService();
+ const user = makeUser();
+ bookRepo.findById.mockResolvedValue({
+ book: {
+ books: { id: 1, libraryId: 7 },
+ book_metadata: { title: 'Old', isbn13: null, isbn10: null },
+ },
+ narratorRows: [],
+ authorRows: [],
+ genreRows: [],
+ });
+ const fallback = { provider: MetadataProviderKey.GOODREADS, source: 'fallback' as const, stale: true, cachedAt: '2026-01-01T00:00:00.000Z' };
+ pipeline.runWithSources.mockResolvedValue({
+ cache: {
+ servedProviders: [{ provider: MetadataProviderKey.GOOGLE, source: 'cache', stale: false, cachedAt: '2026-02-01T00:00:00.000Z' }, fallback],
+ outboundRequests: 1,
+ },
+ resolved: { title: 'Resolved' },
+ sources: {},
+ providerIds: {},
+ });
+ vi.spyOn(service, 'updateMetadata').mockResolvedValue({ book: { id: 1 }, write: null, libraryAutoWriteEnabled: false } as never);
+
+ const result = await service.refreshMetadata(1, false, user);
+
+ expect(result).toEqual(expect.objectContaining({ id: 1, metadataCache: { fallbackProviders: [fallback] } }));
+ });
+
it('refreshMetadata persists provider ids returned by pipeline', async () => {
const { service, bookRepo, pipeline } = makeService();
const user = makeUser();
@@ -1349,6 +1390,7 @@ describe('BookService', () => {
genreRows: [],
});
pipeline.runWithSources.mockResolvedValue({
+ cache: NO_CACHE_USE,
resolved: { title: 'Resolved' },
sources: {},
providerIds: {
@@ -1393,6 +1435,7 @@ describe('BookService', () => {
communityRatingRows: [],
});
pipeline.runWithSources.mockResolvedValue({
+ cache: NO_CACHE_USE,
resolved: { hardcoverEditionId: '8941973' },
sources: {},
providerIds: {
@@ -1432,7 +1475,7 @@ describe('BookService', () => {
{ id: 2, format: 'm4b', role: 'content' },
],
});
- pipeline.runWithSources.mockResolvedValue({ resolved: {}, sources: {}, providerIds: {} });
+ pipeline.runWithSources.mockResolvedValue({ cache: NO_CACHE_USE, resolved: {}, sources: {}, providerIds: {} });
await service.refreshMetadata(1, true, makeUser());
@@ -1455,7 +1498,7 @@ describe('BookService', () => {
genreRows: [],
communityRatingRows: [],
});
- pipeline.runWithSources.mockResolvedValue({ resolved: {}, sources: {}, providerIds: {} });
+ pipeline.runWithSources.mockResolvedValue({ cache: NO_CACHE_USE, resolved: {}, sources: {}, providerIds: {} });
await service.refreshMetadata(1, true, user);
@@ -1481,6 +1524,7 @@ describe('BookService', () => {
communityRatingRows: [],
});
pipeline.runWithSources.mockResolvedValue({
+ cache: NO_CACHE_USE,
resolved: {
communityRatings: [{ provider: MetadataProviderKey.HARDCOVER, rating: 4.25, ratingCount: 12345, updatedAt: null }],
},
@@ -1519,6 +1563,7 @@ describe('BookService', () => {
genreRows: [],
});
pipeline.runWithSources.mockResolvedValue({
+ cache: NO_CACHE_USE,
resolved: {
seriesName: 'Sword of Truth',
seriesIndex: 11,
@@ -1567,6 +1612,7 @@ describe('BookService', () => {
genreRows: [],
});
pipeline.runWithSources.mockResolvedValue({
+ cache: NO_CACHE_USE,
resolved: {
title: 'Resolved',
narrators: ['Narrator One'],
@@ -1615,6 +1661,7 @@ describe('BookService', () => {
genreRows: [],
});
pipeline.runWithSources.mockResolvedValue({
+ cache: NO_CACHE_USE,
resolved: {
title: 'Resolved',
comicMetadata: {
@@ -1663,6 +1710,7 @@ describe('BookService', () => {
genreRows: [],
});
pipeline.runWithSources.mockResolvedValue({
+ cache: NO_CACHE_USE,
resolved: { title: 'Resolved', authors: ['A'], coverUrl: 'https://img/c.jpg', hardcoverEditionId: '8941973' },
sources: {},
providerIds: {
@@ -2556,11 +2604,22 @@ describe('BookService', () => {
const result = await service.bulkRefreshMetadata([1, 2], user, onProgress);
expect(result).toEqual({ processed: 2, failed: 0 });
- expect(refreshSpy).toHaveBeenCalledWith(1, false, user);
- expect(refreshSpy).toHaveBeenCalledWith(2, false, user);
+ expect(refreshSpy).toHaveBeenCalledWith(1, false, user, { cacheDirective: { policy: 'default', allowFallback: true } });
+ expect(refreshSpy).toHaveBeenCalledWith(2, false, user, { cacheDirective: { policy: 'default', allowFallback: true } });
expect(onProgress).toHaveBeenCalledTimes(2);
});
+ it('bulkRefreshMetadata asks providers live when the caller ignores the cache', async () => {
+ const { service, bookRepo } = makeService();
+ const user = makeUser();
+ const refreshSpy = vi.spyOn(service, 'refreshMetadata').mockResolvedValue({ id: 1 } as never);
+ bookRepo.findLibraryIdsByBookIds.mockResolvedValue([{ id: 1, libraryId: 7 }]);
+
+ await service.bulkRefreshMetadata([1], user, undefined, { ignoreCache: true });
+
+ expect(refreshSpy).toHaveBeenCalledWith(1, false, user, { cacheDirective: { policy: 'refresh', allowFallback: true } });
+ });
+
it('removes every content file before deleting records, then cleans up covers', async () => {
const { service, bookRepo, libraryService } = makeService();
const user = makeUser();
diff --git a/server/src/modules/book/book.service.ts b/server/src/modules/book/book.service.ts
index 307bfce85..cfeb72c19 100644
--- a/server/src/modules/book/book.service.ts
+++ b/server/src/modules/book/book.service.ts
@@ -56,6 +56,7 @@ import type {
BookKoboState,
BookMetadataRefreshPreviewFields,
BookMetadataRefreshPreviewResponse,
+ MetadataCacheUsage,
BookMetadataLockField,
BookDeletionAuditMeta,
BookQuery,
@@ -87,6 +88,7 @@ import { MetadataScoreService } from '../metadata-score/metadata-score.service';
import { LibraryService } from '../library/library.service';
import { books } from '../../db/schema';
import { ExistingMetadataFields, MetadataFetchPipeline, ResolvedMetadataFields } from '../metadata-fetch/metadata-fetch-pipeline';
+import type { CacheDirective } from '../../common/provider-cache/provider-cache-spec';
import { splitMetadataIsbn } from '../../common/text-match/isbn-normalize';
import type { MetadataSearchParams } from '../metadata-fetch/providers/metadata-search-params';
import { FileRenameService, RENAME_RELEVANT_FIELDS } from '../file-write/file-rename.service';
@@ -147,6 +149,8 @@ const METADATA_UPDATE_FAILPOINTS = [
] as const;
const METADATA_PROVIDER_KEY_SET = new Set(Object.values(MetadataProviderKey));
+/** An explicit single-book refresh goes live; a provider that fails may still answer from the cache. */
+const EXPLICIT_REFRESH_DIRECTIVE: CacheDirective = { policy: 'refresh', allowFallback: true };
export type MetadataUpdateFailpoint = (typeof METADATA_UPDATE_FAILPOINTS)[number];
export type ExportScope = 'primary' | 'all' | 'audio';
@@ -2870,7 +2874,16 @@ export class BookService {
};
}
- async refreshMetadata(id: number, preview: boolean, user: RequestUser): Promise {
+ /**
+ * An explicit refresh goes live (a provider that fails may still answer from the cache, and the
+ * response says which did); the bulk refresh passes its own directive so it can use the cache.
+ */
+ async refreshMetadata(
+ id: number,
+ preview: boolean,
+ user: RequestUser,
+ options?: { cacheDirective?: CacheDirective },
+ ): Promise<(BookDetailDto & { metadataCache?: MetadataCacheUsage }) | BookMetadataRefreshPreviewResponse> {
const event = 'book.refresh_metadata';
const startedAt = Date.now();
this.logger.log(`[${event}] [start] bookId=${id} userId=${user.id} preview=${preview} - refresh metadata started`);
@@ -2928,7 +2941,11 @@ export class BookService {
resolved,
providerIds: resolvedProviderIds,
diagnostics,
- } = await this.pipeline.runWithSources(searchParams, existingFields, book.books.libraryId, coverInputs.options);
+ cache,
+ } = await this.pipeline.runWithSources(searchParams, existingFields, book.books.libraryId, {
+ ...coverInputs.options,
+ cacheDirective: options?.cacheDirective ?? EXPLICIT_REFRESH_DIRECTIVE,
+ });
if (preview) {
const previewResult = this.buildMetadataRefreshPreview(resolved, resolvedProviderIds);
@@ -2996,10 +3013,11 @@ export class BookService {
if (coverDownloaded) detail = await this.getDetail(id, user);
const result = detail ?? (await this.getDetail(id, user));
+ const fallbackProviders = cache.servedProviders.filter((provider) => provider.source === 'fallback');
this.logger.log(
- `[${event}] [end] bookId=${id} preview=false durationMs=${Date.now() - startedAt} updatedFields=${updatedFields} skippedLockedFields=${skippedFields.join('|') || 'none'} coverDownloaded=${coverDownloaded} - refresh metadata completed`,
+ `[${event}] [end] bookId=${id} preview=false durationMs=${Date.now() - startedAt} updatedFields=${updatedFields} skippedLockedFields=${skippedFields.join('|') || 'none'} coverDownloaded=${coverDownloaded} cacheServed=${cache.servedProviders.length} cacheFallbacks=${fallbackProviders.length} - refresh metadata completed`,
);
- return result;
+ return fallbackProviders.length > 0 ? { ...result, metadataCache: { fallbackProviders } } : result;
} catch (err) {
const errorClass = err instanceof Error ? err.name : 'Error';
const errorMessage = sanitizeLogValue(err instanceof Error ? err.message : String(err));
@@ -3014,11 +3032,14 @@ export class BookService {
bookIds: number[],
user: RequestUser,
onProgress?: (event: { bookId: number; success: boolean; detail?: BookDetailDto; error?: string }) => void,
- options?: { isCancelled?: () => boolean },
+ options?: { isCancelled?: () => boolean; ignoreCache?: boolean },
): Promise<{ processed: number; failed: number }> {
const event = 'book.bulk_refresh_metadata';
const startedAt = Date.now();
- this.logger.log(`[${event}] [start] count=${bookIds.length} userId=${user.id} - bulk refresh metadata started`);
+ const cacheDirective: CacheDirective = { policy: options?.ignoreCache ? 'refresh' : 'default', allowFallback: true };
+ this.logger.log(
+ `[${event}] [start] count=${bookIds.length} userId=${user.id} ignoreCache=${options?.ignoreCache === true} - bulk refresh metadata started`,
+ );
try {
if (bookIds.length === 0) {
this.logger.log(`[${event}] [end] count=0 durationMs=${Date.now() - startedAt} processed=0 failed=0 - bulk refresh metadata completed`);
@@ -3039,7 +3060,7 @@ export class BookService {
let detail: BookDetailDto | undefined;
let errorMessage: string | undefined;
try {
- const refreshed = await this.refreshMetadata(id, false, user);
+ const refreshed = await this.refreshMetadata(id, false, user, { cacheDirective });
detail = refreshed as BookDetailDto;
processed++;
success = true;
diff --git a/server/src/modules/book/dto/bulk-refresh-metadata.dto.ts b/server/src/modules/book/dto/bulk-refresh-metadata.dto.ts
new file mode 100644
index 000000000..da461fb6e
--- /dev/null
+++ b/server/src/modules/book/dto/bulk-refresh-metadata.dto.ts
@@ -0,0 +1,10 @@
+import { IsBoolean, IsOptional } from 'class-validator';
+
+import { BulkBookIdsDto } from './bulk-book-ids.dto';
+
+/** Only the metadata refresh accepts `ignoreCache`; other bulk endpoints keep rejecting it. */
+export class BulkRefreshMetadataDto extends BulkBookIdsDto {
+ @IsOptional()
+ @IsBoolean()
+ ignoreCache?: boolean;
+}
diff --git a/server/src/modules/metadata-cache/dto/metadata-cache.dto.ts b/server/src/modules/metadata-cache/dto/metadata-cache.dto.ts
new file mode 100644
index 000000000..32a18b3b8
--- /dev/null
+++ b/server/src/modules/metadata-cache/dto/metadata-cache.dto.ts
@@ -0,0 +1,85 @@
+import { Transform, Type } from 'class-transformer';
+import { ArrayMaxSize, IsArray, IsBoolean, IsIn, IsInt, IsOptional, IsString, Max, MaxLength, Min, MinLength, ValidateNested } from 'class-validator';
+import { METADATA_CACHE_LIMITS, METADATA_CACHE_NAMESPACES, type MetadataCacheNamespace } from '@bookorbit/types';
+
+const L = METADATA_CACHE_LIMITS;
+
+export class MetadataCacheProviderOverrideDto {
+ @IsIn(METADATA_CACHE_NAMESPACES)
+ namespace!: MetadataCacheNamespace;
+
+ @IsString()
+ @MinLength(1)
+ @MaxLength(64)
+ provider!: string;
+
+ @IsOptional()
+ @IsBoolean()
+ enabled?: boolean;
+
+ @IsOptional()
+ @IsInt()
+ @Min(L.resultsTtlSeconds.min)
+ @Max(L.resultsTtlSeconds.max)
+ resultsTtlSeconds?: number;
+
+ @IsOptional()
+ @IsInt()
+ @Min(L.emptyTtlSeconds.min)
+ @Max(L.emptyTtlSeconds.max)
+ emptyTtlSeconds?: number;
+}
+
+export class UpdateMetadataCacheConfigDto {
+ @IsOptional()
+ @IsBoolean()
+ enabled?: boolean;
+
+ @IsOptional()
+ @IsInt()
+ @Min(L.resultsTtlSeconds.min)
+ @Max(L.resultsTtlSeconds.max)
+ resultsTtlSeconds?: number;
+
+ @IsOptional()
+ @IsInt()
+ @Min(L.emptyTtlSeconds.min)
+ @Max(L.emptyTtlSeconds.max)
+ emptyTtlSeconds?: number;
+
+ @IsOptional()
+ @IsInt()
+ @Min(L.staleGraceSeconds.min)
+ @Max(L.staleGraceSeconds.max)
+ staleGraceSeconds?: number;
+
+ @IsOptional()
+ @IsInt()
+ @Min(L.maxStorageMb.min)
+ @Max(L.maxStorageMb.max)
+ maxStorageMb?: number;
+
+ @IsOptional()
+ @IsArray()
+ @ArrayMaxSize(L.maxProviderOverrides)
+ @ValidateNested({ each: true })
+ @Type(() => MetadataCacheProviderOverrideDto)
+ providerOverrides?: MetadataCacheProviderOverrideDto[];
+}
+
+export class ClearMetadataCacheQueryDto {
+ @IsOptional()
+ @IsIn(METADATA_CACHE_NAMESPACES)
+ namespace?: MetadataCacheNamespace;
+
+ @IsOptional()
+ @IsString()
+ @MinLength(1)
+ @MaxLength(64)
+ provider?: string;
+
+ @IsOptional()
+ @Transform(({ value }: { value: unknown }) => (value === 'true' || value === true ? true : value === 'false' || value === false ? false : value))
+ @IsBoolean()
+ emptyOnly?: boolean;
+}
diff --git a/server/src/modules/metadata-cache/metadata-cache-admin.service.ts b/server/src/modules/metadata-cache/metadata-cache-admin.service.ts
new file mode 100644
index 000000000..92e4547df
--- /dev/null
+++ b/server/src/modules/metadata-cache/metadata-cache-admin.service.ts
@@ -0,0 +1,95 @@
+import { Injectable, Logger } from '@nestjs/common';
+import type { MetadataCacheClearRequest, MetadataCacheClearResponse, MetadataCacheConfigResponse, MetadataCacheStats } from '@bookorbit/types';
+
+import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
+import { CLEAR_SYNC_BUDGET_MS } from './metadata-cache.constants';
+import { MetadataCacheConfigService, type MetadataCacheConfigPatch } from './metadata-cache-config.service';
+import { MetadataCacheProviderCatalog } from './metadata-cache-provider-catalog';
+import { MetadataCacheRepository } from './metadata-cache.repository';
+import { MetadataCacheSweeper, markerCondition } from './metadata-cache-sweeper.service';
+import { MetadataCacheBudget, MetadataCacheCounterService } from './metadata-cache-state';
+
+@Injectable()
+export class MetadataCacheAdminService {
+ private readonly logger = new Logger(MetadataCacheAdminService.name);
+
+ constructor(
+ private readonly config: MetadataCacheConfigService,
+ private readonly catalog: MetadataCacheProviderCatalog,
+ private readonly repository: MetadataCacheRepository,
+ private readonly sweeper: MetadataCacheSweeper,
+ private readonly counters: MetadataCacheCounterService,
+ private readonly budget: MetadataCacheBudget,
+ ) {}
+
+ async getConfig(): Promise {
+ return { config: await this.config.getConfig(), providers: this.catalog.describe() };
+ }
+
+ async updateConfig(patch: MetadataCacheConfigPatch, userId: number): Promise {
+ const config = await this.config.updateConfig(patch);
+ this.logger.log(
+ `[metadata_cache.config_update] [end] userId=${userId} enabled=${config.enabled} overrides=${config.providerOverrides.length} - cache settings saved`,
+ );
+ this.sweeper.requestSweep('settings');
+ return { config, providers: this.catalog.describe() };
+ }
+
+ async getStats(): Promise {
+ const [table, config] = await Promise.all([this.repository.stats(), this.config.getConfig()]);
+ return {
+ entries: table.entries,
+ payloadBytes: table.payloadBytes,
+ relationBytes: table.relationBytes,
+ maxStorageBytes: config.maxStorageMb * 1024 * 1024,
+ oldestFetchedAt: table.oldestFetchedAt?.toISOString() ?? null,
+ providers: table.providers,
+ counters: this.counters.snapshot(),
+ sweepRunning: this.sweeper.isRunning,
+ lastSweepAt: this.sweeper.lastRunAt?.toISOString() ?? null,
+ };
+ }
+
+ /**
+ * The marker is persisted first so in-flight writes started earlier are discarded and an
+ * interrupted partial clear is resumed by the sweeper.
+ */
+ async clear(request: MetadataCacheClearRequest, userId: number): Promise {
+ const startedAt = Date.now();
+ const scope = `namespace=${request.namespace ?? 'all'} provider=${sanitizeLogValue(request.provider ?? 'all')} emptyOnly=${request.emptyOnly === true}`;
+ this.logger.log(`[metadata_cache.clear] [start] userId=${userId} ${scope} - cache clear started`);
+ try {
+ const at = new Date();
+ const marker = {
+ ...(request.namespace ? { namespace: request.namespace } : {}),
+ ...(request.provider ? { provider: request.provider } : {}),
+ ...(request.emptyOnly ? { emptyOnly: true } : {}),
+ };
+ await this.config.addClearMarker(marker, at);
+
+ let deleted: number;
+ let pending = false;
+ if (!request.namespace && !request.provider && !request.emptyOnly) {
+ deleted = await this.repository.count();
+ await this.repository.truncate();
+ this.budget.set(0);
+ } else {
+ const condition = markerCondition({ ...marker, at: at.toISOString() });
+ deleted = await this.sweeper.deleteWhile(condition, Date.now() + CLEAR_SYNC_BUDGET_MS);
+ pending = (await this.repository.count(condition)) > 0;
+ if (pending) this.sweeper.requestSweep('clear');
+ }
+ this.logger.log(
+ `[metadata_cache.clear] [end] userId=${userId} ${scope} durationMs=${Date.now() - startedAt} deleted=${deleted} pending=${pending} - cache clear completed`,
+ );
+ return { deleted, pending };
+ } catch (error) {
+ const errorClass = error instanceof Error ? error.name : 'UnknownError';
+ const message = sanitizeLogValue(error instanceof Error ? error.message : String(error));
+ this.logger.warn(
+ `[metadata_cache.clear] [fail] userId=${userId} ${scope} durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${message}" - cache clear failed`,
+ );
+ throw error;
+ }
+ }
+}
diff --git a/server/src/modules/metadata-cache/metadata-cache-config.service.test.ts b/server/src/modules/metadata-cache/metadata-cache-config.service.test.ts
new file mode 100644
index 000000000..b4d78bc51
--- /dev/null
+++ b/server/src/modules/metadata-cache/metadata-cache-config.service.test.ts
@@ -0,0 +1,112 @@
+import { BadRequestException } from '@nestjs/common';
+import { DEFAULT_METADATA_CACHE_CONFIG, METADATA_CACHE_LIMITS } from '@bookorbit/types';
+
+import {
+ effectiveCacheSettings,
+ latestMarkerAt,
+ MetadataCacheConfigService,
+ normalizeMetadataCacheConfig,
+ type ClearMarker,
+} from './metadata-cache-config.service';
+import { MetadataCacheProviderCatalog } from './metadata-cache-provider-catalog';
+import type { MetadataCacheSettingsRepository } from './metadata-cache-settings.repository';
+
+function settingsStore(initial: Record = {}) {
+ const values = new Map(Object.entries(initial));
+ const repository = {
+ read: vi.fn((key: string) => Promise.resolve(values.get(key) ?? null)),
+ update: vi.fn((key: string, apply: (current: string | null) => string) => {
+ const next = apply(values.get(key) ?? null);
+ values.set(key, next);
+ return Promise.resolve(next);
+ }),
+ };
+ return { repository, values };
+}
+
+describe('MetadataCacheConfigService', () => {
+ function makeService(initial: Record = {}) {
+ const store = settingsStore(initial);
+ const catalog = new MetadataCacheProviderCatalog();
+ catalog.register('book', [{ provider: 'google', label: 'Google' }]);
+ const service = new MetadataCacheConfigService(store.repository as unknown as MetadataCacheSettingsRepository, catalog);
+ return { service, store };
+ }
+
+ it('returns defaults when nothing is stored or the stored value is corrupt', async () => {
+ expect(await makeService().service.getConfig()).toEqual(DEFAULT_METADATA_CACHE_CONFIG);
+ expect(await makeService({ metadata_cache_config: '{not json' }).service.getConfig()).toEqual(DEFAULT_METADATA_CACHE_CONFIG);
+ });
+
+ it('clamps stored values into range and drops malformed overrides', () => {
+ const config = normalizeMetadataCacheConfig({
+ resultsTtlSeconds: 1,
+ emptyTtlSeconds: 10 ** 12,
+ maxStorageMb: 'big',
+ providerOverrides: [
+ { namespace: 'book', provider: 'google', resultsTtlSeconds: 3600.7, enabled: false },
+ { namespace: 'video', provider: 'x' },
+ { namespace: 'book', provider: ' ' },
+ { namespace: 'book', provider: 'google', emptyTtlSeconds: 0 },
+ ],
+ });
+
+ expect(config.resultsTtlSeconds).toBe(METADATA_CACHE_LIMITS.resultsTtlSeconds.min);
+ expect(config.emptyTtlSeconds).toBe(METADATA_CACHE_LIMITS.emptyTtlSeconds.max);
+ expect(config.maxStorageMb).toBe(DEFAULT_METADATA_CACHE_CONFIG.maxStorageMb);
+ expect(config.providerOverrides).toEqual([{ namespace: 'book', provider: 'google', emptyTtlSeconds: 0 }]);
+ });
+
+ it('merges a patch into the stored settings and serves the update immediately', async () => {
+ const { service } = makeService();
+ await service.getConfig();
+
+ await service.updateConfig({ resultsTtlSeconds: 7200 });
+
+ expect((await service.getConfig()).resultsTtlSeconds).toBe(7200);
+ });
+
+ it('rejects overrides for providers that do not exist', async () => {
+ const { service } = makeService();
+
+ await expect(service.updateConfig({ providerOverrides: [{ namespace: 'book', provider: 'nope' }] })).rejects.toBeInstanceOf(BadRequestException);
+ });
+
+ it('persists and removes clear markers', async () => {
+ const { service } = makeService();
+ const at = new Date('2026-01-01T00:00:00Z');
+
+ await service.addClearMarker({ namespace: 'book', provider: 'google' }, at);
+ expect((await service.snapshot()).markers).toEqual([{ namespace: 'book', provider: 'google', at: at.toISOString() }]);
+
+ await service.removeClearMarkers(() => true);
+ expect((await service.snapshot()).markers).toEqual([]);
+ });
+});
+
+describe('effectiveCacheSettings', () => {
+ it('applies a provider override over the global defaults', () => {
+ const config = {
+ ...DEFAULT_METADATA_CACHE_CONFIG,
+ providerOverrides: [{ namespace: 'book' as const, provider: 'google', resultsTtlSeconds: 60, enabled: false }],
+ };
+
+ expect(effectiveCacheSettings(config, 'book', 'google')).toMatchObject({ enabled: false, resultsTtlMs: 60_000 });
+ expect(effectiveCacheSettings(config, 'author', 'google')).toMatchObject({ enabled: true, resultsTtlMs: config.resultsTtlSeconds * 1000 });
+ });
+});
+
+describe('latestMarkerAt', () => {
+ const markers: ClearMarker[] = [
+ { at: '2026-01-01T00:00:00.000Z' },
+ { namespace: 'book', provider: 'google', at: '2026-02-01T00:00:00.000Z' },
+ { emptyOnly: true, at: '2026-03-01T00:00:00.000Z' },
+ ];
+
+ it('picks the newest marker whose scope covers the entry', () => {
+ expect(latestMarkerAt(markers, 'book', 'google', 'results')).toBe(Date.parse('2026-02-01T00:00:00.000Z'));
+ expect(latestMarkerAt(markers, 'book', 'google', 'empty')).toBe(Date.parse('2026-03-01T00:00:00.000Z'));
+ expect(latestMarkerAt(markers, 'author', 'audnexus', 'found')).toBe(Date.parse('2026-01-01T00:00:00.000Z'));
+ expect(latestMarkerAt([], 'book', 'google', 'results')).toBe(0);
+ });
+});
diff --git a/server/src/modules/metadata-cache/metadata-cache-config.service.ts b/server/src/modules/metadata-cache/metadata-cache-config.service.ts
new file mode 100644
index 000000000..fbb7250c3
--- /dev/null
+++ b/server/src/modules/metadata-cache/metadata-cache-config.service.ts
@@ -0,0 +1,228 @@
+import { BadRequestException, Injectable, Logger } from '@nestjs/common';
+import {
+ DEFAULT_METADATA_CACHE_CONFIG,
+ METADATA_CACHE_LIMITS,
+ METADATA_CACHE_NAMESPACES,
+ type MetadataCacheConfig,
+ type MetadataCacheEntryStatus,
+ type MetadataCacheNamespace,
+ type MetadataCacheProviderOverride,
+} from '@bookorbit/types';
+
+import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
+import { CONFIG_MEMO_MS, METADATA_CACHE_CLEAR_MARKERS_KEY, METADATA_CACHE_CONFIG_KEY } from './metadata-cache.constants';
+import { MetadataCacheProviderCatalog } from './metadata-cache-provider-catalog';
+import { MetadataCacheSettingsRepository } from './metadata-cache-settings.repository';
+
+/**
+ * Rows of a scope fetched before `at` are treated as gone. Persisted before any row is deleted, so a
+ * partial clear survives a restart and in-flight writes that started earlier are discarded.
+ */
+export interface ClearMarker {
+ namespace?: MetadataCacheNamespace;
+ provider?: string;
+ emptyOnly?: boolean;
+ at: string;
+}
+
+export interface EffectiveCacheSettings {
+ enabled: boolean;
+ resultsTtlMs: number;
+ emptyTtlMs: number;
+ staleGraceMs: number;
+}
+
+export interface CacheSettingsSnapshot {
+ config: MetadataCacheConfig;
+ markers: ClearMarker[];
+}
+
+export type MetadataCacheConfigPatch = Partial;
+
+const EMPTY_STATUSES: readonly MetadataCacheEntryStatus[] = ['empty', 'not_found'];
+
+@Injectable()
+export class MetadataCacheConfigService {
+ private readonly logger = new Logger(MetadataCacheConfigService.name);
+ private memo: { loadedAt: number; snapshot: Promise } | null = null;
+
+ constructor(
+ private readonly settings: MetadataCacheSettingsRepository,
+ private readonly catalog: MetadataCacheProviderCatalog,
+ ) {}
+
+ snapshot(): Promise {
+ const now = Date.now();
+ if (this.memo && now - this.memo.loadedAt < CONFIG_MEMO_MS) return this.memo.snapshot;
+ const snapshot = this.load();
+ this.memo = { loadedAt: now, snapshot };
+ snapshot.catch(() => {
+ if (this.memo?.snapshot === snapshot) this.memo = null;
+ });
+ return snapshot;
+ }
+
+ async getConfig(): Promise {
+ return (await this.snapshot()).config;
+ }
+
+ async updateConfig(patch: MetadataCacheConfigPatch): Promise {
+ if (patch.providerOverrides) this.assertKnownProviders(patch.providerOverrides);
+ const value = await this.settings.update(METADATA_CACHE_CONFIG_KEY, (current) => {
+ const base = asObject(parseConfig(current, (error) => this.logParseFailure(METADATA_CACHE_CONFIG_KEY, error)));
+ return JSON.stringify(normalizeMetadataCacheConfig({ ...base, ...patch }));
+ });
+ this.invalidate();
+ return normalizeMetadataCacheConfig(JSON.parse(value));
+ }
+
+ async addClearMarker(marker: Omit, at: Date): Promise {
+ await this.settings.update(METADATA_CACHE_CLEAR_MARKERS_KEY, (current) => {
+ const markers = parseMarkers(current);
+ markers.push({ ...marker, at: at.toISOString() });
+ return JSON.stringify(markers);
+ });
+ this.invalidate();
+ }
+
+ async removeClearMarkers(shouldRemove: (marker: ClearMarker) => boolean): Promise {
+ await this.settings.update(METADATA_CACHE_CLEAR_MARKERS_KEY, (current) => JSON.stringify(parseMarkers(current).filter((m) => !shouldRemove(m))));
+ this.invalidate();
+ }
+
+ invalidate(): void {
+ this.memo = null;
+ }
+
+ private async load(): Promise {
+ const [configValue, markersValue] = await Promise.all([
+ this.settings.read(METADATA_CACHE_CONFIG_KEY),
+ this.settings.read(METADATA_CACHE_CLEAR_MARKERS_KEY),
+ ]);
+ return {
+ config: normalizeMetadataCacheConfig(parseConfig(configValue, (error) => this.logParseFailure(METADATA_CACHE_CONFIG_KEY, error))),
+ markers: parseMarkers(markersValue),
+ };
+ }
+
+ private assertKnownProviders(overrides: readonly MetadataCacheProviderOverride[]): void {
+ const unknown = overrides.filter((override) => !this.catalog.has(override.namespace, override.provider));
+ if (unknown.length > 0) {
+ throw new BadRequestException(`Unknown metadata cache providers: ${unknown.map((o) => `${o.namespace}:${o.provider}`).join(', ')}`);
+ }
+ }
+
+ private logParseFailure(key: string, error: unknown): void {
+ const errorClass = error instanceof Error ? error.name : 'UnknownError';
+ const message = sanitizeLogValue(error instanceof Error ? error.message : String(error));
+ this.logger.warn(`[metadata_cache.config_parse] [fail] key=${key} errorClass=${errorClass} error="${message}" - using defaults`);
+ }
+}
+
+export function effectiveCacheSettings(config: MetadataCacheConfig, namespace: MetadataCacheNamespace, provider: string): EffectiveCacheSettings {
+ const override = config.providerOverrides.find((o) => o.namespace === namespace && o.provider === provider);
+ return {
+ enabled: config.enabled && override?.enabled !== false,
+ resultsTtlMs: (override?.resultsTtlSeconds ?? config.resultsTtlSeconds) * 1000,
+ emptyTtlMs: (override?.emptyTtlSeconds ?? config.emptyTtlSeconds) * 1000,
+ staleGraceMs: config.staleGraceSeconds * 1000,
+ };
+}
+
+export function isEmptyStatus(status: MetadataCacheEntryStatus | string): boolean {
+ return (EMPTY_STATUSES as readonly string[]).includes(status);
+}
+
+export function markerApplies(marker: ClearMarker, namespace: string, provider: string, status: string): boolean {
+ if (marker.namespace && marker.namespace !== namespace) return false;
+ if (marker.provider && marker.provider !== provider) return false;
+ if (marker.emptyOnly && !isEmptyStatus(status)) return false;
+ return true;
+}
+
+/** Epoch ms of the newest marker covering this entry, or 0. */
+export function latestMarkerAt(markers: readonly ClearMarker[], namespace: string, provider: string, status: string): number {
+ let latest = 0;
+ for (const marker of markers) {
+ if (!markerApplies(marker, namespace, provider, status)) continue;
+ const at = Date.parse(marker.at);
+ if (Number.isFinite(at) && at > latest) latest = at;
+ }
+ return latest;
+}
+
+export function normalizeMetadataCacheConfig(raw: unknown): MetadataCacheConfig {
+ const value = asObject(raw);
+ const limits = METADATA_CACHE_LIMITS;
+ return {
+ enabled: typeof value.enabled === 'boolean' ? value.enabled : DEFAULT_METADATA_CACHE_CONFIG.enabled,
+ resultsTtlSeconds: clampInt(value.resultsTtlSeconds, limits.resultsTtlSeconds, DEFAULT_METADATA_CACHE_CONFIG.resultsTtlSeconds),
+ emptyTtlSeconds: clampInt(value.emptyTtlSeconds, limits.emptyTtlSeconds, DEFAULT_METADATA_CACHE_CONFIG.emptyTtlSeconds),
+ staleGraceSeconds: clampInt(value.staleGraceSeconds, limits.staleGraceSeconds, DEFAULT_METADATA_CACHE_CONFIG.staleGraceSeconds),
+ maxStorageMb: clampInt(value.maxStorageMb, limits.maxStorageMb, DEFAULT_METADATA_CACHE_CONFIG.maxStorageMb),
+ providerOverrides: normalizeOverrides(value.providerOverrides),
+ };
+}
+
+function normalizeOverrides(raw: unknown): MetadataCacheProviderOverride[] {
+ if (!Array.isArray(raw)) return [];
+ const byKey = new Map();
+ for (const item of raw) {
+ const entry = asObject(item);
+ const namespace = entry.namespace;
+ const provider = typeof entry.provider === 'string' ? entry.provider.trim() : '';
+ if (!(METADATA_CACHE_NAMESPACES as readonly unknown[]).includes(namespace) || !provider) continue;
+ const override: MetadataCacheProviderOverride = { namespace: namespace as MetadataCacheNamespace, provider };
+ if (typeof entry.enabled === 'boolean') override.enabled = entry.enabled;
+ const results = optionalClampInt(entry.resultsTtlSeconds, METADATA_CACHE_LIMITS.resultsTtlSeconds);
+ if (results !== undefined) override.resultsTtlSeconds = results;
+ const empty = optionalClampInt(entry.emptyTtlSeconds, METADATA_CACHE_LIMITS.emptyTtlSeconds);
+ if (empty !== undefined) override.emptyTtlSeconds = empty;
+ byKey.set(`${override.namespace}:${provider}`, override);
+ }
+ return [...byKey.values()].slice(0, METADATA_CACHE_LIMITS.maxProviderOverrides);
+}
+
+function parseConfig(value: string | null, onError: (error: unknown) => void): unknown {
+ if (value === null) return {};
+ try {
+ return JSON.parse(value);
+ } catch (error) {
+ onError(error);
+ return {};
+ }
+}
+
+function parseMarkers(value: string | null): ClearMarker[] {
+ if (value === null) return [];
+ try {
+ const parsed: unknown = JSON.parse(value);
+ if (!Array.isArray(parsed)) return [];
+ return parsed
+ .map((item) => asObject(item))
+ .filter((item) => typeof item.at === 'string' && Number.isFinite(Date.parse(item.at)))
+ .map((item) => ({
+ ...((METADATA_CACHE_NAMESPACES as readonly unknown[]).includes(item.namespace)
+ ? { namespace: item.namespace as MetadataCacheNamespace }
+ : {}),
+ ...(typeof item.provider === 'string' && item.provider ? { provider: item.provider } : {}),
+ ...(item.emptyOnly === true ? { emptyOnly: true } : {}),
+ at: item.at as string,
+ }));
+ } catch {
+ return [];
+ }
+}
+
+function asObject(value: unknown): Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record) : {};
+}
+
+function clampInt(value: unknown, range: { min: number; max: number }, fallback: number): number {
+ return optionalClampInt(value, range) ?? fallback;
+}
+
+function optionalClampInt(value: unknown, range: { min: number; max: number }): number | undefined {
+ if (typeof value !== 'number' || !Number.isFinite(value)) return undefined;
+ return Math.min(range.max, Math.max(range.min, Math.floor(value)));
+}
diff --git a/server/src/modules/metadata-cache/metadata-cache-freshness.ts b/server/src/modules/metadata-cache/metadata-cache-freshness.ts
new file mode 100644
index 000000000..8b9ad4472
--- /dev/null
+++ b/server/src/modules/metadata-cache/metadata-cache-freshness.ts
@@ -0,0 +1,54 @@
+import type { MetadataCacheEntryStatus } from '@bookorbit/types';
+
+import { NEW_RELEASE_TTL_CAP_MS, NEW_RELEASE_WINDOW_MS } from './metadata-cache.constants';
+import { isEmptyStatus, type EffectiveCacheSettings } from './metadata-cache-config.service';
+import { keyJitterFraction } from './metadata-cache-key';
+
+export type EntryFreshness = 'fresh' | 'stale' | 'expired';
+
+export interface FreshnessInput {
+ status: MetadataCacheEntryStatus | string;
+ fetchedAt: Date;
+ keyHash: string;
+ publishedDates: readonly string[];
+ settings: EffectiveCacheSettings;
+ now: number;
+}
+
+/**
+ * Freshness is decided when an entry is read, with the settings in force at that moment, so a TTL
+ * change applies to entries already stored. `stale` entries only answer for a provider that failed.
+ */
+export function entryFreshness(input: FreshnessInput): EntryFreshness {
+ const ttl = effectiveTtlMs(input);
+ const age = input.now - input.fetchedAt.getTime();
+ if (age < ttl) return 'fresh';
+ if (age < ttl + input.settings.staleGraceMs) return 'stale';
+ return 'expired';
+}
+
+export function effectiveTtlMs(input: Omit): number {
+ let ttl = isEmptyStatus(input.status) ? input.settings.emptyTtlMs : input.settings.resultsTtlMs;
+ if (isRecentRelease(input.publishedDates, input.now)) ttl = Math.min(ttl, NEW_RELEASE_TTL_CAP_MS);
+ return ttl * (0.9 + 0.1 * keyJitterFraction(input.keyHash));
+}
+
+/** A date in the future or within the release window. Partial dates count as their latest possible day. */
+export function isRecentRelease(dates: readonly string[], now: number): boolean {
+ for (const date of dates) {
+ if (typeof date !== 'string') continue;
+ const latest = latestPossibleTime(date);
+ if (latest !== null && latest >= now - NEW_RELEASE_WINDOW_MS) return true;
+ }
+ return false;
+}
+
+function latestPossibleTime(value: string): number | null {
+ const trimmed = value.trim();
+ const year = /^(\d{4})$/.exec(trimmed);
+ if (year) return Date.UTC(Number(year[1]), 11, 31);
+ const month = /^(\d{4})-(\d{2})$/.exec(trimmed);
+ if (month) return Date.UTC(Number(month[1]), Number(month[2]), 0);
+ const parsed = Date.parse(trimmed);
+ return Number.isFinite(parsed) ? parsed : null;
+}
diff --git a/server/src/modules/metadata-cache/metadata-cache-key.test.ts b/server/src/modules/metadata-cache/metadata-cache-key.test.ts
new file mode 100644
index 000000000..c1183c402
--- /dev/null
+++ b/server/src/modules/metadata-cache/metadata-cache-key.test.ts
@@ -0,0 +1,72 @@
+import { NEW_RELEASE_TTL_CAP_MS } from './metadata-cache.constants';
+import type { EffectiveCacheSettings } from './metadata-cache-config.service';
+import { effectiveTtlMs, entryFreshness, isRecentRelease } from './metadata-cache-freshness';
+import { buildCacheKey, hashFingerprint, keyJitterFraction } from './metadata-cache-key';
+
+const DAY = 24 * 60 * 60 * 1000;
+const settings: EffectiveCacheSettings = { enabled: true, resultsTtlMs: 30 * DAY, emptyTtlMs: 3 * DAY, staleGraceMs: 30 * DAY };
+
+describe('cache keys', () => {
+ const parts = {
+ namespace: 'book' as const,
+ provider: 'google',
+ providerVersion: 1,
+ operation: 'search' as const,
+ fingerprintHash: hashFingerprint('{}'),
+ inputs: { title: 'Dune', author: 'Frank Herbert' },
+ };
+
+ it('is stable for equal inputs in any order', () => {
+ expect(buildCacheKey(parts)).toBe(buildCacheKey({ ...parts, inputs: { author: 'Frank Herbert', title: 'Dune' } }));
+ });
+
+ it('changes with every part that can change an answer', () => {
+ const base = buildCacheKey(parts);
+ expect(buildCacheKey({ ...parts, namespace: 'author' })).not.toBe(base);
+ expect(buildCacheKey({ ...parts, provider: 'amazon' })).not.toBe(base);
+ expect(buildCacheKey({ ...parts, providerVersion: 2 })).not.toBe(base);
+ expect(buildCacheKey({ ...parts, operation: 'lookup' })).not.toBe(base);
+ expect(buildCacheKey({ ...parts, fingerprintHash: hashFingerprint('{"domain":"amazon.de"}') })).not.toBe(base);
+ expect(buildCacheKey({ ...parts, inputs: { title: 'dune', author: 'Frank Herbert' } })).not.toBe(base);
+ });
+
+ it('spreads jitter across [0, 1)', () => {
+ const fraction = keyJitterFraction(buildCacheKey(parts));
+ expect(fraction).toBeGreaterThanOrEqual(0);
+ expect(fraction).toBeLessThan(1);
+ });
+});
+
+describe('freshness', () => {
+ const now = Date.parse('2026-06-01T00:00:00Z');
+ const keyHash = 'f'.repeat(64);
+
+ it('is fresh within the TTL, stale within the grace, then expired', () => {
+ const at = (ageDays: number) =>
+ entryFreshness({ status: 'results', fetchedAt: new Date(now - ageDays * DAY), keyHash: '0'.repeat(64), publishedDates: [], settings, now });
+
+ expect(at(1)).toBe('fresh');
+ expect(at(40)).toBe('stale');
+ expect(at(70)).toBe('expired');
+ });
+
+ it('uses the empty-results TTL for empty answers', () => {
+ expect(entryFreshness({ status: 'not_found', fetchedAt: new Date(now - 4 * DAY), keyHash, publishedDates: [], settings, now })).toBe('stale');
+ });
+
+ it('never extends a TTL with jitter', () => {
+ expect(effectiveTtlMs({ status: 'results', keyHash, publishedDates: [], settings, now })).toBeLessThanOrEqual(settings.resultsTtlMs);
+ expect(effectiveTtlMs({ status: 'results', keyHash, publishedDates: [], settings, now })).toBeGreaterThanOrEqual(settings.resultsTtlMs * 0.9);
+ });
+
+ it('caps the TTL of recent and upcoming releases', () => {
+ expect(effectiveTtlMs({ status: 'results', keyHash, publishedDates: ['2026-05-20'], settings, now })).toBeLessThanOrEqual(NEW_RELEASE_TTL_CAP_MS);
+ });
+
+ it('reads partial dates as their latest possible day', () => {
+ expect(isRecentRelease(['2026'], now)).toBe(true);
+ expect(isRecentRelease(['2026-05'], now)).toBe(true);
+ expect(isRecentRelease(['2025'], now)).toBe(false);
+ expect(isRecentRelease(['someday'], now)).toBe(false);
+ });
+});
diff --git a/server/src/modules/metadata-cache/metadata-cache-key.ts b/server/src/modules/metadata-cache/metadata-cache-key.ts
new file mode 100644
index 000000000..3d913852d
--- /dev/null
+++ b/server/src/modules/metadata-cache/metadata-cache-key.ts
@@ -0,0 +1,38 @@
+import { createHash } from 'node:crypto';
+import type { MetadataCacheNamespace } from '@bookorbit/types';
+
+import { canonicalJson } from '../../common/provider-cache/canonicalize';
+import type { CacheOperation } from '../../common/provider-cache/provider-cache-spec';
+import { CACHE_SCHEMA_VERSION } from './metadata-cache.constants';
+
+export interface CacheKeyParts {
+ namespace: MetadataCacheNamespace;
+ provider: string;
+ providerVersion: number;
+ operation: CacheOperation;
+ fingerprintHash: string;
+ inputs: Record;
+}
+
+/** Short stable hash of a provider's result-affecting configuration; the raw values are never stored. */
+export function hashFingerprint(fingerprint: string): string {
+ return createHash('sha256').update(fingerprint).digest('hex').slice(0, 16);
+}
+
+export function buildCacheKey(parts: CacheKeyParts): string {
+ const material = canonicalJson({
+ ns: parts.namespace,
+ sv: CACHE_SCHEMA_VERSION[parts.namespace],
+ p: parts.provider,
+ pv: parts.providerVersion,
+ op: parts.operation,
+ cfg: parts.fingerprintHash,
+ in: parts.inputs,
+ });
+ return createHash('sha256').update(material).digest('hex');
+}
+
+/** A fixed per-key fraction in [0, 1), so expiries of entries written together spread out. */
+export function keyJitterFraction(keyHash: string): number {
+ return Number.parseInt(keyHash.slice(0, 8), 16) / 0x100000000;
+}
diff --git a/server/src/modules/metadata-cache/metadata-cache-provider-catalog.ts b/server/src/modules/metadata-cache/metadata-cache-provider-catalog.ts
new file mode 100644
index 000000000..88f5bb156
--- /dev/null
+++ b/server/src/modules/metadata-cache/metadata-cache-provider-catalog.ts
@@ -0,0 +1,52 @@
+import { Injectable } from '@nestjs/common';
+import type { MetadataCacheNamespace, MetadataCacheProviderInfo } from '@bookorbit/types';
+
+import type { ProviderCacheSpec } from '../../common/provider-cache/provider-cache-spec';
+
+export interface CatalogEntry {
+ namespace: MetadataCacheNamespace;
+ provider: string;
+ label: string;
+ spec?: ProviderCacheSpec;
+}
+
+/**
+ * The providers each namespace has registered. Lets the cache validate overrides, find orphaned
+ * entries, and describe providers to the settings page without depending on either provider module.
+ */
+@Injectable()
+export class MetadataCacheProviderCatalog {
+ private readonly entries = new Map();
+
+ register(namespace: MetadataCacheNamespace, providers: readonly Omit[]): void {
+ for (const provider of providers) {
+ this.entries.set(catalogKey(namespace, provider.provider), { ...provider, namespace });
+ }
+ }
+
+ get(namespace: MetadataCacheNamespace, provider: string): CatalogEntry | undefined {
+ return this.entries.get(catalogKey(namespace, provider));
+ }
+
+ has(namespace: MetadataCacheNamespace, provider: string): boolean {
+ return this.entries.has(catalogKey(namespace, provider));
+ }
+
+ list(): CatalogEntry[] {
+ return [...this.entries.values()];
+ }
+
+ describe(): MetadataCacheProviderInfo[] {
+ return this.list().map((entry) => ({
+ namespace: entry.namespace,
+ provider: entry.provider,
+ label: entry.label,
+ cacheable: entry.spec !== undefined,
+ cachesEmptyResults: entry.spec?.trustEmpty === true,
+ }));
+ }
+}
+
+function catalogKey(namespace: MetadataCacheNamespace, provider: string): string {
+ return `${namespace}:${provider}`;
+}
diff --git a/server/src/modules/metadata-cache/metadata-cache-settings.repository.ts b/server/src/modules/metadata-cache/metadata-cache-settings.repository.ts
new file mode 100644
index 000000000..f38622cfd
--- /dev/null
+++ b/server/src/modules/metadata-cache/metadata-cache-settings.repository.ts
@@ -0,0 +1,30 @@
+import { Inject, Injectable } from '@nestjs/common';
+import { eq, sql } from 'drizzle-orm';
+import { NodePgDatabase } from 'drizzle-orm/node-postgres';
+
+import { DB } from '../../db';
+import * as schema from '../../db/schema';
+
+type Db = NodePgDatabase;
+
+/** The cache's settings rows in `app_settings`, on the main pool. */
+@Injectable()
+export class MetadataCacheSettingsRepository {
+ constructor(@Inject(DB) private readonly db: Db) {}
+
+ async read(key: string): Promise {
+ const row = await this.db.query.appSettings.findFirst({ where: eq(schema.appSettings.key, key) });
+ return row?.value ?? null;
+ }
+
+ /** Read-modify-write under an advisory lock so concurrent saves never lose each other's changes. */
+ async update(key: string, apply: (current: string | null) => string): Promise {
+ return this.db.transaction(async (tx) => {
+ await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${key})::bigint)`);
+ const row = await tx.query.appSettings.findFirst({ where: eq(schema.appSettings.key, key) });
+ const value = apply(row?.value ?? null);
+ await tx.insert(schema.appSettings).values({ key, value }).onConflictDoUpdate({ target: schema.appSettings.key, set: { value } });
+ return value;
+ });
+ }
+}
diff --git a/server/src/modules/metadata-cache/metadata-cache-state.ts b/server/src/modules/metadata-cache/metadata-cache-state.ts
new file mode 100644
index 000000000..42f0413a1
--- /dev/null
+++ b/server/src/modules/metadata-cache/metadata-cache-state.ts
@@ -0,0 +1,84 @@
+import { Injectable } from '@nestjs/common';
+import type { MetadataCacheCounters } from '@bookorbit/types';
+
+import { ACCESS_MAX_TRACKED_KEYS, BUDGET_SWEEP_THRESHOLD } from './metadata-cache.constants';
+
+/** Since-boot counters for the stats endpoint. */
+@Injectable()
+export class MetadataCacheCounterService {
+ private readonly counters: MetadataCacheCounters = {
+ hits: 0,
+ misses: 0,
+ fallbacks: 0,
+ liveCalls: 0,
+ writes: 0,
+ skippedWrites: 0,
+ writeFailures: 0,
+ readFailures: 0,
+ uncacheable: {},
+ };
+
+ increment(counter: Exclude): void {
+ this.counters[counter] += 1;
+ }
+
+ uncacheable(reason: string): void {
+ this.counters.uncacheable[reason] = (this.counters.uncacheable[reason] ?? 0) + 1;
+ }
+
+ snapshot(): MetadataCacheCounters {
+ return { ...this.counters, uncacheable: { ...this.counters.uncacheable } };
+ }
+}
+
+/**
+ * Live payload bytes, recomputed by every sweep and adjusted on each write and delete. Admission is
+ * based on this rather than on table size, which does not shrink after deletes and could otherwise
+ * keep writes closed forever.
+ */
+@Injectable()
+export class MetadataCacheBudget {
+ private liveBytes = 0;
+
+ set(bytes: number): void {
+ this.liveBytes = Math.max(0, bytes);
+ }
+
+ add(bytes: number): void {
+ if (!Number.isFinite(bytes)) return;
+ this.liveBytes = Math.max(0, this.liveBytes + bytes);
+ }
+
+ subtract(bytes: number): void {
+ this.liveBytes = Math.max(0, this.liveBytes - bytes);
+ }
+
+ get bytes(): number {
+ return this.liveBytes;
+ }
+
+ admits(maxBytes: number): boolean {
+ return this.liveBytes < maxBytes;
+ }
+
+ needsSweep(maxBytes: number): boolean {
+ return this.liveBytes > maxBytes * BUDGET_SWEEP_THRESHOLD;
+ }
+}
+
+/** Keys answered from the cache, flushed in batches so reads do not each become a write. */
+@Injectable()
+export class MetadataCacheAccessTracker {
+ private readonly touched = new Set();
+
+ record(keyHash: string): void {
+ if (this.touched.size >= ACCESS_MAX_TRACKED_KEYS) return;
+ this.touched.add(keyHash);
+ }
+
+ drain(): string[] {
+ const keys = [...this.touched];
+ this.touched.clear();
+ return keys;
+ }
+}
diff --git a/server/src/modules/metadata-cache/metadata-cache-sweeper.service.ts b/server/src/modules/metadata-cache/metadata-cache-sweeper.service.ts
new file mode 100644
index 000000000..660387776
--- /dev/null
+++ b/server/src/modules/metadata-cache/metadata-cache-sweeper.service.ts
@@ -0,0 +1,225 @@
+import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
+import { Interval } from '@nestjs/schedule';
+import { and, eq, inArray, lt, ne, notInArray, or, sql, type SQL } from 'drizzle-orm';
+
+import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
+import { providerMetadataCache as t } from '../../db/schema';
+import {
+ ACCESS_FLUSH_INTERVAL_MS,
+ ACCESS_TOUCH_STALE_MS,
+ BUDGET_EVICT_TARGET,
+ CACHE_SCHEMA_VERSION,
+ CLEAR_MARKER_RETENTION_MS,
+ SWEEP_BATCH_SIZE,
+ SWEEP_INTERVAL_MS,
+ SWEEP_TIME_BUDGET_MS,
+} from './metadata-cache.constants';
+import { effectiveCacheSettings, MetadataCacheConfigService, type ClearMarker } from './metadata-cache-config.service';
+import { hashFingerprint } from './metadata-cache-key';
+import { MetadataCacheProviderCatalog } from './metadata-cache-provider-catalog';
+import { MetadataCacheRepository, type DeletedBatch } from './metadata-cache.repository';
+import { MetadataCacheAccessTracker, MetadataCacheBudget } from './metadata-cache-state';
+
+const EMPTY_STATUSES = ['empty', 'not_found'];
+
+interface SweepTotals {
+ orphans: number;
+ expired: number;
+ cleared: number;
+ evicted: number;
+ bytes: number;
+}
+
+@Injectable()
+export class MetadataCacheSweeper implements OnApplicationBootstrap {
+ private readonly logger = new Logger(MetadataCacheSweeper.name);
+ private running: Promise | null = null;
+ private rerunReason: string | null = null;
+ private lastSweepAt: Date | null = null;
+
+ constructor(
+ private readonly repository: MetadataCacheRepository,
+ private readonly config: MetadataCacheConfigService,
+ private readonly catalog: MetadataCacheProviderCatalog,
+ private readonly budget: MetadataCacheBudget,
+ private readonly access: MetadataCacheAccessTracker,
+ ) {}
+
+ onApplicationBootstrap(): void {
+ void this.repository
+ .sumPayloadBytes()
+ .then((bytes) => this.budget.set(bytes))
+ .catch(() => undefined);
+ }
+
+ get isRunning(): boolean {
+ return this.running !== null;
+ }
+
+ get lastRunAt(): Date | null {
+ return this.lastSweepAt;
+ }
+
+ @Interval(SWEEP_INTERVAL_MS)
+ scheduledSweep(): void {
+ this.requestSweep('scheduled');
+ }
+
+ @Interval(ACCESS_FLUSH_INTERVAL_MS)
+ async flushAccess(): Promise {
+ const keys = this.access.drain();
+ if (keys.length === 0) return;
+ const now = new Date();
+ await this.repository.touch(keys, now, new Date(now.getTime() - ACCESS_TOUCH_STALE_MS)).catch(() => undefined);
+ }
+
+ /** At most one sweep runs; a request during a sweep schedules one more afterwards. */
+ requestSweep(reason: string): void {
+ if (this.running) {
+ this.rerunReason = reason;
+ return;
+ }
+ this.running = this.sweep(reason).finally(() => {
+ this.running = null;
+ const next = this.rerunReason;
+ this.rerunReason = null;
+ if (next) this.requestSweep(next);
+ });
+ }
+
+ /** Waits for the current sweep, if any. */
+ async idle(): Promise {
+ while (this.running) await this.running;
+ }
+
+ private async sweep(reason: string): Promise {
+ const startedAt = Date.now();
+ const deadline = startedAt + SWEEP_TIME_BUDGET_MS;
+ this.logger.log(`[metadata_cache.sweep] [start] reason=${reason} - cache sweep started`);
+ const totals: SweepTotals = { orphans: 0, expired: 0, cleared: 0, evicted: 0, bytes: 0 };
+ try {
+ const snapshot = await this.config.snapshot();
+ totals.orphans = await this.deleteWhile(await this.orphanCondition(), deadline, totals);
+ totals.expired = await this.deleteExpired(snapshot.config, deadline, totals);
+ totals.cleared = await this.applyMarkers(snapshot.markers, deadline, totals);
+
+ this.budget.set(await this.repository.sumPayloadBytes());
+ totals.evicted = await this.enforceBudget(snapshot.config.maxStorageMb * 1024 * 1024, deadline, totals);
+ this.lastSweepAt = new Date();
+ this.logger.log(
+ `[metadata_cache.sweep] [end] reason=${reason} durationMs=${Date.now() - startedAt} orphans=${totals.orphans} expired=${totals.expired} cleared=${totals.cleared} evicted=${totals.evicted} bytesFreed=${totals.bytes} liveBytes=${this.budget.bytes} - cache sweep completed`,
+ );
+ } catch (error) {
+ const errorClass = error instanceof Error ? error.name : 'UnknownError';
+ const message = sanitizeLogValue(error instanceof Error ? error.message : String(error));
+ this.logger.warn(
+ `[metadata_cache.sweep] [fail] reason=${reason} durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${message}" - cache sweep failed`,
+ );
+ }
+ }
+
+ /** Rows no current provider would ever read: unknown providers, old versions, old configuration. */
+ private async orphanCondition(): Promise {
+ const clauses: SQL[] = [];
+ for (const namespace of Object.keys(CACHE_SCHEMA_VERSION) as (keyof typeof CACHE_SCHEMA_VERSION)[]) {
+ const entries = this.catalog.list().filter((entry) => entry.namespace === namespace && entry.spec);
+ const known = entries.map((entry) => entry.provider);
+ const namespaceClauses: SQL[] = [ne(t.schemaVersion, CACHE_SCHEMA_VERSION[namespace])];
+ if (known.length > 0) namespaceClauses.push(notInArray(t.provider, known));
+ else namespaceClauses.push(sql`true`);
+ for (const entry of entries) {
+ const providerClauses: SQL[] = [ne(t.providerVersion, entry.spec!.version)];
+ const context = await entry.spec!.context().catch(() => null);
+ if (context) providerClauses.push(ne(t.configFingerprint, hashFingerprint(context.fingerprint)));
+ namespaceClauses.push(and(eq(t.provider, entry.provider), or(...providerClauses))!);
+ }
+ clauses.push(and(eq(t.namespace, namespace), or(...namespaceClauses))!);
+ }
+ clauses.push(notInArray(t.namespace, Object.keys(CACHE_SCHEMA_VERSION)));
+ return or(...clauses)!;
+ }
+
+ private async deleteExpired(
+ config: Awaited>['config'],
+ deadline: number,
+ totals: SweepTotals,
+ ): Promise {
+ const now = Date.now();
+ let deleted = 0;
+ for (const entry of this.catalog.list()) {
+ if (!entry.spec) continue;
+ const settings = effectiveCacheSettings(config, entry.namespace, entry.provider);
+ const resultsCutoff = new Date(now - settings.resultsTtlMs - settings.staleGraceMs);
+ const emptyCutoff = new Date(now - settings.emptyTtlMs - settings.staleGraceMs);
+ const condition = and(
+ eq(t.namespace, entry.namespace),
+ eq(t.provider, entry.provider),
+ or(
+ and(notInArray(t.status, EMPTY_STATUSES), lt(t.fetchedAt, resultsCutoff)),
+ and(inArray(t.status, EMPTY_STATUSES), lt(t.fetchedAt, emptyCutoff)),
+ ),
+ )!;
+ deleted += await this.deleteWhile(condition, deadline, totals);
+ if (Date.now() >= deadline) break;
+ }
+ return deleted;
+ }
+
+ private async applyMarkers(markers: readonly ClearMarker[], deadline: number, totals: SweepTotals): Promise {
+ let deleted = 0;
+ const finished: ClearMarker[] = [];
+ for (const marker of markers) {
+ const condition = markerCondition(marker);
+ const removed = await this.deleteWhile(condition, deadline, totals);
+ deleted += removed;
+ if (Date.now() >= deadline) break;
+ if (Date.now() - Date.parse(marker.at) >= CLEAR_MARKER_RETENTION_MS && (await this.repository.count(condition)) === 0) finished.push(marker);
+ }
+ if (finished.length > 0) await this.config.removeClearMarkers((marker) => finished.some((done) => sameMarker(done, marker)));
+ return deleted;
+ }
+
+ private async enforceBudget(maxBytes: number, deadline: number, totals: SweepTotals): Promise {
+ if (!this.budget.needsSweep(maxBytes)) return 0;
+ const target = maxBytes * BUDGET_EVICT_TARGET;
+ let evicted = 0;
+ while (this.budget.bytes > target && Date.now() < deadline) {
+ const batch = await this.repository.deleteLeastRecentlyAccessed(SWEEP_BATCH_SIZE);
+ this.account(batch, totals);
+ evicted += batch.count;
+ if (batch.count === 0) break;
+ }
+ return evicted;
+ }
+
+ /** Deletes matching rows in bounded batches until none are left or the sweep runs out of time. */
+ async deleteWhile(condition: SQL, deadline: number, totals?: SweepTotals): Promise {
+ let deleted = 0;
+ while (Date.now() < deadline) {
+ const batch = await this.repository.deleteBatch(condition, SWEEP_BATCH_SIZE);
+ if (totals) this.account(batch, totals);
+ else this.budget.subtract(batch.bytes);
+ deleted += batch.count;
+ if (batch.count < SWEEP_BATCH_SIZE) break;
+ }
+ return deleted;
+ }
+
+ private account(batch: DeletedBatch, totals: SweepTotals): void {
+ totals.bytes += batch.bytes;
+ this.budget.subtract(batch.bytes);
+ }
+}
+
+export function markerCondition(marker: Omit & { at?: string }): SQL {
+ const clauses: SQL[] = [];
+ if (marker.namespace) clauses.push(eq(t.namespace, marker.namespace));
+ if (marker.provider) clauses.push(eq(t.provider, marker.provider));
+ if (marker.emptyOnly) clauses.push(inArray(t.status, EMPTY_STATUSES));
+ if (marker.at) clauses.push(lt(t.fetchedAt, new Date(marker.at)));
+ return clauses.length > 0 ? and(...clauses)! : sql`true`;
+}
+
+function sameMarker(a: ClearMarker, b: ClearMarker): boolean {
+ return a.at === b.at && a.namespace === b.namespace && a.provider === b.provider && a.emptyOnly === b.emptyOnly;
+}
diff --git a/server/src/modules/metadata-cache/metadata-cache.constants.ts b/server/src/modules/metadata-cache/metadata-cache.constants.ts
new file mode 100644
index 000000000..51076eaaf
--- /dev/null
+++ b/server/src/modules/metadata-cache/metadata-cache.constants.ts
@@ -0,0 +1,48 @@
+import type { MetadataCacheNamespace } from '@bookorbit/types';
+
+export const METADATA_CACHE_CONFIG_KEY = 'metadata_cache_config';
+export const METADATA_CACHE_CLEAR_MARKERS_KEY = 'metadata_cache_clear_markers';
+
+/** Bumped on a breaking change to the stored candidate shape of a namespace. */
+export const CACHE_SCHEMA_VERSION: Record = { book: 1, author: 1 };
+
+export const MAX_ENTRY_BYTES = 512 * 1024;
+
+const SECOND = 1000;
+const MINUTE = 60 * SECOND;
+const HOUR = 60 * MINUTE;
+const DAY = 24 * HOUR;
+
+export const CONFIG_MEMO_MS = 10 * SECOND;
+
+/** Pre-release and just-released metadata churns, so such answers are kept for less time. */
+export const NEW_RELEASE_WINDOW_MS = 60 * DAY;
+export const NEW_RELEASE_TTL_CAP_MS = 3 * DAY;
+
+export const ACCESS_FLUSH_INTERVAL_MS = MINUTE;
+export const ACCESS_TOUCH_STALE_MS = 6 * HOUR;
+export const ACCESS_MAX_TRACKED_KEYS = 10_000;
+
+export const SWEEP_INTERVAL_MS = 6 * HOUR;
+export const SWEEP_BATCH_SIZE = 1000;
+export const SWEEP_TIME_BUDGET_MS = 60 * SECOND;
+export const SWEEP_LONG_STATEMENT_TIMEOUT_MS = 30 * SECOND;
+export const CLEAR_SYNC_BUDGET_MS = 2 * SECOND;
+/** A clear marker only has to outlive operations that started before it. */
+export const CLEAR_MARKER_RETENTION_MS = 15 * MINUTE;
+
+/** Sweep when live payload passes this share of the budget, and evict down to the lower one. */
+export const BUDGET_SWEEP_THRESHOLD = 0.9;
+export const BUDGET_EVICT_TARGET = 0.8;
+
+/**
+ * The cache has its own small pool so a burst of concurrent provider lookups can never take the
+ * connections the rest of the app needs, and so a slow read is cancelled by Postgres rather than
+ * abandoned by a JS timer.
+ */
+export const CACHE_POOL = {
+ max: 4,
+ statementTimeoutMs: 1000,
+ connectionTimeoutMs: 250,
+ idleTimeoutMs: 30 * SECOND,
+} as const;
diff --git a/server/src/modules/metadata-cache/metadata-cache.controller.ts b/server/src/modules/metadata-cache/metadata-cache.controller.ts
new file mode 100644
index 000000000..a7340d43a
--- /dev/null
+++ b/server/src/modules/metadata-cache/metadata-cache.controller.ts
@@ -0,0 +1,37 @@
+import { Body, Controller, Delete, Get, Put, Query } from '@nestjs/common';
+import { Permission, type MetadataCacheClearResponse, type MetadataCacheConfigResponse, type MetadataCacheStats } from '@bookorbit/types';
+
+import { CurrentUser } from '../../common/decorators/current-user.decorator';
+import { RequirePermission } from '../../common/decorators/require-permission.decorator';
+import type { RequestUser } from '../../common/types/request-user';
+import { ClearMetadataCacheQueryDto, UpdateMetadataCacheConfigDto } from './dto/metadata-cache.dto';
+import { MetadataCacheAdminService } from './metadata-cache-admin.service';
+
+@Controller('metadata-cache')
+export class MetadataCacheController {
+ constructor(private readonly admin: MetadataCacheAdminService) {}
+
+ @Get('config')
+ @RequirePermission(Permission.ManageMetadataConfig)
+ getConfig(): Promise {
+ return this.admin.getConfig();
+ }
+
+ @Put('config')
+ @RequirePermission(Permission.ManageMetadataConfig)
+ updateConfig(@Body() dto: UpdateMetadataCacheConfigDto, @CurrentUser() user: RequestUser): Promise {
+ return this.admin.updateConfig(dto, user.id);
+ }
+
+ @Get('stats')
+ @RequirePermission(Permission.ManageMetadataConfig)
+ getStats(): Promise {
+ return this.admin.getStats();
+ }
+
+ @Delete()
+ @RequirePermission(Permission.ManageMetadataConfig)
+ clear(@Query() dto: ClearMetadataCacheQueryDto, @CurrentUser() user: RequestUser): Promise {
+ return this.admin.clear(dto, user.id);
+ }
+}
diff --git a/server/src/modules/metadata-cache/metadata-cache.db.ts b/server/src/modules/metadata-cache/metadata-cache.db.ts
new file mode 100644
index 000000000..3784fb391
--- /dev/null
+++ b/server/src/modules/metadata-cache/metadata-cache.db.ts
@@ -0,0 +1,30 @@
+import { Injectable, OnModuleDestroy } from '@nestjs/common';
+import { ConfigService } from '@nestjs/config';
+import { drizzle, NodePgDatabase } from 'drizzle-orm/node-postgres';
+
+import { InstrumentedPgPool } from '../../db/instrumented-pg-pool';
+import { createPostgresClientConfig } from '../../db/postgres-connection-config';
+import * as schema from '../../db/schema';
+import { CACHE_POOL } from './metadata-cache.constants';
+
+@Injectable()
+export class MetadataCacheDb implements OnModuleDestroy {
+ readonly pool: InstrumentedPgPool;
+ readonly db: NodePgDatabase;
+
+ constructor(config: ConfigService) {
+ this.pool = new InstrumentedPgPool(
+ createPostgresClientConfig(config.getOrThrow('db.url'), {
+ max: CACHE_POOL.max,
+ idleTimeoutMillis: CACHE_POOL.idleTimeoutMs,
+ connectionTimeoutMillis: CACHE_POOL.connectionTimeoutMs,
+ statement_timeout: CACHE_POOL.statementTimeoutMs,
+ }),
+ );
+ this.db = drizzle(this.pool, { schema });
+ }
+
+ async onModuleDestroy(): Promise {
+ await this.pool.end();
+ }
+}
diff --git a/server/src/modules/metadata-cache/metadata-cache.module.ts b/server/src/modules/metadata-cache/metadata-cache.module.ts
new file mode 100644
index 000000000..20c40b329
--- /dev/null
+++ b/server/src/modules/metadata-cache/metadata-cache.module.ts
@@ -0,0 +1,31 @@
+import { Module } from '@nestjs/common';
+
+import { MetadataCacheAdminService } from './metadata-cache-admin.service';
+import { MetadataCacheConfigService } from './metadata-cache-config.service';
+import { MetadataCacheController } from './metadata-cache.controller';
+import { MetadataCacheDb } from './metadata-cache.db';
+import { MetadataCacheProviderCatalog } from './metadata-cache-provider-catalog';
+import { MetadataCacheRepository } from './metadata-cache.repository';
+import { MetadataCacheSettingsRepository } from './metadata-cache-settings.repository';
+import { MetadataCacheAccessTracker, MetadataCacheBudget, MetadataCacheCounterService } from './metadata-cache-state';
+import { MetadataCacheSweeper } from './metadata-cache-sweeper.service';
+import { ProviderOperationExecutor } from './provider-operation-executor.service';
+
+@Module({
+ providers: [
+ MetadataCacheDb,
+ MetadataCacheRepository,
+ MetadataCacheSettingsRepository,
+ MetadataCacheProviderCatalog,
+ MetadataCacheConfigService,
+ MetadataCacheCounterService,
+ MetadataCacheBudget,
+ MetadataCacheAccessTracker,
+ MetadataCacheSweeper,
+ ProviderOperationExecutor,
+ MetadataCacheAdminService,
+ ],
+ controllers: [MetadataCacheController],
+ exports: [ProviderOperationExecutor, MetadataCacheProviderCatalog],
+})
+export class MetadataCacheModule {}
diff --git a/server/src/modules/metadata-cache/metadata-cache.repository.ts b/server/src/modules/metadata-cache/metadata-cache.repository.ts
new file mode 100644
index 000000000..266614d3d
--- /dev/null
+++ b/server/src/modules/metadata-cache/metadata-cache.repository.ts
@@ -0,0 +1,155 @@
+import { Injectable } from '@nestjs/common';
+import type { MetadataCacheNamespace } from '@bookorbit/types';
+import { and, eq, inArray, lt, lte, sql, type SQL } from 'drizzle-orm';
+
+import { providerMetadataCache, type NewProviderMetadataCacheRow, type ProviderMetadataCacheRow } from '../../db/schema';
+import { SWEEP_LONG_STATEMENT_TIMEOUT_MS } from './metadata-cache.constants';
+import { MetadataCacheDb } from './metadata-cache.db';
+
+export interface DeletedBatch {
+ count: number;
+ bytes: number;
+}
+
+export interface CacheTableStats {
+ entries: number;
+ payloadBytes: number;
+ relationBytes: number;
+ oldestFetchedAt: Date | null;
+ providers: { namespace: MetadataCacheNamespace; provider: string; entries: number; emptyEntries: number; payloadBytes: number }[];
+}
+
+const t = providerMetadataCache;
+
+@Injectable()
+export class MetadataCacheRepository {
+ constructor(private readonly cacheDb: MetadataCacheDb) {}
+
+ private get db() {
+ return this.cacheDb.db;
+ }
+
+ async find(keyHash: string): Promise {
+ const rows = await this.db.select().from(t).where(eq(t.keyHash, keyHash)).limit(1);
+ return rows[0] ?? null;
+ }
+
+ /** Newest fetch wins; an older answer never replaces a newer one, and on a tie the later write wins. */
+ /** Stores the row unless a newer answer is already there, and returns how many bytes storage grew by. */
+ async upsert(row: NewProviderMetadataCacheRow): Promise {
+ const update: Partial = { ...row };
+ delete update.keyHash;
+ delete update.id;
+ const written = await this.db
+ .insert(t)
+ .values(row)
+ .onConflictDoUpdate({ target: t.keyHash, set: update, setWhere: lte(t.fetchedAt, row.fetchedAt) })
+ // A subquery in RETURNING reads the statement's snapshot, so it sees the row this replaced.
+ .returning({
+ previousBytes: sql`(select previous.payload_bytes from ${t} as previous where previous.key_hash = ${row.keyHash})`,
+ });
+ if (written.length === 0) return 0;
+ return row.payloadBytes - (written[0].previousBytes ?? 0);
+ }
+
+ async delete(keyHash: string): Promise {
+ await this.db.delete(t).where(eq(t.keyHash, keyHash));
+ }
+
+ async touch(keyHashes: readonly string[], now: Date, staleBefore: Date): Promise {
+ if (keyHashes.length === 0) return;
+ await this.db
+ .update(t)
+ .set({ lastAccessedAt: now })
+ .where(and(inArray(t.keyHash, [...keyHashes]), lt(t.lastAccessedAt, staleBefore)));
+ }
+
+ async deleteBatch(where: SQL, limit: number): Promise