spec(SD-0002 §6.3.1): storage design — Cloud SQL additive, per-env GCS bucket, key layout, app-served images with CDN seam

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 12:38:36 -07:00
parent 4869c6141b
commit 50574fe68b
@@ -743,6 +743,57 @@ meaning are #15's design. In this MVP a non-`standalone` `Type` or a
non-empty `Component n` column is a row error ("kits arrive in a coming
release").
#### 6.3.1 Storage: GCP resources, layout & serving
**Catalog data — Cloud SQL for PostgreSQL, no new resource.** The §6.3
tables are additive forward-only migrations in the existing per-environment
Cloud SQL instance (PPE: `ecomm-ppe-pg`; prod: its sibling when prod stands
up). At MVP scale (≤ 5k products × ~30 columns + run/error records) this is
tens of megabytes. Image *bytes* never enter Postgres (§6.7).
**Image bytes — one GCS bucket per environment**, project
`wiggleverse-ecomm`, same region as the VM, Standard class, uniform
bucket-level access, versioning off, **no public access**. Provisioning is a
flotilla/launch-app gesture (a `provision-bucket` sibling of
`provision-datastore`), run by the operator when the first slice needing it
reaches PPE (handbook §8.5); the bucket name reaches the app via deployment
config (SD-0001 INV-8). The VM's service account gets
`roles/storage.objectAdmin` scoped to this bucket only. Local dev and tests
use the `objectstore` port's local-disk adapter under the identical key
scheme — no GCS, no emulator.
**Key layout** — tenant-scoped, immutable, cache-friendly:
```
gs://wiggleverse-ecomm-<env>-media/
storefronts/{storefront_id}/product-images/{image_id}/original ← bytes as fetched
storefronts/{storefront_id}/product-images/{image_id}/thumb.webp ← ~160px
storefronts/{storefront_id}/product-images/{image_id}/card.webp ← ~480px
storefronts/{storefront_id}/product-images/{image_id}/detail.webp ← ≤1600px
import-drafts/{draft_id}/upload.csv ← preview blobs
```
- The `storefronts/{storefront_id}/` prefix extends INV-14 into storage.
- Keys are `image_id`-addressed and **written once, never mutated** — a
changed image is a new image id, so every object is immutable and serves
with `Cache-Control: public, max-age=31536000, immutable`.
- Renditions normalize to **WebP**; the `original` keeps its source format
(provenance; renditions can be re-derived if the bar or sizes change).
- `import-drafts/` carries a **GCS lifecycle rule: delete after 1 day** —
the deterministic safety net behind app-level draft deletion (PUC-3a), so
a crash can never leak a merchant's uploaded file.
**Serving — app-served with a CDN seam (decided, §6.7).** Surfaces load
images from `GET /api/products/images/{image_id}/{rendition}` (§6.4): the
app authorizes (image → storefront, INV-14/16), streams from the bucket, and
sets the immutable cache headers. Exported `Image Src` URLs therefore live
on the **app's own domain** — they survive the `market.wiggleverse.org`
rebrand (#23) via ordinary redirects, and hosted-URL recognition (§6.3) is a
same-origin path parse. **The seam:** when shopper-facing storefronts (#24)
bring real image traffic, a CDN-fronted bucket (LB + Cloud CDN +
`images.<domain>`) takes over the same keys; only the URL builder and the
serving route change.
### 6.4 Interfaces & contracts
All under `/api/products/*`; session cookie + storefront membership required
@@ -762,6 +813,7 @@ a spec change.
| `GET /api/products/export` | `?status=all\|active\|draft\|archived` | `200 text/csv` (streamed) — canonical format, hosted image URLs (PUC-9, INV-12) | `409 empty_catalog` |
| `GET /api/products/sample.csv` | — | `200 text/csv` — the documented sample (PUC-11) | — |
| `GET /api/products/summary` | — | `200 {product_count, image_problem_count, latest_run_id}` — drives the Products page header + notice band (§5.2) | — |
| `GET /api/products/images/{image_id}/{rendition}` | `rendition ∈ original\|thumb\|card\|detail` | `200 image bytes` — storefront-authorized, streamed from the bucket, `Cache-Control: immutable` (§6.3.1, INV-16) | `404` (incl. wrong storefront) · `409 not_fetched` (status ≠ fetched → SPA shows placeholder) |
### 6.5 PerProduct-Use-Case design
@@ -968,6 +1020,8 @@ endpoints; PUC-11 (sample CSV) is a static documented asset served at
| Progress delivery | SPA polls run detail | WebSockets/SSE | One poll endpoint, no connection infra; seconds-stale progress is fine for this UX. |
| Notifications | In-app only | Email summaries via SmtpMailer | Operator decision; revisit if imports go async/long. |
| Object storage | New `platform/objectstore` port (local/GCS) | Store images as DB blobs; reuse a public bucket directly in domain code | Follows the mailer port pattern (SD-0001); DB blobs bloat backups and can't serve renditions efficiently. |
| Image serving | App-served route + private bucket, CDN seam named (§6.3.1) | Public bucket with direct GCS URLs; CDN + custom domain now; signed URLs | Exported URLs stay on the app domain (rebrand-safe #23, hosted-URL recognition trivial); bucket stays private; CDN waits for shopper traffic (#24). Signed URLs ruled out — expiry poisons the round-trip (INV-12). |
| Rendition format | WebP renditions, original kept as-source | Keep source format everywhere; AVIF | WebP: universal browser support + good compression; original retained so renditions can be re-derived (incl. a future AVIF tier). |
### 6.8 Testing strategy