/* V6 baseline: creation schema (M4 AI 创作), extracted from patbond-doc/docs/database/patbond_postgresql.sql (reviewed target model, lines 60/68 schema + 556-717 objects) per iteration-4 T4-01. Scope notes: - All 3 creation objects are created here: generation_models, generation_styles, generation_jobs. Both media kinds ('image', 'video') are structurally supported from the start, as modeled — the target model keys the catalog by (id, media_kind) composite unique so a job can only reference a model/style of its own kind. Video feature rollout is a contract/client concern, not a schema concern (see ADR-018 supersede). - Zero cross-schema FKs need stripping, unlike V3 and V5: every target schema already exists — identity (V1), media (V1), pet_health (V3). This is the first baseline migration that lands its FKs complete. - V5's header registered two deferred constraints. This migration settles the first one, as instructed there ("M4 补回"): * community.posts.generation_job_id -> creation.generation_jobs(id) ON DELETE SET NULL is re-added at the end of this file. The constraint is named explicitly as posts_generation_job_id_fkey so that a database built by V1..V6 is name-identical to one built from the target model (where the FK is declared inline and auto-named by PostgreSQL). * community.posts.region_id -> platform.regions(id) stays STRIPPED; the region system belongs to M5 (ADR-018 scope cut) — M5 补回. - Primary keys keep DEFAULT gen_random_uuid() as modeled (V1/V3/V5 同规); application code supplies explicit UUIDv7 ids, the default is a fallback. - updated_at triggers reuse platform.set_updated_at() (function from V1). Two deliberate deviations from the target model, both decided in iteration-4 and recorded here so the divergence is never silent: 1. generation_jobs.input_asset_id is NULLABLE (target model: NOT NULL). Decision A2: the reference image is optional — mainstream image and video providers support both text-to-* and reference-guided generation, so the presence of an input asset is what selects between them at request time. The target model's NOT NULL would have made text-to-image unrepresentable. Whether a specific model *requires* an input is deliberately NOT encoded here (no requires_input flag invented beyond the reviewed model); it is enforced by the application against the catalog. 2. generation_jobs.first_attempt_at is ADDED (absent from the target model). Required by iteration-4/06 §4.2: queue wait and pure execution time can only be separated if the first attempt's start survives retries. The target model cannot express this — its ck_generation_jobs_state forces started_at IS NULL on the 'queued' branch, so re-queueing a failed job necessarily erases the original start. first_attempt_at is therefore written once (on the first transition into 'running') and never overwritten, and is deliberately EXCLUDED from the 'queued' branch's null-out requirements. ck_generation_jobs_first_attempt pins the invariant against attempt_count. */ CREATE SCHEMA creation; COMMENT ON SCHEMA creation IS 'Asynchronous AI image/video generation'; CREATE TABLE creation.generation_models ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), code varchar(64) NOT NULL, display_name varchar(128) NOT NULL, provider_code varchar(64) NOT NULL, provider_model_name varchar(128) NOT NULL, media_kind varchar(16) NOT NULL, enabled boolean NOT NULL DEFAULT true, sort_order integer NOT NULL DEFAULT 0, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), UNIQUE (code, media_kind), -- (id, media_kind) is redundant given the PK, but is the target of the -- composite FK from generation_jobs: it is what makes "a job's model must -- be of the job's own media_kind" enforceable by the database. UNIQUE (id, media_kind), CONSTRAINT ck_generation_models_kind CHECK (media_kind IN ('image', 'video')), CONSTRAINT ck_generation_models_names CHECK ( code = btrim(code) AND char_length(code) BETWEEN 2 AND 64 AND char_length(btrim(display_name)) BETWEEN 1 AND 128 ) ); CREATE INDEX ix_generation_models_kind_order ON creation.generation_models (media_kind, sort_order, id) WHERE enabled; CREATE TABLE creation.generation_styles ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), code varchar(64) NOT NULL, title varchar(64) NOT NULL, subtitle varchar(128), media_kind varchar(16) NOT NULL, preview_asset_id uuid REFERENCES media.assets(id) ON DELETE SET NULL, enabled boolean NOT NULL DEFAULT true, sort_order integer NOT NULL DEFAULT 0, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), UNIQUE (code, media_kind), UNIQUE (id, media_kind), CONSTRAINT ck_generation_styles_kind CHECK (media_kind IN ('image', 'video')), CONSTRAINT ck_generation_styles_names CHECK ( code = btrim(code) AND char_length(code) BETWEEN 2 AND 64 AND char_length(btrim(title)) BETWEEN 1 AND 64 ) ); CREATE INDEX ix_generation_styles_preview ON creation.generation_styles (preview_asset_id); CREATE INDEX ix_generation_styles_kind_order ON creation.generation_styles (media_kind, sort_order, id) WHERE enabled; CREATE TABLE creation.generation_jobs ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), user_id uuid NOT NULL REFERENCES identity.users(id) ON DELETE RESTRICT, pet_id uuid REFERENCES pet_health.pets(id) ON DELETE SET NULL, media_kind varchar(16) NOT NULL, model_id uuid NOT NULL, style_id uuid, -- DEVIATION 1 (see header): nullable, not NOT NULL. NULL = text-to-*, -- present = reference-guided generation. input_asset_id uuid REFERENCES media.assets(id) ON DELETE RESTRICT, output_asset_id uuid REFERENCES media.assets(id) ON DELETE SET NULL, prompt varchar(10000), negative_prompt varchar(3000), width_px integer, height_px integer, duration_ms bigint, upscale boolean NOT NULL DEFAULT false, parameters jsonb NOT NULL DEFAULT '{}'::jsonb, -- Snapshots freeze what was actually asked of the provider: the catalog -- may be re-pointed or disabled later, historical jobs must stay readable. provider_code_snapshot varchar(64) NOT NULL, provider_model_snapshot varchar(128) NOT NULL, model_version_snapshot varchar(64) NOT NULL, style_code_snapshot varchar(64), status varchar(16) NOT NULL DEFAULT 'queued', progress smallint NOT NULL DEFAULT 0, priority smallint NOT NULL DEFAULT 0, attempt_count smallint NOT NULL DEFAULT 0, max_attempts smallint NOT NULL DEFAULT 3, provider_request_id varchar(128), error_code varchar(64), error_message varchar(1000), idempotency_key varchar(128) NOT NULL, request_hash bytea NOT NULL, next_attempt_at timestamptz NOT NULL DEFAULT now(), lease_owner varchar(128), lease_expires_at timestamptz, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), -- DEVIATION 2 (see header): written once on the first transition into -- 'running', never overwritten, survives re-queueing. first_attempt_at timestamptz, started_at timestamptz, completed_at timestamptz, version integer NOT NULL DEFAULT 0, UNIQUE (user_id, idempotency_key), FOREIGN KEY (model_id, media_kind) REFERENCES creation.generation_models(id, media_kind) ON DELETE RESTRICT, FOREIGN KEY (style_id, media_kind) REFERENCES creation.generation_styles(id, media_kind) ON DELETE RESTRICT, CONSTRAINT ck_generation_jobs_kind CHECK (media_kind IN ('image', 'video')), CONSTRAINT ck_generation_jobs_dimensions CHECK ( (width_px IS NULL OR width_px BETWEEN 64 AND 8192) AND (height_px IS NULL OR height_px BETWEEN 64 AND 8192) AND (duration_ms IS NULL OR duration_ms > 0) ), CONSTRAINT ck_generation_jobs_parameters CHECK (jsonb_typeof(parameters) = 'object'), CONSTRAINT ck_generation_jobs_idempotency CHECK ( idempotency_key = btrim(idempotency_key) AND char_length(idempotency_key) BETWEEN 1 AND 128 AND octet_length(request_hash) = 32 ), CONSTRAINT ck_generation_jobs_status CHECK ( status IN ('queued', 'running', 'succeeded', 'failed', 'cancelled') ), CONSTRAINT ck_generation_jobs_progress CHECK (progress BETWEEN 0 AND 100), CONSTRAINT ck_generation_jobs_attempts CHECK ( attempt_count >= 0 AND max_attempts > 0 AND attempt_count <= max_attempts ), CONSTRAINT ck_generation_jobs_state CHECK ( ( status = 'queued' AND progress = 0 AND started_at IS NULL AND completed_at IS NULL AND output_asset_id IS NULL AND error_code IS NULL AND lease_owner IS NULL AND lease_expires_at IS NULL ) OR ( status = 'running' AND started_at IS NOT NULL AND completed_at IS NULL AND output_asset_id IS NULL AND error_code IS NULL AND lease_owner IS NOT NULL AND lease_expires_at IS NOT NULL ) OR ( status = 'succeeded' AND progress = 100 AND started_at IS NOT NULL AND completed_at IS NOT NULL AND output_asset_id IS NOT NULL AND error_code IS NULL AND lease_owner IS NULL AND lease_expires_at IS NULL ) OR ( status = 'failed' AND completed_at IS NOT NULL AND error_code IS NOT NULL AND output_asset_id IS NULL AND lease_owner IS NULL AND lease_expires_at IS NULL ) OR ( status = 'cancelled' AND completed_at IS NOT NULL AND lease_owner IS NULL AND lease_expires_at IS NULL ) ), CONSTRAINT ck_generation_jobs_lease CHECK ( (lease_owner IS NULL) = (lease_expires_at IS NULL) AND (lease_expires_at IS NULL OR started_at IS NULL OR lease_expires_at > started_at) ), -- DEVIATION 2's invariant: first_attempt_at exists exactly when the job -- has been attempted at least once, and never postdates the current -- attempt's start. Deliberately absent from ck_generation_jobs_state's -- 'queued' branch: a re-queued job keeps it. CONSTRAINT ck_generation_jobs_first_attempt CHECK ( (attempt_count = 0) = (first_attempt_at IS NULL) AND ( first_attempt_at IS NULL OR started_at IS NULL OR first_attempt_at <= started_at ) ), CONSTRAINT ck_generation_jobs_version CHECK (version >= 0) ); -- Provider-side de-duplication: the pair (provider, provider_request_id) is -- what makes "did I already submit this upstream?" answerable after a timeout -- or a worker restart — i.e. the basis of not paying twice. CREATE UNIQUE INDEX uq_generation_jobs_provider_request ON creation.generation_jobs (provider_code_snapshot, provider_request_id) WHERE provider_request_id IS NOT NULL; CREATE INDEX ix_generation_jobs_user_created ON creation.generation_jobs (user_id, created_at DESC, id DESC); CREATE INDEX ix_generation_jobs_pet ON creation.generation_jobs (pet_id); CREATE INDEX ix_generation_jobs_model ON creation.generation_jobs (model_id); CREATE INDEX ix_generation_jobs_style ON creation.generation_jobs (style_id); CREATE INDEX ix_generation_jobs_model_kind ON creation.generation_jobs (model_id, media_kind); CREATE INDEX ix_generation_jobs_style_kind ON creation.generation_jobs (style_id, media_kind); CREATE INDEX ix_generation_jobs_input ON creation.generation_jobs (input_asset_id); CREATE INDEX ix_generation_jobs_output ON creation.generation_jobs (output_asset_id); -- Column order matches the dequeue statement's ORDER BY exactly, so the -- FOR UPDATE SKIP LOCKED claim is an index scan, not a sort. CREATE INDEX ix_generation_jobs_queue ON creation.generation_jobs (priority DESC, next_attempt_at, created_at, id) WHERE status = 'queued'; -- Drives the lease reaper and the poll-due scan over in-flight jobs. CREATE INDEX ix_generation_jobs_running ON creation.generation_jobs (lease_expires_at, id) WHERE status = 'running'; -- Automatic updated_at maintenance (function created in V1). Business version -- increments remain explicit so optimistic locking stays visible in -- repository update statements. CREATE TRIGGER trg_generation_models_updated_at BEFORE UPDATE ON creation.generation_models FOR EACH ROW EXECUTE FUNCTION platform.set_updated_at(); CREATE TRIGGER trg_generation_styles_updated_at BEFORE UPDATE ON creation.generation_styles FOR EACH ROW EXECUTE FUNCTION platform.set_updated_at(); CREATE TRIGGER trg_generation_jobs_updated_at BEFORE UPDATE ON creation.generation_jobs FOR EACH ROW EXECUTE FUNCTION platform.set_updated_at(); -- V5's deferred constraint, settled per its header instruction ("M4 补回"). -- Named explicitly to match the auto-generated name of the target model's -- inline declaration, so V1..V6 and the target model stay name-identical. -- community.posts.region_id stays bare — that one is M5's to settle. ALTER TABLE community.posts ADD CONSTRAINT posts_generation_job_id_fkey FOREIGN KEY (generation_job_id) REFERENCES creation.generation_jobs(id) ON DELETE SET NULL;