diff --git a/AGENTS.md b/AGENTS.md index ec53041..ee6694c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,7 +47,7 @@ Use impeccable for user-facing UI: `craft` for new UI, `audit`/`polish` on exist - Plugins (`plugins/*`) import only `@sttracker/plugin-sdk`. They never import `packages/db` or core internals, and they return `SourceRecord`s instead of writing. - `packages/*` and `plugins/*` never import from `apps/*`. -- IDs are ULIDs generated in app code. Ratings are integers 0–100. Dates carry a precision +- IDs are UUIDv7 (`newId()`) generated in app code. Ratings are integers 0–100. Dates carry a precision (`day | month | year | unknown`). - Tests never call live APIs. Plugin tests use recorded fixtures through the SDK test harness. - Write-back (`push`) is opt-in per connection and off by default. No plugin pushes in Phase 1. diff --git a/packages/db/src/migrations/0001_initial.ts b/packages/db/src/migrations/0001_initial.ts new file mode 100644 index 0000000..5d4f13a --- /dev/null +++ b/packages/db/src/migrations/0001_initial.ts @@ -0,0 +1,425 @@ +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(); + } + }, +}; diff --git a/packages/db/src/migrations/index.ts b/packages/db/src/migrations/index.ts index 418b1a7..c8c4ed7 100644 --- a/packages/db/src/migrations/index.ts +++ b/packages/db/src/migrations/index.ts @@ -1,4 +1,7 @@ import type { Migration } from "kysely/migration"; +import { initial } from "./0001_initial.ts"; /** All migrations, keyed by sortable name. Imported statically so bundles need no filesystem. */ -export const migrations: Record = {}; +export const migrations: Record = { + "0001_initial": initial, +}; diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 2310ae7..81c27bf 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -1,2 +1,364 @@ -/** Kysely table types. Placeholder until the schema migration lands. */ -export interface Database {} +/** + * Kysely table types. snake_case columns; timestamps are ISO-8601 text; JSON columns are text + * (use `toJson`/`fromJson`); booleans are integer 0/1. All IDs come from app code (`newId()`), + * so nothing here is `Generated<>`. + */ + +export type Role = "admin" | "member"; +export type EntryStatus = "planned" | "in_progress" | "paused" | "completed" | "dropped" | "none"; +export type Visibility = "private" | "friends" | "public"; +export type TokenScope = "read" | "write" | "ingest"; +export type CustomFieldLevel = "entry" | "engagement" | "copy"; +export type ListKind = "list" | "grouping" | "thread"; +export type AttachmentEntity = "entry" | "engagement" | "copy" | "list" | "work"; +export type SuggestionStatus = "pending" | "accepted" | "rejected"; +export type ConnectionStatus = "active" | "paused" | "error"; +export type RunMode = "full" | "changes" | "file"; +export type RunStatus = "running" | "ok" | "failed"; +export type ChangeType = "added" | "changed" | "removed" | "merged" | "conflict"; +export type ChangeStatus = "pending" | "auto_applied" | "accepted" | "ignored" | "kept_mine"; +export type JobStatus = "queued" | "running" | "done" | "failed"; + +export interface UsersTable { + id: string; + username: string; + display_name: string; + password_hash: string; + role: Role; + preferences: string; + created_at: string; + updated_at: string; +} + +export interface SessionsTable { + id: string; + user_id: string; + token_hash: string; + expires_at: string; + created_at: string; +} + +export interface InvitesTable { + id: string; + code_hash: string; + created_by: string; + used_by: string | null; + expires_at: string; + created_at: string; +} + +export interface AppTokensTable { + id: string; + user_id: string; + name: string; + token_hash: string; + /** JSON `TokenScope[]`. */ + scopes: string; + last_used_at: string | null; + revoked_at: string | null; + created_at: string; +} + +export interface WorksTable { + id: string; + media_type: string; + title: string; + original_title: string; + year: number | null; + release_date: string | null; + creators: string; + description: string; + cover_url: string; + cover_attachment_id: string | null; + metadata: string; + parent_id: string | null; + position: string | null; + owner_user_id: string | null; + merged_into_id: string | null; + created_at: string; + updated_at: string; +} + +export interface ExternalIdsTable { + id: string; + work_id: string; + namespace: string; + value: string; +} + +export interface WorkAliasesTable { + id: string; + user_id: string; + from_work_id: string; + to_work_id: string; +} + +export interface WorkHistoryTable { + id: string; + work_id: string; + before: string; + after: string; + actor: string; + created_at: string; +} + +export interface EntriesTable { + id: string; + user_id: string; + work_id: string; + status: EntryStatus; + rating: number | null; + review: string | null; + private_notes: string | null; + /** 0 or 1. */ + favorite: number; + tags: string; + custom_fields: string; + visibility: Visibility; + overrides: string; + media_type_override: string | null; + field_sources: string; + created_at: string; + updated_at: string; + deleted_at: string | null; +} + +export interface EngagementsTable { + id: string; + entry_id: string; + started_at: string | null; + started_precision: string; + finished_at: string | null; + finished_precision: string; + progress: string; + platform: string | null; + rating: number | null; + notes: string | null; + custom_fields: string; + field_sources: string; + created_at: string; + updated_at: string; + deleted_at: string | null; +} + +export interface EngagementSourcesTable { + id: string; + engagement_id: string; + connection_id: string; + record_key: string; +} + +export interface ListensTable { + id: string; + user_id: string; + listened_at: string; + artist: string; + track: string; + album: string | null; + duration_ms: number | null; + mbids: string; + work_id: string | null; + source: string; + source_key: string; + created_at: string; +} + +export interface ListsTable { + id: string; + user_id: string; + name: string; + description: string; + kind: ListKind; + visibility: Visibility; + rating: number | null; + review: string | null; + created_at: string; + updated_at: string; + deleted_at: string | null; +} + +export interface ListItemsTable { + id: string; + list_id: string; + work_id: string; + position: number; + note: string | null; +} + +export interface CustomFieldDefinitionsTable { + id: string; + user_id: string; + name: string; + type: string; + options: string; + /** JSON `MediaType[] | "all"`. */ + applies_to: string; + level: CustomFieldLevel; + created_at: string; +} + +export interface CopiesTable { + id: string; + user_id: string; + work_id: string | null; + title_hint: string | null; + format: string | null; + acquired_from: string | null; + acquired_at: string | null; + acquired_precision: string; + price_cents: number | null; + currency: string | null; + notes: string | null; + custom_fields: string; + source_ref: string | null; + created_at: string; + updated_at: string; + deleted_at: string | null; +} + +export interface AttachmentsTable { + id: string; + user_id: string; + sha256: string; + mime: string; + size: number; + filename: string; + caption: string | null; + taken_at: string | null; + visibility: Visibility | null; + created_at: string; +} + +export interface AttachmentLinksTable { + id: string; + attachment_id: string; + entity: AttachmentEntity; + entity_id: string; +} + +export interface UserHistoryTable { + id: string; + user_id: string; + entity: string; + entity_id: string; + before: string; + after: string; + actor: string; + created_at: string; +} + +export interface MatchSuggestionsTable { + id: string; + user_id: string; + work_id: string; + candidate_work_id: string; + score: number; + status: SuggestionStatus; + created_at: string; +} + +export interface SourceConnectionsTable { + id: string; + user_id: string; + plugin_id: string; + name: string; + config: string; + credentials_enc: string | null; + schedule_minutes: number | null; + cursor: string | null; + status: ConnectionStatus; + last_run_at: string | null; + created_at: string; +} + +export interface SourceRunsTable { + id: string; + connection_id: string; + mode: RunMode; + started_at: string; + finished_at: string | null; + status: RunStatus; + counts: string; + log: string; + error: string | null; +} + +export interface SourceRecordsTable { + id: string; + connection_id: string; + key: string; + kind: string; + current_hash: string; + first_seen: string; + last_seen: string; + deleted_at: string | null; + merged_into_key: string | null; + target_entity: string | null; + target_id: string | null; +} + +export interface SourceRecordVersionsTable { + id: string; + record_id: string; + hash: string; + /** Base64 of gzipped raw record. */ + raw_gz: string; + seen_at: string; +} + +export interface ChangeEventsTable { + id: string; + user_id: string; + connection_id: string | null; + run_id: string | null; + record_id: string | null; + type: ChangeType; + target_entity: string; + target_id: string | null; + payload: string; + status: ChangeStatus; + created_at: string; + resolved_at: string | null; +} + +export interface JobsTable { + id: string; + type: string; + payload: string; + run_at: string; + status: JobStatus; + attempts: number; + last_error: string | null; + locked_at: string | null; + lock_key: string | null; + created_at: string; +} + +export interface Database { + users: UsersTable; + sessions: SessionsTable; + invites: InvitesTable; + app_tokens: AppTokensTable; + works: WorksTable; + external_ids: ExternalIdsTable; + work_aliases: WorkAliasesTable; + work_history: WorkHistoryTable; + entries: EntriesTable; + engagements: EngagementsTable; + engagement_sources: EngagementSourcesTable; + listens: ListensTable; + lists: ListsTable; + list_items: ListItemsTable; + custom_field_definitions: CustomFieldDefinitionsTable; + copies: CopiesTable; + attachments: AttachmentsTable; + attachment_links: AttachmentLinksTable; + user_history: UserHistoryTable; + match_suggestions: MatchSuggestionsTable; + source_connections: SourceConnectionsTable; + source_runs: SourceRunsTable; + source_records: SourceRecordsTable; + source_record_versions: SourceRecordVersionsTable; + change_events: ChangeEventsTable; + jobs: JobsTable; +} diff --git a/packages/db/src/testing.ts b/packages/db/src/testing.ts index c8dade8..651c261 100644 --- a/packages/db/src/testing.ts +++ b/packages/db/src/testing.ts @@ -1,5 +1,5 @@ import { SQL } from "bun"; -import { afterAll, afterEach, beforeEach, describe } from "bun:test"; +import { afterAll, afterEach, beforeAll, beforeEach, describe } from "bun:test"; import { createDb, type Db } from "./db.ts"; import type { Dialect } from "./dialect.ts"; import { migrateToLatest } from "./migrate.ts"; @@ -17,33 +17,50 @@ export function forEachDialect(name: string, fn: (getDb: () => Db) => void): voi for (const dialect of dialects) { describe(`${name} [${dialect}]`, () => { let current: Db | undefined; - const created: string[] = []; + let currentName: string | undefined; + const template = `st_tpl_${crypto.randomUUID().replaceAll("-", "")}`; + + if (dialect === "postgres") { + // Migrate one template per describe block, then clone it per test (much faster). + beforeAll(async () => { + await withAdmin(pgUrl!, (admin) => admin.unsafe(`create database "${template}"`)); + const tpl = await createDb(urlFor(pgUrl!, template)); + try { + await migrateToLatest(tpl); + } finally { + await tpl.close(); + } + }, HOOK_TIMEOUT_MS); + } beforeEach(async () => { - let url = "sqlite://:memory:"; if (dialect === "postgres") { - const dbName = `st_${crypto.randomUUID().replaceAll("-", "")}`; - await withAdmin(pgUrl!, (admin) => admin.unsafe(`create database "${dbName}"`)); - created.push(dbName); - const u = new URL(pgUrl!); - u.pathname = `/${dbName}`; - url = u.toString(); + currentName = `st_${crypto.randomUUID().replaceAll("-", "")}`; + await withAdmin(pgUrl!, (admin) => + admin.unsafe(`create database "${currentName}" template "${template}"`), + ); + current = await createDb(urlFor(pgUrl!, currentName)); + } else { + current = await createDb("sqlite://:memory:"); + await migrateToLatest(current); } - current = await createDb(url); - await migrateToLatest(current); - }); + }, HOOK_TIMEOUT_MS); afterEach(async () => { await current?.close(); current = undefined; - }); + if (currentName) { + const dbName = currentName; + currentName = undefined; + await withAdmin(pgUrl!, (admin) => admin.unsafe(`drop database if exists "${dbName}" with (force)`)); + } + }, HOOK_TIMEOUT_MS); - afterAll(async () => { - if (created.length === 0) return; - await withAdmin(pgUrl!, async (admin) => { - for (const dbName of created) await admin.unsafe(`drop database if exists "${dbName}" with (force)`); - }); - }); + if (dialect === "postgres") { + afterAll(async () => { + await withAdmin(pgUrl!, (admin) => admin.unsafe(`drop database if exists "${template}" with (force)`)); + }, HOOK_TIMEOUT_MS); + } fn(() => { if (!current) throw new Error("getDb() called outside a test"); @@ -53,6 +70,14 @@ export function forEachDialect(name: string, fn: (getDb: () => Db) => void): voi } } +const HOOK_TIMEOUT_MS = 30_000; + +function urlFor(base: string, dbName: string): string { + const u = new URL(base); + u.pathname = `/${dbName}`; + return u.toString(); +} + async function withAdmin(url: string, f: (admin: SQL) => Promise): Promise { const admin = new SQL(url); try { diff --git a/packages/db/test/schema.test.ts b/packages/db/test/schema.test.ts new file mode 100644 index 0000000..6bef1c2 --- /dev/null +++ b/packages/db/test/schema.test.ts @@ -0,0 +1,90 @@ +import { expect, test } from "bun:test"; +import { sql, type Kysely } from "kysely"; +import { Migrator } from "kysely/migration"; +import { newId, nowIso } from "../src/ids.ts"; +import { toJson } from "../src/json.ts"; +import { migrations } from "../src/migrations/index.ts"; +import { forEachDialect } from "../src/testing.ts"; + +forEachDialect("schema", (getDb) => { + test("all tables exist and unique constraints hold", async () => { + const k = getDb().k; + const now = nowIso(); + const userId = newId(); + await k + .insertInto("users") + .values({ + id: userId, + username: "ada", + display_name: "Ada", + password_hash: "x", + role: "admin", + preferences: toJson({}), + created_at: now, + updated_at: now, + }) + .execute(); + + const workId = newId(); + await k + .insertInto("works") + .values({ + id: workId, + media_type: "book", + title: "Dune", + original_title: "Dune", + year: 1965, + release_date: null, + creators: toJson([]), + description: "", + cover_url: "", + cover_attachment_id: null, + metadata: toJson({}), + parent_id: null, + position: null, + owner_user_id: null, + merged_into_id: null, + created_at: now, + updated_at: now, + }) + .execute(); + + await k + .insertInto("external_ids") + .values({ id: newId(), work_id: workId, namespace: "isbn", value: "123" }) + .execute(); + await expect( + k.insertInto("external_ids").values({ id: newId(), work_id: workId, namespace: "isbn", value: "123" }).execute(), + ).rejects.toThrow(); + + const entry = { + user_id: userId, + work_id: workId, + status: "planned" as const, + rating: null, + review: null, + private_notes: null, + favorite: 0, + tags: toJson([]), + custom_fields: toJson({}), + visibility: "private" as const, + overrides: toJson({}), + media_type_override: null, + field_sources: toJson({}), + created_at: now, + updated_at: now, + deleted_at: null, + }; + await k.insertInto("entries").values({ id: newId(), ...entry }).execute(); + await expect(k.insertInto("entries").values({ id: newId(), ...entry }).execute()).rejects.toThrow(); + }); + + test("migrating down removes the tables", async () => { + const k = getDb().k as Kysely; + const migrator = new Migrator({ db: k, provider: { getMigrations: async () => migrations } }); + const { error } = await migrator.migrateDown(); + expect(error).toBeUndefined(); + const probe = sql`select 1 from users`.execute(k); + await expect(probe).rejects.toThrow(); + }); +});