From 64b8ab30ad60fe96185dc86021062fe4808348ca Mon Sep 17 00:00:00 2001 From: Till JS Date: Fri, 10 Apr 2026 02:51:41 +0200 Subject: [PATCH] fix(mana-media): commit initial schema migration + run on startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The media schema/tables were never created on fresh deploys because mana-media only shipped a `db:push` script and nothing ever ran it in the container. Result: every upload returned 500 the moment a new environment came up (just hit prod again on mana.how). - Add `db:generate` + `db:migrate` scripts and a migrate.ts runner - Generate the initial migration covering media/media_references/ media_thumbnails (matches what was already on local + prod, which were stamped manually so the migrator skips on existing deploys) - Call runMigrations() at startup in src/index.ts so future fresh containers self-bootstrap. Idempotent — drizzle tracks state in drizzle.__drizzle_migrations. Co-Authored-By: Claude Opus 4.6 (1M context) --- services/mana-media/apps/api/package.json | 2 + .../mana-media/apps/api/src/db/migrate.ts | 46 ++ .../migrations/0000_marvelous_micromacro.sql | 68 +++ .../src/db/migrations/meta/0000_snapshot.json | 561 ++++++++++++++++++ .../api/src/db/migrations/meta/_journal.json | 13 + services/mana-media/apps/api/src/index.ts | 7 + 6 files changed, 697 insertions(+) create mode 100644 services/mana-media/apps/api/src/db/migrate.ts create mode 100644 services/mana-media/apps/api/src/db/migrations/0000_marvelous_micromacro.sql create mode 100644 services/mana-media/apps/api/src/db/migrations/meta/0000_snapshot.json create mode 100644 services/mana-media/apps/api/src/db/migrations/meta/_journal.json diff --git a/services/mana-media/apps/api/package.json b/services/mana-media/apps/api/package.json index a4b395faa..bb7b4a5e6 100644 --- a/services/mana-media/apps/api/package.json +++ b/services/mana-media/apps/api/package.json @@ -7,6 +7,8 @@ "start": "bun run src/index.ts", "type-check": "tsc --noEmit", "db:push": "drizzle-kit push", + "db:generate": "drizzle-kit generate", + "db:migrate": "bun run src/db/migrate.ts", "db:studio": "drizzle-kit studio" }, "dependencies": { diff --git a/services/mana-media/apps/api/src/db/migrate.ts b/services/mana-media/apps/api/src/db/migrate.ts new file mode 100644 index 000000000..698a57da8 --- /dev/null +++ b/services/mana-media/apps/api/src/db/migrate.ts @@ -0,0 +1,46 @@ +/** + * Drizzle migration runner. + * + * Run on every container startup (and as `pnpm db:migrate` for local use) + * so that fresh deployments end up with the `media` schema and tables + * without any manual SQL. Drizzle's migrator tracks applied migrations + * in `drizzle.__drizzle_migrations`, so re-runs are no-ops. + */ +import { drizzle } from 'drizzle-orm/postgres-js'; +import { migrate } from 'drizzle-orm/postgres-js/migrator'; +import postgres from 'postgres'; +import { fileURLToPath } from 'url'; +import { dirname, resolve } from 'path'; + +export async function runMigrations(databaseUrl: string): Promise { + // Migrations live next to this file at runtime, regardless of cwd. + const here = dirname(fileURLToPath(import.meta.url)); + const migrationsFolder = resolve(here, 'migrations'); + + // `max: 1` is required by drizzle's migrator. + const sql = postgres(databaseUrl, { max: 1 }); + try { + const db = drizzle(sql); + await migrate(db, { migrationsFolder }); + } finally { + await sql.end({ timeout: 5 }); + } +} + +// Allow `bun run src/db/migrate.ts` for manual / CI use. +if (import.meta.main) { + const databaseUrl = process.env.DATABASE_URL; + if (!databaseUrl) { + console.error('DATABASE_URL is required'); + process.exit(1); + } + runMigrations(databaseUrl) + .then(() => { + console.log('Migrations applied'); + process.exit(0); + }) + .catch((err) => { + console.error('Migration failed:', err); + process.exit(1); + }); +} diff --git a/services/mana-media/apps/api/src/db/migrations/0000_marvelous_micromacro.sql b/services/mana-media/apps/api/src/db/migrations/0000_marvelous_micromacro.sql new file mode 100644 index 000000000..036972da5 --- /dev/null +++ b/services/mana-media/apps/api/src/db/migrations/0000_marvelous_micromacro.sql @@ -0,0 +1,68 @@ +CREATE SCHEMA "media"; +--> statement-breakpoint +CREATE TABLE "media"."media" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "content_hash" text NOT NULL, + "original_name" text, + "mime_type" text NOT NULL, + "size" bigint NOT NULL, + "original_key" text NOT NULL, + "status" text DEFAULT 'uploading' NOT NULL, + "width" integer, + "height" integer, + "format" text, + "has_alpha" boolean, + "exif_data" jsonb, + "date_taken" timestamp with time zone, + "camera_make" text, + "camera_model" text, + "focal_length" text, + "aperture" text, + "iso" integer, + "exposure_time" text, + "gps_latitude" text, + "gps_longitude" text, + "thumbnail_key" text, + "medium_key" text, + "large_key" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "media_content_hash_unique" UNIQUE("content_hash") +); +--> statement-breakpoint +CREATE TABLE "media"."media_references" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "media_id" uuid NOT NULL, + "user_id" text NOT NULL, + "app" text NOT NULL, + "source_url" text, + "metadata" jsonb, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "media"."media_thumbnails" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "media_id" uuid NOT NULL, + "width" integer NOT NULL, + "height" integer NOT NULL, + "fit" text DEFAULT 'cover' NOT NULL, + "format" text DEFAULT 'webp' NOT NULL, + "quality" integer DEFAULT 80 NOT NULL, + "storage_key" text NOT NULL, + "size" integer NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "media"."media_references" ADD CONSTRAINT "media_references_media_id_media_id_fk" FOREIGN KEY ("media_id") REFERENCES "media"."media"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "media"."media_thumbnails" ADD CONSTRAINT "media_thumbnails_media_id_media_id_fk" FOREIGN KEY ("media_id") REFERENCES "media"."media"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "media_content_hash_idx" ON "media"."media" USING btree ("content_hash");--> statement-breakpoint +CREATE INDEX "media_status_idx" ON "media"."media" USING btree ("status");--> statement-breakpoint +CREATE INDEX "media_created_at_idx" ON "media"."media" USING btree ("created_at");--> statement-breakpoint +CREATE INDEX "media_date_taken_idx" ON "media"."media" USING btree ("date_taken");--> statement-breakpoint +CREATE INDEX "media_camera_idx" ON "media"."media" USING btree ("camera_make","camera_model");--> statement-breakpoint +CREATE INDEX "media_ref_media_id_idx" ON "media"."media_references" USING btree ("media_id");--> statement-breakpoint +CREATE INDEX "media_ref_user_id_idx" ON "media"."media_references" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "media_ref_app_idx" ON "media"."media_references" USING btree ("app");--> statement-breakpoint +CREATE INDEX "media_ref_user_app_idx" ON "media"."media_references" USING btree ("user_id","app");--> statement-breakpoint +CREATE INDEX "media_thumb_media_id_idx" ON "media"."media_thumbnails" USING btree ("media_id");--> statement-breakpoint +CREATE INDEX "media_thumb_params_idx" ON "media"."media_thumbnails" USING btree ("media_id","width","height","fit","format"); \ No newline at end of file diff --git a/services/mana-media/apps/api/src/db/migrations/meta/0000_snapshot.json b/services/mana-media/apps/api/src/db/migrations/meta/0000_snapshot.json new file mode 100644 index 000000000..e93bedf30 --- /dev/null +++ b/services/mana-media/apps/api/src/db/migrations/meta/0000_snapshot.json @@ -0,0 +1,561 @@ +{ + "id": "270c9891-ddcf-4b1e-b987-d91f033e0767", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "media.media": { + "name": "media", + "schema": "media", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "original_key": { + "name": "original_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'uploading'" + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "has_alpha": { + "name": "has_alpha", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "exif_data": { + "name": "exif_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "date_taken": { + "name": "date_taken", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "camera_make": { + "name": "camera_make", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "camera_model": { + "name": "camera_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "focal_length": { + "name": "focal_length", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "aperture": { + "name": "aperture", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "iso": { + "name": "iso", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "exposure_time": { + "name": "exposure_time", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "gps_latitude": { + "name": "gps_latitude", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "gps_longitude": { + "name": "gps_longitude", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thumbnail_key": { + "name": "thumbnail_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "medium_key": { + "name": "medium_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "large_key": { + "name": "large_key", + "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": { + "media_content_hash_idx": { + "name": "media_content_hash_idx", + "columns": [ + { + "expression": "content_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "media_status_idx": { + "name": "media_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "media_created_at_idx": { + "name": "media_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "media_date_taken_idx": { + "name": "media_date_taken_idx", + "columns": [ + { + "expression": "date_taken", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "media_camera_idx": { + "name": "media_camera_idx", + "columns": [ + { + "expression": "camera_make", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "camera_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "media_content_hash_unique": { + "name": "media_content_hash_unique", + "nullsNotDistinct": false, + "columns": ["content_hash"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "media.media_references": { + "name": "media_references", + "schema": "media", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "media_id": { + "name": "media_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "app": { + "name": "app", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "media_ref_media_id_idx": { + "name": "media_ref_media_id_idx", + "columns": [ + { + "expression": "media_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "media_ref_user_id_idx": { + "name": "media_ref_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "media_ref_app_idx": { + "name": "media_ref_app_idx", + "columns": [ + { + "expression": "app", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "media_ref_user_app_idx": { + "name": "media_ref_user_app_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "app", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "media_references_media_id_media_id_fk": { + "name": "media_references_media_id_media_id_fk", + "tableFrom": "media_references", + "tableTo": "media", + "schemaTo": "media", + "columnsFrom": ["media_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "media.media_thumbnails": { + "name": "media_thumbnails", + "schema": "media", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "media_id": { + "name": "media_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "fit": { + "name": "fit", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'cover'" + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'webp'" + }, + "quality": { + "name": "quality", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 80 + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "media_thumb_media_id_idx": { + "name": "media_thumb_media_id_idx", + "columns": [ + { + "expression": "media_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "media_thumb_params_idx": { + "name": "media_thumb_params_idx", + "columns": [ + { + "expression": "media_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "width", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "height", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "fit", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "format", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "media_thumbnails_media_id_media_id_fk": { + "name": "media_thumbnails_media_id_media_id_fk", + "tableFrom": "media_thumbnails", + "tableTo": "media", + "schemaTo": "media", + "columnsFrom": ["media_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": { + "media": "media" + }, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/services/mana-media/apps/api/src/db/migrations/meta/_journal.json b/services/mana-media/apps/api/src/db/migrations/meta/_journal.json new file mode 100644 index 000000000..f5a84d7dd --- /dev/null +++ b/services/mana-media/apps/api/src/db/migrations/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1775759324563, + "tag": "0000_marvelous_micromacro", + "breakpoints": true + } + ] +} diff --git a/services/mana-media/apps/api/src/index.ts b/services/mana-media/apps/api/src/index.ts index 497830cd0..85168da12 100644 --- a/services/mana-media/apps/api/src/index.ts +++ b/services/mana-media/apps/api/src/index.ts @@ -3,6 +3,7 @@ import { cors } from 'hono/cors'; import { Queue, Worker, Job } from 'bullmq'; import { collectDefaultMetrics, Registry, Counter, Histogram } from 'prom-client'; import { getDb, closeConnection } from './db'; +import { runMigrations } from './db/migrate'; import { StorageService } from './services/storage'; import { UploadService } from './services/upload'; import { ProcessService } from './services/process'; @@ -16,6 +17,12 @@ const port = parseInt(process.env.PORT || '3015'); // Database const databaseUrl = process.env.DATABASE_URL; if (!databaseUrl) throw new Error('DATABASE_URL is required'); + +// Apply pending Drizzle migrations before opening the pool. Idempotent — +// drizzle tracks applied migrations in drizzle.__drizzle_migrations, so +// existing deployments are unaffected. +await runMigrations(databaseUrl); + const db = getDb(databaseUrl); // Services