CAP-680 · step 5 of the plan

What CAP-680 Does

CAP-680 builds step 5: the machinery that copies every pre-cutover object into the per-account layout and rewrites the records that name it. This page is what it delivers. What it leaves behind is on the next page.

Commits
10Across three repositories
API
53 files+3,182 −355 in backendApi
Web
11 files+1,621 −24 in capExpertApp
Descriptors
2118 active, 2 skipped, 1 without write-back
Console
11Admin endpoints behind AdminGuard
The one-line summary

Two ledger tables, three background jobs and an admin console. The ledger is keyed on the destination object rather than the source, because one legacy object referenced by several accounts owes each of them its own copy — the path is the access model, so a shared object cannot stay shared.

The ledger

Two tables, added by 20260905110855-create-file-migration-tables.js.

The index that enforces the model

file_migration_queue_destination_uq is unique on destination_path where the row is neither deleted nor skipped. That single index is what collapses the many references sharing one destination into the one copy they owe, and what makes a re-seed a no-op rather than a duplicate.

The 21 descriptors

MIGRATION_DESCRIPTORS in shared/modules/storage/migration-targets.ts is the mirror of the upload table: upload-targets.ts says where a new file goes, this says where every pre-cutover file came from. The two meet at UploadModule, and the destination is always derived through finalPrefixFor rather than spelled out again.

DescriptorLegacy folderWrite-back shape
camp_inventory_items.imagesinventory/imagesarray
inventory_sticker_details.sticker_nameinventory/imagesscalar
warranties.fileinventory/warrantycsv
document_manager.file_pathdocument-managerscalar
feedbacks.attachmentfeedbackarray
chat_messages.textchat-message-documentsscalar
transactions.sellerInvoiceinventory-invoice-pdfjsonbKey
transactions.buyerInvoiceinventory-invoice-pdfjsonbKey
transactions.poDocumentdocument-manager/purchase-ordersjsonbKey
transactions.poAttachmentstransaction/po-attachmentjsonbArrayKey
transactions.cerDocumentcer-documentsjsonbKey
transactions.billOfSalebill-of-salesjsonbKey
transactions.shippingQuotedocument-manager/shipping-documentsjsonbKey
transactions.bolDocumentdocument-manager/shipping-documentsjsonbKey
equipment_models.imagesinventory/imagescsv
equipment_models.filesinventory/user-manualcsv
equipment_categories.default_imagecategoriesscalar
bill_of_sale_requests.file_namebill-of-salesscalar
qr_codes.imageassets/QRnone — path derived from the id
ezestimator_requests.file_pathez-estimator-requestsskipped — no upload module
capture_billings.invoice_pathskipped — owner ambiguous
Five shapes, because five things can hold a path

scalar is assigned. csv and array are rebuilt element by element, preserving order. jsonbKey replaces one key in place. jsonbArrayKey rebuilds an array of objects with one key swapped inside each element — added for poAttachments, the only array-of-objects column in the set, which had gone without a target and was being copied but left naming the legacy path.

What each descriptor moves, with a real file

One actual value out of clone1 per descriptor, and the destination its upload module gives it. {CAP} stands for the owning account, because the destination is resolved per row.

DescriptorKey / shapeAn actual file today → where the copier sends it
camp_inventory_items.imagesarray elementinventory/images/00022jpe_1778195211181.jpegimg/{CAP}/assets/00022jpe_1778195211181.jpeg
inventory_sticker_details.sticker_namewhole valueinventory/images/00022jpe_1778195211181.jpegimg/{CAP}/assets/00022jpe_1778195211181.jpeg
warranties.filecomma list entryinventory/warranty/CDL_USHV_Lafayette_Horizonpd_1774621762664.pdfdoc/{CAP}/assets/warranty/CDL_USHV_Lafayette_Horizonpd_1774621762664.pdf
document_manager.file_pathwhole valuedocument-manager/01022026_Asset_7168_PMpd_1782934508186.pdfdoc/{CAP}/document-manager/01022026_Asset_7168_PMpd_1782934508186.pdf
feedbacks.attachmentarray elementfeedback/Cooper_Surgical_Cleartone_pd_1717612100050.pdfdoc/{CAP}/feedback/Cooper_Surgical_Cleartone_pd_1717612100050.pdf
chat_messages.textwhole valuechat-message-documents/1010SalesSheetpd_1771260090875.pdfdoc/{CAP}/chat-message/1010SalesSheetpd_1771260090875.pdf
transactions.additional_documentssellerInvoicedoc/CAP-NJQJGDJ/copilot-invoice/sold_17887864806705WE6X.pdfdoc/{CAP}/invoice/sold_17887864806705WE6X.pdf
transactions.additional_documentsbuyerInvoiceezrfp-invoice-pdf/purchased_1680301533907.pdfdoc/{CAP}/invoice/purchased_1680301533907.pdf
transactions.additional_documentspoDocumentcer-documents/1015_Stretcher_Equi_Demojpe_1724265729018.jpegdoc/{CAP}/document-manager/purchase-orders/1015_Stretcher_Equi_Demojpe_1724265729018.jpeg
transactions.additional_documentspoAttachments[].attachmentcer-documents/396891798735333pd_1734545674644.pdfdoc/{CAP}/transaction/po-attachment/396891798735333pd_1734545674644.pdf
transactions.additional_documentscerDocumentcer-documents/Bill_of_Salepd_1723044118308.pdfdoc/{CAP}/cer-documents/Bill_of_Salepd_1723044118308.pdf
transactions.additional_documentsbillOfSalebill-of-sales/Bill_of_sale_1719219486320Z5RTY.pdfdoc/{CAP}/bill-of-sales/Bill_of_sale_1719219486320Z5RTY.pdf
transactions.additional_documentsshippingQuotedocument-manager/shipping-documents/1743547691705_1743547691705.pdfdoc/{CAP}/shipping-document/1743547691705_1743547691705.pdf
transactions.additional_documentsbolDocumentdocument-manager/shipping-documents/Bill of Lading 1765229943240_1765229943241.pdfdoc/{CAP}/shipping-document/Bill of Lading 1765229943240_1765229943241.pdf
equipment_models.imagescomma list entryinventory/images/00H99jpe_1755814330672.jpegimg/model-library/00H99jpe_1755814330672.jpeg
equipment_models.filescomma list entryinventory-reference/files/000443_GEM_Premier_ChemSTAT_IFUpd_1706009038989.pdfdoc/model-library/spec-files/000443_GEM_Premier_ChemSTAT_IFUpd_1706009038989.pdf
equipment_categories.default_imagewhole valuecategories/default_Anesthesia_Equipment.jpgimg/category/default_Anesthesia_Equipment.jpg
bill_of_sale_requests.file_namewhole valuebill-of-sales/Bill_of_sale_1719219486320Z5RTY.pdfdoc/{CAP}/bill-of-sales/Bill_of_sale_1719219486320Z5RTY.pdf
qr_codes.imagederived from the idassets/QR/000001.pngimg/qr/000001.png
ezestimator_requests.file_pathwhole value(table is empty)skipped — nothing to copy
capture_billings.invoice_pathwhole value(table is empty)skipped — nothing to copy
The destination comes from the module, not from where the object is

Three descriptors declare a legacy folder the data does not match, and because finalPrefixFor derives the destination from the module, the copy lands in the domain the descriptor names rather than the one the file came from.

Nothing is lost — the record is rewritten to the new path, so it still reads — but the per-account tree is the access model, and a CER document filed under purchase orders puts the file in a folder its kind does not belong to.

The two skipped descriptors cost nothing today

ezestimator_requests and capture_billings are both empty tables in clone1 — zero rows. Their skipReason entries are correct and there is no work behind them to schedule.

Three stages

Stage 1

Seed — fill the queue

complete
Shape
One set-based INSERT per descriptor. Source paths, destinations and owners are all resolved in SQL, so a table with hundreds of thousands of references costs one statement rather than a row loop.
Two guards
ON CONFLICT on the destination collapses shared destinations into the one copy owed. A NOT EXISTS covers what that index cannot — a skipped row sits outside the partial index, so without it a re-seed would insert the work again. It matches on destination and source, because a stored value that gains a folder yields a different source but the same destination.
Why a lock
Neither guard survives two seeders at once: both would pass NOT EXISTS before either inserts. A Redis lock with a 600-second TTL makes the button safe to press twice.
Skipped rows
A descriptor with a skipReason seeds as skipped carrying that reason, so it appears in the report rather than being silently absent.
Stage 2

Run — copy the objects

complete
Shape
A chain, not a loop. Each invocation takes one batch, copies it, and dispatches the next — so no invocation approaches the Cloud Tasks delivery ceiling, and a deploy mid-run costs at most the batch in flight.
Parallelism
lane is what makes it parallel. Start dispatches one chain per lane and each link re-dispatches into its own lane, so the number of batches in flight stays at the configured count without any lane knowing about the others. Files copied at once is parallelBatches × fileConcurrency.
Stopping
file_migration_enabled is re-read at the head of every link. That is the whole of the stop mechanism. A rerun carries a batchId and never chains, so retrying a batch cannot restart a stopped run.
Failure
Errors are swallowed rather than rethrown: the outcome of every file is already recorded on its row, so a Cloud Tasks redelivery would repeat work whose result is known. A batch left running past 30 minutes is a run that died; the log surfaces it and a rerun clears it.
Tuning
Four environment variables, all required and range-checked by Joi at boot: batch size, batch delay, parallel batches, file concurrency. Deployment tuning rather than app state, so unlike the run switch the page does not write them.
Stage 3

Write back — rewrite the records

complete
Shape
One statement per descriptor for the whole queue, so there is nothing to chain and nothing to divide between lanes — a handful of statements that finish in seconds.
Rollback
The ledger keeps both halves of every rewrite — stored_value is what the column held before, destination_path what it holds after — so the rollback is the same SQL with the two swapped, rather than a second implementation that could drift from it.
The wider match
A pass matches a wider set of statuses than it retires from, on purpose: the match is on the value the column actually holds, and that value is gone once the rewrite has run. A row retired without its column being touched is therefore picked up by the next press instead of being stranded beyond the reach of both directions.
Declared omissions
A descriptor with neither a writeBack nor a noWriteBack is a gap, and every batch carrying one says so. Without that, its records would keep the legacy path while the ledger claimed otherwise.
Locking
One short-lived, heartbeated lock per direction. Held outright, a process killed mid-pass would never reach its own release and every later attempt would be refused until it lapsed.

The console

capExpertApp/src/app/modules/developer/file-migration/ — 1,570 lines across the component and its template, behind AdminGuard on eleven endpoints:

EndpointDoes
GET /file-migration/summaryQueue and batch totals, plus what is running
GET /file-migration/batchesBatch list, paged
GET /file-migration/batches/:id/filesThe files in one batch
GET /file-migrationThe file list, filterable
POST /file-migration/seedFill the queue
POST /file-migration/runSet the run flag and dispatch one chain per lane
POST /file-migration/stopClear the run flag; the chain stops at its next link
POST /file-migration/write-backRewrite the records
POST /file-migration/roll-backThe same pass reversed
POST /file-migration/batches/:id/retryRe-run one batch without restarting the run
POST /file-migration/files/:id/retryRe-run one file
What the page tells you that the ledger alone does not

A Redis activity key with a 30-second TTL, heartbeated every 10 seconds, names the background action in flight. The import role sets it while seeding, writing back or resetting; the primary role reports it, so the page can say what is busy. Short on purpose — a job killed mid-flight never runs its own cleanup, so the key has to lapse by itself rather than lock the page for minutes.

The list is coloured by what the record names rather than by ledger status alone, which is what makes a row whose copy succeeded but whose write-back did not visible at a glance.

Five correctness decisions worth knowing

  1. Soft-deleted rows are included. Nothing filters on deleted_at. A soft-deleted row still names an object that is still in the bucket, and the row can be restored — so the file is owed a copy and the column is owed the rewrite. Skipping them would leave the only reference to those objects on a path the migration has moved away from.
  2. The layout guard reads the path, not the config. A record written during a rehearsal keeps <prefix>/img/… after the prefix is cleared. Comparing against the prefix in force now would read those as legacy and queue every one again — so the test is on the path's own first two segments. No legacy folder has img or doc there, so it cannot swallow a real one.
  3. Ownership is resolved per column, not per table. transactions.additional_documents holds one file per key and the keys do not share an owner — an invoice is issued to each side separately, while a PO and its attachments are the buyer's. Each key is therefore its own descriptor rather than one pass over the blob.
  4. QR codes are derived, not stored. Nothing stores a QR code's path; every reader computes it from the id. So the seeder computes it the same way, and greatest(6, length(id)) pads rather than truncates — plain lpad(id, 6) would turn 1000000 into 100000, which is another code's file rather than a missing one.
  5. Two descriptors shared an upload module and hid an omission. bill_of_sale_requests.file_name shares BillOfSale with transactions.billOfSale, whose statement was retiring those rows on its behalf — marking them written back while the request still named the legacy path. It now has its own write-back target.