Skip to content

Commit 38cd620

Browse files
committed
apply origin id for versions of imgs
1 parent 3a80ebc commit 38cd620

2 files changed

Lines changed: 123 additions & 26 deletions

File tree

graphile/graphile-settings/src/upload-resolver.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -119,13 +119,14 @@ async function insertFileRecord(
119119
key: string,
120120
etag: string,
121121
createdBy: string | null,
122+
contentType: string | null,
122123
): Promise<void> {
123124
const pool = getPgPool();
124125
await pool.query(
125126
`INSERT INTO files_store_public.files
126-
(id, database_id, bucket_key, key, etag, created_by)
127-
VALUES ($1, $2, $3, $4, $5, $6)`,
128-
[fileId, Number(databaseId), bucketKey, key, etag, createdBy],
127+
(id, database_id, bucket_key, key, etag, created_by, mime_type)
128+
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
129+
[fileId, Number(databaseId), bucketKey, key, etag, createdBy, contentType],
129130
);
130131
}
131132

@@ -165,7 +166,7 @@ export async function streamToStorage(
165166

166167
const result = await storage.upload(key, detected.stream, { contentType });
167168

168-
await insertFileRecord(fileId, databaseId, bucketKey, key, result.etag, opts?.userId || null);
169+
await insertFileRecord(fileId, databaseId, bucketKey, key, result.etag, opts?.userId || null, contentType);
169170

170171
const url = await storage.presignGet(key, 3600);
171172
return { key, url, filename, mime: contentType };
@@ -244,7 +245,7 @@ async function uploadResolver(
244245
contentType: detectedContentType,
245246
});
246247

247-
await insertFileRecord(fileId, databaseId, bucketKey, key, result.etag, userId);
248+
await insertFileRecord(fileId, databaseId, bucketKey, key, result.etag, userId, detectedContentType);
248249

249250
const url = await storage.presignGet(key, 3600);
250251

migrations/files_store.sql

Lines changed: 117 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -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.';
101103
COMMENT 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
164182
CREATE 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;
402427
BEGIN
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;
479550
COMMENT 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

Comments
 (0)