Storage reorganisation · detailed structure
Step by Step
Each of the six steps and three gates in full — what it changes, how it is built, how it reverses, and where it stands today. The state on each card is measured, not asserted.
- Complete
- 2Steps 1 and 3
- Partly done
- 2Step 2 has no mobile leg; step 4 has no flag
- Built, not run
- 1Step 5 — the copier
- Unbuilt
- 1Step 6
- Gates passed
- 0 of 3Gate 1 substantially met, not signed off
Steps 1 to 3 change nothing a user can see and can ship on ordinary release days. Behaviour changes at step 4. Nothing is deleted or moved at any point, in any step — so every reversal below lands on an original that was never touched.
Step 1 — Store the full file path
Widen the columns and fill in where every file is now
complete- What it does
- Every column and JSON key that holds a file name is retyped to fit a complete path, then backfilled with where the file is now — the legacy flat folder — never where it is going.
- Why first
- Nothing else can be built on a value that does not say where its file is. Until the row carries the whole answer, every reader has to keep guessing the folder.
- How it was built
- Nine columns retyped: two widened to
varchar(512), two totext, and five converted from a comma-joined string tovarchar[]with each element trimmed and the empties dropped. Seventeen columns and thirty-two JSON key paths backfilled. Widths were raised before any value was written, so no statement in the backfill could overflow. - The rule that protects it
- A value that already contained a slash was left alone — it is either a complete path already or an address somewhere else, and prefixing either would break it.
- Reversal
- Each migration has a
down. The originals are untouched in the bucket, so a reverse restores bare names against folders that still hold every object. - State
- Complete. 1,488,632 values re-counted in
clone1; 1,488,594 hold a complete path. The 38 that do not are pre-existing and appear identically incapClone.
Step 2 — One rule finds every file
Every path comes out of one table
partial- What it does
- Every place that glued a location together by hand now calls one shared rule. A module name goes in, a prefix comes out; nothing else builds a storage path.
- Why it matters
- A single rule is what makes step 4 a change in one place rather than a change in every upload site, and what stops a value already in the new tree being prefixed a second time.
- How it was built
UPLOAD_MODULE_TARGETSinbackendApi/src/shared/modules/storage/upload-targets.tsis the table.uploadTargetForthrows when a target whose scope needs a branch does not name one, so a misconfigured module fails at presign rather than quietly writing a level too high. The front end mirrors it incapExpertApp/src/app/core/utils/media-paths.ts.- Reversal
- Code-only. Reverting the branch restores the hand-built prefixes; no data changes.
- State
- Done for the API and the web app. The mobile leg is not: neither
mobileAppnormobileApicarries any of this storage work.mobileApihas CAP-2031 commits, but they are unrelated release fixes onrelease/v15.
Gate 1 — stop here
The point of no ambiguity
partial- The criteria
- Steps 1 and 2 finished and live. Every repo green. Nowhere builds a location by hand. Older records holding only a file name still display. Nothing the website depends on has been lost from the published interface. The step-1 reversal performed once. Zero files moved.
- Why a full stop
- The system now knows where every file lives and not one file has moved. That is the only moment in the plan where both halves are simultaneously true, and it is the base every later rollback returns to.
- State
- Substantially met, not signed off. Zero files have moved, and the path rule is in place across the API and web app. Two criteria are open: the mobile leg of step 2, and evidence that the step-1 reversal has been performed once for real.
Step 3 — Thumbnails learn both shapes
A thumbnail beside every picture, in either layout
deployed · running- What it does
- The thumbnail service watches the whole
img/tree, not one folder, and writes the small copy at<same folder>/thumb/<same file name>. - Why before step 4
- A picture uploaded into the new layout with no service watching it gets no thumbnail, and nothing goes back for it later. The service is trigger-only.
- How it was built
- A separate Node 22 service in
thumbnailService/, on a GCS finalize trigger. A staged thumbnail is moved rather than regenerated; an original under 50 KB is copied as its own thumbnail. Public and thumbnail are separate decisions.THUMB_FOLDER_KEYSis derived from thethumbflags on the upload targets, so marking a module thumbnailed is one edit. - Reversal
- Stop the trigger. Thumbnails are derived objects that no record references — readers compute the address from the picture — so removing them breaks no row.
- State
- Deployed and running as a Cloud Function on the bucket's finalize event, and
node --testreports 78 of 78 passing. This step is closed.
Step 4 — New uploads change shape
New files land in the account folder
partial- What the plan says
- Flip one setting. New files land in the account's folder, staged under
temp/until the record is saved. Existing files untouched. - How it was actually built
- The staging half is built exactly as described —
tempPrefixForstages,commitUploadsfiles on save and returns the final paths, and the caller writes them back onto the record. The flag was not.finalPrefixForreturns the new prefix unconditionally; no setting gates it. - Why that matters
- The setting was the plan's mechanism for ordering step 4 after step 3, and for reversing step 4 without a deploy. Without it, step 4 went live as soon as steps 1–2 shipped rather than on a deliberate flip, and it can now only be reversed by a deploy. In
clone126 values already sit in the new tree. The ordering held — step 3 is deployed, so those uploads did get their thumbnails — but by circumstance rather than by design. - Reversal
- Code-only, and therefore a deploy rather than a setting change. Objects already written to the new layout stay readable, because the value in the row is a complete path either way.
- State
- Live in effect, but not as the plan specifies. The behaviour is on; the switch that was supposed to control it does not exist.
file_migration_enabledis a different flag — it is the copier's run switch, not the upload shape. Since step 3 is deployed, the practical risk is reversibility rather than missing thumbnails.
Gate 2 — stop here
An upload works end to end, and an abandoned one is reclaimed
not started- The criteria
- An upload from web and from phone lands in the right folder and gets a thumbnail; five abandoned uploads never leave
temp/. - State
- Cannot currently be passed. The thumbnail half needs step 3 deployed, and the phone half needs the mobile leg of step 2. The reclaim half is built:
TempUploadCleanupJobruns daily and releases a staged object only when two independent answers agree — nothing in the schema names its path, and the ledger accounts for the object.
Step 5 — Copy the existing files
Move the pre-cutover objects into the new layout
built · not run- What it does
- Account by account, copy each pre-cutover object to where the new layout says it belongs, verify it, rewrite the record, and leave the original in place.
- How it was built
- CAP-680. Two ledger tables, a seeder that fills the queue with one row per destination object, a runner that copies in chained batches across parallel lanes, and a write-back pass that rewrites the records. Set out in full on CAP-680.
- Reversal
- Built and symmetric. The ledger keeps both halves of every rewrite, so the rollback is the same SQL with
stored_valueanddestination_pathswapped rather than a second implementation that could drift. - State
- Built, never run.
file_migration_queueandfile_migration_batcheshold zero rows inclone1. The copier also has no descriptor for 30 of the 73 columns that can hold a path — see CAP-680 Gaps.
Gate 3 — stop here
One account, for real, including the rollback
not started- The criteria
- One account copied, verified, and rolled back once for real before anything runs at scale.
- Why it is the strictest gate
- It is the only gate that tests the reversal on live data. Every later batch depends on the rollback working, and a rollback that has never been run is a claim rather than a fact.
- State
- Not performed. The queue has never been seeded, so no account has been copied and nothing has been rolled back. A rehearsal mode exists for exactly this —
FILE_MIGRATION_TARGET_PREFIXwrites every copy under a scratch folder while still copying and writing back consistently, so the whole path can be exercised end to end without touching a real destination.
Step 6 — Keep and watch the old folders
Let evidence decide when the old folders can go
not started- What it does
- Stop writing to the legacy folders, log which app versions still read them, and let the evidence — not a date — decide when they can be removed.
- Why it is last
- Customers run mobile versions we cannot update. The log is the only way to know when the last of them has stopped asking.
- State
- Unbuilt. No legacy-read logging exists anywhere in
backendApi. The first half is true by construction — nothing writes to the legacy folders any more, becausefinalPrefixForonly produces new-layout paths — but nothing is recording who still reads them.
Step 6's logging and step 4's flag are the only items in the plan that are neither repair nor migration. They were deliberately left until the copier existed, on the reasoning that there is no point logging legacy reads before anything has moved. That reasoning holds for step 6. It does not hold for step 4's flag, which was the mechanism for keeping step 4 behind step 3 — and step 4 is already live.