CAP-2031 · stored file paths

Field Changes & New Layout

Every file column now holds the file’s complete path instead of its bare name, and new uploads are filed under the account that owns them. This page lists what changed, column by column, and the path shape those columns now hold. Checked against clone1, with capClone as the comparison.

Columns retyped
9Widened, or split into an array
Columns backfilled
17Bare name to complete path
JSON key paths
32Across 10 JSON columns
Values verified
1,489,015Read back out of clone1
Bare names left
38All of them pre-existing

The change

one line
Was — the file name
pump_1788.jpg
Every reader had to know which folder the value came from, and that folder was not written down anywhere the row could see.
Now — the complete path
img/CAP-BBA2A600/assets/pump_1788.jpg
Read the stored value as it stands. Do not prefix a folder onto it.

The new layout

one bucket, two trees

Everything stays in the bucket we already have. The path is what separates one account’s files from another’s, so the path shape is the access model — not a cosmetic detail. The first segment is always the tree:

TreeHolds
imgPictures — asset photos, logos, tag sheets, QR codes
docEverything else — quotes, contracts, invoices, exports, PDFs

The second segment says who the file belongs to, and that decides the rest of the shape:

account        <tree>/<capexId>/<domain>/<file>
account-user   <tree>/<capexId>/<branch>/<userId>/<domain>/<file>
user           <tree>/<capexId>/user/<domain>/<file>
library        img/<branch>/<file>          doc/<branch>/<domain>/<file>
global         <tree>/<branch>/<file>   belongs to nobody

Worked through, with real values out of clone1:

img/CAP-0YNN74P/assets/2BQISjpe_1782915476233CTS5HSW.jpeg          account
img/CAP-BBA2A600/user/profile/avatar_1788.png                      user
doc/CAP-NJQJGDJ/copilot-quote/OrderCAPSKOCI7V4DZZE9K.pdf           account
doc/CAP-NJQJGDJ/copilot/warranty/file_example_XLS_PRPTP48.xls      account
doc/CAP-048YNSJ/document-manager/purchase-orders/OrderCAPSK.pdf    account
doc/CAP-BBA2A600/export/1/assets/camp_export_1788.xlsx             account-user — user 1 ran it
img/model-library/pump_x200.jpg                                    library
doc/export/system/nightly_1788.xlsx                                global

A browser upload cannot be written to its final home, because the record referencing it does not exist yet. So it lands in temp, which takes the owner slot and leaves everything below it intact — a staged path already says what it will become:

img/temp/assetsimg/<capexId>/assets
doc/temp/export/1/assetsdoc/<capexId>/export/1/assets

Filing copies; it does not move. A failed filing therefore still reads — the record keeps the staged path, and that path still resolves. Where a module has thumbnails, the small copy sits at <same folder>/thumb/<same file name>.

Column type changes

9 columns

A complete path does not fit where a file name did. Widths were raised before any value was written, so no statement in the backfill could overflow.

Table.columnBeforeAfterWhy
users.profilevarchar(150)varchar(512)One path per row
equipment_categories.default_imagevarchar(255)varchar(512)One path per row
equipment_models.filesvarchar(255)textSeveral paths joined in one value — no fixed width bounds the total
warranties.filevarchar(255)textSeveral paths joined in one value
feedbacks.attachmentvarchar(200)varchar[]One path per element
copilot_launch_proposals.warranty_documentvarchar(255)varchar[]One path per element
copilot_launch_proposals.service_agreementvarchar(255)varchar[]One path per element
copilot_launch_proposals.equipment_condition_attachmentvarchar(255)varchar[]One path per element
copilot_requests.attachmentstextvarchar[]One path per element

The five varchar[] columns used to hold one comma-joined string; each file is now its own element, trimmed, with the empties dropped. A value that was nothing but separators became NULL rather than an array of blanks — an empty array reads as present in JS and would defeat every .length guard. Three further columns took paths without needing a widening, because they were already text: equipment_models.images, copilot_launch_proposals.quote and copilot_requests.quote.

Columns backfilled to complete paths

17 columns

Existing rows were filled in with where the file is now — the legacy flat folder — never where it is going. The copier moves the object and the save then records the new path. 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.

Table.columnShapeFolder it was resolved against
users.profileone pathprofile/
equipment_categories.default_imageone pathcategories/
equipment_models.imagescomma listinventory/images/
equipment_models.filescomma listinventory-reference/files/
inventory_sticker_details.sticker_nameone pathinventory/images/
camp_inventory_items.imagesvarchar[]rebuilt from sticker_name
warranties.filecomma listinventory/warranty/
bill_of_sale_requests.file_nameone pathbill-of-sales/
feedbacks.attachmentvarchar[]feedback/
shipping_estimates.bol_documentone pathdocument-manager/shipping-documents/
docusign_requests.attachmentsone pathcer-documents/ or pa-documents/, by request_type
copilot_launch_proposals.quotecomma listezrfp-quote/
copilot_launch_proposals.warranty_documentvarchar[]ezrfp-warranty/
copilot_launch_proposals.service_agreementvarchar[]service-agreement/
copilot_launch_proposals.equipment_condition_attachmentvarchar[]inventory/images/
copilot_requests.quotecomma listezrfp-quote/
copilot_requests.attachmentsvarchar[]ezrfp/attachments/

Comma lists and arrays were rebuilt entry by entry in their original order — the first picture is the one shown as the preview, so a reshuffle would have changed what customers see on thousands of rows. docusign_requests.attachments also carries a fix: a CER row named pa-documents/… while the object sat in cer-documents/. Those rows are re-pointed at the folder the object is actually in.

File references inside JSON

32 key paths · 10 columns

Each row was read, rewritten in memory and written back only where a key actually changed. Order inside an array is preserved.

Table.columnKeys that now hold a complete path
users.agreement_detailsname
biomeds.extracted_databiomedFile
copilot_launch_proposals.additional_detailsparentQuote · specifications[].fileName
copilot_parent_requests.action_logsequipments[].changes[].new · equipments[].changes[].old
only where the sibling key is quote or attachments — without that test a status or a zip code would be prefixed as though it were a file
transactions.additional_documentspoDocument · poAttachments[].attachment · cerDocument · billOfSale · shippingQuote · bolDocument
transaction_equipment_details.equipment_detailsimages · referenceImages · userManualPdf · serviceContract · warrantyFile · equipmentCategoryData.img · inventoryStickerData.inventoryStickerDetail[] · …[].stickerName · …[].sticker · childInventories[].equipmentCategoryData.img · childInventories[]…stickerName · tradeInventoryDetail.images · tradeInventoryDetail.newImages[] · tradeInventoryDetail.equipmentCategoryData.img
po_requests.additional_detailscomparisonView.fileName
shipping_estimates.request_detailsitems[].images[]
shipping_estimates.shipping_responses[].fileName
shipping_estimates.quote_email_detailsattachments[]

Columns that already stored complete paths were left exactly as they were, as was email_logs.data — a frozen copy of what the mailer was sent, which rewriting would falsify.

Also changed

4 things worth knowing
equipment_models.images_old — new column
A straight snapshot of images taken before the conversion. 1,607 of its non-empty rows held scraped vendor JSON rather than file names; those were cleared out of images so the backfill only ever prefixed real names. It is an archive: no entity declares it and nothing should read it.
transactions.additional_documents — two new keys
sellerInvoice and buyerInvoice, built from invoice_name plus the folder the order’s request type wrote to. Transfers raise no invoice, so those rows get neither key; FMV shares one object between both sides.
file_migration_batches · file_migration_queue — new tables
The ledger that drives moving pre-cutover objects into the new layout. One queue row per destination object, not per source: one legacy object referenced by several accounts owes each of them its own copy, because the path is the access model.
update_search_key_for_warranties() — replaced
The warranty search key now takes only the part after the last slash. Left alone, inventory/warranty/abc.pdf would have indexed the folder too, and a search for “warranty” would have matched every row that has a document.

Read back out of the database

clone1 · 2026-09-08

Every value in every column and key above, counted by whether it contains a slash.

Column or keyValuesComplete pathBare name
inventory_sticker_details.sticker_name757,840757,8400
camp_inventory_items.images678,643678,60538
biomeds.extracted_data → biomedFile20,05220,0520
copilot_launch_proposals.quote9,0439,0430
equipment_models.images8,2808,2800
equipment_models.files5,0885,0880
copilot_launch_proposals.additional_details → parentQuote3,4353,4350
users.agreement_details → name1,0861,0860
transactions.additional_documents → sellerInvoice9629620
transactions.additional_documents → buyerInvoice9619610
transactions.additional_documents → poDocument7547540
copilot_requests.attachments6846840
copilot_launch_proposals.warranty_document6806800
transactions.additional_documents → poAttachments[].attachment4824820
transaction_equipment_details.equipment_details → images3893890
copilot_requests.quote1141140
equipment_categories.default_image1051050
shipping_estimates.shipping_responses → [].fileName92920
copilot_launch_proposals.service_agreement74740
docusign_requests.attachments53530
po_requests.additional_details → comparisonView.fileName36360
transactions.additional_documents → shippingQuote35350
users.profile29290
transactions.additional_documents → cerDocument22220
warranties.file18180
transaction_equipment_details.equipment_details → userManualPdf15150
transactions.additional_documents → billOfSale12120
shipping_estimates.bol_document10100
bill_of_sale_requests.file_name880
transactions.additional_documents → bolDocument770
transaction_equipment_details.equipment_details → warrantyFile330
feedbacks.attachment220
copilot_launch_proposals.equipment_condition_attachment110
Total1,489,0151,488,97738

Two things to expect

before you read a path
Both path shapes are live at once, on purpose
An old file keeps its legacy flat path — inventory/images/x.jpg. A new save writes the new layout — img/CAP-…/assets/x.jpg. The backfill copies each legacy object to its new home and never deletes the source, so a half-migrated table reads correctly throughout. In clone1 today: 757,830 sticker paths still legacy, 10 already moved. A value with a slash is used as it stands either way.
38 bare names remain, and they predate this
All in camp_inventory_items.images, which is derived from inventory_sticker_details.sticker_name — and that column is fully converted. So those 38 array entries point at stickers that no longer exist. The same 38 are in capClone, so they are older than this change. Cleanup, not a blocker.

Against the prod clone

capClone

capClone is not a clean “before” picture. It already carries the conversion — every value in it is a complete path too — but all of them are still in the legacy flat folders. The new img/ and doc/ layout appears only in clone1.

MigrationcapCloneclone1
20260902101441 … 101448  widen + backfillappliedapplied
20260904102729  invoice pathsappliedapplied
20260902101440  PO paths on transactionsnot appliedapplied
20260905110855 · 110946  file-migration tablesnot appliedapplied
20260908064726  presigned-url refs to jsonbnot appliedapplied

The before-types in the type table were read from clone2 and capCloneDev, where the widening has not run — not inferred from the migration files.