fix(mana-media): commit initial schema migration + run on startup

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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-10 02:51:41 +02:00
parent 80b23dd9ff
commit 64b8ab30ad
6 changed files with 697 additions and 0 deletions

View file

@ -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": {

View file

@ -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<void> {
// 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);
});
}

View file

@ -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");

View file

@ -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": {}
}
}

View file

@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1775759324563,
"tag": "0000_marvelous_micromacro",
"breakpoints": true
}
]
}

View file

@ -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