From a9137d17da028caffafa03f63c714141081ef93c Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Thu, 11 Jun 2026 08:49:15 -0700 Subject: [PATCH] =?UTF-8?q?spec(SD-0002=20=C2=A76.1=E2=80=936.4):=20invari?= =?UTF-8?q?ants=20INV-10..18,=20architecture,=20data=20model,=20REST=20con?= =?UTF-8?q?tracts?= 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 | 170 +++++++++++++++++- 1 file changed, 169 insertions(+), 1 deletion(-) diff --git a/specs/SD-0002-products-bulk-csv-import-export.md b/specs/SD-0002-products-bulk-csv-import-export.md index 87a7ca5..979239c 100644 --- a/specs/SD-0002-products-bulk-csv-import-export.md +++ b/specs/SD-0002-products-bulk-csv-import-export.md @@ -597,7 +597,175 @@ on demand and never committed. All surfaces live inside SD-0001's admin shell ## 6. Technical Design -*(not yet authored)* +Builds on SD-0001's shipped architecture (FastAPI backend, layered +`main → domains → platform` with import-linter enforcement; React/Vite admin +SPA; PostgreSQL everywhere). Invariant numbering continues from SD-0001 +(INV-1…9) so citations stay unique app-wide. + +### 6.1 Invariants + +- **INV-10 — Import never deletes.** No code path of the import/export + mechanism removes a product, variant, or image record from the catalog. + The domain API exposes no bulk-delete; "upsert" is the only mutation. + *Enforced:* domain surface (no delete function exists) + tests asserting + catalog cardinality never decreases across any import. +- **INV-11 — Nothing applies without confirmation.** Validation and preview + write nothing to catalog tables; only an explicit confirm mutates, and it + applies exactly the diff the preview showed — or refuses as stale + (BUC-2's faithfulness, mechanically). *Enforced:* the preview path holds + no write connection to catalog tables (tests assert zero writes); confirm + recomputes the diff and compares its fingerprint to the previewed one + (§6.5, PUC-3/4). +- **INV-12 — Export → import round-trips to a no-op.** Exporting a catalog + and re-importing the file unmodified produces an empty diff (PUC-10, + issue #13's acceptance bar). The canonical codec is lossless for every + field the catalog stores. *Enforced:* property-style test — for arbitrary + catalogs: `diff(catalog, import(export(catalog))) = ∅`. +- **INV-13 — `Handle` is product identity; the option-value combination is + variant identity.** Per storefront, one product per handle; per product, + one variant per (Option1, Option2, Option3) value combination. SKU is + correctable *data*, never identity. *Enforced:* unique indexes + (`(storefront_id, handle)`; `(product_id, option1_value, option2_value, + option3_value)`). +- **INV-14 — Every catalog row carries its storefront.** All product-domain + tables reference `storefront_id` directly or via their product; every + query is storefront-scoped (extends SD-0001 INV-5 to its first real + tenant-data domain). *Enforced:* FK constraints + service-layer scoping + + tests with two storefronts asserting zero bleed. +- **INV-15 — Imported HTML is sanitized at the boundary.** `Description` + HTML is sanitized against an allowlist (formatting tags; no scripts, + event handlers, or embeds) before storage. The catalog never stores HTML + we would not render. *Enforced:* sanitizer in the import path + tests + with hostile fixtures. +- **INV-16 — Surfaces serve only hosted images.** No storefront or admin + surface ever hotlinks a merchant-supplied URL; an image is + fetched-and-hosted (with renditions) or it is a placeholder with a + tracked status. The source URL is retained as data (provenance, retry). + *Enforced:* render paths take storage keys, not URLs; tests. +- **INV-17 — Dialects normalize at the boundary.** Every accepted file is + mapped to the canonical row model *before* validation and diffing; the + products domain knows exactly one model. A new dialect (XLSX, another + platform) is a new edge adapter, never a second code path through the + domain. *Enforced:* layering — dialect adapters live at the API/codec + edge; domain functions accept only canonical rows. +- **INV-18 — Import work is bounded and fails honestly.** Hard caps, + rejected whole-file with a named reason (PUC-5a): ≤ 5,000 data rows, + ≤ 10 MB file. Image fetching is bounded per image (≤ 20 MB, 30 s + timeout, content-type must be an image format we process). Over a cap is + an honest refusal, never a partial silent attempt (SD-0001 INV-9). + *Enforced:* checks at the upload/fetch boundaries + tests at the caps. + +### 6.2 High-level architecture + +```mermaid +flowchart LR + subgraph Browser + SPA[Admin SPA
Products section §5] + end + subgraph "ecomm backend (FastAPI, one process)" + BFF[REST BFF
main.py] + subgraph domains + ACC[accounts] + SF[storefronts] + PROD[products ◄ new
catalog · import/export
csv codec · dialects · diff] + end + subgraph platform + DB[db
Postgres + migrations] + OBJ[objectstore ◄ new
port: local / GCS] + IMG[images ◄ new
validate + renditions] + DEPS[deps] + end + BG[image-fetch task
in-process, post-commit ◄ new] + end + STORE[(PostgreSQL)] + GCS[(Object storage
GCS · dev: local disk)] + HOSTS[Merchant image hosts
egress fetch] + + SPA -->|JSON + multipart /api/*| BFF + BFF --> PROD + BFF --> ACC + BFF --> SF + PROD --> DB + PROD --> OBJ + PROD --> IMG + DB --> STORE + OBJ --> GCS + BG --> HOSTS + BG --> OBJ + BG --> DB +``` + +- **`products` domain (new)** — owns the catalog (products, variants, + images) and the import/export use cases: the canonical row model, the CSV + codec (parse/serialize), dialect detection + the Shopify mapping + (INV-17), validation, the diff engine (catalog × canonical rows → + add/update/unchanged/error), upsert application (INV-10/11/13), import + drafts and runs. Must never mint identity or storefronts; everything it + touches is storefront-scoped (INV-14). +- **`platform/objectstore` (new)** — a small port (`put / get / delete / + public_url`) with two adapters per the mailer pattern: local-disk + (dev/tests) and GCS (PPE/prod; bucket + credentials from deployment + config, SD-0001 INV-8). Owns no semantics. +- **`platform/images` (new)** — pure image processing: verify the bytes are + a decodable image (jpeg/png/webp), check the resolution bar, emit + renditions (thumb ~160px / card ~480px / detail ≤ 1600px — downscale + only). Model-free, deterministic, no I/O of its own. +- **Image-fetch task (new)** — the post-commit phase (PUC-7): an in-process + asyncio task per run that walks the run's pending images, fetches with + INV-18's bounds, runs `platform/images`, stores renditions via + `objectstore`, and updates per-image status + run progress. **This is the + async seam:** it is the only fire-and-forget work in the app; when + catalogs outgrow the in-process model, this task body moves onto a real + queue without changing its inputs/outputs (§6.9 covers crash recovery). +- **REST BFF** — gains the `/api/products/*` endpoints (§6.4); still owns no + business logic (SD-0001 INV-6). +- **Admin SPA** — gains the Products section (§5); renders what the BFF + returns; live progress is polling (no websockets in the MVP — deliberate). + +### 6.3 Data model & ownership + +All owned by the `products` domain; system of record PostgreSQL. Monetary +values are `NUMERIC`; no currency column — the MVP is single-implicit-currency +(open question Q-1, §13). + +| Entity | Key fields | Notes | +| --- | --- | --- | +| `product` | `id`, `storefront_id` (FK, INV-14), `handle` (unique per storefront, INV-13), `title`, `description_html` (sanitized, INV-15), `vendor`, `product_type` (`standalone`; `kit_virtual` / `kit_assembled` reserved for #15), `google_product_category`, `tags` (`text[]`), `status` (`draft \| active \| archived`), `published` (bool), `option1_name`…`option3_name`, timestamps | Option *names* live on the product; a no-option product has all names NULL and exactly one variant. | +| `variant` | `id`, `product_id` (FK), `position`, `option1_value`…`option3_value` (unique combo per product, INV-13), `sku`, `barcode`, `price`, `cost`, `weight` + `weight_unit`, `volume` + `volume_unit`, `tax_id_1`, `tax_id_2`, `inventory_tracker`, `inventory_qty`, `image_id` (FK → `product_image`, nullable), timestamps | The no-option product's single variant has NULL option values (uniqueness via `NULLS NOT DISTINCT`). SKU is indexed (component references, #15) but not unique — it is data, not identity. | +| `product_image` | `id`, `product_id` (FK), `position`, `source_url`, `alt_text`, `status` (`pending \| fetched \| rejected_low_res \| rejected_not_image \| failed`), `failure_reason`, storage keys (`original`, `thumb`, `card`, `detail`), `import_run_id` (FK — the run that introduced/last fetched it), `fetched_at` | Identity within a product is `source_url`: re-importing the same URL is "unchanged"; a new URL at a position is an update. INV-16 governs serving. | +| `import_draft` | `id`, `storefront_id`, `account_id`, `file_name`, `dialect`, file blob (objectstore key), diff summary + fingerprint, `unknown_columns`, `expires_at` (~1 h), `created_at` | The preview's server side. Canceled or expired drafts are deleted outright — no trace (PUC-3a). Never appears in history. | +| `import_run` | `id`, `storefront_id`, `account_id`, `file_name`, `dialect`, counts (`products_added`, `products_updated`, `rows_errored`), `status` (`applying \| fetching_images \| complete \| complete_with_problems`), `created_at`, `completed_at` | Created only at confirm (PUC-4). The durable record (PUC-8). | +| `import_run_error` | `id`, `run_id` (FK), `line_number`, `column_name`, `message` | One row per rejected CSV row (PUC-5); merchant-language message. | + +**Deliberately absent:** any `component` / kit table — the CSV format +reserves the columns (§6.5 dialect spec), but the entities, validation, and +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.4 Interfaces & contracts + +All under `/api/products/*`; session cookie + storefront membership required +(SD-0001's gates); errors share SD-0001's shape +`{"error": {"code", "message"}}`. Adding fields is free; renaming/removing is +a spec change. + +| Endpoint | In | Out | Errors | +| --- | --- | --- | --- | +| `POST /api/products/imports` | multipart CSV file | `201 {draft}` — dialect, summary counts, `unknown_columns`, `expires_at` (draft persisted server-side, INV-11) | `400 not_csv` · `400 missing_required_column` · `400 unknown_dialect` · `400 too_many_rows` · `413 file_too_large` (all PUC-5a — no draft created) | +| `GET /api/products/imports/drafts/{id}` | — | `200 {draft}` summary (re-fetch for the preview shell) | `404` · `410 draft_expired` | +| `GET /api/products/imports/drafts/{id}/records` | `?kind=add\|update\|unchanged\|error`, paging | `200 {records: [{handle, title, kind, variant_count, detail}]}` — `detail` carries field-level before → after diffs (PUC-3) or the error rows | `404` · `410 draft_expired` | +| `POST /api/products/imports/drafts/{id}/confirm` | — | `201 {run_id}` — diff re-computed and fingerprint-checked, applied in one transaction (INV-11), image task started, draft deleted | `404` · `410 draft_expired` · `409 preview_stale` (catalog changed since validation — re-upload) · `409 nothing_to_apply` (PUC-10) | +| `DELETE /api/products/imports/drafts/{id}` | — | `204` — draft and its blob deleted, no trace (PUC-3a) | `404` (idempotent enough) | +| `GET /api/products/imports/runs` | paging | `200 {runs: [...]}` — history, newest first (PUC-8) | — | +| `GET /api/products/imports/runs/{id}` | — | `200 {run}` — counts, status, error rows, image outcomes + `image_progress {done, total}`; the SPA polls this while `status != complete*` (PUC-4/7) | `404` | +| `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) | — | + +*(§§6.5–6.10 — per-PUC design incl. the canonical column spec and Shopify +mapping, NFRs, decisions, testing, failure modes, migration — next in the +loop.)* ## 7. Delivery Plan