diff --git a/apps/server/drizzle/0020_bizarre_otto_octavius.sql b/apps/server/drizzle/0020_bizarre_otto_octavius.sql
new file mode 100644
index 000000000..e29f57e7b
--- /dev/null
+++ b/apps/server/drizzle/0020_bizarre_otto_octavius.sql
@@ -0,0 +1,156 @@
+ALTER TYPE "public"."job_status" ADD VALUE 'cancelled';--> statement-breakpoint
+ALTER TABLE "ccip_embeddings" ADD COLUMN "input_revision" text;--> statement-breakpoint
+ALTER TABLE "ccip_embeddings" ADD COLUMN "preprocessing_profile" text DEFAULT 'dghs-imgutils-rs/full-image-default/v1' NOT NULL;--> statement-breakpoint
+ALTER TABLE "ccip_embeddings" DROP CONSTRAINT "uq_ccip_embeddings_region_model_version";--> statement-breakpoint
+ALTER TABLE "ccip_embeddings" ADD CONSTRAINT "uq_ccip_embeddings_region_model_version" UNIQUE("region_id","model","embedding_version","preprocessing_profile");--> statement-breakpoint
+DROP INDEX IF EXISTS "idx_ccip_embeddings_embedding_cosine";--> statement-breakpoint
+ALTER TABLE "jobs" ADD COLUMN "queue_name" text;--> statement-breakpoint
+ALTER TABLE "jobs" ADD COLUMN "target_id" text;--> statement-breakpoint
+ALTER TABLE "jobs" ADD COLUMN "input_revision" text;--> statement-breakpoint
+ALTER TABLE "jobs" ADD COLUMN "dedupe_key" text;--> statement-breakpoint
+ALTER TABLE "jobs" ADD COLUMN "concurrency_key" text;--> statement-breakpoint
+ALTER TABLE "jobs" ADD COLUMN "available_at" timestamp DEFAULT now() NOT NULL;--> statement-breakpoint
+ALTER TABLE "jobs" ADD COLUMN "attempt_count" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
+ALTER TABLE "jobs" ADD COLUMN "max_attempts" integer DEFAULT 5 NOT NULL;--> statement-breakpoint
+ALTER TABLE "jobs" ADD COLUMN "lease_duration_ms" integer DEFAULT 300000 NOT NULL;--> statement-breakpoint
+ALTER TABLE "jobs" ADD COLUMN "claim_token" uuid;--> statement-breakpoint
+ALTER TABLE "jobs" ADD COLUMN "claimed_by" text;--> statement-breakpoint
+ALTER TABLE "jobs" ADD COLUMN "claimed_at" timestamp;--> statement-breakpoint
+ALTER TABLE "jobs" ADD COLUMN "heartbeat_at" timestamp;--> statement-breakpoint
+ALTER TABLE "jobs" ADD COLUMN "error_code" text;--> statement-breakpoint
+ALTER TABLE "lancedb_sync_dirty" ADD COLUMN "generation" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
+ALTER TABLE "media_regions" ADD COLUMN "source_width" integer;--> statement-breakpoint
+ALTER TABLE "media_regions" ADD COLUMN "source_height" integer;--> statement-breakpoint
+ALTER TABLE "media_regions" ADD COLUMN "source_revision" text;--> statement-breakpoint
+ALTER TABLE "media_regions" ADD COLUMN "region_revision" text;--> statement-breakpoint
+ALTER TABLE "media_regions" ADD COLUMN "label" text;--> statement-breakpoint
+ALTER TABLE "media_regions" ADD COLUMN "manual_reason" text;--> statement-breakpoint
+ALTER TABLE "media_regions" ADD COLUMN "detection_key" text;--> statement-breakpoint
+ALTER TABLE "media_regions" ADD COLUMN "detector_model" text;--> statement-breakpoint
+ALTER TABLE "media_relations" ADD COLUMN "source_region_id" uuid;--> statement-breakpoint
+ALTER TABLE "media_relations" ADD COLUMN "derivation_key" text;--> statement-breakpoint
+UPDATE "jobs"
+SET
+ "queue_name" = CASE
+ WHEN "type" IN ('auto_tagging', 'extract_ccip_vector') THEN 'ai'
+ ELSE 'default'
+ END,
+ "available_at" = "created_at";--> statement-breakpoint
+DO $$
+BEGIN
+ IF EXISTS (
+ SELECT 1
+ FROM "media_regions" AS region
+ INNER JOIN "media" AS media ON media."id" = region."media_id"
+ WHERE media."width" <= 0 OR media."height" <= 0
+ ) THEN
+ RAISE EXCEPTION 'Cannot migrate media_regions: source media dimensions must be positive';
+ END IF;
+END
+$$;--> statement-breakpoint
+UPDATE "media_regions" AS region
+SET
+ "source_width" = media."width",
+ "source_height" = media."height",
+ "source_revision" = encode(
+ sha256(
+ convert_to(
+ concat(
+ '{"version":1,"mediaId":', to_json(media."id"::text)::text,
+ ',"mediaSourceId":', to_json(media."source_id"::text)::text,
+ ',"modifiedAtMs":', floor(extract(epoch FROM region."source_modified_at") * 1000)::bigint,
+ ',"fileSize":', coalesce(media."file_size"::text, 'null'),
+ ',"width":', media."width",
+ ',"height":', media."height", '}'
+ ),
+ 'UTF8'
+ )
+ ),
+ 'hex'
+ )
+FROM "media" AS media
+WHERE media."id" = region."media_id";--> statement-breakpoint
+UPDATE "media_regions"
+SET "region_revision" = encode(
+ sha256(
+ convert_to(
+ concat(
+ '{"version":1,"sourceRevision":', to_json("source_revision")::text,
+ ',"kind":', to_json("kind"::text)::text,
+ ',"x":', coalesce(to_json("x")::text, 'null'),
+ ',"y":', coalesce(to_json("y")::text, 'null'),
+ ',"width":', coalesce(to_json("width")::text, 'null'),
+ ',"height":', coalesce(to_json("height")::text, 'null'),
+ ',"label":', coalesce(to_json("label")::text, 'null'),
+ ',"detector":', coalesce(to_json("detector")::text, 'null'),
+ ',"detectorModel":', coalesce(to_json("detector_model")::text, 'null'),
+ ',"detectorVersion":', coalesce(to_json("detector_version")::text, 'null'),
+ ',"manualReason":', coalesce(to_json("manual_reason")::text, 'null'), '}'
+ ),
+ 'UTF8'
+ )
+ ),
+ 'hex'
+);--> statement-breakpoint
+WITH embedding_sources AS (
+ SELECT
+ embedding."id",
+ embedding."model",
+ embedding."embedding_version",
+ embedding."preprocessing_profile",
+ encode(
+ sha256(
+ convert_to(
+ concat(
+ '{"version":1,"mediaId":', to_json(media."id"::text)::text,
+ ',"mediaSourceId":', to_json(media."source_id"::text)::text,
+ ',"modifiedAtMs":', floor(extract(epoch FROM embedding."media_modified_at") * 1000)::bigint,
+ ',"fileSize":', coalesce(media."file_size"::text, 'null'),
+ ',"width":', media."width",
+ ',"height":', media."height", '}'
+ ),
+ 'UTF8'
+ )
+ ),
+ 'hex'
+ ) AS source_revision
+ FROM "ccip_embeddings" AS embedding
+ INNER JOIN "media_regions" AS region ON region."id" = embedding."region_id"
+ INNER JOIN "media" AS media ON media."id" = region."media_id"
+)
+UPDATE "ccip_embeddings" AS embedding
+SET "input_revision" = encode(
+ sha256(
+ convert_to(
+ concat(
+ '{"version":1,"sourceRevision":', to_json(source."source_revision")::text,
+ ',"model":', to_json(source."model")::text,
+ ',"embeddingVersion":', source."embedding_version",
+ ',"preprocessingProfile":', to_json(source."preprocessing_profile")::text, '}'
+ ),
+ 'UTF8'
+ )
+ ),
+ 'hex'
+)
+FROM embedding_sources AS source
+WHERE source."id" = embedding."id";--> statement-breakpoint
+ALTER TABLE "media_regions" ALTER COLUMN "source_width" SET NOT NULL;--> statement-breakpoint
+ALTER TABLE "media_regions" ALTER COLUMN "source_height" SET NOT NULL;--> statement-breakpoint
+ALTER TABLE "media_regions" ALTER COLUMN "source_revision" SET NOT NULL;--> statement-breakpoint
+ALTER TABLE "media_regions" ALTER COLUMN "region_revision" SET NOT NULL;--> statement-breakpoint
+ALTER TABLE "ccip_embeddings" ALTER COLUMN "input_revision" SET NOT NULL;--> statement-breakpoint
+ALTER TABLE "media_relations" ADD CONSTRAINT "media_relations_source_region_id_media_regions_id_fk" FOREIGN KEY ("source_region_id") REFERENCES "public"."media_regions"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
+CREATE INDEX "idx_jobs_claim" ON "jobs" USING btree ("queue_name","available_at","created_at","id") WHERE "jobs"."status" = 'pending';--> statement-breakpoint
+CREATE INDEX "idx_jobs_stale_lease" ON "jobs" USING btree ("heartbeat_at","claimed_at") WHERE "jobs"."status" = 'in_progress';--> statement-breakpoint
+CREATE INDEX "idx_jobs_parent_status" ON "jobs" USING btree ("parent_id","status");--> statement-breakpoint
+CREATE INDEX "idx_jobs_status_updated" ON "jobs" USING btree ("status","updated_at");--> statement-breakpoint
+CREATE UNIQUE INDEX "uq_jobs_active_dedupe" ON "jobs" USING btree ("dedupe_key") WHERE "jobs"."dedupe_key" IS NOT NULL AND "jobs"."status" IN ('pending', 'in_progress');--> statement-breakpoint
+CREATE UNIQUE INDEX "uq_jobs_running_concurrency" ON "jobs" USING btree ("concurrency_key") WHERE "jobs"."concurrency_key" IS NOT NULL AND "jobs"."status" = 'in_progress';--> statement-breakpoint
+CREATE UNIQUE INDEX "uq_media_regions_detection_key" ON "media_regions" USING btree ("media_id","detection_key") WHERE "media_regions"."detection_key" IS NOT NULL;--> statement-breakpoint
+CREATE INDEX "idx_media_relations_source_region" ON "media_relations" USING btree ("source_region_id");--> statement-breakpoint
+CREATE UNIQUE INDEX "uq_media_relations_derivation_key" ON "media_relations" USING btree ("derivation_key") WHERE "media_relations"."derivation_key" IS NOT NULL;--> statement-breakpoint
+ALTER TABLE "jobs" ADD CONSTRAINT "jobs_attempt_count_nonnegative" CHECK ("jobs"."attempt_count" >= 0);--> statement-breakpoint
+ALTER TABLE "jobs" ADD CONSTRAINT "jobs_max_attempts_positive" CHECK ("jobs"."max_attempts" > 0);--> statement-breakpoint
+ALTER TABLE "jobs" ADD CONSTRAINT "jobs_lease_duration_positive" CHECK ("jobs"."lease_duration_ms" > 0);--> statement-breakpoint
+ALTER TABLE "media_regions" ADD CONSTRAINT "media_regions_source_dimensions_positive" CHECK ("media_regions"."source_width" > 0 AND "media_regions"."source_height" > 0);
diff --git a/apps/server/drizzle/meta/0020_snapshot.json b/apps/server/drizzle/meta/0020_snapshot.json
new file mode 100644
index 000000000..eb20bfd56
--- /dev/null
+++ b/apps/server/drizzle/meta/0020_snapshot.json
@@ -0,0 +1,3661 @@
+{
+ "id": "86a7541b-16bf-4712-96e3-885ae9fc080e",
+ "prevId": "4df58052-f274-418d-a9ab-54e66bb81847",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.author_accounts": {
+ "name": "author_accounts",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "author_id": {
+ "name": "author_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "platform": {
+ "name": "platform",
+ "type": "author_platform",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "profile_url": {
+ "name": "profile_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "idx_author_accounts_author_id": {
+ "name": "idx_author_accounts_author_id",
+ "columns": [
+ {
+ "expression": "author_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_author_accounts_platform_account_unique": {
+ "name": "idx_author_accounts_platform_account_unique",
+ "columns": [
+ {
+ "expression": "platform",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "account_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "author_accounts_author_id_authors_id_fk": {
+ "name": "author_accounts_author_id_authors_id_fk",
+ "tableFrom": "author_accounts",
+ "tableTo": "authors",
+ "columnsFrom": [
+ "author_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.authors": {
+ "name": "authors",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "idx_authors_account_id": {
+ "name": "idx_authors_account_id",
+ "columns": [
+ {
+ "expression": "account_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_authors_name": {
+ "name": "idx_authors_name",
+ "columns": [
+ {
+ "expression": "name",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.categories": {
+ "name": "categories",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "''"
+ },
+ "color": {
+ "name": "color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'#808080'"
+ },
+ "parent_id": {
+ "name": "parent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "source": {
+ "name": "source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'manual'"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "categories_parent_id_categories_id_fk": {
+ "name": "categories_parent_id_categories_id_fk",
+ "tableFrom": "categories",
+ "tableTo": "categories",
+ "columnsFrom": [
+ "parent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "categories_name_unique": {
+ "name": "categories_name_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "name"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.ccip_embeddings": {
+ "name": "ccip_embeddings",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "region_id": {
+ "name": "region_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "embedding": {
+ "name": "embedding",
+ "type": "vector(768)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "model": {
+ "name": "model",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "embedding_version": {
+ "name": "embedding_version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "media_modified_at": {
+ "name": "media_modified_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "input_revision": {
+ "name": "input_revision",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "preprocessing_profile": {
+ "name": "preprocessing_profile",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'dghs-imgutils-rs/full-image-default/v1'"
+ },
+ "extracted_at": {
+ "name": "extracted_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "idx_ccip_embeddings_region_id": {
+ "name": "idx_ccip_embeddings_region_id",
+ "columns": [
+ {
+ "expression": "region_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "ccip_embeddings_region_id_media_regions_id_fk": {
+ "name": "ccip_embeddings_region_id_media_regions_id_fk",
+ "tableFrom": "ccip_embeddings",
+ "tableTo": "media_regions",
+ "columnsFrom": [
+ "region_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "uq_ccip_embeddings_region_model_version": {
+ "name": "uq_ccip_embeddings_region_model_version",
+ "nullsNotDistinct": false,
+ "columns": [
+ "region_id",
+ "model",
+ "embedding_version",
+ "preprocessing_profile"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.character_ips": {
+ "name": "character_ips",
+ "schema": "",
+ "columns": {
+ "character_id": {
+ "name": "character_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ip_id": {
+ "name": "ip_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source": {
+ "name": "source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'manual'"
+ }
+ },
+ "indexes": {
+ "idx_character_ips_ip_id_character_id": {
+ "name": "idx_character_ips_ip_id_character_id",
+ "columns": [
+ {
+ "expression": "ip_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "character_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "character_ips_character_id_characters_id_fk": {
+ "name": "character_ips_character_id_characters_id_fk",
+ "tableFrom": "character_ips",
+ "tableTo": "characters",
+ "columnsFrom": [
+ "character_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "character_ips_ip_id_ips_id_fk": {
+ "name": "character_ips_ip_id_ips_id_fk",
+ "tableFrom": "character_ips",
+ "tableTo": "ips",
+ "columnsFrom": [
+ "ip_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "character_ips_character_id_ip_id_pk": {
+ "name": "character_ips_character_id_ip_id_pk",
+ "columns": [
+ "character_id",
+ "ip_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.characters": {
+ "name": "characters",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "''"
+ },
+ "source": {
+ "name": "source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'manual'"
+ },
+ "aliases": {
+ "name": "aliases",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "characters_name_unique": {
+ "name": "characters_name_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "name"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.collections": {
+ "name": "collections",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "''"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "collections_user_id_users_id_fk": {
+ "name": "collections_user_id_users_id_fk",
+ "tableFrom": "collections",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.ips": {
+ "name": "ips",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "''"
+ },
+ "source": {
+ "name": "source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'manual'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "ips_name_unique": {
+ "name": "ips_name_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "name"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.jobs": {
+ "name": "jobs",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_id": {
+ "name": "source_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "job_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "queue_name": {
+ "name": "queue_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "target_id": {
+ "name": "target_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "input_revision": {
+ "name": "input_revision",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "dedupe_key": {
+ "name": "dedupe_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "concurrency_key": {
+ "name": "concurrency_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "available_at": {
+ "name": "available_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "attempt_count": {
+ "name": "attempt_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "max_attempts": {
+ "name": "max_attempts",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 5
+ },
+ "lease_duration_ms": {
+ "name": "lease_duration_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 300000
+ },
+ "claim_token": {
+ "name": "claim_token",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "claimed_by": {
+ "name": "claimed_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "claimed_at": {
+ "name": "claimed_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "heartbeat_at": {
+ "name": "heartbeat_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "error_code": {
+ "name": "error_code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "payload": {
+ "name": "payload",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "result": {
+ "name": "result",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "parent_id": {
+ "name": "parent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "idx_jobs_pending_created": {
+ "name": "idx_jobs_pending_created",
+ "columns": [
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "\"jobs\".\"status\" = 'pending' AND \"jobs\".\"type\" <> 'import_request'",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_jobs_pending_type_created": {
+ "name": "idx_jobs_pending_type_created",
+ "columns": [
+ {
+ "expression": "type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "\"jobs\".\"status\" = 'pending' AND \"jobs\".\"type\" <> 'import_request'",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_jobs_pending_lancedb_source": {
+ "name": "idx_jobs_pending_lancedb_source",
+ "columns": [
+ {
+ "expression": "source_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "\"jobs\".\"status\" = 'pending'\n\t\t\t\t\tAND \"jobs\".\"type\" IN ('sync_lancedb', 'sync_lancedb_full', 'sync_lancedb_delta')\n\t\t\t\t\tAND \"jobs\".\"source_id\" IS NOT NULL",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_jobs_active_lancedb_source": {
+ "name": "idx_jobs_active_lancedb_source",
+ "columns": [
+ {
+ "expression": "source_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "\"jobs\".\"status\" = 'in_progress'\n\t\t\t\t\tAND \"jobs\".\"type\" IN ('sync_lancedb', 'sync_lancedb_full', 'sync_lancedb_delta')\n\t\t\t\t\tAND \"jobs\".\"source_id\" IS NOT NULL",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_jobs_claim": {
+ "name": "idx_jobs_claim",
+ "columns": [
+ {
+ "expression": "queue_name",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "available_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "\"jobs\".\"status\" = 'pending'",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_jobs_stale_lease": {
+ "name": "idx_jobs_stale_lease",
+ "columns": [
+ {
+ "expression": "heartbeat_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "claimed_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "\"jobs\".\"status\" = 'in_progress'",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_jobs_parent_status": {
+ "name": "idx_jobs_parent_status",
+ "columns": [
+ {
+ "expression": "parent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_jobs_status_updated": {
+ "name": "idx_jobs_status_updated",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "updated_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "uq_jobs_active_dedupe": {
+ "name": "uq_jobs_active_dedupe",
+ "columns": [
+ {
+ "expression": "dedupe_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"jobs\".\"dedupe_key\" IS NOT NULL AND \"jobs\".\"status\" IN ('pending', 'in_progress')",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "uq_jobs_running_concurrency": {
+ "name": "uq_jobs_running_concurrency",
+ "columns": [
+ {
+ "expression": "concurrency_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"jobs\".\"concurrency_key\" IS NOT NULL AND \"jobs\".\"status\" = 'in_progress'",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "jobs_source_id_media_sources_id_fk": {
+ "name": "jobs_source_id_media_sources_id_fk",
+ "tableFrom": "jobs",
+ "tableTo": "media_sources",
+ "columnsFrom": [
+ "source_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "jobs_parent_id_jobs_id_fk": {
+ "name": "jobs_parent_id_jobs_id_fk",
+ "tableFrom": "jobs",
+ "tableTo": "jobs",
+ "columnsFrom": [
+ "parent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "jobs_attempt_count_nonnegative": {
+ "name": "jobs_attempt_count_nonnegative",
+ "value": "\"jobs\".\"attempt_count\" >= 0"
+ },
+ "jobs_max_attempts_positive": {
+ "name": "jobs_max_attempts_positive",
+ "value": "\"jobs\".\"max_attempts\" > 0"
+ },
+ "jobs_lease_duration_positive": {
+ "name": "jobs_lease_duration_positive",
+ "value": "\"jobs\".\"lease_duration_ms\" > 0"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.lancedb_sync_dirty": {
+ "name": "lancedb_sync_dirty",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "source_id": {
+ "name": "source_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "media_id": {
+ "name": "media_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "operation": {
+ "name": "operation",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'upsert'"
+ },
+ "generation": {
+ "name": "generation",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "attempts": {
+ "name": "attempts",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "last_error": {
+ "name": "last_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "idx_lancedb_sync_dirty_source_updated": {
+ "name": "idx_lancedb_sync_dirty_source_updated",
+ "columns": [
+ {
+ "expression": "source_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "updated_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "lancedb_sync_dirty_source_id_media_sources_id_fk": {
+ "name": "lancedb_sync_dirty_source_id_media_sources_id_fk",
+ "tableFrom": "lancedb_sync_dirty",
+ "tableTo": "media_sources",
+ "columnsFrom": [
+ "source_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "lancedb_sync_dirty_source_media_unique": {
+ "name": "lancedb_sync_dirty_source_media_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "source_id",
+ "media_id"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.media_authors": {
+ "name": "media_authors",
+ "schema": "",
+ "columns": {
+ "media_id": {
+ "name": "media_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "author_id": {
+ "name": "author_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "idx_media_authors_author_id_media_id": {
+ "name": "idx_media_authors_author_id_media_id",
+ "columns": [
+ {
+ "expression": "author_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "media_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "media_authors_media_id_media_id_fk": {
+ "name": "media_authors_media_id_media_id_fk",
+ "tableFrom": "media_authors",
+ "tableTo": "media",
+ "columnsFrom": [
+ "media_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "media_authors_author_id_authors_id_fk": {
+ "name": "media_authors_author_id_authors_id_fk",
+ "tableFrom": "media_authors",
+ "tableTo": "authors",
+ "columnsFrom": [
+ "author_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "media_authors_media_id_author_id_pk": {
+ "name": "media_authors_media_id_author_id_pk",
+ "columns": [
+ "media_id",
+ "author_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.media_categories": {
+ "name": "media_categories",
+ "schema": "",
+ "columns": {
+ "media_id": {
+ "name": "media_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "category_id": {
+ "name": "category_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "idx_media_categories_category_id_media_id": {
+ "name": "idx_media_categories_category_id_media_id",
+ "columns": [
+ {
+ "expression": "category_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "media_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "media_categories_media_id_media_id_fk": {
+ "name": "media_categories_media_id_media_id_fk",
+ "tableFrom": "media_categories",
+ "tableTo": "media",
+ "columnsFrom": [
+ "media_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "media_categories_category_id_categories_id_fk": {
+ "name": "media_categories_category_id_categories_id_fk",
+ "tableFrom": "media_categories",
+ "tableTo": "categories",
+ "columnsFrom": [
+ "category_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "media_categories_media_id_category_id_pk": {
+ "name": "media_categories_media_id_category_id_pk",
+ "columns": [
+ "media_id",
+ "category_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.media_characters": {
+ "name": "media_characters",
+ "schema": "",
+ "columns": {
+ "media_id": {
+ "name": "media_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "character_id": {
+ "name": "character_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "confidence": {
+ "name": "confidence",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "source": {
+ "name": "source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'manual'"
+ }
+ },
+ "indexes": {
+ "idx_media_characters_character_id_media_id": {
+ "name": "idx_media_characters_character_id_media_id",
+ "columns": [
+ {
+ "expression": "character_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "media_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "media_characters_media_id_media_id_fk": {
+ "name": "media_characters_media_id_media_id_fk",
+ "tableFrom": "media_characters",
+ "tableTo": "media",
+ "columnsFrom": [
+ "media_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "media_characters_character_id_characters_id_fk": {
+ "name": "media_characters_character_id_characters_id_fk",
+ "tableFrom": "media_characters",
+ "tableTo": "characters",
+ "columnsFrom": [
+ "character_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "media_characters_media_id_character_id_pk": {
+ "name": "media_characters_media_id_character_id_pk",
+ "columns": [
+ "media_id",
+ "character_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.media_collections": {
+ "name": "media_collections",
+ "schema": "",
+ "columns": {
+ "collection_id": {
+ "name": "collection_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "media_id": {
+ "name": "media_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "display_order": {
+ "name": "display_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "idx_media_collections_media_id": {
+ "name": "idx_media_collections_media_id",
+ "columns": [
+ {
+ "expression": "media_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "media_collections_collection_id_collections_id_fk": {
+ "name": "media_collections_collection_id_collections_id_fk",
+ "tableFrom": "media_collections",
+ "tableTo": "collections",
+ "columnsFrom": [
+ "collection_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "media_collections_media_id_media_id_fk": {
+ "name": "media_collections_media_id_media_id_fk",
+ "tableFrom": "media_collections",
+ "tableTo": "media",
+ "columnsFrom": [
+ "media_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "media_collections_collection_id_media_id_pk": {
+ "name": "media_collections_collection_id_media_id_pk",
+ "columns": [
+ "collection_id",
+ "media_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.media_details": {
+ "name": "media_details",
+ "schema": "",
+ "columns": {
+ "media_id": {
+ "name": "media_id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "rating": {
+ "name": "rating",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "default": 0
+ },
+ "favorite": {
+ "name": "favorite",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": false
+ },
+ "view_count": {
+ "name": "view_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "default": 0
+ },
+ "last_viewed_at": {
+ "name": "last_viewed_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'1970-01-01 00:00:00'"
+ }
+ },
+ "indexes": {
+ "idx_media_details_rating": {
+ "name": "idx_media_details_rating",
+ "columns": [
+ {
+ "expression": "rating",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_media_details_favorite": {
+ "name": "idx_media_details_favorite",
+ "columns": [
+ {
+ "expression": "favorite",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_media_details_view_count": {
+ "name": "idx_media_details_view_count",
+ "columns": [
+ {
+ "expression": "view_count",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "media_details_media_id_media_id_fk": {
+ "name": "media_details_media_id_media_id_fk",
+ "tableFrom": "media_details",
+ "tableTo": "media",
+ "columnsFrom": [
+ "media_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.media_generation_info": {
+ "name": "media_generation_info",
+ "schema": "",
+ "columns": {
+ "media_id": {
+ "name": "media_id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "prompt": {
+ "name": "prompt",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "negative_prompt": {
+ "name": "negative_prompt",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "workflow": {
+ "name": "workflow",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "loras": {
+ "name": "loras",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "vae": {
+ "name": "vae",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "hypernetworks": {
+ "name": "hypernetworks",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "embeddings": {
+ "name": "embeddings",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ai_generated": {
+ "name": "ai_generated",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": false
+ },
+ "model_name": {
+ "name": "model_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "''"
+ },
+ "seed": {
+ "name": "seed",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false,
+ "default": -1
+ },
+ "cfg_scale": {
+ "name": "cfg_scale",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "default": 0
+ },
+ "steps": {
+ "name": "steps",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "default": 0
+ }
+ },
+ "indexes": {
+ "idx_media_generation_info_metadata": {
+ "name": "idx_media_generation_info_metadata",
+ "columns": [
+ {
+ "expression": "metadata",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_media_generation_info_ai_generated": {
+ "name": "idx_media_generation_info_ai_generated",
+ "columns": [
+ {
+ "expression": "ai_generated",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_media_generation_info_model_name": {
+ "name": "idx_media_generation_info_model_name",
+ "columns": [
+ {
+ "expression": "model_name",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "media_generation_info_media_id_media_id_fk": {
+ "name": "media_generation_info_media_id_media_id_fk",
+ "tableFrom": "media_generation_info",
+ "tableTo": "media",
+ "columnsFrom": [
+ "media_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.media_ips": {
+ "name": "media_ips",
+ "schema": "",
+ "columns": {
+ "media_id": {
+ "name": "media_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ip_id": {
+ "name": "ip_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "confidence": {
+ "name": "confidence",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "source": {
+ "name": "source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'manual'"
+ }
+ },
+ "indexes": {
+ "idx_media_ips_ip_id_media_id": {
+ "name": "idx_media_ips_ip_id_media_id",
+ "columns": [
+ {
+ "expression": "ip_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "media_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "media_ips_media_id_media_id_fk": {
+ "name": "media_ips_media_id_media_id_fk",
+ "tableFrom": "media_ips",
+ "tableTo": "media",
+ "columnsFrom": [
+ "media_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "media_ips_ip_id_ips_id_fk": {
+ "name": "media_ips_ip_id_ips_id_fk",
+ "tableFrom": "media_ips",
+ "tableTo": "ips",
+ "columnsFrom": [
+ "ip_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "media_ips_media_id_ip_id_pk": {
+ "name": "media_ips_media_id_ip_id_pk",
+ "columns": [
+ "media_id",
+ "ip_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.media_projects": {
+ "name": "media_projects",
+ "schema": "",
+ "columns": {
+ "media_id": {
+ "name": "media_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "project_id": {
+ "name": "project_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "idx_media_projects_project_id_media_id": {
+ "name": "idx_media_projects_project_id_media_id",
+ "columns": [
+ {
+ "expression": "project_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "media_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "media_projects_media_id_media_id_fk": {
+ "name": "media_projects_media_id_media_id_fk",
+ "tableFrom": "media_projects",
+ "tableTo": "media",
+ "columnsFrom": [
+ "media_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "media_projects_project_id_projects_id_fk": {
+ "name": "media_projects_project_id_projects_id_fk",
+ "tableFrom": "media_projects",
+ "tableTo": "projects",
+ "columnsFrom": [
+ "project_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "media_projects_media_id_project_id_pk": {
+ "name": "media_projects_media_id_project_id_pk",
+ "columns": [
+ "media_id",
+ "project_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.media_regions": {
+ "name": "media_regions",
+ "schema": "",
+ "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
+ },
+ "kind": {
+ "name": "kind",
+ "type": "media_region_kind",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "x": {
+ "name": "x",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "y": {
+ "name": "y",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "width": {
+ "name": "width",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "height": {
+ "name": "height",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "source_modified_at": {
+ "name": "source_modified_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_width": {
+ "name": "source_width",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_height": {
+ "name": "source_height",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_revision": {
+ "name": "source_revision",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "region_revision": {
+ "name": "region_revision",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "label": {
+ "name": "label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "manual_reason": {
+ "name": "manual_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "detection_key": {
+ "name": "detection_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "detector": {
+ "name": "detector",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "detector_model": {
+ "name": "detector_model",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "detector_version": {
+ "name": "detector_version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "score": {
+ "name": "score",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "idx_media_regions_media_id": {
+ "name": "idx_media_regions_media_id",
+ "columns": [
+ {
+ "expression": "media_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "uq_media_regions_full_media_id": {
+ "name": "uq_media_regions_full_media_id",
+ "columns": [
+ {
+ "expression": "media_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"media_regions\".\"kind\" = 'full'",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "uq_media_regions_detection_key": {
+ "name": "uq_media_regions_detection_key",
+ "columns": [
+ {
+ "expression": "media_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "detection_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"media_regions\".\"detection_key\" IS NOT NULL",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "media_regions_media_id_media_id_fk": {
+ "name": "media_regions_media_id_media_id_fk",
+ "tableFrom": "media_regions",
+ "tableTo": "media",
+ "columnsFrom": [
+ "media_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "media_regions_bbox_by_kind": {
+ "name": "media_regions_bbox_by_kind",
+ "value": "(\n\t\t\t\t(\"media_regions\".\"kind\" = 'full' AND \"media_regions\".\"x\" IS NULL AND \"media_regions\".\"y\" IS NULL AND \"media_regions\".\"width\" IS NULL AND \"media_regions\".\"height\" IS NULL)\n\t\t\t\tOR\n\t\t\t\t(\"media_regions\".\"kind\" <> 'full' AND \"media_regions\".\"x\" IS NOT NULL AND \"media_regions\".\"y\" IS NOT NULL AND \"media_regions\".\"width\" IS NOT NULL AND \"media_regions\".\"height\" IS NOT NULL\n\t\t\t\t\tAND \"media_regions\".\"x\" >= 0 AND \"media_regions\".\"y\" >= 0 AND \"media_regions\".\"width\" > 0 AND \"media_regions\".\"height\" > 0\n\t\t\t\t\tAND \"media_regions\".\"x\" + \"media_regions\".\"width\" <= 1 AND \"media_regions\".\"y\" + \"media_regions\".\"height\" <= 1)\n\t\t\t)"
+ },
+ "media_regions_score_range": {
+ "name": "media_regions_score_range",
+ "value": "\"media_regions\".\"score\" IS NULL OR (\"media_regions\".\"score\" >= 0 AND \"media_regions\".\"score\" <= 1)"
+ },
+ "media_regions_source_dimensions_positive": {
+ "name": "media_regions_source_dimensions_positive",
+ "value": "\"media_regions\".\"source_width\" > 0 AND \"media_regions\".\"source_height\" > 0"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.media_relations": {
+ "name": "media_relations",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "parent_media_id": {
+ "name": "parent_media_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "child_media_id": {
+ "name": "child_media_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "relation_type": {
+ "name": "relation_type",
+ "type": "media_relation_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "order_index": {
+ "name": "order_index",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "source_region_id": {
+ "name": "source_region_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "derivation_key": {
+ "name": "derivation_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "idx_media_relations_child": {
+ "name": "idx_media_relations_child",
+ "columns": [
+ {
+ "expression": "child_media_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_media_relations_type": {
+ "name": "idx_media_relations_type",
+ "columns": [
+ {
+ "expression": "relation_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_media_relations_source_region": {
+ "name": "idx_media_relations_source_region",
+ "columns": [
+ {
+ "expression": "source_region_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "uq_media_relations_derivation_key": {
+ "name": "uq_media_relations_derivation_key",
+ "columns": [
+ {
+ "expression": "derivation_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"media_relations\".\"derivation_key\" IS NOT NULL",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "media_relations_parent_media_id_media_id_fk": {
+ "name": "media_relations_parent_media_id_media_id_fk",
+ "tableFrom": "media_relations",
+ "tableTo": "media",
+ "columnsFrom": [
+ "parent_media_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "media_relations_child_media_id_media_id_fk": {
+ "name": "media_relations_child_media_id_media_id_fk",
+ "tableFrom": "media_relations",
+ "tableTo": "media",
+ "columnsFrom": [
+ "child_media_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "media_relations_source_region_id_media_regions_id_fk": {
+ "name": "media_relations_source_region_id_media_regions_id_fk",
+ "tableFrom": "media_relations",
+ "tableTo": "media_regions",
+ "columnsFrom": [
+ "source_region_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "parent_child_type_unique": {
+ "name": "parent_child_type_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "parent_media_id",
+ "child_media_id",
+ "relation_type"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.media_sources": {
+ "name": "media_sources",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "type": {
+ "name": "type",
+ "type": "media_source_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "connection_info": {
+ "name": "connection_info",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.media_sync": {
+ "name": "media_sync",
+ "schema": "",
+ "columns": {
+ "media_id": {
+ "name": "media_id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "sync_status": {
+ "name": "sync_status",
+ "type": "media_sync_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'synced'"
+ },
+ "backup_urls": {
+ "name": "backup_urls",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'{}'"
+ },
+ "last_synced_at": {
+ "name": "last_synced_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sync_attempts": {
+ "name": "sync_attempts",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "default": 0
+ },
+ "last_error": {
+ "name": "last_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "media_sync_media_id_media_id_fk": {
+ "name": "media_sync_media_id_media_id_fk",
+ "tableFrom": "media_sync",
+ "tableTo": "media",
+ "columnsFrom": [
+ "media_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.media_tags": {
+ "name": "media_tags",
+ "schema": "",
+ "columns": {
+ "media_id": {
+ "name": "media_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "tag_id": {
+ "name": "tag_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "tag_type": {
+ "name": "tag_type",
+ "type": "tag_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'positive'"
+ },
+ "confidence": {
+ "name": "confidence",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "source": {
+ "name": "source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'manual'"
+ }
+ },
+ "indexes": {
+ "idx_media_tags_tag_id_tag_type_media_id": {
+ "name": "idx_media_tags_tag_id_tag_type_media_id",
+ "columns": [
+ {
+ "expression": "tag_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "tag_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "media_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "media_tags_media_id_media_id_fk": {
+ "name": "media_tags_media_id_media_id_fk",
+ "tableFrom": "media_tags",
+ "tableTo": "media",
+ "columnsFrom": [
+ "media_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "media_tags_tag_id_tags_id_fk": {
+ "name": "media_tags_tag_id_tags_id_fk",
+ "tableFrom": "media_tags",
+ "tableTo": "tags",
+ "columnsFrom": [
+ "tag_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "media_tags_media_id_tag_id_tag_type_pk": {
+ "name": "media_tags_media_id_tag_id_tag_type_pk",
+ "columns": [
+ "media_id",
+ "tag_id",
+ "tag_type"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.media_technical_info": {
+ "name": "media_technical_info",
+ "schema": "",
+ "columns": {
+ "media_id": {
+ "name": "media_id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "color_profile": {
+ "name": "color_profile",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "''"
+ },
+ "exif_data": {
+ "name": "exif_data",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'{}'"
+ },
+ "hash_md5": {
+ "name": "hash_md5",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "''"
+ },
+ "hash_perceptual": {
+ "name": "hash_perceptual",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "''"
+ },
+ "duration_seconds": {
+ "name": "duration_seconds",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "frame_rate": {
+ "name": "frame_rate",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "bitrate_kbps": {
+ "name": "bitrate_kbps",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "video_codec": {
+ "name": "video_codec",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "audio_codec": {
+ "name": "audio_codec",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "idx_media_technical_info_hash_md5": {
+ "name": "idx_media_technical_info_hash_md5",
+ "columns": [
+ {
+ "expression": "hash_md5",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "media_technical_info_media_id_media_id_fk": {
+ "name": "media_technical_info_media_id_media_id_fk",
+ "tableFrom": "media_technical_info",
+ "tableTo": "media",
+ "columnsFrom": [
+ "media_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.media_urls": {
+ "name": "media_urls",
+ "schema": "",
+ "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
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "idx_media_urls_media_id": {
+ "name": "idx_media_urls_media_id",
+ "columns": [
+ {
+ "expression": "media_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_media_urls_url": {
+ "name": "idx_media_urls_url",
+ "columns": [
+ {
+ "expression": "url",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_media_urls_media_id_url_unique": {
+ "name": "idx_media_urls_media_id_url_unique",
+ "columns": [
+ {
+ "expression": "media_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "url",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "media_urls_media_id_media_id_fk": {
+ "name": "media_urls_media_id_media_id_fk",
+ "tableFrom": "media_urls",
+ "tableTo": "media",
+ "columnsFrom": [
+ "media_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.media": {
+ "name": "media",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "source_id": {
+ "name": "source_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "file_path": {
+ "name": "file_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "file_name": {
+ "name": "file_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "media_type": {
+ "name": "media_type",
+ "type": "media_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "width": {
+ "name": "width",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "height": {
+ "name": "height",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "file_size": {
+ "name": "file_size",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "modified_at": {
+ "name": "modified_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "indexed_at": {
+ "name": "indexed_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "status": {
+ "name": "status",
+ "type": "media_organization_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'active'"
+ }
+ },
+ "indexes": {
+ "idx_media_source_id": {
+ "name": "idx_media_source_id",
+ "columns": [
+ {
+ "expression": "source_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_media_file_size": {
+ "name": "idx_media_file_size",
+ "columns": [
+ {
+ "expression": "file_size",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_media_file_name": {
+ "name": "idx_media_file_name",
+ "columns": [
+ {
+ "expression": "file_name",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_media_created_at": {
+ "name": "idx_media_created_at",
+ "columns": [
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_media_description": {
+ "name": "idx_media_description",
+ "columns": [
+ {
+ "expression": "description",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "media_source_id_media_sources_id_fk": {
+ "name": "media_source_id_media_sources_id_fk",
+ "tableFrom": "media",
+ "tableTo": "media_sources",
+ "columnsFrom": [
+ "source_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "source_id_file_path_unique": {
+ "name": "source_id_file_path_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "source_id",
+ "file_path"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.presets": {
+ "name": "presets",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "sort": {
+ "name": "sort",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "order": {
+ "name": "order",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "mode": {
+ "name": "mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "presets_name_unique": {
+ "name": "presets_name_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "name"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.projects": {
+ "name": "projects",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "''"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ },
+ "archived_at": {
+ "name": "archived_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "idx_projects_name": {
+ "name": "idx_projects_name",
+ "columns": [
+ {
+ "expression": "name",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "projects_name_unique": {
+ "name": "projects_name_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "name"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.similar_media": {
+ "name": "similar_media",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "media1_id": {
+ "name": "media1_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "media2_id": {
+ "name": "media2_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "similarity_score": {
+ "name": "similarity_score",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "default": 0
+ },
+ "algorithm": {
+ "name": "algorithm",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'perceptual'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "idx_similar_media_score": {
+ "name": "idx_similar_media_score",
+ "columns": [
+ {
+ "expression": "similarity_score",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "similar_media_media1_id_media_id_fk": {
+ "name": "similar_media_media1_id_media_id_fk",
+ "tableFrom": "similar_media",
+ "tableTo": "media",
+ "columnsFrom": [
+ "media1_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "similar_media_media2_id_media_id_fk": {
+ "name": "similar_media_media2_id_media_id_fk",
+ "tableFrom": "similar_media",
+ "tableTo": "media",
+ "columnsFrom": [
+ "media2_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "media1Id_media2Id_algorithm_unique": {
+ "name": "media1Id_media2Id_algorithm_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "media1_id",
+ "media2_id",
+ "algorithm"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.tags": {
+ "name": "tags",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "attribute": {
+ "name": "attribute",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "color": {
+ "name": "color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "source": {
+ "name": "source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'manual'"
+ },
+ "author_id": {
+ "name": "author_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "idx_tags_author_id": {
+ "name": "idx_tags_author_id",
+ "columns": [
+ {
+ "expression": "author_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "tags_author_id_authors_id_fk": {
+ "name": "tags_author_id_authors_id_fk",
+ "tableFrom": "tags",
+ "tableTo": "authors",
+ "columnsFrom": [
+ "author_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "tags_name_unique": {
+ "name": "tags_name_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "name"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.users": {
+ "name": "users",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "users_email_unique": {
+ "name": "users_email_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "email"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.view_history": {
+ "name": "view_history",
+ "schema": "",
+ "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
+ },
+ "viewed_at": {
+ "name": "viewed_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "''"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "view_history_media_id_media_id_fk": {
+ "name": "view_history_media_id_media_id_fk",
+ "tableFrom": "view_history",
+ "tableTo": "media",
+ "columnsFrom": [
+ "media_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {
+ "public.author_platform": {
+ "name": "author_platform",
+ "schema": "public",
+ "values": [
+ "twitter",
+ "pixiv-fanbox",
+ "danbooru"
+ ]
+ },
+ "public.job_status": {
+ "name": "job_status",
+ "schema": "public",
+ "values": [
+ "pending",
+ "in_progress",
+ "completed",
+ "failed",
+ "cancelled"
+ ]
+ },
+ "public.media_organization_status": {
+ "name": "media_organization_status",
+ "schema": "public",
+ "values": [
+ "active",
+ "archived",
+ "deleted"
+ ]
+ },
+ "public.media_region_kind": {
+ "name": "media_region_kind",
+ "schema": "public",
+ "values": [
+ "full",
+ "person",
+ "manual"
+ ]
+ },
+ "public.media_relation_type": {
+ "name": "media_relation_type",
+ "schema": "public",
+ "values": [
+ "variant",
+ "version",
+ "page",
+ "derivative",
+ "edit",
+ "source"
+ ]
+ },
+ "public.media_source_type": {
+ "name": "media_source_type",
+ "schema": "public",
+ "values": [
+ "local",
+ "sftp",
+ "s3"
+ ]
+ },
+ "public.media_sync_status": {
+ "name": "media_sync_status",
+ "schema": "public",
+ "values": [
+ "synced",
+ "pending",
+ "failed"
+ ]
+ },
+ "public.media_type": {
+ "name": "media_type",
+ "schema": "public",
+ "values": [
+ "image",
+ "video",
+ "audio"
+ ]
+ },
+ "public.tag_type": {
+ "name": "tag_type",
+ "schema": "public",
+ "values": [
+ "positive",
+ "negative"
+ ]
+ }
+ },
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
diff --git a/apps/server/drizzle/meta/_journal.json b/apps/server/drizzle/meta/_journal.json
index 7a100fc3d..407def906 100644
--- a/apps/server/drizzle/meta/_journal.json
+++ b/apps/server/drizzle/meta/_journal.json
@@ -141,6 +141,13 @@
"when": 1784389747874,
"tag": "0019_purple_devos",
"breakpoints": true
+ },
+ {
+ "idx": 20,
+ "version": "7",
+ "when": 1784815653388,
+ "tag": "0020_bizarre_otto_octavius",
+ "breakpoints": true
}
]
-}
+}
\ No newline at end of file
diff --git a/apps/server/nitro.config.ts b/apps/server/nitro.config.ts
index e32fe49c4..16f51c3f7 100644
--- a/apps/server/nitro.config.ts
+++ b/apps/server/nitro.config.ts
@@ -28,24 +28,54 @@ export default defineNitroConfig({
fs.existsSync(pgliteLocalPath) ? pgliteLocalPath : pgliteRootPath,
);
const pgliteDistPath = path.join(pglitePkgPath, "dist");
+ const pgvectorLocalPath = path.resolve(
+ __dirname,
+ "node_modules/@electric-sql/pglite-pgvector/package.json",
+ );
+ const pgvectorRootPath = path.resolve(
+ __dirname,
+ "../../node_modules/@electric-sql/pglite-pgvector/package.json",
+ );
+ const pgvectorPkgPath = path.dirname(
+ fs.existsSync(pgvectorLocalPath) ? pgvectorLocalPath : pgvectorRootPath,
+ );
- const assetsToCopy = ["pglite.data", "pglite.wasm"];
+ const assetsToCopy = [
+ {
+ source: path.join(pgliteDistPath, "pglite.data"),
+ destination: path.join(libsDir, "pglite.data"),
+ },
+ {
+ source: path.join(pgliteDistPath, "pglite.wasm"),
+ destination: path.join(libsDir, "pglite.wasm"),
+ },
+ {
+ source: path.join(pgvectorPkgPath, "dist", "vector.tar.gz"),
+ destination: path.join(libsDir, "vector.tar.gz"),
+ },
+ ];
- for (const asset of assetsToCopy) {
- const source = path.join(pgliteDistPath, asset);
- const destination = path.join(libsDir, asset);
-
- if (fs.existsSync(source)) {
- if (!fs.existsSync(libsDir)) {
- fs.mkdirSync(libsDir, { recursive: true });
- }
- fs.copyFileSync(source, destination);
- console.log(`[Nitro] Successfully copied ${asset} to ${destination}`);
- } else {
- console.warn(`[Nitro] Warning: ${asset} not found at ${source}`);
- }
+ fs.mkdirSync(libsDir, { recursive: true });
+ for (const asset of assetsToCopy) {
+ if (!fs.existsSync(asset.source) || fs.statSync(asset.source).size === 0) {
+ throw new Error(
+ `Required PGlite runtime asset is missing or empty: ${asset.source}`,
+ );
+ }
+ fs.copyFileSync(asset.source, asset.destination);
+ console.log(
+ `[Nitro] Successfully copied ${path.basename(asset.source)} to ${asset.destination}`,
+ );
}
+ const migrationsSource = path.join(__dirname, "drizzle");
+ const migrationsDestination = path.join(serverDir, "drizzle");
+ const journalSource = path.join(migrationsSource, "meta", "_journal.json");
+ if (!fs.existsSync(journalSource) || fs.statSync(journalSource).size === 0) {
+ throw new Error(`Drizzle migration journal is missing or empty: ${journalSource}`);
+ }
+ fs.cpSync(migrationsSource, migrationsDestination, { recursive: true });
+
// Copy yt-dlp binary for bundled youtube-dl-exec
const ytDlpLocalPath = path.resolve(__dirname, "node_modules/youtube-dl-exec/bin/yt-dlp");
const ytDlpRootPath = path.resolve(
diff --git a/apps/server/package.json b/apps/server/package.json
index 592308bfe..69466f132 100644
--- a/apps/server/package.json
+++ b/apps/server/package.json
@@ -17,6 +17,8 @@
"db:drop": "drizzle-kit drop",
"db:dump": "bun scripts/dump-db.ts",
"db:restore": "bun scripts/restore-db.ts",
+ "db:validate-rehearsal": "bun scripts/validate-postgres-rehearsal.ts",
+ "db:set-jobs-logged": "bun scripts/set-jobs-logged.ts",
"ccip:migrate-from-lancedb": "bun scripts/migrate-ccip-lancedb.ts",
"lancedb:sync-slow": "bun scripts/sync-lancedb-slow.ts",
"measure:dev-startup": "bun scripts/measure-dev-startup.ts",
@@ -27,6 +29,7 @@
"test": "bun run test:unit && bun run test:integration && bun run test:e2e",
"test:unit": "vp test run -c vitest.unit.config.ts",
"test:integration": "vp test run -c vitest.integration.config.ts",
+ "test:pglite-bundle": "bun scripts/verify-pglite-bundle.ts",
"test:e2e": "bun scripts/run-e2e.ts --mode=all",
"test:e2e:dev": "bun scripts/run-e2e.ts --mode=dev",
"test:e2e:production": "bun scripts/run-e2e.ts --mode=production",
diff --git a/apps/server/public/openapi.json b/apps/server/public/openapi.json
index f5a11829b..f182dced1 100644
--- a/apps/server/public/openapi.json
+++ b/apps/server/public/openapi.json
@@ -67,7 +67,9 @@
"operationId": "sources.list",
"summary": "メディアソース一覧取得",
"description": "登録されているすべてのメディアソース(ローカル、SFTP、S3等)を取得します。",
- "tags": ["Media Sources"],
+ "tags": [
+ "Media Sources"
+ ],
"responses": {
"200": {
"description": "OK",
@@ -92,7 +94,9 @@
"operationId": "sources.get",
"summary": "メディアソース詳細取得",
"description": "UUIDを指定して特定のメディアソースの情報を取得します。",
- "tags": ["Media Sources"],
+ "tags": [
+ "Media Sources"
+ ],
"responses": {
"200": {
"description": "OK",
@@ -117,7 +121,9 @@
"operationId": "sources.create",
"summary": "メディアソース作成",
"description": "新しいメディアソースを登録します。",
- "tags": ["Media Sources"],
+ "tags": [
+ "Media Sources"
+ ],
"responses": {
"200": {
"description": "OK",
@@ -142,7 +148,9 @@
"operationId": "sources.update",
"summary": "メディアソース更新",
"description": "既存のメディアソースの設定を更新します。",
- "tags": ["Media Sources"],
+ "tags": [
+ "Media Sources"
+ ],
"responses": {
"200": {
"description": "OK",
@@ -167,7 +175,9 @@
"operationId": "sources.delete",
"summary": "メディアソース削除",
"description": "メディアソースを削除し、監視を停止します。",
- "tags": ["Media Sources"],
+ "tags": [
+ "Media Sources"
+ ],
"responses": {
"200": {
"description": "OK",
@@ -191,7 +201,9 @@
"post": {
"operationId": "sources.sync",
"summary": "sync",
- "tags": ["Media Sources"],
+ "tags": [
+ "Media Sources"
+ ],
"responses": {
"200": {
"description": "OK",
@@ -215,7 +227,9 @@
"post": {
"operationId": "sources.dump",
"summary": "dump",
- "tags": ["Media Sources"],
+ "tags": [
+ "Media Sources"
+ ],
"responses": {
"200": {
"description": "OK",
@@ -239,7 +253,9 @@
"post": {
"operationId": "sources.restore",
"summary": "restore",
- "tags": ["Media Sources"],
+ "tags": [
+ "Media Sources"
+ ],
"responses": {
"200": {
"description": "OK",
@@ -263,7 +279,9 @@
"post": {
"operationId": "sources.importZip",
"summary": "importZip",
- "tags": ["Media Sources"],
+ "tags": [
+ "Media Sources"
+ ],
"responses": {
"200": {
"description": "OK",
@@ -287,7 +305,9 @@
"post": {
"operationId": "sources.importNdjson",
"summary": "importNdjson",
- "tags": ["Media Sources"],
+ "tags": [
+ "Media Sources"
+ ],
"responses": {
"200": {
"description": "OK",
@@ -311,7 +331,9 @@
"post": {
"operationId": "sources.importLanceDB",
"summary": "importLanceDB",
- "tags": ["Media Sources"],
+ "tags": [
+ "Media Sources"
+ ],
"responses": {
"200": {
"description": "OK",
@@ -336,7 +358,9 @@
"operationId": "sources.status",
"summary": "メディアソースの状態取得",
"description": "スキャン進捗やファイル数などの統計情報を取得します。",
- "tags": ["Media Sources"],
+ "tags": [
+ "Media Sources"
+ ],
"responses": {
"200": {
"description": "OK",
@@ -360,7 +384,9 @@
"post": {
"operationId": "sources.events",
"summary": "events",
- "tags": ["Media Sources"],
+ "tags": [
+ "Media Sources"
+ ],
"responses": {
"200": {
"description": "OK",
@@ -382,7 +408,9 @@
"type": "number"
}
},
- "required": ["event"]
+ "required": [
+ "event"
+ ]
},
{
"type": "object",
@@ -398,7 +426,9 @@
"type": "number"
}
},
- "required": ["event"]
+ "required": [
+ "event"
+ ]
},
{
"type": "object",
@@ -414,7 +444,9 @@
"type": "number"
}
},
- "required": ["event"]
+ "required": [
+ "event"
+ ]
}
]
}
@@ -428,7 +460,9 @@
"post": {
"operationId": "tags.list",
"summary": "list",
- "tags": ["Tags"],
+ "tags": [
+ "Tags"
+ ],
"responses": {
"200": {
"description": "OK",
@@ -452,7 +486,9 @@
"post": {
"operationId": "tags.get",
"summary": "get",
- "tags": ["Tags"],
+ "tags": [
+ "Tags"
+ ],
"responses": {
"200": {
"description": "OK",
@@ -476,7 +512,9 @@
"post": {
"operationId": "tags.create",
"summary": "create",
- "tags": ["Tags"],
+ "tags": [
+ "Tags"
+ ],
"responses": {
"200": {
"description": "OK",
@@ -500,7 +538,9 @@
"post": {
"operationId": "tags.update",
"summary": "update",
- "tags": ["Tags"],
+ "tags": [
+ "Tags"
+ ],
"responses": {
"200": {
"description": "OK",
@@ -524,7 +564,9 @@
"post": {
"operationId": "tags.delete",
"summary": "delete",
- "tags": ["Tags"],
+ "tags": [
+ "Tags"
+ ],
"responses": {
"200": {
"description": "OK",
@@ -549,7 +591,9 @@
"operationId": "media.search",
"summary": "メディア検索",
"description": "タグ、プロジェクト、キャラクターなどの条件でメディアを検索します。",
- "tags": ["Media"],
+ "tags": [
+ "Media"
+ ],
"responses": {
"200": {
"description": "OK",
@@ -573,7 +617,9 @@
"post": {
"operationId": "media.searchSimilar",
"summary": "searchSimilar",
- "tags": ["Media"],
+ "tags": [
+ "Media"
+ ],
"responses": {
"200": {
"description": "OK",
@@ -597,7 +643,9 @@
"post": {
"operationId": "media.get",
"summary": "get",
- "tags": ["Media"],
+ "tags": [
+ "Media"
+ ],
"responses": {
"200": {
"description": "OK",
@@ -621,7 +669,9 @@
"post": {
"operationId": "media.getDetails",
"summary": "getDetails",
- "tags": ["Media"],
+ "tags": [
+ "Media"
+ ],
"responses": {
"200": {
"description": "OK",
@@ -645,7 +695,9 @@
"post": {
"operationId": "media.findDuplicates",
"summary": "findDuplicates",
- "tags": ["Media"],
+ "tags": [
+ "Media"
+ ],
"responses": {
"200": {
"description": "OK",
@@ -669,7 +721,9 @@
"post": {
"operationId": "media.getContent",
"summary": "getContent",
- "tags": ["Media"],
+ "tags": [
+ "Media"
+ ],
"responses": {
"200": {
"description": "OK",
@@ -693,7 +747,9 @@
"post": {
"operationId": "media.getTags",
"summary": "getTags",
- "tags": ["Media"],
+ "tags": [
+ "Media"
+ ],
"responses": {
"200": {
"description": "OK",
@@ -717,7 +773,9 @@
"post": {
"operationId": "media.update",
"summary": "update",
- "tags": ["Media"],
+ "tags": [
+ "Media"
+ ],
"responses": {
"200": {
"description": "OK",
@@ -741,7 +799,9 @@
"post": {
"operationId": "media.sync",
"summary": "sync",
- "tags": ["Media"],
+ "tags": [
+ "Media"
+ ],
"responses": {
"200": {
"description": "OK",
@@ -765,7 +825,9 @@
"post": {
"operationId": "media.delete",
"summary": "delete",
- "tags": ["Media"],
+ "tags": [
+ "Media"
+ ],
"responses": {
"200": {
"description": "OK",
@@ -789,7 +851,9 @@
"post": {
"operationId": "media.copy",
"summary": "copy",
- "tags": ["Media"],
+ "tags": [
+ "Media"
+ ],
"responses": {
"200": {
"description": "OK",
@@ -813,7 +877,9 @@
"post": {
"operationId": "media.move",
"summary": "move",
- "tags": ["Media"],
+ "tags": [
+ "Media"
+ ],
"responses": {
"200": {
"description": "OK",
@@ -837,7 +903,9 @@
"post": {
"operationId": "media.upload",
"summary": "upload",
- "tags": ["Media"],
+ "tags": [
+ "Media"
+ ],
"responses": {
"200": {
"description": "OK",
@@ -861,7 +929,9 @@
"post": {
"operationId": "media.bulkEdit",
"summary": "bulkEdit",
- "tags": ["Media"],
+ "tags": [
+ "Media"
+ ],
"responses": {
"200": {
"description": "OK",
@@ -885,7 +955,9 @@
"post": {
"operationId": "media.bulkDelete",
"summary": "bulkDelete",
- "tags": ["Media"],
+ "tags": [
+ "Media"
+ ],
"responses": {
"200": {
"description": "OK",
@@ -909,7 +981,9 @@
"post": {
"operationId": "media.bulkMove",
"summary": "bulkMove",
- "tags": ["Media"],
+ "tags": [
+ "Media"
+ ],
"responses": {
"200": {
"description": "OK",
@@ -933,7 +1007,9 @@
"post": {
"operationId": "media.bulkTag",
"summary": "bulkTag",
- "tags": ["Media"],
+ "tags": [
+ "Media"
+ ],
"responses": {
"200": {
"description": "OK",
@@ -957,7 +1033,9 @@
"post": {
"operationId": "media.bulkCopyToSource",
"summary": "bulkCopyToSource",
- "tags": ["Media"],
+ "tags": [
+ "Media"
+ ],
"responses": {
"200": {
"description": "OK",
@@ -981,7 +1059,124 @@
"post": {
"operationId": "media.bulkMoveToSource",
"summary": "bulkMoveToSource",
- "tags": ["Media"],
+ "tags": [
+ "Media"
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "anyOf": [
+ {},
+ {
+ "not": {}
+ }
+ ]
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/mediaRegions/list": {
+ "post": {
+ "operationId": "mediaRegions.list",
+ "summary": "list",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "anyOf": [
+ {},
+ {
+ "not": {}
+ }
+ ]
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/mediaRegions/createManual": {
+ "post": {
+ "operationId": "mediaRegions.createManual",
+ "summary": "createManual",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "anyOf": [
+ {},
+ {
+ "not": {}
+ }
+ ]
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/mediaRegions/update": {
+ "post": {
+ "operationId": "mediaRegions.update",
+ "summary": "update",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "anyOf": [
+ {},
+ {
+ "not": {}
+ }
+ ]
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/mediaRegions/delete": {
+ "post": {
+ "operationId": "mediaRegions.delete",
+ "summary": "delete",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "anyOf": [
+ {},
+ {
+ "not": {}
+ }
+ ]
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/mediaRegions/materialize": {
+ "post": {
+ "operationId": "mediaRegions.materialize",
+ "summary": "materialize",
"responses": {
"200": {
"description": "OK",
@@ -1005,7 +1200,9 @@
"post": {
"operationId": "categories.list",
"summary": "list",
- "tags": ["Categories"],
+ "tags": [
+ "Categories"
+ ],
"responses": {
"200": {
"description": "OK",
@@ -1029,7 +1226,9 @@
"post": {
"operationId": "categories.get",
"summary": "get",
- "tags": ["Categories"],
+ "tags": [
+ "Categories"
+ ],
"responses": {
"200": {
"description": "OK",
@@ -1053,7 +1252,9 @@
"post": {
"operationId": "categories.create",
"summary": "create",
- "tags": ["Categories"],
+ "tags": [
+ "Categories"
+ ],
"responses": {
"200": {
"description": "OK",
@@ -1077,7 +1278,9 @@
"post": {
"operationId": "categories.update",
"summary": "update",
- "tags": ["Categories"],
+ "tags": [
+ "Categories"
+ ],
"responses": {
"200": {
"description": "OK",
@@ -1101,7 +1304,9 @@
"post": {
"operationId": "categories.delete",
"summary": "delete",
- "tags": ["Categories"],
+ "tags": [
+ "Categories"
+ ],
"responses": {
"200": {
"description": "OK",
@@ -1125,7 +1330,9 @@
"post": {
"operationId": "projects.list",
"summary": "list",
- "tags": ["Projects"],
+ "tags": [
+ "Projects"
+ ],
"responses": {
"200": {
"description": "OK",
@@ -1149,7 +1356,9 @@
"post": {
"operationId": "projects.get",
"summary": "get",
- "tags": ["Projects"],
+ "tags": [
+ "Projects"
+ ],
"responses": {
"200": {
"description": "OK",
@@ -1173,7 +1382,9 @@
"post": {
"operationId": "projects.create",
"summary": "create",
- "tags": ["Projects"],
+ "tags": [
+ "Projects"
+ ],
"responses": {
"200": {
"description": "OK",
@@ -1197,7 +1408,9 @@
"post": {
"operationId": "projects.update",
"summary": "update",
- "tags": ["Projects"],
+ "tags": [
+ "Projects"
+ ],
"responses": {
"200": {
"description": "OK",
@@ -1221,7 +1434,9 @@
"post": {
"operationId": "projects.delete",
"summary": "delete",
- "tags": ["Projects"],
+ "tags": [
+ "Projects"
+ ],
"responses": {
"200": {
"description": "OK",
@@ -1245,7 +1460,9 @@
"post": {
"operationId": "projects.listForMedia",
"summary": "listForMedia",
- "tags": ["Projects"],
+ "tags": [
+ "Projects"
+ ],
"responses": {
"200": {
"description": "OK",
@@ -1269,7 +1486,9 @@
"post": {
"operationId": "projects.addToMedia",
"summary": "addToMedia",
- "tags": ["Projects"],
+ "tags": [
+ "Projects"
+ ],
"responses": {
"200": {
"description": "OK",
@@ -1293,7 +1512,9 @@
"post": {
"operationId": "projects.removeFromMedia",
"summary": "removeFromMedia",
- "tags": ["Projects"],
+ "tags": [
+ "Projects"
+ ],
"responses": {
"200": {
"description": "OK",
@@ -1317,7 +1538,9 @@
"post": {
"operationId": "characters.list",
"summary": "list",
- "tags": ["Characters"],
+ "tags": [
+ "Characters"
+ ],
"responses": {
"200": {
"description": "OK",
@@ -1341,7 +1564,9 @@
"post": {
"operationId": "characters.get",
"summary": "get",
- "tags": ["Characters"],
+ "tags": [
+ "Characters"
+ ],
"responses": {
"200": {
"description": "OK",
@@ -1365,7 +1590,9 @@
"post": {
"operationId": "characters.create",
"summary": "create",
- "tags": ["Characters"],
+ "tags": [
+ "Characters"
+ ],
"responses": {
"200": {
"description": "OK",
@@ -1389,7 +1616,9 @@
"post": {
"operationId": "characters.update",
"summary": "update",
- "tags": ["Characters"],
+ "tags": [
+ "Characters"
+ ],
"responses": {
"200": {
"description": "OK",
@@ -1413,7 +1642,9 @@
"post": {
"operationId": "characters.delete",
"summary": "delete",
- "tags": ["Characters"],
+ "tags": [
+ "Characters"
+ ],
"responses": {
"200": {
"description": "OK",
@@ -1437,7 +1668,9 @@
"post": {
"operationId": "characters.listForMedia",
"summary": "listForMedia",
- "tags": ["Characters"],
+ "tags": [
+ "Characters"
+ ],
"responses": {
"200": {
"description": "OK",
@@ -1461,7 +1694,9 @@
"post": {
"operationId": "characters.addToMedia",
"summary": "addToMedia",
- "tags": ["Characters"],
+ "tags": [
+ "Characters"
+ ],
"responses": {
"200": {
"description": "OK",
@@ -1485,7 +1720,9 @@
"post": {
"operationId": "characters.removeFromMedia",
"summary": "removeFromMedia",
- "tags": ["Characters"],
+ "tags": [
+ "Characters"
+ ],
"responses": {
"200": {
"description": "OK",
@@ -1509,7 +1746,9 @@
"post": {
"operationId": "ips.list",
"summary": "list",
- "tags": ["IPs"],
+ "tags": [
+ "IPs"
+ ],
"responses": {
"200": {
"description": "OK",
@@ -1533,7 +1772,9 @@
"post": {
"operationId": "ips.get",
"summary": "get",
- "tags": ["IPs"],
+ "tags": [
+ "IPs"
+ ],
"responses": {
"200": {
"description": "OK",
@@ -1557,7 +1798,9 @@
"post": {
"operationId": "ips.create",
"summary": "create",
- "tags": ["IPs"],
+ "tags": [
+ "IPs"
+ ],
"responses": {
"200": {
"description": "OK",
@@ -1581,7 +1824,9 @@
"post": {
"operationId": "ips.update",
"summary": "update",
- "tags": ["IPs"],
+ "tags": [
+ "IPs"
+ ],
"responses": {
"200": {
"description": "OK",
@@ -1605,7 +1850,9 @@
"post": {
"operationId": "ips.delete",
"summary": "delete",
- "tags": ["IPs"],
+ "tags": [
+ "IPs"
+ ],
"responses": {
"200": {
"description": "OK",
@@ -1629,7 +1876,9 @@
"post": {
"operationId": "ips.listForMedia",
"summary": "listForMedia",
- "tags": ["IPs"],
+ "tags": [
+ "IPs"
+ ],
"responses": {
"200": {
"description": "OK",
@@ -1653,7 +1902,9 @@
"post": {
"operationId": "ips.addToMedia",
"summary": "addToMedia",
- "tags": ["IPs"],
+ "tags": [
+ "IPs"
+ ],
"responses": {
"200": {
"description": "OK",
@@ -1677,7 +1928,9 @@
"post": {
"operationId": "ips.removeFromMedia",
"summary": "removeFromMedia",
- "tags": ["IPs"],
+ "tags": [
+ "IPs"
+ ],
"responses": {
"200": {
"description": "OK",
@@ -1701,7 +1954,9 @@
"post": {
"operationId": "thumbnails.generate",
"summary": "generate",
- "tags": ["Thumbnails"],
+ "tags": [
+ "Thumbnails"
+ ],
"responses": {
"200": {
"description": "OK",
@@ -1725,7 +1980,9 @@
"post": {
"operationId": "thumbnails.clear",
"summary": "clear",
- "tags": ["Thumbnails"],
+ "tags": [
+ "Thumbnails"
+ ],
"responses": {
"200": {
"description": "OK",
@@ -1749,7 +2006,9 @@
"post": {
"operationId": "downloads.start",
"summary": "start",
- "tags": ["Downloads"],
+ "tags": [
+ "Downloads"
+ ],
"responses": {
"200": {
"description": "OK",
@@ -1773,7 +2032,9 @@
"post": {
"operationId": "directories.list",
"summary": "list",
- "tags": ["Directories"],
+ "tags": [
+ "Directories"
+ ],
"responses": {
"200": {
"description": "OK",
@@ -1797,7 +2058,9 @@
"post": {
"operationId": "directories.create",
"summary": "create",
- "tags": ["Directories"],
+ "tags": [
+ "Directories"
+ ],
"responses": {
"200": {
"description": "OK",
@@ -1821,7 +2084,9 @@
"post": {
"operationId": "directories.delete",
"summary": "delete",
- "tags": ["Directories"],
+ "tags": [
+ "Directories"
+ ],
"responses": {
"200": {
"description": "OK",
@@ -1845,7 +2110,9 @@
"post": {
"operationId": "directories.rename",
"summary": "rename",
- "tags": ["Directories"],
+ "tags": [
+ "Directories"
+ ],
"responses": {
"200": {
"description": "OK",
@@ -1870,7 +2137,35 @@
"operationId": "ai.tag",
"summary": "AI自動タグ付け",
"description": "画像を解析して、関連するタグ(DeepDanbooru等)を自動生成します。",
- "tags": ["AI"],
+ "tags": [
+ "AI"
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "anyOf": [
+ {},
+ {
+ "not": {}
+ }
+ ]
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/ai/tagOppaiOracle": {
+ "post": {
+ "operationId": "ai.tagOppaiOracle",
+ "summary": "tagOppaiOracle",
+ "tags": [
+ "AI"
+ ],
"responses": {
"200": {
"description": "OK",
@@ -1894,7 +2189,9 @@
"post": {
"operationId": "ai.ccipFeature",
"summary": "ccipFeature",
- "tags": ["AI"],
+ "tags": [
+ "AI"
+ ],
"responses": {
"200": {
"description": "OK",
@@ -1918,7 +2215,9 @@
"post": {
"operationId": "ai.ccipDifference",
"summary": "ccipDifference",
- "tags": ["AI"],
+ "tags": [
+ "AI"
+ ],
"responses": {
"200": {
"description": "OK",
@@ -1942,7 +2241,9 @@
"post": {
"operationId": "ai.ccipDistances",
"summary": "ccipDistances",
- "tags": ["AI"],
+ "tags": [
+ "AI"
+ ],
"responses": {
"200": {
"description": "OK",
@@ -1966,7 +2267,9 @@
"post": {
"operationId": "ai.scanBatchTaggingTargets",
"summary": "scanBatchTaggingTargets",
- "tags": ["AI"],
+ "tags": [
+ "AI"
+ ],
"responses": {
"200": {
"description": "OK",
@@ -1990,7 +2293,9 @@
"post": {
"operationId": "ai.startBatchTagging",
"summary": "startBatchTagging",
- "tags": ["AI"],
+ "tags": [
+ "AI"
+ ],
"responses": {
"200": {
"description": "OK",
@@ -2014,7 +2319,9 @@
"post": {
"operationId": "ai.ccipVectorStatus",
"summary": "ccipVectorStatus",
- "tags": ["AI"],
+ "tags": [
+ "AI"
+ ],
"responses": {
"200": {
"description": "OK",
@@ -2038,7 +2345,9 @@
"post": {
"operationId": "ai.startCcipExtraction",
"summary": "startCcipExtraction",
- "tags": ["AI"],
+ "tags": [
+ "AI"
+ ],
"responses": {
"200": {
"description": "OK",
@@ -2062,7 +2371,9 @@
"post": {
"operationId": "ai.scanBatchCcipTargets",
"summary": "scanBatchCcipTargets",
- "tags": ["AI"],
+ "tags": [
+ "AI"
+ ],
"responses": {
"200": {
"description": "OK",
@@ -2086,7 +2397,9 @@
"post": {
"operationId": "ai.startBatchCcipExtraction",
"summary": "startBatchCcipExtraction",
- "tags": ["AI"],
+ "tags": [
+ "AI"
+ ],
"responses": {
"200": {
"description": "OK",
@@ -2110,7 +2423,9 @@
"post": {
"operationId": "ai.detectAndCropCharacters",
"summary": "detectAndCropCharacters",
- "tags": ["AI"],
+ "tags": [
+ "AI"
+ ],
"responses": {
"200": {
"description": "OK",
@@ -2157,7 +2472,9 @@
"post": {
"operationId": "utils.fetchUrl",
"summary": "fetchUrl",
- "tags": ["Utilities"],
+ "tags": [
+ "Utilities"
+ ],
"responses": {
"200": {
"description": "OK",
@@ -2294,7 +2611,9 @@
"type": "number"
}
},
- "required": ["event"]
+ "required": [
+ "event"
+ ]
},
{
"type": "object",
@@ -2310,7 +2629,9 @@
"type": "number"
}
},
- "required": ["event"]
+ "required": [
+ "event"
+ ]
},
{
"type": "object",
@@ -2326,7 +2647,32 @@
"type": "number"
}
},
- "required": ["event"]
+ "required": [
+ "event"
+ ]
+ }
+ ]
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/jobs/get": {
+ "post": {
+ "operationId": "jobs.get",
+ "summary": "get",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "anyOf": [
+ {},
+ {
+ "not": {}
}
]
}
@@ -2361,7 +2707,9 @@
"type": "number"
}
},
- "required": ["event"]
+ "required": [
+ "event"
+ ]
},
{
"type": "object",
@@ -2377,7 +2725,9 @@
"type": "number"
}
},
- "required": ["event"]
+ "required": [
+ "event"
+ ]
},
{
"type": "object",
@@ -2393,7 +2743,9 @@
"type": "number"
}
},
- "required": ["event"]
+ "required": [
+ "event"
+ ]
}
]
}
@@ -2588,4 +2940,4 @@
}
}
}
-}
+}
\ No newline at end of file
diff --git a/apps/server/scripts/dump-db.ts b/apps/server/scripts/dump-db.ts
index 18dca4735..23d1dd120 100644
--- a/apps/server/scripts/dump-db.ts
+++ b/apps/server/scripts/dump-db.ts
@@ -1,49 +1,121 @@
///
-import { $ } from "bun";
-import { mkdir } from "node:fs/promises";
+import { access, mkdir, rename, rm, stat } from "node:fs/promises";
import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { logger } from "../src/infrastructure/logger";
-// Load environment variables
-const DB_USER = process.env.DB_USER || "postgres";
-const DB_DATABASE = process.env.DB_DATABASE || "solid-imager";
-const CONTAINER_NAME = "solid-imager-db-1"; // Assuming default naming convention, or retrieve from docker-compose
-
-// Backup configuration
-const BACKUP_DIR = "backups";
-const TIMESTAMP = new Date().toISOString().replace(/[:.]/g, "-");
-const FILENAME = `backup-${TIMESTAMP}.sql`;
-const FILEPATH = path.join(BACKUP_DIR, FILENAME);
-
-console.log("📦 Starting database backup...");
-
-try {
- // Ensure backup directory exists
- await mkdir(BACKUP_DIR, { recursive: true });
-
- // Determine container name dynamically if possible, or use a consistent name
- // Using 'docker compose ps' to find the container name for service 'db'
- const containerNameOutput = await $`docker compose ps -q db`.text();
- const containerId = containerNameOutput.trim();
-
- if (!containerId) {
- console.error("❌ Could not find running database container. Is Docker Compose up?");
- process.exit(1);
- }
-
- console.log(`🐳 Found database container ID: ${containerId}`);
- console.log(`📂 Saving backup to: ${FILEPATH}`);
-
- // Execute pg_dump inside the container
- // We use Bun.spawn to pipe stdout directly to a file
- // Note: We avoid passing password via CLI args for security, relying on .pgpass or trust in container,
- // but standard postgres image usually allows 'postgres' user without pass locally or env var.
- // Since we are exec-ing AS the user inside container, auth usually works.
-
- // Using -U (user) and -d (database)
- await $`docker exec -t ${containerId} pg_dump -U ${DB_USER} -d ${DB_DATABASE} --clean --if-exists > ${FILEPATH}`;
-
- console.log("✅ Backup completed successfully!");
-} catch (error) {
- console.error("❌ Backup failed:", error);
- process.exit(1);
+const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
+
+type DumpOptions = {
+ composeFile: string;
+ service: string;
+ output: string;
+};
+
+function valueAfter(args: string[], index: number, option: string): string {
+ const value = args[index + 1];
+ if (!value) throw new Error(`${option} requires a value`);
+ return value;
+}
+
+function parseOptions(args: string[]): DumpOptions {
+ const timestamp = new Date().toISOString().replaceAll(/[:.]/g, "-");
+ const options: DumpOptions = {
+ composeFile: path.join(repoRoot, "compose.yml"),
+ service: "db",
+ output: path.resolve(process.cwd(), "backups", `backup-${timestamp}.dump`),
+ };
+ for (let index = 0; index < args.length; index += 1) {
+ const argument = args[index];
+ if (argument === "--compose-file") {
+ options.composeFile = path.resolve(
+ valueAfter(args, index, "--compose-file"),
+ );
+ index += 1;
+ } else if (argument === "--service") {
+ options.service = valueAfter(args, index, "--service");
+ index += 1;
+ } else if (argument === "--output") {
+ options.output = path.resolve(valueAfter(args, index, "--output"));
+ index += 1;
+ } else {
+ throw new Error(`Unknown argument: ${argument}`);
+ }
+ }
+ return options;
+}
+
+async function assertAbsent(filePath: string): Promise {
+ try {
+ await access(filePath);
+ throw new Error(`Refusing to overwrite existing file: ${filePath}`);
+ } catch (error) {
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") {
+ return;
+ }
+ throw error;
+ }
}
+
+async function main(): Promise {
+ const options = parseOptions(process.argv.slice(2));
+ const databaseUser = process.env.DB_USER ?? "postgres";
+ const databaseName = process.env.DB_DATABASE ?? "solid-imager";
+ const partialPath = `${options.output}.partial`;
+ await mkdir(path.dirname(options.output), { recursive: true });
+ await assertAbsent(options.output);
+ await assertAbsent(partialPath);
+
+ logger.info(
+ {
+ composeFile: options.composeFile,
+ service: options.service,
+ output: options.output,
+ },
+ "Starting custom-format PostgreSQL dump",
+ );
+ const processHandle = Bun.spawn(
+ [
+ "docker",
+ "compose",
+ "-f",
+ options.composeFile,
+ "exec",
+ "-T",
+ options.service,
+ "pg_dump",
+ "--username",
+ databaseUser,
+ "--dbname",
+ databaseName,
+ "--format=custom",
+ "--no-owner",
+ "--no-privileges",
+ ],
+ {
+ stdout: Bun.file(partialPath),
+ stderr: "pipe",
+ },
+ );
+ const stderr = await new Response(processHandle.stderr).text();
+ const exitCode = await processHandle.exited;
+ if (exitCode !== 0) {
+ await rm(partialPath, { force: true });
+ throw new Error(`pg_dump exited with ${exitCode}: ${stderr.trim()}`);
+ }
+ const outputStat = await stat(partialPath);
+ if (outputStat.size === 0) {
+ await rm(partialPath, { force: true });
+ throw new Error("pg_dump produced an empty file");
+ }
+ await rename(partialPath, options.output);
+ logger.info(
+ { output: options.output, bytes: outputStat.size },
+ "PostgreSQL dump completed",
+ );
+}
+
+main().catch((error: unknown) => {
+ logger.error({ err: error }, "PostgreSQL dump failed");
+ process.exitCode = 1;
+});
diff --git a/apps/server/scripts/lib/ccip-migration-core.ts b/apps/server/scripts/lib/ccip-migration-core.ts
new file mode 100644
index 000000000..055175165
--- /dev/null
+++ b/apps/server/scripts/lib/ccip-migration-core.ts
@@ -0,0 +1,494 @@
+import { createHash } from "node:crypto";
+import { createReadStream } from "node:fs";
+import {
+ access,
+ mkdir,
+ readFile,
+ readdir,
+ realpath,
+ rename,
+ rm,
+ stat,
+ writeFile,
+} from "node:fs/promises";
+import path from "node:path";
+import type { CcipVectorRecord } from "@solid-imager/application/ports/ccip-vector-store";
+import { z } from "zod";
+
+export const CCIP_MIGRATION_TOOL_VERSION = "ccip-pgvector-migration-v1";
+export const CCIP_VECTOR_DIMENSIONS = 768;
+
+const uuidSchema = z.string().uuid();
+const dateSchema = z.coerce.date().refine((value) => !Number.isNaN(value.getTime()));
+const vectorRecordSchema = z.object({
+ regionId: z.string().uuid().nullable(),
+ regionKind: z.enum(["full", "person", "manual"]),
+ mediaId: z.string().uuid(),
+ mediaSourceId: z.string().uuid(),
+ vector: z.array(z.number().finite()).length(CCIP_VECTOR_DIMENSIONS),
+ model: z.string().min(1),
+ embeddingVersion: z.number().int().nonnegative(),
+ mediaModifiedAt: dateSchema,
+ inputRevision: z.string().min(1),
+ preprocessingProfile: z.string().min(1),
+ extractedAt: dateSchema,
+});
+
+export type DirectoryManifestEntry = {
+ path: string;
+ bytes: number;
+ sha256: string;
+};
+
+export type DirectoryManifest = {
+ root: string;
+ entries: DirectoryManifestEntry[];
+ totalBytes: number;
+ fingerprint: string;
+};
+
+export type MigrationIssueCode =
+ | "SOURCE_READ_FAILED"
+ | "SOURCE_CHANGED"
+ | "INVALID_RECORD"
+ | "ZERO_NORM_VECTOR"
+ | "CONFLICTING_DUPLICATE"
+ | "SOURCE_ORDER_CHANGED"
+ | "ORPHAN_MEDIA"
+ | "MEDIA_SOURCE_MISMATCH"
+ | "CANONICAL_RECORD_MISSING"
+ | "CHECKPOINT_MISMATCH"
+ | "PARITY_MISMATCH"
+ | "RUST_RERANK_SKIPPED"
+ | "RUST_RERANK_FAILED";
+
+export type MigrationIssue = {
+ code: MigrationIssueCode;
+ message: string;
+ logicalKey?: string;
+ mediaId?: string;
+};
+
+export type ScanSummary = {
+ rawRows: number;
+ uniqueLogicalRows: number;
+ collapsedDuplicates: number;
+ issues: MigrationIssue[];
+};
+
+export type CheckpointIdentity = {
+ sourceFingerprint: string;
+ codeFingerprint: string;
+ schemaFingerprint: string;
+ optionsFingerprint: string;
+};
+
+export type MigrationCheckpoint = CheckpointIdentity & {
+ version: 1;
+ toolVersion: typeof CCIP_MIGRATION_TOOL_VERSION;
+ lastCompletedKey: string | null;
+ completedRecords: number;
+ updatedAt: string;
+};
+
+const checkpointSchema = z.object({
+ version: z.literal(1),
+ toolVersion: z.literal(CCIP_MIGRATION_TOOL_VERSION),
+ sourceFingerprint: z.string().length(64),
+ codeFingerprint: z.string().length(64),
+ schemaFingerprint: z.string().length(64),
+ optionsFingerprint: z.string().length(64),
+ lastCompletedKey: z.string().nullable(),
+ completedRecords: z.number().int().nonnegative(),
+ updatedAt: z.string().datetime(),
+});
+
+export type ExistingMedia = {
+ id: string;
+ mediaSourceId: string;
+};
+
+export function stableJson(value: unknown): string {
+ return JSON.stringify(sortJson(value));
+}
+
+function sortJson(value: unknown): unknown {
+ if (Array.isArray(value)) {
+ return value.map(sortJson);
+ }
+ if (typeof value === "object" && value !== null) {
+ return Object.fromEntries(
+ Object.entries(value)
+ .sort(([left], [right]) => left.localeCompare(right))
+ .map(([key, item]) => [key, sortJson(item)]),
+ );
+ }
+ return value;
+}
+
+export function sha256(value: string | Uint8Array): string {
+ return createHash("sha256").update(value).digest("hex");
+}
+
+async function hashFile(filePath: string): Promise {
+ const hash = createHash("sha256");
+ for await (const chunk of createReadStream(filePath)) {
+ hash.update(chunk);
+ }
+ return hash.digest("hex");
+}
+
+async function listFiles(root: string, relative = ""): Promise {
+ const directory = path.join(root, relative);
+ const entries = await readdir(directory, { withFileTypes: true });
+ const files: string[] = [];
+ for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
+ const child = relative ? path.posix.join(relative, entry.name) : entry.name;
+ if (entry.isSymbolicLink()) {
+ throw new Error(`Symbolic links are not allowed in a CCIP snapshot: ${child}`);
+ }
+ if (entry.isDirectory()) {
+ files.push(...(await listFiles(root, child)));
+ } else if (entry.isFile()) {
+ files.push(child);
+ } else {
+ throw new Error(`Unsupported file type in a CCIP snapshot: ${child}`);
+ }
+ }
+ return files;
+}
+
+export async function createDirectoryManifest(
+ directory: string,
+): Promise {
+ const root = await realpath(directory);
+ const rootStat = await stat(root);
+ if (!rootStat.isDirectory()) {
+ throw new Error(`CCIP source is not a directory: ${root}`);
+ }
+ const entries: DirectoryManifestEntry[] = [];
+ for (const relativePath of await listFiles(root)) {
+ const absolutePath = path.join(root, relativePath);
+ const before = await stat(absolutePath);
+ const fileHash = await hashFile(absolutePath);
+ const after = await stat(absolutePath);
+ if (
+ before.size !== after.size ||
+ before.mtimeMs !== after.mtimeMs ||
+ before.ino !== after.ino
+ ) {
+ throw new Error(`CCIP source changed while hashing: ${relativePath}`);
+ }
+ entries.push({ path: relativePath, bytes: after.size, sha256: fileHash });
+ }
+ const totalBytes = entries.reduce((total, entry) => total + entry.bytes, 0);
+ return {
+ root,
+ entries,
+ totalBytes,
+ fingerprint: sha256(stableJson(entries)),
+ };
+}
+
+export function manifestsMatch(
+ left: DirectoryManifest,
+ right: DirectoryManifest,
+): boolean {
+ return left.fingerprint === right.fingerprint && stableJson(left.entries) === stableJson(right.entries);
+}
+
+export async function filesFingerprint(files: string[]): Promise {
+ const entries: Array<{ path: string; bytes: number; sha256: string }> = [];
+ for (const file of [...files].sort()) {
+ const absolutePath = path.resolve(file);
+ const fileStat = await stat(absolutePath);
+ entries.push({
+ path: absolutePath,
+ bytes: fileStat.size,
+ sha256: await hashFile(absolutePath),
+ });
+ }
+ return sha256(stableJson(entries));
+}
+
+export function sourceLogicalKey(record: CcipVectorRecord): string {
+ return stableJson([
+ record.mediaId,
+ record.model,
+ record.embeddingVersion,
+ record.preprocessingProfile,
+ ]);
+}
+
+export function canonicalLogicalKey(record: CcipVectorRecord): string {
+ if (!record.regionId) {
+ throw new Error(`Canonical CCIP record is missing regionId: ${record.mediaId}`);
+ }
+ return stableJson([
+ record.regionId,
+ record.model,
+ record.embeddingVersion,
+ record.preprocessingProfile,
+ ]);
+}
+
+export function validateRecord(value: unknown): {
+ record?: CcipVectorRecord;
+ issues: MigrationIssue[];
+} {
+ const parsed = vectorRecordSchema.safeParse(value);
+ if (!parsed.success) {
+ return {
+ issues: [
+ {
+ code: "INVALID_RECORD",
+ message: z.prettifyError(parsed.error),
+ mediaId: readStringField(value, "mediaId"),
+ },
+ ],
+ };
+ }
+ const squaredNorm = parsed.data.vector.reduce(
+ (total, component) => total + component * component,
+ 0,
+ );
+ if (!Number.isFinite(squaredNorm) || squaredNorm === 0) {
+ return {
+ issues: [
+ {
+ code: "ZERO_NORM_VECTOR",
+ message: `CCIP vector has zero or non-finite norm: ${parsed.data.mediaId}`,
+ mediaId: parsed.data.mediaId,
+ },
+ ],
+ };
+ }
+ return { record: parsed.data, issues: [] };
+}
+
+function readStringField(value: unknown, field: string): string | undefined {
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
+ return undefined;
+ }
+ const fieldValue = Reflect.get(value, field);
+ return typeof fieldValue === "string" ? fieldValue : undefined;
+}
+
+function duplicatePayload(record: CcipVectorRecord): string {
+ return stableJson({
+ regionId: record.regionId,
+ regionKind: record.regionKind,
+ mediaId: record.mediaId,
+ mediaSourceId: record.mediaSourceId,
+ vector: record.vector,
+ model: record.model,
+ embeddingVersion: record.embeddingVersion,
+ mediaModifiedAt: record.mediaModifiedAt.toISOString(),
+ inputRevision: record.inputRevision,
+ preprocessingProfile: record.preprocessingProfile,
+ });
+}
+
+export async function scanCollapsedRecords(
+ batches: AsyncIterable,
+ onRecord?: (record: CcipVectorRecord, logicalKey: string) => Promise,
+): Promise {
+ const summary: ScanSummary = {
+ rawRows: 0,
+ uniqueLogicalRows: 0,
+ collapsedDuplicates: 0,
+ issues: [],
+ };
+ let current:
+ | { key: string; record: CcipVectorRecord; payload: string; conflict: boolean }
+ | undefined;
+ let previousCompletedKey: string | undefined;
+
+ const flush = async () => {
+ if (!current) return;
+ summary.uniqueLogicalRows += 1;
+ if (!current.conflict && onRecord) {
+ await onRecord(current.record, current.key);
+ }
+ previousCompletedKey = current.key;
+ current = undefined;
+ };
+
+ try {
+ for await (const batch of batches) {
+ for (const value of batch) {
+ summary.rawRows += 1;
+ const validation = validateRecord(value);
+ if (!validation.record) {
+ summary.issues.push(...validation.issues);
+ continue;
+ }
+ const record = validation.record;
+ const key = sourceLogicalKey(record);
+ const payload = duplicatePayload(record);
+ if (!current || current.key !== key) {
+ await flush();
+ if (previousCompletedKey && key.localeCompare(previousCompletedKey) < 0) {
+ summary.issues.push({
+ code: "SOURCE_ORDER_CHANGED",
+ message: `Legacy CCIP rows are not ordered deterministically: ${key}`,
+ logicalKey: key,
+ mediaId: record.mediaId,
+ });
+ }
+ current = { key, record, payload, conflict: false };
+ continue;
+ }
+ if (current.payload !== payload) {
+ if (!current.conflict) {
+ summary.issues.push({
+ code: "CONFLICTING_DUPLICATE",
+ message: `Conflicting legacy CCIP records: ${key}`,
+ logicalKey: key,
+ mediaId: record.mediaId,
+ });
+ }
+ current.conflict = true;
+ continue;
+ }
+ summary.collapsedDuplicates += 1;
+ if (record.extractedAt.getTime() > current.record.extractedAt.getTime()) {
+ current.record = record;
+ }
+ }
+ }
+ await flush();
+ } catch (error) {
+ summary.issues.push({
+ code: "SOURCE_READ_FAILED",
+ message: error instanceof Error ? error.message : String(error),
+ });
+ }
+ return summary;
+}
+
+export function validateMediaReferences(
+ records: CcipVectorRecord[],
+ existingMedia: ReadonlyMap,
+): MigrationIssue[] {
+ const issues: MigrationIssue[] = [];
+ for (const record of records) {
+ const media = existingMedia.get(record.mediaId);
+ if (!media) {
+ issues.push({
+ code: "ORPHAN_MEDIA",
+ message: `Legacy CCIP record references missing media: ${record.mediaId}`,
+ logicalKey: sourceLogicalKey(record),
+ mediaId: record.mediaId,
+ });
+ } else if (media.mediaSourceId !== record.mediaSourceId) {
+ issues.push({
+ code: "MEDIA_SOURCE_MISMATCH",
+ message: `Legacy CCIP media source mismatch: ${record.mediaId}`,
+ logicalKey: sourceLogicalKey(record),
+ mediaId: record.mediaId,
+ });
+ }
+ }
+ return issues;
+}
+
+export function createOptionsFingerprint(options: Record): string {
+ return sha256(stableJson(options));
+}
+
+export function createCheckpoint(
+ identity: CheckpointIdentity,
+ lastCompletedKey: string | null,
+ completedRecords: number,
+): MigrationCheckpoint {
+ return {
+ version: 1,
+ toolVersion: CCIP_MIGRATION_TOOL_VERSION,
+ ...identity,
+ lastCompletedKey,
+ completedRecords,
+ updatedAt: new Date().toISOString(),
+ };
+}
+
+export async function readCheckpoint(filePath: string): Promise {
+ return checkpointSchema.parse(JSON.parse(await readFile(filePath, "utf8")));
+}
+
+export function assertCheckpointCompatible(
+ checkpoint: MigrationCheckpoint,
+ identity: CheckpointIdentity,
+): void {
+ for (const field of [
+ "sourceFingerprint",
+ "codeFingerprint",
+ "schemaFingerprint",
+ "optionsFingerprint",
+ ] as const) {
+ if (checkpoint[field] !== identity[field]) {
+ throw new Error(`Checkpoint ${field} does not match this migration run`);
+ }
+ }
+}
+
+export async function assertPathAbsent(filePath: string): Promise {
+ try {
+ await access(filePath);
+ throw new Error(`Refusing to overwrite existing file: ${filePath}`);
+ } catch (error) {
+ if (isNodeError(error) && error.code === "ENOENT") return;
+ throw error;
+ }
+}
+
+function isNodeError(error: unknown): error is NodeJS.ErrnoException {
+ return error instanceof Error && "code" in error;
+}
+
+export async function writeJsonNoOverwrite(
+ filePath: string,
+ value: unknown,
+): Promise {
+ const absolutePath = path.resolve(filePath);
+ const partialPath = `${absolutePath}.partial`;
+ await mkdir(path.dirname(absolutePath), { recursive: true });
+ await assertPathAbsent(absolutePath);
+ await assertPathAbsent(partialPath);
+ try {
+ await writeFile(partialPath, `${JSON.stringify(value, null, 2)}\n`, {
+ flag: "wx",
+ });
+ await rename(partialPath, absolutePath);
+ } catch (error) {
+ await rm(partialPath, { force: true });
+ throw error;
+ }
+}
+
+export async function writeCheckpointAtomic(
+ filePath: string,
+ checkpoint: MigrationCheckpoint,
+ allowReplace: boolean,
+): Promise {
+ const absolutePath = path.resolve(filePath);
+ const partialPath = `${absolutePath}.partial`;
+ await mkdir(path.dirname(absolutePath), { recursive: true });
+ if (!allowReplace) await assertPathAbsent(absolutePath);
+ await assertPathAbsent(partialPath);
+ try {
+ await writeFile(partialPath, `${JSON.stringify(checkpoint, null, 2)}\n`, {
+ flag: "wx",
+ });
+ await rename(partialPath, absolutePath);
+ } catch (error) {
+ await rm(partialPath, { force: true });
+ throw error;
+ }
+}
+
+export function parseUuid(value: string, option: string): string {
+ const result = uuidSchema.safeParse(value);
+ if (!result.success) throw new Error(`${option} requires a UUID`);
+ return result.data;
+}
diff --git a/apps/server/scripts/restore-db.ts b/apps/server/scripts/restore-db.ts
index 7ce20b9a6..1786534f1 100644
--- a/apps/server/scripts/restore-db.ts
+++ b/apps/server/scripts/restore-db.ts
@@ -1,100 +1,220 @@
///
-import { $ } from "bun";
-import { readdir, stat } from "node:fs/promises";
+import { open, stat } from "node:fs/promises";
import path from "node:path";
-
-// Load environment variables
-const DB_USER = process.env.DB_USER || "postgres";
-const DB_DATABASE = process.env.DB_DATABASE || "solid-imager";
-const BACKUP_DIR = "backups";
-
-// Get target backup file from args or find latest
-const args = process.argv.slice(2);
-let targetFile = args[0];
-
-if (!targetFile) {
- try {
- // Check if backup directory exists
- const dirStats = await stat(BACKUP_DIR).catch(() => null);
- if (!dirStats || !dirStats.isDirectory()) {
- console.error(`❌ Backup directory '${BACKUP_DIR}' not found.`);
- process.exit(1);
- }
-
- const files = await readdir(BACKUP_DIR);
- const sqlFiles = files.filter((f) => f.endsWith(".sql"));
-
- if (sqlFiles.length === 0) {
- console.error("❌ No backup files found in backups/ directory.");
- process.exit(1);
- }
-
- // Sort by modification time desc to get the latest
- const fileStats = await Promise.all(
- sqlFiles.map(async (file) => {
- const filePath = path.join(BACKUP_DIR, file);
- const stats = await stat(filePath);
- return { file, mtime: stats.mtime.getTime() };
- }),
- );
-
- fileStats.sort((a, b) => b.mtime - a.mtime);
- targetFile = path.join(BACKUP_DIR, fileStats[0].file);
- console.log(`ℹ️ No file specified. Using latest backup: ${targetFile}`);
- } catch (error) {
- console.error("❌ Error finding backup files:", error);
- process.exit(1);
- }
-} else {
- // Validate provided file exists
- try {
- await stat(targetFile);
- } catch {
- console.error(`❌ Specified backup file not found: ${targetFile}`);
- process.exit(1);
- }
+import { fileURLToPath } from "node:url";
+import { logger } from "../src/infrastructure/logger";
+
+const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
+
+type RestoreOptions = {
+ composeFile: string;
+ service: string;
+ input: string;
+ confirmedEmptyTarget: boolean;
+};
+
+function valueAfter(args: string[], index: number, option: string): string {
+ const value = args[index + 1];
+ if (!value) throw new Error(`${option} requires a value`);
+ return value;
}
-console.log(
- `\n⚠️ WARNING: This will OVERWRITE the database '${DB_DATABASE}' with data from '${targetFile}'.`,
-);
-console.log("⚠️ Current data in the database will be lost/modified.");
-console.log("⏳ Starting in 5 seconds... Press Ctrl+C to cancel.");
-
-await new Promise((r) => setTimeout(r, 1000));
-process.stdout.write("5...");
-await new Promise((r) => setTimeout(r, 1000));
-process.stdout.write(" 4...");
-await new Promise((r) => setTimeout(r, 1000));
-process.stdout.write(" 3...");
-await new Promise((r) => setTimeout(r, 1000));
-process.stdout.write(" 2...");
-await new Promise((r) => setTimeout(r, 1000));
-process.stdout.write(" 1...\n");
-
-console.log("📦 Starting database restore...");
-
-try {
- // Find container
- const containerNameOutput = await $`docker compose ps -q db`.text();
- const containerId = containerNameOutput.trim();
-
- if (!containerId) {
- console.error("❌ Could not find running database container. Is Docker Compose up?");
- process.exit(1);
- }
+function parseOptions(args: string[]): RestoreOptions {
+ const options: RestoreOptions = {
+ composeFile: path.join(repoRoot, "compose.yml"),
+ service: "db",
+ input: "",
+ confirmedEmptyTarget: false,
+ };
+ for (let index = 0; index < args.length; index += 1) {
+ const argument = args[index];
+ if (argument === "--compose-file") {
+ options.composeFile = path.resolve(
+ valueAfter(args, index, "--compose-file"),
+ );
+ index += 1;
+ } else if (argument === "--service") {
+ options.service = valueAfter(args, index, "--service");
+ index += 1;
+ } else if (argument === "--input") {
+ options.input = path.resolve(valueAfter(args, index, "--input"));
+ index += 1;
+ } else if (argument === "--confirm-empty-target") {
+ options.confirmedEmptyTarget = true;
+ } else {
+ throw new Error(`Unknown argument: ${argument}`);
+ }
+ }
+ if (!options.input) throw new Error("--input is required");
+ if (!options.confirmedEmptyTarget) {
+ throw new Error("--confirm-empty-target is required for restore");
+ }
+ return options;
+}
- console.log(`🐳 Found database container ID: ${containerId}`);
+function composeCommand(options: RestoreOptions, command: string[]): string[] {
+ return [
+ "docker",
+ "compose",
+ "-f",
+ options.composeFile,
+ "exec",
+ "-T",
+ options.service,
+ ...command,
+ ];
+}
- // Execute restore
- // We use Bun.file to read the SQL file and pipe it into the docker exec command
- const fileInput = Bun.file(targetFile);
+async function runCapture(command: string[]): Promise {
+ const processHandle = Bun.spawn(command, { stdout: "pipe", stderr: "pipe" });
+ const [stdout, stderr, exitCode] = await Promise.all([
+ new Response(processHandle.stdout).text(),
+ new Response(processHandle.stderr).text(),
+ processHandle.exited,
+ ]);
+ if (exitCode !== 0) {
+ throw new Error(`${command[0]} exited with ${exitCode}: ${stderr.trim()}`);
+ }
+ return stdout.trim();
+}
- // Note: -i is required for docker exec to accept stdin
- await $`docker exec -i ${containerId} psql -U ${DB_USER} -d ${DB_DATABASE} < ${fileInput}`;
+async function isCustomDump(filePath: string): Promise {
+ const handle = await open(filePath, "r");
+ try {
+ const signature = Buffer.alloc(5);
+ await handle.read(signature, 0, signature.length, 0);
+ return signature.toString("ascii") === "PGDMP";
+ } finally {
+ await handle.close();
+ }
+}
- console.log("✅ Restore completed successfully!");
-} catch (error) {
- console.error("❌ Restore failed:", error);
- process.exit(1);
+async function main(): Promise {
+ const options = parseOptions(process.argv.slice(2));
+ const databaseUser = process.env.DB_USER ?? "postgres";
+ const databaseName = process.env.DB_DATABASE ?? "solid-imager";
+ const inputStat = await stat(options.input);
+ if (!inputStat.isFile() || inputStat.size === 0) {
+ throw new Error(`Restore input must be a non-empty file: ${options.input}`);
+ }
+
+ const objectCountOutput = await runCapture(
+ composeCommand(options, [
+ "psql",
+ "-X",
+ "--username",
+ databaseUser,
+ "--dbname",
+ databaseName,
+ "--tuples-only",
+ "--no-align",
+ "--command",
+ `WITH user_objects AS (
+ SELECT class.oid
+ FROM pg_catalog.pg_class class
+ INNER JOIN pg_catalog.pg_namespace namespace ON namespace.oid = class.relnamespace
+ WHERE class.relkind IN ('r', 'p', 'S', 'v', 'm', 'f')
+ AND namespace.nspname = 'public'
+ AND NOT EXISTS (
+ SELECT 1 FROM pg_catalog.pg_depend dependency
+ WHERE dependency.classid = 'pg_class'::regclass
+ AND dependency.objid = class.oid
+ AND dependency.deptype = 'e'
+ )
+ UNION ALL
+ SELECT type.oid
+ FROM pg_catalog.pg_type type
+ INNER JOIN pg_catalog.pg_namespace namespace ON namespace.oid = type.typnamespace
+ WHERE type.typtype IN ('e', 'd')
+ AND namespace.nspname = 'public'
+ AND NOT EXISTS (
+ SELECT 1 FROM pg_catalog.pg_depend dependency
+ WHERE dependency.classid = 'pg_type'::regclass
+ AND dependency.objid = type.oid
+ AND dependency.deptype = 'e'
+ )
+ UNION ALL
+ SELECT procedure.oid
+ FROM pg_catalog.pg_proc procedure
+ INNER JOIN pg_catalog.pg_namespace namespace ON namespace.oid = procedure.pronamespace
+ WHERE namespace.nspname = 'public'
+ AND NOT EXISTS (
+ SELECT 1 FROM pg_catalog.pg_depend dependency
+ WHERE dependency.classid = 'pg_proc'::regclass
+ AND dependency.objid = procedure.oid
+ AND dependency.deptype = 'e'
+ )
+ UNION ALL
+ SELECT namespace.oid
+ FROM pg_catalog.pg_namespace namespace
+ WHERE namespace.nspname NOT IN ('public', 'pg_catalog', 'information_schema')
+ AND namespace.nspname !~ '^pg_'
+ )
+ SELECT count(*) FROM user_objects;`,
+ ]),
+ );
+ const objectCount = Number.parseInt(objectCountOutput, 10);
+ if (!Number.isSafeInteger(objectCount) || objectCount !== 0) {
+ throw new Error(
+ `Restore target is not empty (${objectCountOutput || "unknown"} user objects)`,
+ );
+ }
+
+ const custom = await isCustomDump(options.input);
+ const restoreCommand = custom
+ ? [
+ "pg_restore",
+ "--exit-on-error",
+ "--clean",
+ "--if-exists",
+ "--no-owner",
+ "--no-privileges",
+ "--username",
+ databaseUser,
+ "--dbname",
+ databaseName,
+ ]
+ : [
+ "psql",
+ "-X",
+ "--set=ON_ERROR_STOP=1",
+ "--username",
+ databaseUser,
+ "--dbname",
+ databaseName,
+ ];
+ logger.info(
+ {
+ input: options.input,
+ format: custom ? "custom" : "plain",
+ composeFile: options.composeFile,
+ service: options.service,
+ },
+ "Starting PostgreSQL restore into verified empty target",
+ );
+ const processHandle = Bun.spawn(composeCommand(options, restoreCommand), {
+ stdin: Bun.file(options.input).stream(),
+ stdout: "pipe",
+ stderr: "pipe",
+ });
+ const [stdout, stderr, exitCode] = await Promise.all([
+ new Response(processHandle.stdout).text(),
+ new Response(processHandle.stderr).text(),
+ processHandle.exited,
+ ]);
+ void stdout;
+ if (exitCode !== 0) {
+ throw new Error(
+ `${custom ? "pg_restore" : "psql"} exited with ${exitCode}: ${stderr.trim()}`,
+ );
+ }
+ logger.info(
+ { input: options.input, bytes: inputStat.size },
+ "PostgreSQL restore completed",
+ );
}
+
+main().catch((error: unknown) => {
+ logger.error({ err: error }, "PostgreSQL restore failed");
+ process.exitCode = 1;
+});
diff --git a/apps/server/scripts/set-jobs-logged.ts b/apps/server/scripts/set-jobs-logged.ts
new file mode 100644
index 000000000..255bec728
--- /dev/null
+++ b/apps/server/scripts/set-jobs-logged.ts
@@ -0,0 +1,173 @@
+import { sql } from "drizzle-orm";
+import { z } from "zod";
+import type { DrizzleExecutor } from "@solid-imager/db/types";
+import { db } from "../src/infrastructure/db";
+import { logger } from "../src/infrastructure/logger";
+
+const auditRowSchema = z.object({
+ relpersistence: z.enum(["p", "u"]),
+ totalJobs: z.coerce.number().int().nonnegative(),
+ tableBytes: z.coerce.number().int().nonnegative(),
+ indexBytes: z.coerce.number().int().nonnegative(),
+ totalBytes: z.coerce.number().int().nonnegative(),
+ inProgressJobs: z.coerce.number().int().nonnegative(),
+ missingQueueNames: z.coerce.number().int().nonnegative(),
+ orphanParents: z.coerce.number().int().nonnegative(),
+ duplicateActiveDedupeKeys: z.coerce.number().int().nonnegative(),
+ duplicateRunningConcurrencyKeys: z.coerce.number().int().nonnegative(),
+ invalidRetryRows: z.coerce.number().int().nonnegative(),
+});
+
+type Audit = z.infer;
+
+const maxLockWaitMs = 5_000;
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null;
+}
+
+function firstRow(value: unknown): unknown {
+ if (Array.isArray(value)) return value[0];
+ if (isRecord(value) && Array.isArray(value.rows)) return value.rows[0];
+ return undefined;
+}
+
+async function auditJobs(
+ executor: Pick = db,
+): Promise {
+ const result = await executor.execute(sql`
+ SELECT
+ class.relpersistence AS "relpersistence",
+ (SELECT count(*) FROM jobs) AS "totalJobs",
+ pg_relation_size(class.oid) AS "tableBytes",
+ pg_indexes_size(class.oid) AS "indexBytes",
+ pg_total_relation_size(class.oid) AS "totalBytes",
+ (SELECT count(*) FROM jobs WHERE status = 'in_progress') AS "inProgressJobs",
+ (SELECT count(*) FROM jobs WHERE queue_name IS NULL) AS "missingQueueNames",
+ (SELECT count(*) FROM jobs child LEFT JOIN jobs parent ON parent.id = child.parent_id WHERE child.parent_id IS NOT NULL AND parent.id IS NULL) AS "orphanParents",
+ (SELECT count(*) FROM (SELECT dedupe_key FROM jobs WHERE dedupe_key IS NOT NULL AND status IN ('pending', 'in_progress') GROUP BY dedupe_key HAVING count(*) > 1) duplicates) AS "duplicateActiveDedupeKeys",
+ (SELECT count(*) FROM (SELECT concurrency_key FROM jobs WHERE concurrency_key IS NOT NULL AND status = 'in_progress' GROUP BY concurrency_key HAVING count(*) > 1) duplicates) AS "duplicateRunningConcurrencyKeys",
+ (SELECT count(*) FROM jobs WHERE attempt_count < 0 OR max_attempts <= 0 OR lease_duration_ms <= 0) AS "invalidRetryRows"
+ FROM pg_class class
+ INNER JOIN pg_namespace namespace ON namespace.oid = class.relnamespace
+ WHERE class.relname = 'jobs' AND namespace.nspname = current_schema()
+ `);
+ return auditRowSchema.parse(firstRow(result));
+}
+
+function assertReadyForRewrite(audit: Audit): void {
+ if (audit.inProgressJobs > 0) {
+ throw new Error(
+ `Jobs are not quiesced: ${audit.inProgressJobs} job(s) are in_progress`,
+ );
+ }
+ const invalidRows =
+ audit.missingQueueNames +
+ audit.orphanParents +
+ audit.duplicateActiveDedupeKeys +
+ audit.duplicateRunningConcurrencyKeys +
+ audit.invalidRetryRows;
+ if (invalidRows > 0) {
+ throw new Error(
+ `Jobs validation failed with ${invalidRows} row/group violation(s)`,
+ );
+ }
+}
+
+function isReadyForRewrite(audit: Audit): boolean {
+ try {
+ assertReadyForRewrite(audit);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+async function main(): Promise {
+ const startedAt = new Date();
+ const startedAtMs = Date.now();
+ const args = new Set(process.argv.slice(2));
+ const apply = args.has("--apply");
+ if (args.size > (apply ? 2 : 0)) {
+ throw new Error(
+ "Usage: bun scripts/set-jobs-logged.ts [--apply --confirm-jobs-quiesced]",
+ );
+ }
+ if (process.env.DB_HOST === "pglite") {
+ throw new Error("SET LOGGED maintenance is only valid for PostgreSQL");
+ }
+ const before = await auditJobs();
+ if (!apply) {
+ const finishedAt = new Date();
+ process.stdout.write(
+ `${JSON.stringify(
+ {
+ mode: "dry-run",
+ ready: isReadyForRewrite(before),
+ startedAt: startedAt.toISOString(),
+ finishedAt: finishedAt.toISOString(),
+ elapsedMs: Date.now() - startedAtMs,
+ maxLockWaitMs,
+ before,
+ },
+ null,
+ 2,
+ )}\n`,
+ );
+ return;
+ }
+ if (!args.has("--confirm-jobs-quiesced")) {
+ throw new Error("--apply requires --confirm-jobs-quiesced");
+ }
+ assertReadyForRewrite(before);
+ let rewriteElapsedMs = 0;
+ if (before.relpersistence === "u") {
+ const rewriteStartedAtMs = Date.now();
+ await db.transaction(async (transaction) => {
+ await transaction.execute(sql`SET LOCAL lock_timeout = '5s'`);
+ await transaction.execute(
+ sql`SELECT pg_advisory_xact_lock(hashtext('solid-imager.jobs.set-logged'))`,
+ );
+ assertReadyForRewrite(await auditJobs(transaction));
+ await transaction.execute(sql`ALTER TABLE jobs SET LOGGED`);
+ });
+ rewriteElapsedMs = Date.now() - rewriteStartedAtMs;
+ }
+ const after = await auditJobs();
+ if (after.relpersistence !== "p") {
+ throw new Error("jobs relpersistence did not become permanent");
+ }
+ logger.info(
+ {
+ totalJobs: after.totalJobs,
+ tableBytes: after.tableBytes,
+ indexBytes: after.indexBytes,
+ totalBytes: after.totalBytes,
+ rewriteElapsedMs,
+ },
+ "jobs table is WAL-logged and validated",
+ );
+ const finishedAt = new Date();
+ process.stdout.write(
+ `${JSON.stringify(
+ {
+ mode: "apply",
+ changed: before.relpersistence === "u",
+ startedAt: startedAt.toISOString(),
+ finishedAt: finishedAt.toISOString(),
+ elapsedMs: Date.now() - startedAtMs,
+ rewriteElapsedMs,
+ maxLockWaitMs,
+ before,
+ after,
+ },
+ null,
+ 2,
+ )}\n`,
+ );
+}
+
+main().catch((error: unknown) => {
+ logger.error({ err: error }, "SET LOGGED maintenance failed");
+ process.exitCode = 1;
+});
diff --git a/apps/server/scripts/validate-postgres-rehearsal.ts b/apps/server/scripts/validate-postgres-rehearsal.ts
new file mode 100644
index 000000000..49eb3d297
--- /dev/null
+++ b/apps/server/scripts/validate-postgres-rehearsal.ts
@@ -0,0 +1,404 @@
+///
+import { readFile, rename, writeFile } from "node:fs/promises";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { z } from "zod";
+
+const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
+
+const reportSchema = z.object({
+ ok: z.boolean(),
+ serverMajor: z.number().int(),
+ serverVersionNum: z.number().int(),
+ serverVersion: z.string(),
+ vectorAvailable: z.boolean(),
+ vectorVersion: z.string().nullable(),
+ migrations: z.array(
+ z.object({
+ id: z.number().int(),
+ hash: z.string(),
+ createdAt: z.number().int(),
+ }),
+ ),
+ constraints: z.array(
+ z.object({
+ name: z.string(),
+ type: z.string(),
+ definition: z.string(),
+ validated: z.boolean(),
+ }),
+ ),
+ invalidConstraintCount: z.number().int().nonnegative(),
+ tableCounts: z.record(z.string(), z.number().int().nonnegative()),
+ readWriteProbe: z.literal(true),
+ vectorProbe: z.boolean(),
+ mismatches: z.array(z.string()),
+});
+
+type ValidationReport = z.infer;
+type Options = {
+ composeFile: string;
+ service: string;
+ expectedReport?: string;
+ output?: string;
+ expectedMajor: number;
+ expectVectorAvailable: boolean;
+ expectedVectorVersion?: string;
+ allowedAddedTableCounts: Map;
+};
+
+const defaultAllowedAddedTableCounts = new Map([
+ ["ccip_embeddings", 0],
+ ["media_regions", 0],
+]);
+
+function valueAfter(args: string[], index: number, option: string): string {
+ const value = args[index + 1];
+ if (!value) throw new Error(`${option} requires a value`);
+ return value;
+}
+
+function parseOptions(args: string[]): Options {
+ const options: Options = {
+ composeFile: path.join(repoRoot, "compose.pg18-rehearsal.yml"),
+ service: "db-pg18-rehearsal",
+ expectedMajor: 18,
+ expectVectorAvailable: true,
+ expectedVectorVersion: "0.8.5",
+ allowedAddedTableCounts: new Map(defaultAllowedAddedTableCounts),
+ };
+ for (let index = 0; index < args.length; index += 1) {
+ const argument = args[index];
+ if (argument === "--compose-file") {
+ options.composeFile = path.resolve(
+ valueAfter(args, index, "--compose-file"),
+ );
+ index += 1;
+ } else if (argument === "--service") {
+ options.service = valueAfter(args, index, "--service");
+ index += 1;
+ } else if (argument === "--expected-report") {
+ options.expectedReport = path.resolve(
+ valueAfter(args, index, "--expected-report"),
+ );
+ index += 1;
+ } else if (argument === "--output") {
+ options.output = path.resolve(valueAfter(args, index, "--output"));
+ index += 1;
+ } else if (argument === "--expected-major") {
+ const value = Number.parseInt(
+ valueAfter(args, index, "--expected-major"),
+ 10,
+ );
+ if (!Number.isSafeInteger(value) || value < 10) {
+ throw new Error("--expected-major must be a PostgreSQL major version");
+ }
+ options.expectedMajor = value;
+ index += 1;
+ } else if (argument === "--expected-vector-version") {
+ options.expectedVectorVersion = valueAfter(
+ args,
+ index,
+ "--expected-vector-version",
+ );
+ index += 1;
+ } else if (argument === "--allow-any-vector-version") {
+ options.expectVectorAvailable = true;
+ options.expectedVectorVersion = undefined;
+ } else if (argument === "--expect-vector-unavailable") {
+ options.expectVectorAvailable = false;
+ options.expectedVectorVersion = undefined;
+ } else if (argument === "--allow-added-table") {
+ const value = valueAfter(args, index, "--allow-added-table");
+ const match = /^([a-z][a-z0-9_]*)=(\d+)$/.exec(value);
+ if (!match) {
+ throw new Error(
+ "--allow-added-table must use the form table_name=expected_count",
+ );
+ }
+ options.allowedAddedTableCounts.set(
+ match[1],
+ parseInteger(match[2], `${match[1]} expected row count`),
+ );
+ index += 1;
+ } else {
+ throw new Error(`Unknown argument: ${argument}`);
+ }
+ }
+ return options;
+}
+
+async function query(options: Options, sql: string): Promise {
+ const databaseUser = process.env.DB_USER ?? "postgres";
+ const databaseName = process.env.DB_DATABASE ?? "solid-imager";
+ const processHandle = Bun.spawn(
+ [
+ "docker",
+ "compose",
+ "-f",
+ options.composeFile,
+ "exec",
+ "-T",
+ options.service,
+ "psql",
+ "-X",
+ "--set=ON_ERROR_STOP=1",
+ "--username",
+ databaseUser,
+ "--dbname",
+ databaseName,
+ "--tuples-only",
+ "--no-align",
+ "--command",
+ sql,
+ ],
+ { stdout: "pipe", stderr: "pipe" },
+ );
+ const [stdout, stderr, exitCode] = await Promise.all([
+ new Response(processHandle.stdout).text(),
+ new Response(processHandle.stderr).text(),
+ processHandle.exited,
+ ]);
+ if (exitCode !== 0) {
+ throw new Error(`psql exited with ${exitCode}: ${stderr.trim()}`);
+ }
+ return stdout.trim();
+}
+
+function parseInteger(value: string, name: string): number {
+ const parsed = Number.parseInt(value, 10);
+ if (!Number.isSafeInteger(parsed) || parsed < 0) {
+ throw new Error(`Invalid ${name}: ${value}`);
+ }
+ return parsed;
+}
+
+async function collectReport(options: Options): Promise {
+ await query(options, "ANALYZE;");
+ const metadata = z
+ .object({
+ serverVersionNum: z.number().int(),
+ serverVersion: z.string(),
+ vectorVersion: z.string().nullable(),
+ invalidConstraintCount: z.number().int(),
+ })
+ .parse(
+ JSON.parse(
+ await query(
+ options,
+ `SELECT json_build_object(
+ 'serverVersionNum', current_setting('server_version_num')::integer,
+ 'serverVersion', version(),
+ 'vectorVersion', (SELECT extversion FROM pg_extension WHERE extname = 'vector'),
+ 'invalidConstraintCount', (SELECT count(*)::integer FROM pg_constraint WHERE NOT convalidated)
+ )::text;`,
+ ),
+ ),
+ );
+ const tableNamesOutput = await query(
+ options,
+ "SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename;",
+ );
+ const tableNames = tableNamesOutput ? tableNamesOutput.split("\n") : [];
+ const tableCounts: Record = {};
+ for (const tableName of tableNames) {
+ if (!/^[a-z][a-z0-9_]*$/.test(tableName)) {
+ throw new Error(`Unsafe table name returned by PostgreSQL: ${tableName}`);
+ }
+ tableCounts[tableName] = parseInteger(
+ await query(options, `SELECT count(*) FROM public."${tableName}";`),
+ `${tableName} row count`,
+ );
+ }
+ const migrations = z
+ .array(
+ z.object({
+ id: z.number().int(),
+ hash: z.string(),
+ createdAt: z.number().int(),
+ }),
+ )
+ .parse(
+ JSON.parse(
+ await query(
+ options,
+ "SELECT coalesce(json_agg(json_build_object('id', id, 'hash', hash, 'createdAt', created_at) ORDER BY created_at), '[]'::json)::text FROM drizzle.__drizzle_migrations;",
+ ),
+ ),
+ );
+ const constraints = z
+ .array(
+ z.object({
+ name: z.string(),
+ type: z.string(),
+ definition: z.string(),
+ validated: z.boolean(),
+ }),
+ )
+ .parse(
+ JSON.parse(
+ await query(
+ options,
+ `SELECT coalesce(json_agg(
+ json_build_object(
+ 'name', constraint.conname,
+ 'type', constraint.contype::text,
+ 'definition', regexp_replace(pg_get_constraintdef(constraint.oid, true), '\\s+', ' ', 'g'),
+ 'validated', constraint.convalidated
+ )
+ ORDER BY constraint.conname
+ ), '[]'::json)::text
+ FROM pg_constraint constraint
+ INNER JOIN pg_namespace namespace ON namespace.oid = constraint.connamespace
+ WHERE namespace.nspname = 'public';`,
+ ),
+ ),
+ );
+ const readWriteProbe =
+ (await query(
+ options,
+ "BEGIN; CREATE TEMP TABLE pg18_rehearsal_probe(value integer); INSERT INTO pg18_rehearsal_probe VALUES (1); SELECT count(*) FROM pg18_rehearsal_probe; ROLLBACK;",
+ )).includes("1");
+ const vectorAvailable = metadata.vectorVersion !== null;
+ const vectorProbe = vectorAvailable
+ ? (await query(
+ options,
+ "SELECT ('[1,0,0]'::vector <=> '[1,0,0]'::vector) = 0;",
+ )) === "t"
+ : false;
+ if (!readWriteProbe) {
+ throw new Error("PostgreSQL read/write validation failed");
+ }
+ const serverMajor = Math.floor(metadata.serverVersionNum / 10_000);
+ const mismatches: string[] = [];
+ if (serverMajor !== options.expectedMajor) {
+ mismatches.push(
+ `PostgreSQL major: expected ${options.expectedMajor}, got ${serverMajor}`,
+ );
+ }
+ if (vectorAvailable !== options.expectVectorAvailable) {
+ mismatches.push(
+ `vector extension availability: expected ${options.expectVectorAvailable}, got ${vectorAvailable}`,
+ );
+ }
+ if (vectorAvailable && !vectorProbe) {
+ mismatches.push("vector extension probe failed");
+ }
+ if (
+ options.expectVectorAvailable &&
+ options.expectedVectorVersion &&
+ metadata.vectorVersion !== options.expectedVectorVersion
+ ) {
+ mismatches.push(
+ `vector extension: expected ${options.expectedVectorVersion}, got ${metadata.vectorVersion}`,
+ );
+ }
+ if (metadata.invalidConstraintCount !== 0) {
+ mismatches.push(
+ `invalid constraints: expected 0, got ${metadata.invalidConstraintCount}`,
+ );
+ }
+ return {
+ ok: mismatches.length === 0,
+ serverMajor,
+ ...metadata,
+ vectorAvailable,
+ migrations,
+ constraints,
+ tableCounts,
+ readWriteProbe: true,
+ vectorProbe,
+ mismatches,
+ };
+}
+
+function compareReports(
+ report: ValidationReport,
+ expected: ValidationReport,
+ allowedAddedTableCounts: ReadonlyMap,
+): string[] {
+ const mismatches: string[] = [];
+ const expectedTableNames = Object.keys(expected.tableCounts).sort();
+ const actualTableNames = Object.keys(report.tableCounts).sort();
+ const unexpectedAddedTableNames = actualTableNames.filter(
+ (tableName) =>
+ !expectedTableNames.includes(tableName) &&
+ !allowedAddedTableCounts.has(tableName),
+ );
+ if (unexpectedAddedTableNames.length > 0) {
+ mismatches.push(
+ `unexpected target-only tables: ${unexpectedAddedTableNames.join(",")}`,
+ );
+ }
+ for (const [tableName, expectedCount] of Object.entries(expected.tableCounts)) {
+ const actualCount = report.tableCounts[tableName];
+ if (actualCount !== expectedCount) {
+ mismatches.push(
+ `table ${tableName}: expected ${expectedCount}, got ${actualCount ?? "missing"}`,
+ );
+ }
+ }
+ for (const [tableName, expectedCount] of allowedAddedTableCounts) {
+ if (Object.hasOwn(expected.tableCounts, tableName)) continue;
+ const actualCount = report.tableCounts[tableName];
+ if (actualCount !== expectedCount) {
+ mismatches.push(
+ `target-only table ${tableName}: expected ${expectedCount}, got ${actualCount ?? "missing"}`,
+ );
+ }
+ }
+ for (let index = 0; index < expected.migrations.length; index += 1) {
+ const expectedMigration = expected.migrations[index];
+ const actualMigration = report.migrations[index];
+ if (
+ !actualMigration ||
+ actualMigration.id !== expectedMigration.id ||
+ actualMigration.hash !== expectedMigration.hash
+ ) {
+ mismatches.push(
+ `migration prefix mismatch at position ${index}: expected ${expectedMigration.id}/${expectedMigration.hash}, got ${actualMigration ? `${actualMigration.id}/${actualMigration.hash}` : "missing"}`,
+ );
+ }
+ }
+ const actualConstraints = new Map(
+ report.constraints.map((constraint) => [constraint.name, constraint]),
+ );
+ for (const expectedConstraint of expected.constraints) {
+ const actualConstraint = actualConstraints.get(expectedConstraint.name);
+ if (
+ !actualConstraint ||
+ actualConstraint.type !== expectedConstraint.type ||
+ actualConstraint.definition !== expectedConstraint.definition ||
+ actualConstraint.validated !== expectedConstraint.validated
+ ) {
+ mismatches.push(`constraint mismatch: ${expectedConstraint.name}`);
+ }
+ }
+ return mismatches;
+}
+
+async function main(): Promise {
+ const options = parseOptions(process.argv.slice(2));
+ const report = await collectReport(options);
+ if (options.expectedReport) {
+ const expected = reportSchema.parse(
+ JSON.parse(await readFile(options.expectedReport, "utf8")),
+ );
+ report.mismatches = [
+ ...report.mismatches,
+ ...compareReports(report, expected, options.allowedAddedTableCounts),
+ ];
+ report.ok = report.mismatches.length === 0;
+ }
+ const output = `${JSON.stringify(report, null, 2)}\n`;
+ if (options.output) {
+ const partial = `${options.output}.partial`;
+ await writeFile(partial, output, { flag: "wx" });
+ await rename(partial, options.output);
+ } else {
+ process.stdout.write(output);
+ }
+ if (!report.ok) process.exitCode = 1;
+}
+
+await main();
diff --git a/apps/server/scripts/verify-pglite-bundle.ts b/apps/server/scripts/verify-pglite-bundle.ts
new file mode 100644
index 000000000..fb2c54a43
--- /dev/null
+++ b/apps/server/scripts/verify-pglite-bundle.ts
@@ -0,0 +1,128 @@
+import { cp, mkdtemp, readFile, readdir, rm } from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import { pathToFileURL } from "node:url";
+
+type PgliteLike = {
+ exec(sql: string): Promise;
+ query(sql: string): Promise<{ rows: T[] }>;
+ close(): Promise;
+};
+
+type PgliteConstructor = new (
+ dataDir: string,
+ options: { extensions: { vector: unknown } },
+) => PgliteLike;
+
+type MigrationJournal = {
+ entries: Array<{ tag: string }>;
+};
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null;
+}
+
+function findChunk(files: string[], packageName: string): string {
+ const match = files.find(
+ (file) =>
+ file.endsWith(".mjs") &&
+ file.includes(packageName) &&
+ (packageName.includes("pgvector") || !file.includes("pgvector")),
+ );
+ if (!match) {
+ throw new Error(`Bundled module chunk not found for ${packageName}`);
+ }
+ return match;
+}
+
+async function loadBundledRuntime(libsDir: string): Promise<{
+ PGlite: PgliteConstructor;
+ vector: unknown;
+}> {
+ const files = await readdir(libsDir);
+ const pglitePath = path.join(libsDir, findChunk(files, "electric-sql__pglite"));
+ const vectorPath = path.join(
+ libsDir,
+ findChunk(files, "electric-sql__pglite-pgvector"),
+ );
+ const pgliteModule: unknown = await import(pathToFileURL(pglitePath).href);
+ const vectorModule: unknown = await import(pathToFileURL(vectorPath).href);
+ if (
+ !isRecord(pgliteModule) ||
+ typeof pgliteModule.PGlite !== "function" ||
+ !isRecord(vectorModule) ||
+ !("vector" in vectorModule)
+ ) {
+ throw new Error("Bundled PGlite modules do not expose the expected API");
+ }
+ return {
+ PGlite: pgliteModule.PGlite as PgliteConstructor,
+ vector: vectorModule.vector,
+ };
+}
+
+async function main(): Promise {
+ const outputServer = path.resolve(process.cwd(), ".output/server");
+ const sandbox = await mkdtemp(
+ path.join(os.tmpdir(), "solid-imager-pglite-bundle-"),
+ );
+ try {
+ const isolatedServer = path.join(sandbox, "server");
+ await cp(outputServer, isolatedServer, { recursive: true });
+ const libsDir = path.join(isolatedServer, "_libs");
+ const { PGlite, vector } = await loadBundledRuntime(libsDir);
+ const dataDir = path.join(sandbox, "database");
+ let database = new PGlite(dataDir, { extensions: { vector } });
+ const migrationsDir = path.join(isolatedServer, "drizzle");
+ const journal: MigrationJournal = JSON.parse(
+ await readFile(path.join(migrationsDir, "meta", "_journal.json"), "utf8"),
+ );
+ for (const entry of journal.entries) {
+ const migrationSql = await readFile(
+ path.join(migrationsDir, `${entry.tag}.sql`),
+ "utf8",
+ );
+ if (!migrationSql.trim()) {
+ throw new Error(`Bundled migration is empty: ${entry.tag}`);
+ }
+ for (const statement of migrationSql.split("--> statement-breakpoint")) {
+ if (statement.trim()) await database.exec(statement);
+ }
+ }
+ await database.exec(`
+ CREATE TABLE bundle_probe (id integer PRIMARY KEY, embedding vector(3));
+ INSERT INTO bundle_probe VALUES (1, '[1,0,0]');
+ `);
+ await database.close();
+
+ database = new PGlite(dataDir, { extensions: { vector } });
+ const result = await database.query<{
+ count: number;
+ migrationCount: number;
+ vectorInstalled: boolean;
+ }>(`
+ SELECT
+ (SELECT count(*)::integer FROM bundle_probe WHERE embedding <=> '[1,0,0]'::vector = 0) AS count,
+ ${journal.entries.length}::integer AS "migrationCount",
+ EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'vector') AS "vectorInstalled"
+ `);
+ await database.close();
+ const row = result.rows[0];
+ if (
+ row?.count !== 1 ||
+ row.migrationCount !== journal.entries.length ||
+ !row.vectorInstalled
+ ) {
+ throw new Error(
+ "Bundled PGlite migrations/vector database did not survive close/reopen",
+ );
+ }
+ process.stdout.write(
+ `${JSON.stringify({ ok: true, runtime: "isolated-output", vector: true, migrations: journal.entries.length })}\n`,
+ );
+ } finally {
+ await rm(sandbox, { recursive: true, force: true });
+ }
+}
+
+await main();
diff --git a/apps/server/src/application/registry.ts b/apps/server/src/application/registry.ts
index b09ed7034..616419c0c 100644
--- a/apps/server/src/application/registry.ts
+++ b/apps/server/src/application/registry.ts
@@ -1,3 +1,4 @@
+import type { MediaRegionService } from "@solid-imager/application/services/media-region-service";
import type {
IConfigService,
IFileSystem,
@@ -8,6 +9,7 @@ import type { IAuthorRepository } from "@solid-imager/core/domain/repositories/a
import type { CharacterRepository } from "@solid-imager/core/domain/repositories/character-repository";
import type { IIpRepository } from "@solid-imager/core/domain/repositories/ip-repository";
import type { IJobRepository } from "@solid-imager/core/domain/repositories/job-repository";
+import type { IMediaRegionRepository } from "@solid-imager/core/domain/repositories/media-region-repository";
import type { IMediaRepository } from "@solid-imager/core/domain/repositories/media-repository";
import type { IProjectRepository } from "@solid-imager/core/domain/repositories/project-repository";
import type { SourceRepository } from "@solid-imager/core/domain/repositories/source-repository";
@@ -20,6 +22,8 @@ import type { JobWorker } from "~/infrastructure/jobs/job-worker";
export class ServiceRegistry {
private static instance: ServiceRegistry;
private mediaRepository?: IMediaRepository;
+ private mediaRegionRepository?: IMediaRegionRepository;
+ private mediaRegionService?: MediaRegionService;
private sourceRepository?: SourceRepository;
private mediaStorage?: IMediaStorage;
private fileSystem?: IFileSystem;
@@ -49,6 +53,14 @@ export class ServiceRegistry {
this.mediaRepository = repo;
}
+ registerMediaRegionRepository(repo: IMediaRegionRepository): void {
+ this.mediaRegionRepository = repo;
+ }
+
+ registerMediaRegionService(service: MediaRegionService): void {
+ this.mediaRegionService = service;
+ }
+
registerSourceRepository(repo: SourceRepository): void {
this.sourceRepository = repo;
}
@@ -104,6 +116,20 @@ export class ServiceRegistry {
return this.mediaRepository;
}
+ getMediaRegionRepository(): IMediaRegionRepository {
+ if (!this.mediaRegionRepository) {
+ throw new Error("MediaRegionRepository has not been registered.");
+ }
+ return this.mediaRegionRepository;
+ }
+
+ getMediaRegionService(): MediaRegionService {
+ if (!this.mediaRegionService) {
+ throw new Error("MediaRegionService has not been registered.");
+ }
+ return this.mediaRegionService;
+ }
+
getSourceRepository(): SourceRepository {
if (!this.sourceRepository) {
throw new Error("SourceRepository has not been registered.");
@@ -228,6 +254,8 @@ export class ServiceRegistry {
// Helper for testing to reset the registry
async reset(): Promise {
this.mediaRepository = undefined;
+ this.mediaRegionRepository = undefined;
+ this.mediaRegionService = undefined;
this.sourceRepository = undefined;
this.mediaStorage = undefined;
this.fileSystem = undefined;
diff --git a/apps/server/src/application/services/backup-service.ts b/apps/server/src/application/services/backup-service.ts
index 3e031da52..c03b6a9cc 100644
--- a/apps/server/src/application/services/backup-service.ts
+++ b/apps/server/src/application/services/backup-service.ts
@@ -5,10 +5,11 @@ import {
type MediaDumpItem,
mediaDumpItemSchema,
} from "@solid-imager/core/domain/media/schemas";
+import { createMediaSourceRevision } from "@solid-imager/core/domain/media/revision";
import { localConnectionSchema } from "@solid-imager/core/domain/sources/schemas";
import { getErrorMessage } from "@solid-imager/core/utils/get-error-message";
import type { Table } from "drizzle-orm";
-import { and, asc, eq, gt, inArray, lt, sql } from "drizzle-orm";
+import { and, asc, eq, gt, inArray, lt, or, sql } from "drizzle-orm";
import type { PgColumn } from "drizzle-orm/pg-core";
import { LANCEDB_DUMP_VERSION } from "~/application/services/lancedb-dump-service";
import { db } from "~/infrastructure/db";
@@ -260,10 +261,38 @@ export const BackupService = {
const connectionInfo = mediaSource.connectionInfo as { path: string };
const basePath = connectionInfo.path;
+ const revisionRows = await db
+ .select({
+ id: medias.id,
+ mediaSourceId: medias.mediaSourceId,
+ modifiedAt: medias.modifiedAt,
+ fileSize: medias.fileSize,
+ width: medias.width,
+ height: medias.height,
+ })
+ .from(medias)
+ .where(inArray(medias.id, mediaIds));
+ const revisionById = new Map(
+ await Promise.all(
+ revisionRows.map(async (media) => [
+ media.id,
+ await createMediaSourceRevision({
+ mediaId: media.id,
+ mediaSourceId: media.mediaSourceId,
+ modifiedAt: media.modifiedAt,
+ fileSize: media.fileSize,
+ width: media.width,
+ height: media.height,
+ }),
+ ] as const),
+ ),
+ );
for (const id of mediaIds) {
await jobRepo.create({
type: "processMedia",
mediaSourceId,
+ targetId: id,
+ inputRevision: revisionById.get(id) ?? null,
payload: {
mediaId: id,
sourcePath: basePath,
@@ -1079,6 +1108,8 @@ export const BackupService = {
target: [lanceDbSyncDirty.mediaSourceId, lanceDbSyncDirty.mediaId],
set: {
operation,
+ generation: sql`${lanceDbSyncDirty.generation} + 1`,
+ attempts: 0,
lastError: null,
updatedAt: now,
},
@@ -1199,12 +1230,17 @@ export const BackupService = {
itemsToUpsert: upsertItems,
});
- await db.delete(lanceDbSyncDirty).where(
- inArray(
- lanceDbSyncDirty.id,
- dirtyRows.map((row) => row.id),
+ const claimedGenerations = or(
+ ...dirtyRows.map((row) =>
+ and(
+ eq(lanceDbSyncDirty.id, row.id),
+ eq(lanceDbSyncDirty.generation, row.generation),
+ ),
),
);
+ if (claimedGenerations) {
+ await db.delete(lanceDbSyncDirty).where(claimedGenerations);
+ }
logger.info(
{
@@ -1219,19 +1255,24 @@ export const BackupService = {
return { mode: "delta", processed: dirtyRows.length };
} catch (error) {
const message = getErrorMessage(error);
- await db
- .update(lanceDbSyncDirty)
- .set({
- attempts: sql`${lanceDbSyncDirty.attempts} + 1`,
- lastError: message,
- updatedAt: new Date(),
- })
- .where(
- inArray(
- lanceDbSyncDirty.id,
- dirtyRows.map((row) => row.id),
+ const claimedGenerations = or(
+ ...dirtyRows.map((row) =>
+ and(
+ eq(lanceDbSyncDirty.id, row.id),
+ eq(lanceDbSyncDirty.generation, row.generation),
),
- );
+ ),
+ );
+ if (claimedGenerations) {
+ await db
+ .update(lanceDbSyncDirty)
+ .set({
+ attempts: sql`${lanceDbSyncDirty.attempts} + 1`,
+ lastError: message,
+ updatedAt: new Date(),
+ })
+ .where(claimedGenerations);
+ }
throw error;
}
},
diff --git a/apps/server/src/application/services/ccip-vector-service.ts b/apps/server/src/application/services/ccip-vector-service.ts
index c4ba568ba..580ebd757 100644
--- a/apps/server/src/application/services/ccip-vector-service.ts
+++ b/apps/server/src/application/services/ccip-vector-service.ts
@@ -3,6 +3,8 @@ import { CcipVectorService } from "@solid-imager/application/services/ccip-vecto
import { services } from "~/application/registry";
import { taggingService } from "~/application/services/tagging-service";
import { PostgresCcipVectorStore } from "~/infrastructure/ai/postgres-ccip-vector-store";
+import { LanceDbCcipVectorStore } from "~/infrastructure/ai/lancedb-ccip-vector-store";
+import { DualWriteCcipVectorStore } from "~/infrastructure/ai/dual-write-ccip-vector-store";
import { db } from "~/infrastructure/db";
let service: CcipVectorService | null = null;
@@ -14,11 +16,49 @@ export function configureCcipVectorService(logger: ILogger): void {
export function getCcipVectorService(): CcipVectorService {
if (!service) {
+ const config = services.getConfigService().getConfig();
+ const postgresStore = new PostgresCcipVectorStore(db, configuredLogger);
+ const legacyLanceStore = new LanceDbCcipVectorStore(
+ config.lancedb.ccipVectorDir,
+ { legacy: true },
+ );
+ const rollbackStore = new LanceDbCcipVectorStore(
+ config.lancedb.ccipRollbackDir,
+ { readOnly: config.lancedb.ccipStoreMode === "lance-readonly" },
+ );
+ const vectorStore = (() => {
+ switch (config.lancedb.ccipStoreMode) {
+ case "lance":
+ return legacyLanceStore;
+ case "postgres":
+ return postgresStore;
+ case "postgres-dual-write":
+ return new DualWriteCcipVectorStore(
+ postgresStore,
+ [
+ { name: "postgres", store: postgresStore },
+ { name: "lance-rollback", store: rollbackStore },
+ ],
+ configuredLogger,
+ );
+ case "lance-dual-write":
+ return new DualWriteCcipVectorStore(
+ rollbackStore,
+ [
+ { name: "lance-rollback", store: rollbackStore },
+ { name: "postgres", store: postgresStore },
+ ],
+ configuredLogger,
+ );
+ case "lance-readonly":
+ return rollbackStore;
+ }
+ })();
service = new CcipVectorService({
mediaRepository: services.getMediaRepository(),
sourceRepository: services.getSourceRepository(),
taggingService,
- vectorStore: new PostgresCcipVectorStore(db, configuredLogger),
+ vectorStore,
logger: configuredLogger,
});
}
diff --git a/apps/server/src/application/services/job-dispatch-service.ts b/apps/server/src/application/services/job-dispatch-service.ts
index bc4c9882b..658c09c5a 100644
--- a/apps/server/src/application/services/job-dispatch-service.ts
+++ b/apps/server/src/application/services/job-dispatch-service.ts
@@ -1,18 +1,42 @@
import type { DeferredActions } from "@solid-imager/application/ports/media-service";
+import { validateJobPayload } from "@solid-imager/core/domain/jobs/registry";
+import { createMediaSourceRevision } from "@solid-imager/core/domain/media/revision";
+import type { Job } from "@solid-imager/core/domain/repositories/job-repository";
+import { eq } from "drizzle-orm";
import { services } from "~/application/registry";
-import type { Job as DbJob } from "~/infrastructure/db/schema";
+import { db } from "~/infrastructure/db";
+import { medias } from "~/infrastructure/db/schema";
import { RealtimeEventBus } from "~/infrastructure/events/realtime-event-bus";
import {
processAutoTaggingJob,
processBulkTaggingDispatchJob,
} from "~/infrastructure/jobs/tagging-jobs";
import { deleteThumbnail } from "~/infrastructure/jobs/thumbnails";
+import { NonRetryableJobError } from "~/infrastructure/jobs/job-errors";
import { logger } from "~/infrastructure/logger";
// Helper for unified job processing (Called by JobWorker)
-export async function processJob(job: DbJob) {
+export async function processJob(job: Job, signal?: AbortSignal) {
+ const validated = validateJobPayload(job.type, job.payload);
+ if (!validated.success) {
+ const issueSummary = validated.error.issues
+ .map((issue) => `${issue.path.join(".")}: ${issue.message}`)
+ .join("; ");
+ throw new NonRetryableJobError(
+ validated.error.issues.some((issue) => issue.path[0] === "type")
+ ? "UNKNOWN_JOB_TYPE"
+ : "INVALID_JOB_PAYLOAD",
+ `Invalid ${job.type} job: ${issueSummary}`,
+ );
+ }
+ if (signal?.aborted) return;
+ await assertCurrentInputRevision(job);
const mediaSourceId = job.mediaSourceId;
- if (!mediaSourceId && job.type !== "bulk_tagging_dispatch") {
+ if (
+ !mediaSourceId &&
+ job.type !== "bulk_tagging_dispatch" &&
+ job.type !== "batch_ccip_dispatch"
+ ) {
throw new Error(`Job ${job.id} missing mediaSourceId`);
}
@@ -27,12 +51,12 @@ export async function processJob(job: DbJob) {
);
await processDownloadJob(job);
} else if (job.type === "auto_tagging") {
- await processAutoTaggingJob(job);
+ await processAutoTaggingJob(job, signal);
} else if (job.type === "extract_ccip_vector") {
const { processCcipExtractionJob } = await import(
"~/infrastructure/jobs/ccip-jobs"
);
- await processCcipExtractionJob(job);
+ await processCcipExtractionJob(job, signal);
} else if (job.type === "bulk_tagging_dispatch") {
await processBulkTaggingDispatchJob(job);
} else if (job.type === "batch_ccip_dispatch") {
@@ -56,19 +80,64 @@ export async function processJob(job: DbJob) {
"~/application/services/backup-service"
);
const batchSize = getDeltaBatchSize(job.payload);
- const payloadDirty = getDeltaDirtyPayload(job.payload);
- if (payloadDirty.mediaIds.length > 0) {
- await BackupService.queueSourceLanceDBDelta(
- mediaSourceId,
- payloadDirty.mediaIds,
- payloadDirty.operation,
- { enqueueJob: false },
- );
- }
await BackupService.syncSourceLanceDBDeltaCache(mediaSourceId, batchSize);
} else {
- logger.warn({ jobId: job.id, type: job.type }, "Unknown job type");
+ throw new NonRetryableJobError(
+ "UNKNOWN_JOB_TYPE",
+ `Unknown job type: ${job.type}`,
+ );
}
+ if (!signal?.aborted) await assertCurrentInputRevision(job);
+}
+
+async function assertCurrentInputRevision(job: Job): Promise {
+ if (
+ !job.inputRevision ||
+ !["processMedia", "auto_tagging", "extract_ccip_vector"].includes(job.type)
+ ) {
+ return;
+ }
+ const mediaId = job.targetId ?? getPayloadMediaId(job.payload);
+ if (!mediaId) return;
+ const [media] = await db
+ .select({
+ id: medias.id,
+ mediaSourceId: medias.mediaSourceId,
+ modifiedAt: medias.modifiedAt,
+ fileSize: medias.fileSize,
+ width: medias.width,
+ height: medias.height,
+ })
+ .from(medias)
+ .where(eq(medias.id, mediaId))
+ .limit(1);
+ if (!media) {
+ throw new NonRetryableJobError(
+ "TARGET_NOT_FOUND",
+ `Job target media not found: ${mediaId}`,
+ );
+ }
+ const currentRevision = await createMediaSourceRevision({
+ mediaId: media.id,
+ mediaSourceId: media.mediaSourceId,
+ modifiedAt: media.modifiedAt,
+ fileSize: media.fileSize,
+ width: media.width,
+ height: media.height,
+ });
+ if (currentRevision !== job.inputRevision) {
+ throw new NonRetryableJobError(
+ "STALE_INPUT",
+ `Job input revision is stale for media ${mediaId}`,
+ );
+ }
+}
+
+function getPayloadMediaId(payload: unknown): string | null {
+ if (!payload || typeof payload !== "object" || !("mediaId" in payload)) {
+ return null;
+ }
+ return typeof payload.mediaId === "string" ? payload.mediaId : null;
}
function getDeltaBatchSize(payload: unknown): number {
@@ -83,29 +152,6 @@ function getDeltaBatchSize(payload: unknown): number {
return 500;
}
-function getDeltaDirtyPayload(payload: unknown): {
- mediaIds: string[];
- operation: "upsert" | "delete";
-} {
- if (!payload || typeof payload !== "object") {
- return { mediaIds: [], operation: "upsert" };
- }
- const data = payload as {
- mediaIds?: unknown;
- mediaId?: unknown;
- operation?: unknown;
- };
- const mediaIds = Array.isArray(data.mediaIds)
- ? data.mediaIds.filter(
- (value): value is string => typeof value === "string",
- )
- : typeof data.mediaId === "string"
- ? [data.mediaId]
- : [];
- const operation = data.operation === "delete" ? "delete" : "upsert";
- return { mediaIds, operation };
-}
-
export async function executeDeferredActions(actions: DeferredActions) {
if (actions.jobs.length > 0) {
const repo = services.getJobRepository();
@@ -129,6 +175,8 @@ export async function executeDeferredActions(actions: DeferredActions) {
await repo.create({
type: job.type,
mediaSourceId: item.mediaSourceId,
+ targetId: job.targetId,
+ inputRevision: job.inputRevision,
payload: jobPayload,
});
}
diff --git a/apps/server/src/application/services/maintenance-service.ts b/apps/server/src/application/services/maintenance-service.ts
index c517a9783..124496f20 100644
--- a/apps/server/src/application/services/maintenance-service.ts
+++ b/apps/server/src/application/services/maintenance-service.ts
@@ -1,5 +1,6 @@
import fs from "node:fs/promises";
import path from "node:path";
+import { createMediaSourceRevision } from "@solid-imager/core/domain/media/revision";
import type { IMediaRepository } from "@solid-imager/core/domain/repositories/media-repository";
import type { SourceRepository } from "@solid-imager/core/domain/repositories/source-repository";
import type { IJobRepository } from "~/domain/repositories/job-repository";
@@ -308,9 +309,22 @@ export class MaintenanceService {
}
try {
+ const media = await this.mediaRepo.findById(item.id);
+ const inputRevision = media
+ ? await createMediaSourceRevision({
+ mediaId: media.id,
+ mediaSourceId: media.mediaSourceId,
+ modifiedAt: media.modifiedAt,
+ fileSize: media.fileSize,
+ width: media.width,
+ height: media.height,
+ })
+ : null;
return await this.jobRepo.createIfUnique({
type: "processMedia",
mediaSourceId: item.mediaSourceId,
+ targetId: item.id,
+ inputRevision,
payload: {
mediaId: item.id,
sourcePath: basePath,
diff --git a/apps/server/src/components/media/character-crop-modal.tsx b/apps/server/src/components/media/character-crop-modal.tsx
index cb476a406..a6f55ff9b 100644
--- a/apps/server/src/components/media/character-crop-modal.tsx
+++ b/apps/server/src/components/media/character-crop-modal.tsx
@@ -1,6 +1,14 @@
import type { MediaDetails } from "@solid-imager/core/domain/media/schemas";
import { CharacterCropModal as SharedCharacterCropModal } from "@solid-imager/ui/character-crop-modal";
-import { fetchCharacterCrops } from "~/infrastructure/api-clients/ai-api";
+import {
+ createManualMediaRegion,
+ deleteMediaRegion,
+ fetchCharacterCrops,
+ fetchMediaRegions,
+ getMediaRegionRenderUrl,
+ materializeMediaRegion,
+ updateMediaRegion,
+} from "~/infrastructure/api-clients/ai-api";
type CharacterCropModalProps = {
isOpen: boolean;
@@ -11,12 +19,22 @@ type CharacterCropModalProps = {
export default function CharacterCropModal(props: CharacterCropModalProps) {
return (
{
- return fetchCharacterCrops(mediaId, transparent);
+ createManualRegion={createManualMediaRegion}
+ deleteRegion={deleteMediaRegion}
+ detectRegions={async (mediaId: string) => {
+ const result = await fetchCharacterCrops(mediaId, false);
+ if (result.mode !== "media-backed") {
+ throw new Error("Character detection did not return saved regions.");
+ }
+ return result.regions;
}}
+ getRenderUrl={getMediaRegionRenderUrl}
isOpen={props.isOpen}
+ loadRegions={fetchMediaRegions}
+ materializeRegion={materializeMediaRegion}
media={props.media}
onClose={props.onClose}
+ updateRegion={updateMediaRegion}
/>
);
}
diff --git a/apps/server/src/domain/shared/api-contract.ts b/apps/server/src/domain/shared/api-contract.ts
index 571dc826b..cc8d886c0 100644
--- a/apps/server/src/domain/shared/api-contract.ts
+++ b/apps/server/src/domain/shared/api-contract.ts
@@ -8,6 +8,7 @@ import { downloadsRouter } from "~/infrastructure/api/routers/downloads-router";
import { importsRouter } from "~/infrastructure/api/routers/imports-router";
import { ipsRouter } from "~/infrastructure/api/routers/ips-router";
import { jobsRouter } from "~/infrastructure/api/routers/jobs-router";
+import { mediaRegionsRouter } from "~/infrastructure/api/routers/media-regions-router";
import { mediaRouter } from "~/infrastructure/api/routers/media-router";
import { presetsRouter } from "~/infrastructure/api/routers/presets-router";
import { projectsRouter } from "~/infrastructure/api/routers/projects-router";
@@ -24,6 +25,7 @@ export const appRouter = {
sources: sourcesRouter,
tags: tagsRouter,
media: mediaRouter,
+ mediaRegions: mediaRegionsRouter,
categories: categoriesRouter,
projects: projectsRouter,
characters: charactersRouter,
diff --git a/apps/server/src/infrastructure/ai/dual-write-ccip-vector-store.ts b/apps/server/src/infrastructure/ai/dual-write-ccip-vector-store.ts
new file mode 100644
index 000000000..00dd66555
--- /dev/null
+++ b/apps/server/src/infrastructure/ai/dual-write-ccip-vector-store.ts
@@ -0,0 +1,152 @@
+import type {
+ CcipVectorCandidate,
+ CcipEmbeddingKey,
+ CcipVectorMetadata,
+ CcipVectorQuery,
+ CcipVectorReadQuery,
+ CcipVectorRecord,
+ ICcipVectorStore,
+} from "@solid-imager/application/ports/ccip-vector-store";
+import type { ILogger } from "@solid-imager/application/ports/media-service";
+
+export type CcipWriteBackend = {
+ name: string;
+ store: ICcipVectorStore;
+};
+
+export class CcipDualWriteError extends Error {
+ constructor(
+ readonly operation: string,
+ readonly succeededBackends: string[],
+ readonly failedBackend: string,
+ cause: unknown,
+ ) {
+ super(
+ `CCIP ${operation} partially failed at ${failedBackend} after ${succeededBackends.join(", ") || "no successful backend"}`,
+ { cause },
+ );
+ this.name = "CcipDualWriteError";
+ }
+}
+
+/**
+ * Transitional store used only during the rollback observation window. Reads
+ * stay on one authoritative backend while every mutation is synchronously
+ * applied to both backends; a secondary failure is never hidden.
+ */
+export class DualWriteCcipVectorStore implements ICcipVectorStore {
+ constructor(
+ private readonly readStore: ICcipVectorStore,
+ private readonly writeBackends: CcipWriteBackend[],
+ private readonly logger?: ILogger,
+ ) {}
+
+ private async write(
+ operation: string,
+ callback: (store: ICcipVectorStore) => Promise,
+ ): Promise {
+ const succeededBackends: string[] = [];
+ for (const backend of this.writeBackends) {
+ try {
+ await callback(backend.store);
+ succeededBackends.push(backend.name);
+ } catch (error) {
+ this.logger?.error(
+ {
+ err: error,
+ operation,
+ failedBackend: backend.name,
+ succeededBackends,
+ },
+ "CCIP dual-write operation failed",
+ );
+ throw new CcipDualWriteError(
+ operation,
+ [...succeededBackends],
+ backend.name,
+ error,
+ );
+ }
+ }
+ }
+
+ async get(
+ mediaId: string,
+ query: CcipVectorReadQuery,
+ ): Promise {
+ return await this.readStore.get(mediaId, query);
+ }
+
+ async getByRegion(
+ regionId: string,
+ query: CcipVectorReadQuery,
+ ): Promise {
+ return await this.readStore.getByRegion(regionId, query);
+ }
+
+ async getMany(
+ mediaIds: string[],
+ query: CcipVectorReadQuery,
+ ): Promise