@@ -67,6 +67,8 @@ CREATE TABLE files_store_public.files (
6767 source_id uuid,
6868 processing_started_at timestamptz ,
6969 created_by uuid,
70+ origin_id uuid,
71+ mime_type text ,
7072 created_at timestamptz NOT NULL DEFAULT now(),
7173 updated_at timestamptz NOT NULL DEFAULT now(),
7274
@@ -100,6 +102,22 @@ COMMENT ON COLUMN files_store_public.files.source_column IS
100102 ' Column name on the source table (e.g. profile_picture). NULL until domain trigger populates it.' ;
101103COMMENT ON COLUMN files_store_public.files.source_id IS
102104 ' Primary key of the row in the source table. NULL until domain trigger populates it.' ;
105+ COMMENT ON COLUMN files_store_public.files.origin_id IS
106+ ' Self-referential FK to the origin file. NULL for origin rows, set for version rows (thumbnail, medium).' ;
107+ COMMENT ON COLUMN files_store_public.files.mime_type IS
108+ ' Detected MIME type of the file. Set at upload time for origins, at processing time for versions.' ;
109+
110+ -- Self-referential FK (version -> origin, same table)
111+ -- ON DELETE CASCADE: DB-level safety net. The primary deletion path is
112+ -- per-row delete-s3-object jobs (each row gets its own job via trigger).
113+ -- CASCADE only fires if an origin row is directly DELETEd before its
114+ -- version rows -- in that case, version DB rows are removed but version
115+ -- S3 objects are still cleaned up by their already-enqueued jobs.
116+ ALTER TABLE files_store_public .files
117+ ADD CONSTRAINT files_origin_fk
118+ FOREIGN KEY (origin_id, database_id)
119+ REFERENCES files_store_public .files (id, database_id)
120+ ON DELETE CASCADE ;
103121
104122-- ---------------------------------------------------------------------------
105123-- 3. Buckets Table
@@ -164,6 +182,11 @@ CREATE INDEX files_deleting_idx
164182CREATE INDEX files_created_at_brin_idx
165183 ON files_store_public .files USING brin (created_at);
166184
185+ -- Version lookups: "find all versions of this origin"
186+ CREATE INDEX files_origin_id_idx
187+ ON files_store_public .files (origin_id, database_id)
188+ WHERE origin_id IS NOT NULL ;
189+
167190-- ---------------------------------------------------------------------------
168191-- 5. Triggers
169192-- ---------------------------------------------------------------------------
@@ -397,8 +420,10 @@ DECLARE
397420 old_val jsonb;
398421 new_key text ;
399422 old_key text ;
400- base_key text ;
401423 db_id integer ;
424+ origin_file_id uuid;
425+ old_origin_file_id uuid;
426+ versions_json json;
402427BEGIN
403428 -- Get the database_id from session context
404429 db_id := current_setting(' app.database_id' )::integer ;
@@ -418,29 +443,75 @@ BEGIN
418443
419444 -- Handle file replacement: mark old files as deleting
420445 IF old_key IS NOT NULL AND old_key <> ' ' THEN
421- -- Derive base key for the old file (strip version suffix)
422- base_key := regexp_replace(old_key, ' _[^_]+$' , ' ' );
423-
424- -- Mark old origin + all versions as deleting
425- UPDATE files_store_public .files
426- SET status = ' deleting' , status_reason = ' replaced by new file'
427- WHERE database_id = db_id
428- AND (key = old_key OR key LIKE base_key || ' _%' )
429- AND status NOT IN (' deleting' );
446+ -- Find old origin by exact key match
447+ SELECT id INTO old_origin_file_id
448+ FROM files_store_public .files
449+ WHERE key = old_key AND database_id = db_id;
450+
451+ IF old_origin_file_id IS NOT NULL THEN
452+ -- Mark old origin as deleting
453+ UPDATE files_store_public .files
454+ SET status = ' deleting' , status_reason = ' replaced by new file'
455+ WHERE id = old_origin_file_id AND database_id = db_id
456+ AND status NOT IN (' deleting' );
457+
458+ -- Mark old versions as deleting (index hit on origin_id)
459+ UPDATE files_store_public .files
460+ SET status = ' deleting' , status_reason = ' replaced by new file'
461+ WHERE origin_id = old_origin_file_id AND database_id = db_id
462+ AND status NOT IN (' deleting' );
463+ END IF;
430464 END IF;
431465
432466 -- Populate back-reference on new file (origin + versions)
433467 IF new_key IS NOT NULL AND new_key <> ' ' THEN
434- -- Derive base key for the new file
435- base_key := regexp_replace(new_key, ' _[^_]+$' , ' ' );
436-
437- -- Set back-reference on origin + all version rows
438- UPDATE files_store_public .files
439- SET source_table = table_name,
440- source_column = col_name,
441- source_id = NEW .id
442- WHERE database_id = db_id
443- AND (key = new_key OR key LIKE base_key || ' _%' );
468+ -- Find origin by exact key match
469+ SELECT id INTO origin_file_id
470+ FROM files_store_public .files
471+ WHERE key = new_key AND database_id = db_id;
472+
473+ IF origin_file_id IS NOT NULL THEN
474+ -- Update origin row
475+ UPDATE files_store_public .files
476+ SET source_table = table_name, source_column = col_name, source_id = NEW .id
477+ WHERE id = origin_file_id AND database_id = db_id;
478+
479+ -- Update version rows (index hit on origin_id)
480+ UPDATE files_store_public .files
481+ SET source_table = table_name, source_column = col_name, source_id = NEW .id
482+ WHERE origin_id = origin_file_id AND database_id = db_id;
483+
484+ -- Backfill versions into domain JSONB if process-image already completed.
485+ -- This fixes the race condition where process-image runs before domain
486+ -- association (two-step upload path) and can't write back versions.
487+ -- Uses mime_type column for accurate MIME (not hardcoded).
488+ SELECT json_agg(json_build_object(
489+ ' key' , f .key ,
490+ ' mime' , COALESCE(f .mime_type , ' image/jpeg' ),
491+ ' width' , 0 ,
492+ ' height' , 0
493+ ))
494+ INTO versions_json
495+ FROM files_store_public .files f
496+ WHERE f .origin_id = origin_file_id
497+ AND f .database_id = db_id
498+ AND f .status = ' ready' ;
499+
500+ IF versions_json IS NOT NULL THEN
501+ -- RECURSION GUARD: This UPDATE re-fires the current trigger on the
502+ -- domain table. It is safe because only the 'versions' subfield of
503+ -- the JSONB column is modified -- the 'key' field is unchanged.
504+ -- The IS NOT DISTINCT FROM check at the top of this function
505+ -- compares old_key vs new_key (both extracted via ->> 'key'),
506+ -- detects they are equal, and returns early.
507+ -- DO NOT change the early-return comparison to use the full JSONB
508+ -- value instead of just the 'key' field, or this will infinite-loop.
509+ EXECUTE format(
510+ ' UPDATE %s SET %I = jsonb_set(COALESCE(%I, ' ' {}' ' ::jsonb), ' ' {versions}' ' , $1::jsonb) WHERE id = $2' ,
511+ table_name, col_name, col_name
512+ ) USING versions_json, NEW .id ;
513+ END IF;
514+ END IF;
444515 END IF;
445516
446517 RETURN NEW;
@@ -479,7 +550,32 @@ $$ LANGUAGE plpgsql;
479550COMMENT ON FUNCTION files_store_public.mark_files_deleting_on_source_delete() IS
480551 ' Generic trigger function for domain tables. Marks all associated files as deleting when a domain row is deleted.' ;
481552
482- -- 7c. CREATE TRIGGER statements for all 6 tables, 9 columns
553+ -- 7c. Propagate deleting status from origin to version rows.
554+ -- When an origin transitions to 'deleting', mark all its versions as 'deleting' too.
555+ -- Each version row's AFTER UPDATE trigger then enqueues its own delete-s3-object job.
556+
557+ CREATE OR REPLACE FUNCTION files_store_public .files_propagate_deleting_to_versions()
558+ RETURNS trigger AS $$
559+ BEGIN
560+ UPDATE files_store_public .files
561+ SET status = ' deleting' , status_reason = COALESCE(NEW .status_reason , ' origin marked deleting' )
562+ WHERE origin_id = NEW .id
563+ AND database_id = NEW .database_id
564+ AND status NOT IN (' deleting' );
565+ RETURN NEW;
566+ END;
567+ $$ LANGUAGE plpgsql;
568+
569+ CREATE TRIGGER files_after_update_propagate_deleting
570+ AFTER UPDATE ON files_store_public .files
571+ FOR EACH ROW
572+ WHEN (NEW .status = ' deleting' AND OLD .status <> ' deleting' AND NEW .origin_id IS NULL )
573+ EXECUTE FUNCTION files_store_public .files_propagate_deleting_to_versions ();
574+
575+ COMMENT ON TRIGGER files_after_update_propagate_deleting ON files_store_public.files IS
576+ ' When an origin file transitions to deleting, propagate that status to all version rows via origin_id. Each version then gets its own delete-s3-object job via the existing files_after_update_queue_deletion trigger. The WHEN clause filters to origin rows only (origin_id IS NULL).' ;
577+
578+ -- 7d. CREATE TRIGGER statements for all 6 tables, 9 columns
483579--
484580-- Each domain column gets two triggers:
485581-- - AFTER UPDATE: back-reference population + file replacement
0 commit comments