From 5b4550de51393728d6c969902b5e8d2161011779 Mon Sep 17 00:00:00 2001 From: KyleTryon Date: Sat, 18 Apr 2026 11:44:53 -0400 Subject: [PATCH 1/2] refactor: replace custom migration system with Drizzle ORM and add baseline schema migration --- Dockerfile | 2 + .../20260418064431_baseline/migration.sql | 49 ++ .../20260418064431_baseline/snapshot.json | 508 ++++++++++++++++++ apps/server/package.json | 1 + apps/server/src/db/database.ts | 8 +- apps/server/src/db/migrationState.ts | 32 ++ apps/server/src/db/migrations.ts | 369 ------------- apps/server/src/db/schema.ts | 6 - 8 files changed, 597 insertions(+), 378 deletions(-) create mode 100644 apps/server/drizzle/20260418064431_baseline/migration.sql create mode 100644 apps/server/drizzle/20260418064431_baseline/snapshot.json create mode 100644 apps/server/src/db/migrationState.ts delete mode 100644 apps/server/src/db/migrations.ts diff --git a/Dockerfile b/Dockerfile index 3e4a7c9..05a9e12 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,6 +19,7 @@ RUN pnpm install --frozen-lockfile COPY tsconfig.json tsconfig.base.json ./ COPY apps/server/tsconfig.json apps/server/tsconfig.build.json apps/server/ +COPY apps/server/drizzle apps/server/drizzle COPY apps/server/src apps/server/src COPY apps/frontend/components.json apps/frontend/index.html apps/frontend/tsconfig.json apps/frontend/vite.config.js apps/frontend/ COPY apps/frontend/public apps/frontend/public @@ -36,6 +37,7 @@ ENV CLIPARR_DATA_DIR=/data WORKDIR /app COPY --from=build --chown=node:node /prod/apps/server ./apps/server +COPY --from=build --chown=node:node /app/apps/server/drizzle ./apps/server/drizzle COPY --from=build --chown=node:node /app/apps/frontend/dist ./apps/frontend/dist RUN mkdir -p /data && chown node:node /data diff --git a/apps/server/drizzle/20260418064431_baseline/migration.sql b/apps/server/drizzle/20260418064431_baseline/migration.sql new file mode 100644 index 0000000..976aee2 --- /dev/null +++ b/apps/server/drizzle/20260418064431_baseline/migration.sql @@ -0,0 +1,49 @@ +CREATE TABLE `media_sources` ( + `id` text PRIMARY KEY, + `provider_id` text NOT NULL, + `provider_account_id` text NOT NULL, + `external_id` text, + `name` text NOT NULL, + `enabled` integer DEFAULT true NOT NULL, + `base_url` text NOT NULL, + `connection_json` text DEFAULT '{}' NOT NULL, + `credentials_json` text DEFAULT '{}' NOT NULL, + `metadata_json` text DEFAULT '{}' NOT NULL, + `last_checked_at` text, + `last_error` text, + `created_at` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL, + `updated_at` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL, + CONSTRAINT `fk_media_sources_provider_account_id_provider_accounts_id_fk` FOREIGN KEY (`provider_account_id`) REFERENCES `provider_accounts`(`id`) ON DELETE CASCADE +); +--> statement-breakpoint +CREATE TABLE `provider_accounts` ( + `id` text PRIMARY KEY, + `provider_id` text NOT NULL, + `label` text NOT NULL, + `access_token` text, + `access_token_hash` text, + `metadata_json` text DEFAULT '{}' NOT NULL, + `created_at` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL, + `updated_at` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL +); +--> statement-breakpoint +CREATE TABLE `provider_sessions` ( + `id` text PRIMARY KEY, + `provider_id` text NOT NULL, + `provider_account_id` text NOT NULL, + `user_token` text NOT NULL, + `created_at` integer NOT NULL, + `expires_at` integer NOT NULL, + `updated_at` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL, + CONSTRAINT `fk_provider_sessions_provider_account_id_provider_accounts_id_fk` FOREIGN KEY (`provider_account_id`) REFERENCES `provider_accounts`(`id`) ON DELETE CASCADE +); +--> statement-breakpoint +CREATE INDEX `media_sources_enabled_idx` ON `media_sources` (`enabled`);--> statement-breakpoint +CREATE INDEX `media_sources_provider_id_idx` ON `media_sources` (`provider_id`);--> statement-breakpoint +CREATE INDEX `media_sources_provider_account_id_idx` ON `media_sources` (`provider_account_id`);--> statement-breakpoint +CREATE UNIQUE INDEX `media_sources_provider_external_id_idx` ON `media_sources` (`provider_id`,`provider_account_id`,`external_id`);--> statement-breakpoint +CREATE INDEX `provider_accounts_provider_id_idx` ON `provider_accounts` (`provider_id`);--> statement-breakpoint +CREATE UNIQUE INDEX `provider_accounts_provider_access_token_hash_idx` ON `provider_accounts` (`provider_id`,`access_token_hash`) WHERE "provider_accounts"."access_token_hash" IS NOT NULL;--> statement-breakpoint +CREATE INDEX `provider_sessions_provider_id_idx` ON `provider_sessions` (`provider_id`);--> statement-breakpoint +CREATE INDEX `provider_sessions_provider_account_id_idx` ON `provider_sessions` (`provider_account_id`);--> statement-breakpoint +CREATE INDEX `provider_sessions_expires_at_idx` ON `provider_sessions` (`expires_at`); \ No newline at end of file diff --git a/apps/server/drizzle/20260418064431_baseline/snapshot.json b/apps/server/drizzle/20260418064431_baseline/snapshot.json new file mode 100644 index 0000000..67d4e4f --- /dev/null +++ b/apps/server/drizzle/20260418064431_baseline/snapshot.json @@ -0,0 +1,508 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "5cb53d7c-208f-412f-8544-99f25f221859", + "prevIds": [ + "00000000-0000-0000-0000-000000000000" + ], + "ddl": [ + { + "name": "media_sources", + "entityType": "tables" + }, + { + "name": "provider_accounts", + "entityType": "tables" + }, + { + "name": "provider_sessions", + "entityType": "tables" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "media_sources" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "provider_id", + "entityType": "columns", + "table": "media_sources" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "provider_account_id", + "entityType": "columns", + "table": "media_sources" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "external_id", + "entityType": "columns", + "table": "media_sources" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "media_sources" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "true", + "generated": null, + "name": "enabled", + "entityType": "columns", + "table": "media_sources" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "base_url", + "entityType": "columns", + "table": "media_sources" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'{}'", + "generated": null, + "name": "connection_json", + "entityType": "columns", + "table": "media_sources" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'{}'", + "generated": null, + "name": "credentials_json", + "entityType": "columns", + "table": "media_sources" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'{}'", + "generated": null, + "name": "metadata_json", + "entityType": "columns", + "table": "media_sources" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "last_checked_at", + "entityType": "columns", + "table": "media_sources" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "last_error", + "entityType": "columns", + "table": "media_sources" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "media_sources" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))", + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "media_sources" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "provider_accounts" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "provider_id", + "entityType": "columns", + "table": "provider_accounts" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "label", + "entityType": "columns", + "table": "provider_accounts" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "provider_accounts" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token_hash", + "entityType": "columns", + "table": "provider_accounts" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'{}'", + "generated": null, + "name": "metadata_json", + "entityType": "columns", + "table": "provider_accounts" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))", + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "provider_accounts" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))", + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "provider_accounts" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "provider_sessions" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "provider_id", + "entityType": "columns", + "table": "provider_sessions" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "provider_account_id", + "entityType": "columns", + "table": "provider_sessions" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "user_token", + "entityType": "columns", + "table": "provider_sessions" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "provider_sessions" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "expires_at", + "entityType": "columns", + "table": "provider_sessions" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))", + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "provider_sessions" + }, + { + "columns": [ + "provider_account_id" + ], + "tableTo": "provider_accounts", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_media_sources_provider_account_id_provider_accounts_id_fk", + "entityType": "fks", + "table": "media_sources" + }, + { + "columns": [ + "provider_account_id" + ], + "tableTo": "provider_accounts", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_provider_sessions_provider_account_id_provider_accounts_id_fk", + "entityType": "fks", + "table": "provider_sessions" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "media_sources_pk", + "table": "media_sources", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "provider_accounts_pk", + "table": "provider_accounts", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "provider_sessions_pk", + "table": "provider_sessions", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "enabled", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "media_sources_enabled_idx", + "entityType": "indexes", + "table": "media_sources" + }, + { + "columns": [ + { + "value": "provider_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "media_sources_provider_id_idx", + "entityType": "indexes", + "table": "media_sources" + }, + { + "columns": [ + { + "value": "provider_account_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "media_sources_provider_account_id_idx", + "entityType": "indexes", + "table": "media_sources" + }, + { + "columns": [ + { + "value": "provider_id", + "isExpression": false + }, + { + "value": "provider_account_id", + "isExpression": false + }, + { + "value": "external_id", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "media_sources_provider_external_id_idx", + "entityType": "indexes", + "table": "media_sources" + }, + { + "columns": [ + { + "value": "provider_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "provider_accounts_provider_id_idx", + "entityType": "indexes", + "table": "provider_accounts" + }, + { + "columns": [ + { + "value": "provider_id", + "isExpression": false + }, + { + "value": "access_token_hash", + "isExpression": false + } + ], + "isUnique": true, + "where": "\"provider_accounts\".\"access_token_hash\" IS NOT NULL", + "origin": "manual", + "name": "provider_accounts_provider_access_token_hash_idx", + "entityType": "indexes", + "table": "provider_accounts" + }, + { + "columns": [ + { + "value": "provider_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "provider_sessions_provider_id_idx", + "entityType": "indexes", + "table": "provider_sessions" + }, + { + "columns": [ + { + "value": "provider_account_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "provider_sessions_provider_account_id_idx", + "entityType": "indexes", + "table": "provider_sessions" + }, + { + "columns": [ + { + "value": "expires_at", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "provider_sessions_expires_at_idx", + "entityType": "indexes", + "table": "provider_sessions" + } + ], + "renames": [] +} \ No newline at end of file diff --git a/apps/server/package.json b/apps/server/package.json index 3afbca1..a8fed98 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -10,6 +10,7 @@ "lint:eslint": "pnpm exec eslint \"src/**/*.ts\"", "lint:types": "tsc --noEmit", "db:generate": "drizzle-kit generate", + "db:export": "drizzle-kit export --sql", "preview": "NODE_ENV=production tsx src/server.ts", "start": "NODE_ENV=production node dist/server.js", "clean": "rm -rf dist" diff --git a/apps/server/src/db/database.ts b/apps/server/src/db/database.ts index f14f8ee..095d172 100644 --- a/apps/server/src/db/database.ts +++ b/apps/server/src/db/database.ts @@ -3,14 +3,15 @@ import path from "path"; import { DatabaseSync } from "node:sqlite"; import { fileURLToPath } from "url"; import { drizzle, type NodeSQLiteDatabase } from "drizzle-orm/node-sqlite"; -import { runMigrations } from "./migrations.js"; +import { migrate } from "drizzle-orm/node-sqlite/migrator"; +import { prepareDatabaseForMigrations } from "./migrationState.js"; import * as schema from "./schema.js"; import { resolveConfiguredDataDir, workspaceRoot } from "../config/loadEnv.js"; import { assertAppKeyConfigured } from "../security/secrets.js"; const DEFAULT_DATABASE_FILE = "cliparr.sqlite"; const DEFAULT_DEVELOPMENT_DATA_DIR = ".cliparr-data"; -const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const MIGRATIONS_FOLDER = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../drizzle"); type CliparrDatabase = NodeSQLiteDatabase; @@ -60,8 +61,9 @@ export function initializeDatabase() { PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 5000; `); - runMigrations(sqlite); database = drizzle({ client: sqlite, schema }); + prepareDatabaseForMigrations(sqlite); + migrate(database, { migrationsFolder: MIGRATIONS_FOLDER }); return database; } diff --git a/apps/server/src/db/migrationState.ts b/apps/server/src/db/migrationState.ts new file mode 100644 index 0000000..408429d --- /dev/null +++ b/apps/server/src/db/migrationState.ts @@ -0,0 +1,32 @@ +import type { DatabaseSync } from "node:sqlite"; + +const APP_TABLE_NAMES = [ + "provider_accounts", + "media_sources", + "provider_sessions", +] as const; +const DRIZZLE_MIGRATIONS_TABLE = "__drizzle_migrations"; +const LEGACY_MIGRATIONS_TABLE = "schema_migrations"; + +function listUserTables(db: DatabaseSync) { + const rows = db.prepare(` + SELECT name + FROM sqlite_master + WHERE type = 'table' + AND name NOT LIKE 'sqlite_%' + `).all() as Array<{ name: string }>; + + return new Set(rows.map((row) => row.name)); +} + +export function prepareDatabaseForMigrations(db: DatabaseSync) { + const userTables = listUserTables(db); + const hasDrizzleMigrationsTable = userTables.has(DRIZZLE_MIGRATIONS_TABLE); + const existingAppTables = APP_TABLE_NAMES.filter((tableName) => userTables.has(tableName)); + + if (!hasDrizzleMigrationsTable && (existingAppTables.length > 0 || userTables.has(LEGACY_MIGRATIONS_TABLE))) { + throw new Error( + "Detected a pre-release Cliparr database without Drizzle migration state. Delete the existing data directory and start again so the first-release schema can be created cleanly." + ); + } +} diff --git a/apps/server/src/db/migrations.ts b/apps/server/src/db/migrations.ts deleted file mode 100644 index 7dcb8fa..0000000 --- a/apps/server/src/db/migrations.ts +++ /dev/null @@ -1,369 +0,0 @@ -import type { DatabaseSync } from "node:sqlite"; - -interface Migration { - id: number; - name: string; - sql: string; -} - -const migrations: Migration[] = [ - { - id: 1, - name: "create_provider_accounts_and_media_sources", - sql: ` - CREATE TABLE provider_accounts ( - id TEXT PRIMARY KEY, - provider_id TEXT NOT NULL, - label TEXT NOT NULL, - access_token TEXT, - metadata_json TEXT NOT NULL DEFAULT '{}', - created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), - updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) - ); - - CREATE INDEX provider_accounts_provider_id_idx - ON provider_accounts(provider_id); - - CREATE UNIQUE INDEX provider_accounts_provider_access_token_idx - ON provider_accounts(provider_id, access_token) - WHERE access_token IS NOT NULL; - - CREATE TABLE media_sources ( - id TEXT PRIMARY KEY, - provider_id TEXT NOT NULL, - provider_account_id TEXT REFERENCES provider_accounts(id) ON DELETE SET NULL, - external_id TEXT, - name TEXT NOT NULL, - enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)), - base_url TEXT NOT NULL, - connection_json TEXT NOT NULL DEFAULT '{}', - credentials_json TEXT NOT NULL DEFAULT '{}', - metadata_json TEXT NOT NULL DEFAULT '{}', - last_checked_at TEXT, - last_error TEXT, - created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), - updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) - ); - - CREATE INDEX media_sources_enabled_idx - ON media_sources(enabled); - - CREATE INDEX media_sources_provider_id_idx - ON media_sources(provider_id); - - CREATE INDEX media_sources_provider_account_id_idx - ON media_sources(provider_account_id); - - CREATE UNIQUE INDEX media_sources_provider_external_id_idx - ON media_sources(provider_id, external_id) - WHERE external_id IS NOT NULL; - `, - }, - { - id: 2, - name: "create_provider_sessions", - sql: ` - CREATE TABLE IF NOT EXISTS provider_sessions ( - id TEXT PRIMARY KEY, - provider_id TEXT NOT NULL, - provider_account_id TEXT REFERENCES provider_accounts(id) ON DELETE SET NULL, - user_token TEXT NOT NULL, - created_at INTEGER NOT NULL, - expires_at INTEGER NOT NULL, - updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) - ); - - CREATE INDEX IF NOT EXISTS provider_sessions_provider_id_idx - ON provider_sessions(provider_id); - - CREATE INDEX IF NOT EXISTS provider_sessions_provider_account_id_idx - ON provider_sessions(provider_account_id); - - CREATE INDEX IF NOT EXISTS provider_sessions_expires_at_idx - ON provider_sessions(expires_at); - `, - }, - { - id: 3, - name: "ensure_provider_accounts_access_token_uniqueness", - sql: ` - CREATE TEMP TABLE duplicate_provider_accounts AS - SELECT id, canonical_id - FROM ( - SELECT - id, - FIRST_VALUE(id) OVER ( - PARTITION BY provider_id, access_token - ORDER BY created_at ASC, id ASC - ) AS canonical_id, - ROW_NUMBER() OVER ( - PARTITION BY provider_id, access_token - ORDER BY created_at ASC, id ASC - ) AS row_num - FROM provider_accounts - WHERE access_token IS NOT NULL - ) - WHERE row_num > 1; - - UPDATE media_sources - SET provider_account_id = ( - SELECT canonical_id - FROM duplicate_provider_accounts - WHERE duplicate_provider_accounts.id = media_sources.provider_account_id - ) - WHERE provider_account_id IN ( - SELECT id - FROM duplicate_provider_accounts - ); - - UPDATE provider_sessions - SET provider_account_id = ( - SELECT canonical_id - FROM duplicate_provider_accounts - WHERE duplicate_provider_accounts.id = provider_sessions.provider_account_id - ) - WHERE provider_account_id IN ( - SELECT id - FROM duplicate_provider_accounts - ); - - DELETE FROM provider_accounts - WHERE id IN ( - SELECT id - FROM duplicate_provider_accounts - ); - - DROP TABLE duplicate_provider_accounts; - - CREATE UNIQUE INDEX IF NOT EXISTS provider_accounts_provider_access_token_idx - ON provider_accounts(provider_id, access_token) - WHERE access_token IS NOT NULL; - `, - }, - { - id: 4, - name: "add_provider_accounts_access_token_hash", - sql: ` - ALTER TABLE provider_accounts - ADD COLUMN access_token_hash TEXT; - - CREATE UNIQUE INDEX IF NOT EXISTS provider_accounts_provider_access_token_hash_idx - ON provider_accounts(provider_id, access_token_hash) - WHERE access_token_hash IS NOT NULL; - `, - }, - { - id: 5, - name: "drop_legacy_provider_session_resource_fields", - sql: ` - CREATE TABLE provider_sessions_next ( - id TEXT PRIMARY KEY, - provider_id TEXT NOT NULL, - provider_account_id TEXT REFERENCES provider_accounts(id) ON DELETE SET NULL, - user_token TEXT NOT NULL, - created_at INTEGER NOT NULL, - expires_at INTEGER NOT NULL, - updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) - ); - - INSERT INTO provider_sessions_next ( - id, - provider_id, - provider_account_id, - user_token, - created_at, - expires_at, - updated_at - ) - SELECT - id, - provider_id, - provider_account_id, - user_token, - created_at, - expires_at, - updated_at - FROM provider_sessions; - - DROP TABLE provider_sessions; - - ALTER TABLE provider_sessions_next RENAME TO provider_sessions; - - CREATE INDEX provider_sessions_provider_id_idx - ON provider_sessions(provider_id); - - CREATE INDEX provider_sessions_provider_account_id_idx - ON provider_sessions(provider_account_id); - - CREATE INDEX provider_sessions_expires_at_idx - ON provider_sessions(expires_at); - `, - }, - { - id: 6, - name: "scope_media_sources_external_ids_by_provider_account", - sql: ` - DROP INDEX IF EXISTS media_sources_provider_external_id_idx; - - CREATE UNIQUE INDEX media_sources_provider_external_id_idx - ON media_sources(provider_id, provider_account_id, external_id) - WHERE external_id IS NOT NULL AND provider_account_id IS NOT NULL; - `, - }, - { - id: 7, - name: "require_provider_account_ownership", - sql: ` - CREATE TABLE media_sources_next ( - id TEXT PRIMARY KEY, - provider_id TEXT NOT NULL, - provider_account_id TEXT NOT NULL REFERENCES provider_accounts(id) ON DELETE CASCADE, - external_id TEXT, - name TEXT NOT NULL, - enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)), - base_url TEXT NOT NULL, - connection_json TEXT NOT NULL DEFAULT '{}', - credentials_json TEXT NOT NULL DEFAULT '{}', - metadata_json TEXT NOT NULL DEFAULT '{}', - last_checked_at TEXT, - last_error TEXT, - created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), - updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) - ); - - INSERT INTO media_sources_next ( - id, - provider_id, - provider_account_id, - external_id, - name, - enabled, - base_url, - connection_json, - credentials_json, - metadata_json, - last_checked_at, - last_error, - created_at, - updated_at - ) - SELECT - id, - provider_id, - provider_account_id, - external_id, - name, - enabled, - base_url, - connection_json, - credentials_json, - metadata_json, - last_checked_at, - last_error, - created_at, - updated_at - FROM media_sources - WHERE provider_account_id IS NOT NULL; - - DROP TABLE media_sources; - - ALTER TABLE media_sources_next RENAME TO media_sources; - - CREATE INDEX media_sources_enabled_idx - ON media_sources(enabled); - - CREATE INDEX media_sources_provider_id_idx - ON media_sources(provider_id); - - CREATE INDEX media_sources_provider_account_id_idx - ON media_sources(provider_account_id); - - CREATE UNIQUE INDEX media_sources_provider_external_id_idx - ON media_sources(provider_id, provider_account_id, external_id) - WHERE external_id IS NOT NULL; - - CREATE TABLE provider_sessions_next ( - id TEXT PRIMARY KEY, - provider_id TEXT NOT NULL, - provider_account_id TEXT NOT NULL REFERENCES provider_accounts(id) ON DELETE CASCADE, - user_token TEXT NOT NULL, - created_at INTEGER NOT NULL, - expires_at INTEGER NOT NULL, - updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) - ); - - INSERT INTO provider_sessions_next ( - id, - provider_id, - provider_account_id, - user_token, - created_at, - expires_at, - updated_at - ) - SELECT - id, - provider_id, - provider_account_id, - user_token, - created_at, - expires_at, - updated_at - FROM provider_sessions - WHERE provider_account_id IS NOT NULL; - - DROP TABLE provider_sessions; - - ALTER TABLE provider_sessions_next RENAME TO provider_sessions; - - CREATE INDEX provider_sessions_provider_id_idx - ON provider_sessions(provider_id); - - CREATE INDEX provider_sessions_provider_account_id_idx - ON provider_sessions(provider_account_id); - - CREATE INDEX provider_sessions_expires_at_idx - ON provider_sessions(expires_at); - `, - }, - { - id: 8, - name: "make_media_source_external_id_uniqueness_unconditional", - sql: ` - DROP INDEX IF EXISTS media_sources_provider_external_id_idx; - - CREATE UNIQUE INDEX media_sources_provider_external_id_idx - ON media_sources(provider_id, provider_account_id, external_id); - `, - }, -]; - -export function runMigrations(db: DatabaseSync) { - db.exec(` - CREATE TABLE IF NOT EXISTS schema_migrations ( - id INTEGER PRIMARY KEY, - name TEXT NOT NULL UNIQUE, - applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) - ); - `); - - const appliedRows = db.prepare("SELECT id FROM schema_migrations").all() as Array<{ id: number }>; - const appliedIds = new Set(appliedRows.map((row) => Number(row.id))); - const insertMigration = db.prepare("INSERT INTO schema_migrations (id, name) VALUES (?, ?)"); - - for (const migration of migrations) { - if (appliedIds.has(migration.id)) { - continue; - } - - db.exec("BEGIN"); - try { - db.exec(migration.sql); - insertMigration.run(migration.id, migration.name); - db.exec("COMMIT"); - } catch (err) { - db.exec("ROLLBACK"); - throw err; - } - } -} diff --git a/apps/server/src/db/schema.ts b/apps/server/src/db/schema.ts index e58548d..9024427 100644 --- a/apps/server/src/db/schema.ts +++ b/apps/server/src/db/schema.ts @@ -3,12 +3,6 @@ import { index, integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqli const nowIso = sql`(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`; -export const schemaMigrations = sqliteTable("schema_migrations", { - id: integer("id").primaryKey(), - name: text("name").notNull().unique(), - appliedAt: text("applied_at").notNull().default(nowIso), -}); - export const providerAccounts = sqliteTable( "provider_accounts", { From 9648594f8de38ebb2dbda2efc7045a2e6c962a2a Mon Sep 17 00:00:00 2001 From: KyleTryon Date: Sat, 18 Apr 2026 11:50:56 -0400 Subject: [PATCH 2/2] refactor: remove unused Video and CheckCircle2 lucide icons from auth and provider screens --- apps/frontend/src/components/AuthCompleteScreen.tsx | 1 - apps/frontend/src/components/ProviderConnectScreen.tsx | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/frontend/src/components/AuthCompleteScreen.tsx b/apps/frontend/src/components/AuthCompleteScreen.tsx index b4dbf04..e183ef4 100644 --- a/apps/frontend/src/components/AuthCompleteScreen.tsx +++ b/apps/frontend/src/components/AuthCompleteScreen.tsx @@ -1,5 +1,4 @@ import { useEffect } from "react"; -import { CheckCircle2 } from "lucide-react"; export default function AuthCompleteScreen() { useEffect(() => { diff --git a/apps/frontend/src/components/ProviderConnectScreen.tsx b/apps/frontend/src/components/ProviderConnectScreen.tsx index 81bcca4..b1c5824 100644 --- a/apps/frontend/src/components/ProviderConnectScreen.tsx +++ b/apps/frontend/src/components/ProviderConnectScreen.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { AnimatePresence, motion } from "motion/react"; -import { ArrowRight, Check, ExternalLink, Server, Video } from "lucide-react"; +import { ArrowRight, Check, ExternalLink, Server } from "lucide-react"; import { cliparrClient } from "../api/cliparrClient"; import { ProviderGlyph } from "./ProviderGlyph"; import type { ProviderDefinition, ProviderSession } from "../providers/types";