@@ -325,9 +325,25 @@ no half-pair, and the second run created the other 1,438 and corrected none).
325325` from: "<user id>" ` resumes from a progress line instead of re-reading the
326326table.
327327
328+ Since #2054 the same two commands cover the gallery kinds as well.
329+ ` Vutuv.Images.Backfill ` has exactly two shapes — ` %{cols: …} ` , the truth in
330+ columns on a parent row joined by a pointer, and ` %{gallery: …} ` , the truth in
331+ a row of the picture's own joined by its token — and everything around them
332+ (the keyset walk, the classes, the repair, the sample, the printing, both
333+ operator paths) is shared. A gallery source builds itself from
334+ ` Vutuv.Images.mirror_source/1 ` , so adding a kind touches nothing in the
335+ backfill at all; its repair is the same ` mirror/2 ` upsert the request path
336+ writes, so create and correct are one statement and need no transaction; and
337+ it reports no ` missing_pointer ` , which the report leaves out rather than
338+ printing as a zero. The one thing to check when the next kind arrives is the
339+ store's ` version_path/2 ` signature: ` Vutuv.PostImageStore ` and
340+ ` Vutuv.JobPostingImageStore ` take the row, ` Vutuv.OrganizationImageStore ` takes
341+ the token, and ` Vutuv.Moderation.ImageSubjects.image_path_arg/2 ` is the adapter
342+ that already knows.
343+
328344` Backfill.check/1 ` (` mix vutuv.images.backfill --check ` , or
329345` bin/vutuv eval "Vutuv.Release.check_image_rows()" ` ) is the gate before the
330- cut: it counts every member picture against its row * and* against its file on
346+ cut: it counts every picture against its row * and* against its file on
331347disk — the quarantine tree while the picture is ` "pending" ` , the served tree
332348otherwise — prints one line per kind plus the members behind each class of
333349mismatch, and ** fails the command** when anything is outstanding. Both of
@@ -396,10 +412,115 @@ permission, and the only off switch is moving the bytes out of that tree —
396412the quarantine tree the AI gate already uses
397413(` Vutuv.Uploads.quarantine_dir/1 ` ), which nginx has no location for. ` :proxy `
398414means every byte goes through a controller that authorizes the reader first,
399- so the row is the off switch. Avatars and covers are ` :static ` ; the kinds
400- #2015 brings are mostly ` :proxy ` . It raises for a kind nobody has declared,
401- because a picture that inherits a default is one nobody knows how to take
402- offline.
415+ so the row is the off switch. Avatars and covers are ` :static ` , a job-posting
416+ picture is ` :proxy ` ; the remaining kinds #2015 brings are mostly ` :proxy ` too.
417+ It raises for a kind nobody has declared, because a picture that inherits a
418+ default is one nobody knows how to take offline.
419+
420+ ### The gallery kinds (issue #2015 )
421+
422+ A post photo, an organization image, a job-posting picture and a review cover
423+ each kept a table and an uploader of their own, so the ` image ` report type and
424+ the freeze knew one kind only. They move in one release per kind, smallest
425+ first — a ** job-posting picture** went first (#2054 ) precisely to settle the
426+ shape.
427+
428+ ** Three of the four are the same shape; the review cover is not.** A post
429+ photo, an organization image and a job-posting picture each have a row of their
430+ own with a ` token ` , so they move as * gallery* pictures, below. A review's cover
431+ is ` cover ` / ` cover_status ` / ` cover_moderation ` ** columns on the review row**
432+ with no token and no table of its own (` Vutuv.Posts.PostReview ` ), which is the
433+ profile picture's shape, not this one — #2055 lands on ` member_columns/0 ` 's side
434+ of the fence, and ` Vutuv.Images.Backfill ` 's ` %{cols: …} ` source is what it
435+ extends. What #2052 and #2053 copy from here is everything below.
436+
437+ ** The token is the join key, not a pointer.** #2013 added
438+ ` users.avatar_image_id ` because a member row had no stable handle of its own;
439+ a gallery row has carried a ` token ` from the start, ` images.token ` has been
440+ unique across the whole table since #2013 for exactly this, and a gallery token
441+ is minted once and never re-minted (a re-upload is a different row). So the
442+ ` images ` row simply repeats the token, there is no reverse pointer to keep in
443+ step, and this kind has no ` missing_pointer ` class in the check. It also means
444+ the contract deploy drops the old table outright rather than a column at a
445+ time.
446+
447+ ** One kind's own column, six shared ones.** ` images ` gained ` job_posting_id `
448+ (nullable — a posting image is uploaded before the posting is saved, and no
449+ other kind has a posting) plus ` alt ` , ` position ` , ` width ` , ` height ` ,
450+ ` content_type ` and ` size_bytes ` , taken with the types ` job_posting_images `
451+ holds them at (` alt ` and ` content_type ` varchar(255), the rest plain
452+ ` integer ` ). ` post_images ` and ` organization_images ` carry those same six under
453+ the same names, so #2052 and #2053 add their own parent column plus whatever
454+ they hold beyond the six — a post photo also brings ` caption ` , ` crop ` , seven
455+ EXIF columns and its download flags. The ` images_profile_kind_has_owner ` check
456+ constraint was ** extended** rather than the nullable ` user_id ` widened, which is
457+ what #2013 's migration asked for; the parent index is ** partial**
458+ (` WHERE job_posting_id IS NOT NULL ` ), because every other kind's row is NULL
459+ there and the waste would multiply by four — Postgres proves ` IS NOT NULL ` from
460+ the cascade's strict ` = $1 ` and uses it (measured: 16 kB against 48 kB).
461+
462+ ** The double write is one upsert and one delete.**
463+ ` Vutuv.Images.mirror/2 ` takes one gallery row or a list of them and upserts on
464+ the token — one statement however many, so saving ten pictures costs one — and
465+ ` Vutuv.Images.forget/2 ` deletes by token. ` Vutuv.Images.write_mirrored/2 ` pairs
466+ a write with its mirror in one transaction, which is the door a context's own
467+ insert and update go through. ` Vutuv.Images.mirror_source/1 ` is the ** one**
468+ per-kind registry — the copied columns, the source schema, the store — so a
469+ kind cannot be mirrored on the request path and invisible to the backfill,
470+ whose check would otherwise print * "Safe to cut"* for a kind it never looked
471+ at. The names are identical on both sides, so the copy is a per-field
472+ ` Map.fetch!/2 ` : a listed name the source lacks raises. The other direction — a
473+ column added to ` job_posting_images ` and never listed — nothing can see, so a
474+ drift test compares the schema against the list and fails the build.
475+
476+ Every place that writes a job-posting picture goes through one of those: the
477+ upload and the alt edit (` write_mirrored/2 ` ), the attach and detach on save,
478+ the pending sweep, and the AI gate's approve and reject in
479+ ` Vutuv.Moderation.ImageSubjects ` — which asks ` Vutuv.Images.mirrored?/1 ` first,
480+ so the next kind's release is one entry in ` Vutuv.Images ` and nothing there. A
481+ deleted posting or member needs no call: ` images.job_posting_id ` and
482+ ` images.user_id ` cascade exactly as the gallery table's own columns do. ** The
483+ ` frozen_at ` column is deliberately outside the upsert's replace list** , so an
484+ ordinary write can never lift a takedown.
485+
486+ ** What an interruption leaves.** The upload and the alt edit are atomic. The
487+ attach-and-prune on save is not, and never was — it runs after the save — so a
488+ slot dying between an attach and its mirror leaves a ` mismatched_row ` (the
489+ parent disagrees) and one between a prune and its ` forget/2 ` leaves an
490+ ` orphan_row ` . Both are classes the backfill names and repairs; neither is
491+ invisible.
492+
493+ ** Nothing reads the new row yet, and that is the whole of the expand half.**
494+ Every URL is the one it was (` /job_posting_images/<token>/<version>.avif ` ),
495+ ` VutuvWeb.JobPostingImageController ` still authorizes off ` job_posting_images ` ,
496+ and the edit form still renders from ` posting.images ` — so no render path pays
497+ a query for the mirror. The consequence to know: ` Vutuv.Images.freeze/1 ` ,
498+ ` unfreeze/1 ` and ` purge/1 ` ** raise** for such a row rather than half-hiding
499+ it, and ` Vutuv.Moderation ` refuses a report that names one
500+ (` Vutuv.Images.takedown_ready?/1 ` ), because a case opened on a row nothing
501+ consults would go through an uphold that takes nothing offline. A member
502+ reports the posting instead, which is all there was before the row existed.
503+
504+ ** Retiring ` job_posting_images ` is not this release.** It is the deploy after,
505+ and it has to remove the double write in the same step — the mirror, the
506+ ` forget/2 ` calls, the ` @gallery_sources ` entry in the backfill and
507+ ` Vutuv.Images.mirrored?/1 ` 's answer — because a migration may drop only what
508+ the currently deployed release has stopped using. Before it: run
509+ ` mix vutuv.images.backfill --only job_posting_image ` and read the check. The
510+ release in between is the one that moves the proxy, the form and the freeze
511+ onto the row, which is what makes the old table unread in the first place; and
512+ that release, not this one, is where the kind decides whether it wants a
513+ ** bridge** the way ` member_image/2 ` has one.
514+
515+ ** This kind needs no bridge.** #2027 needed one because it moved every reader
516+ onto the row in the same deploy as the row's first appearance, so a picture the
517+ backfill had not reached would have rendered as no picture at all. Here no
518+ reader has moved: a job-posting picture with no mirror row is served exactly as
519+ before, by its own table, so an installation that never runs the backfill sees
520+ nothing change. The bridge question belongs to the release that moves the
521+ readers, and it will have a simpler answer than #2027 's — the old row * is* the
522+ picture, so the fallback is a lookup in the table it is about to leave rather
523+ than four columns read as a row.
403524
404525### The takedown hold (issue #2012 )
405526
0 commit comments