From 50574fe68bf56c4fdf2bf33189a4d3bfe08a8a72 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Thu, 11 Jun 2026 12:38:36 -0700 Subject: [PATCH] =?UTF-8?q?spec(SD-0002=20=C2=A76.3.1):=20storage=20design?= =?UTF-8?q?=20=E2=80=94=20Cloud=20SQL=20additive,=20per-env=20GCS=20bucket?= =?UTF-8?q?,=20key=20layout,=20app-served=20images=20with=20CDN=20seam?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- ...SD-0002-products-bulk-csv-import-export.md | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/specs/SD-0002-products-bulk-csv-import-export.md b/specs/SD-0002-products-bulk-csv-import-export.md index 74ea2e1..64a81e0 100644 --- a/specs/SD-0002-products-bulk-csv-import-export.md +++ b/specs/SD-0002-products-bulk-csv-import-export.md @@ -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--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.`) 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 Per–Product-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