import type { Kysely } from "kysely"; import type { Migration } from "kysely/migration"; /** * Column kinds: `t` text, `i` integer, `r` real, `fk:` text reference. Not null by default; * a trailing `?` makes it nullable. Only portable types, so the same code runs on SQLite and Postgres. */ type Kind = string; type Spec = Array<[name: string, kind: Kind]>; async function create( db: Kysely, name: string, cols: Spec, opts: { unique?: string[][]; indexes?: string[][] } = {}, ): Promise { let b = db.schema.createTable(name).addColumn("id", "text", (c) => c.primaryKey()); const fkCols: string[] = []; for (const [col, kind] of cols) { const nullable = kind.endsWith("?"); const base = nullable ? kind.slice(0, -1) : kind; const isFk = base.startsWith("fk:"); const type = base === "i" ? "integer" : base === "r" ? "real" : "text"; if (isFk) fkCols.push(col); b = b.addColumn(col, type, (c) => { let x = c; if (isFk) x = x.references(`${base.slice(3)}.id`); return nullable ? x : x.notNull(); }); } for (const u of opts.unique ?? []) b = b.addUniqueConstraint(`${name}_${u.join("_")}_uq`, u as Array<"id">); await b.execute(); const indexes = [...fkCols.map((c) => [c]), ...(opts.indexes ?? [])]; for (const cols of indexes) { await db.schema .createIndex(`${name}_${cols.join("_")}_idx`) .on(name) .columns(cols as Array<"id">) .execute(); } } export const initial: Migration = { async up(db: Kysely) { await create( db, "users", [ ["username", "t"], ["display_name", "t"], ["password_hash", "t"], ["role", "t"], ["preferences", "t"], ["created_at", "t"], ["updated_at", "t"], ], { unique: [["username"]] }, ); await create( db, "sessions", [ ["user_id", "fk:users"], ["token_hash", "t"], ["expires_at", "t"], ["created_at", "t"], ], { unique: [["token_hash"]] }, ); await create( db, "invites", [ ["code_hash", "t"], ["created_by", "fk:users"], ["used_by", "fk:users?"], ["expires_at", "t"], ["created_at", "t"], ], { unique: [["code_hash"]] }, ); await create( db, "app_tokens", [ ["user_id", "fk:users"], ["name", "t"], ["token_hash", "t"], ["scopes", "t"], ["last_used_at", "t?"], ["revoked_at", "t?"], ["created_at", "t"], ], { unique: [["token_hash"]] }, ); // works.parent_id is a self reference; works.cover_attachment_id and merged_into_id stay // plain columns (attachments is created later; merges may point at any work). await create( db, "works", [ ["media_type", "t"], ["title", "t"], ["original_title", "t"], ["year", "i?"], ["release_date", "t?"], ["creators", "t"], ["description", "t"], ["cover_url", "t"], ["cover_attachment_id", "t?"], ["metadata", "t"], ["parent_id", "fk:works?"], ["position", "t?"], ["owner_user_id", "fk:users?"], ["merged_into_id", "t?"], ["created_at", "t"], ["updated_at", "t"], ], { indexes: [["merged_into_id"], ["media_type"]] }, ); await create( db, "external_ids", [ ["work_id", "fk:works"], ["namespace", "t"], ["value", "t"], ], { unique: [["namespace", "value"]] }, ); await create( db, "work_aliases", [ ["user_id", "fk:users"], ["from_work_id", "fk:works"], ["to_work_id", "fk:works"], ], { unique: [["user_id", "from_work_id"]] }, ); await create(db, "work_history", [ ["work_id", "fk:works"], ["before", "t"], ["after", "t"], ["actor", "t"], ["created_at", "t"], ]); await create( db, "entries", [ ["user_id", "fk:users"], ["work_id", "fk:works"], ["status", "t"], ["rating", "i?"], ["review", "t?"], ["private_notes", "t?"], ["favorite", "i"], ["tags", "t"], ["custom_fields", "t"], ["visibility", "t"], ["overrides", "t"], ["media_type_override", "t?"], ["field_sources", "t"], ["created_at", "t"], ["updated_at", "t"], ["deleted_at", "t?"], ], { unique: [["user_id", "work_id"]] }, ); await create(db, "engagements", [ ["entry_id", "fk:entries"], ["started_at", "t?"], ["started_precision", "t"], ["finished_at", "t?"], ["finished_precision", "t"], ["progress", "t"], ["platform", "t?"], ["rating", "i?"], ["notes", "t?"], ["custom_fields", "t"], ["field_sources", "t"], ["created_at", "t"], ["updated_at", "t"], ["deleted_at", "t?"], ]); await create( db, "source_connections", [ ["user_id", "fk:users"], ["plugin_id", "t"], ["name", "t"], ["config", "t"], ["credentials_enc", "t?"], ["schedule_minutes", "i?"], ["cursor", "t?"], ["status", "t"], ["last_run_at", "t?"], ["created_at", "t"], ], ); await create( db, "engagement_sources", [ ["engagement_id", "fk:engagements"], ["connection_id", "fk:source_connections"], ["record_key", "t"], ], { unique: [["connection_id", "record_key"]] }, ); await create( db, "listens", [ ["user_id", "fk:users"], ["listened_at", "t"], ["artist", "t"], ["track", "t"], ["album", "t?"], ["duration_ms", "i?"], ["mbids", "t"], ["work_id", "fk:works?"], ["source", "t"], ["source_key", "t"], ["created_at", "t"], ], { unique: [["user_id", "source", "source_key"]], indexes: [["user_id", "listened_at"]] }, ); await create(db, "lists", [ ["user_id", "fk:users"], ["name", "t"], ["description", "t"], ["kind", "t"], ["visibility", "t"], ["rating", "i?"], ["review", "t?"], ["created_at", "t"], ["updated_at", "t"], ["deleted_at", "t?"], ]); await create(db, "list_items", [ ["list_id", "fk:lists"], ["work_id", "fk:works"], ["position", "i"], ["note", "t?"], ]); await create(db, "custom_field_definitions", [ ["user_id", "fk:users"], ["name", "t"], ["type", "t"], ["options", "t"], ["applies_to", "t"], ["level", "t"], ["created_at", "t"], ]); await create(db, "copies", [ ["user_id", "fk:users"], ["work_id", "fk:works?"], ["title_hint", "t?"], ["format", "t?"], ["acquired_from", "t?"], ["acquired_at", "t?"], ["acquired_precision", "t"], ["price_cents", "i?"], ["currency", "t?"], ["notes", "t?"], ["custom_fields", "t"], ["source_ref", "t?"], ["created_at", "t"], ["updated_at", "t"], ["deleted_at", "t?"], ]); await create( db, "attachments", [ ["user_id", "fk:users"], ["sha256", "t"], ["mime", "t"], ["size", "i"], ["filename", "t"], ["caption", "t?"], ["taken_at", "t?"], ["visibility", "t?"], ["created_at", "t"], ], { indexes: [["sha256"]] }, ); await create( db, "attachment_links", [ ["attachment_id", "fk:attachments"], ["entity", "t"], ["entity_id", "t"], ], { indexes: [["entity", "entity_id"]] }, ); await create( db, "user_history", [ ["user_id", "fk:users"], ["entity", "t"], ["entity_id", "t"], ["before", "t"], ["after", "t"], ["actor", "t"], ["created_at", "t"], ], { indexes: [["entity", "entity_id"]] }, ); await create(db, "match_suggestions", [ ["user_id", "fk:users"], ["work_id", "fk:works"], ["candidate_work_id", "fk:works"], ["score", "r"], ["status", "t"], ["created_at", "t"], ]); await create(db, "source_runs", [ ["connection_id", "fk:source_connections"], ["mode", "t"], ["started_at", "t"], ["finished_at", "t?"], ["status", "t"], ["counts", "t"], ["log", "t"], ["error", "t?"], ]); await create( db, "source_records", [ ["connection_id", "fk:source_connections"], ["key", "t"], ["kind", "t"], ["current_hash", "t"], ["first_seen", "t"], ["last_seen", "t"], ["deleted_at", "t?"], ["merged_into_key", "t?"], ["target_entity", "t?"], ["target_id", "t?"], ], { unique: [["connection_id", "key"]], indexes: [["target_entity", "target_id"]] }, ); await create(db, "source_record_versions", [ ["record_id", "fk:source_records"], ["hash", "t"], ["raw_gz", "t"], ["seen_at", "t"], ]); await create( db, "change_events", [ ["user_id", "fk:users"], ["connection_id", "fk:source_connections?"], ["run_id", "fk:source_runs?"], ["record_id", "fk:source_records?"], ["type", "t"], ["target_entity", "t"], ["target_id", "t?"], ["payload", "t"], ["status", "t"], ["created_at", "t"], ["resolved_at", "t?"], ], { indexes: [["user_id", "status"]] }, ); await create( db, "jobs", [ ["type", "t"], ["payload", "t"], ["run_at", "t"], ["status", "t"], ["attempts", "i"], ["last_error", "t?"], ["locked_at", "t?"], ["lock_key", "t?"], ["created_at", "t"], ], { indexes: [["status", "run_at"], ["lock_key"]] }, ); }, async down(db: Kysely) { for (const table of [ "jobs", "change_events", "source_record_versions", "source_records", "source_runs", "match_suggestions", "user_history", "attachment_links", "attachments", "copies", "custom_field_definitions", "list_items", "lists", "listens", "engagement_sources", "source_connections", "engagements", "entries", "work_history", "work_aliases", "external_ids", "works", "app_tokens", "invites", "sessions", "users", ]) { await db.schema.dropTable(table).execute(); } }, };