Compare commits

..

73 Commits

Author SHA1 Message Date
ben.stull c18ddcc561 Merge pull request 'Pivot: strip storefront → network-service seed (v0.9.0)' (#33) from pivot-strip-storefront into main
ci / check (push) Has been cancelled
2026-06-12 16:32:49 +00:00
ben.stull 84a2bdbd78 chore(reset): drop e2e run artifacts swept in by git add -A
ci / check (push) Has been cancelled
ci / check (pull_request) Has been cancelled
These local Playwright run artifacts (.objectstore renditions, .backend.log,
test-results) stopped being ignored when e2e/.gitignore was removed with the suite,
so the strip commit wrongly added them. Remove; e2e is fully gone.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 09:31:37 -07:00
ben.stull df92e3f94c chore(reset)!: strip storefront → network-service seed (v0.9.0)
ci / check (push) Has been cancelled
Pivot ecomm from a generic multi-tenant Shopify-alternative storefront to the
commitment-commerce + cross-maker verified referral NETWORK direction
(ecomm-content/rfcs/network_strategy.md; OHM identity/relationality super-drafts).

Phase A of the reset — clear the decks:
- Strip the storefront surfaces with no carry-forward: the storefronts domain,
  the products import/export/image HTTP spine (service/imagefetch/repo + endpoints),
  the SPA frontend, and the Playwright e2e suite.
- Keep the reusable core: /healthz + /api/auth/* (accounts); the
  catalog-normalization library (codec/dialect/models/serialize/validate/diff/hosted —
  importable, no HTTP yet); platform/* infra. Migrations untouched (append-only).
- Reduce check.sh to backend-only (import-linter + pytest); trim dev.sh and the unused
  GCS overlay env. Repoint app.json/README/CLAUDE.md; bump VERSION 0.8.0 -> 0.9.0.

check.sh green: import-linter (main > domains > platform) KEPT, pytest passing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 09:30:01 -07:00
ben.stull e2d8b5caae Merge pull request 'SLICE-8: Shopify dialect — detect/map Shopify export at the codec boundary + DOC-2/3 (SD-0002 §7.2)' (#32) from slice-8-shopify-dialect into main
ci / check (push) Has been cancelled
2026-06-12 09:37:37 +00:00
ben.stull a851d3587c fix(products): conservative dialect detection — canonical-distinctive columns veto Shopify (SLICE-8 review)
ci / check (push) Has been cancelled
ci / check (pull_request) Has been cancelled
Addresses adversarial-review findings: a canonical file carrying a stray
Shopify-signature name (e.g. SEO Title) no longer misdetects as Shopify and
silently drops canonical Type / corrupts the weight unit (kg->g). A
canonical-distinctive column (Description/Variant Cost/Variant Weight/Google
Product Category/Variant Volume/Tax ID/Position) now vetoes detection, so
detection leans conservative (under-detection warns; over-detection corrupts).
Also closes the dual-named-column shadow (Body (HTML)+Description) and the
stale-weight-unit-on-clear case. Tests cover all three.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 02:36:09 -07:00
ben.stull cb6b5996f3 chore(products): SLICE-8 complete — bump v0.8.0; §12 traceability audited (SD-0002)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 02:28:18 -07:00
ben.stull 17f3b600fd test(e2e): e2e_import_shopify_dialect — Shopify export imports directly (PUC-6, SLICE-8)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 02:26:44 -07:00
ben.stull 6459e34480 docs(products): DOC-4 dialect-adapter notes (SLICE-8)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 02:24:44 -07:00
ben.stull 60a06de302 docs(products): DOC-2 column reference served at /api/products/columns.md (SLICE-8)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 02:24:05 -07:00
ben.stull 0aeccf7d2c feat(products-ui): Shopify 'mapped' label + format help links to column reference (PUC-6/PUC-11, SLICE-8)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 02:22:32 -07:00
ben.stull a00f5baaec test(products): INV-10 holds over a partial Shopify import (SLICE-8)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 02:21:34 -07:00
ben.stull f170950aec test(products): exhaustive Shopify->canonical mapping fixture (SD-0002 §6.5.1, SLICE-8)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 02:21:05 -07:00
ben.stull 0c7a76a305 feat(products): codec detects + maps Shopify dialect at the boundary (INV-17, SLICE-8)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 02:20:21 -07:00
ben.stull daa237dd59 feat(products): Shopify dialect adapter — header detection + canonical mapping (SD-0002 §6.5.1, SLICE-8)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 02:19:15 -07:00
ben.stull e16ec7cf94 docs(plan): SLICE-8 Shopify dialect implementation plan (SD-0002 §7.2)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 02:17:30 -07:00
ben.stull 8ed9b25771 Merge pull request 'SLICE-7: images pipeline end-to-end — fetch/host/serve + INV-12 over images (SD-0002 §7.2)' (#30) from worktree-slice-7-images into main
ci / check (push) Has been cancelled
2026-06-12 08:00:00 +00:00
ben.stull c929282e07 fix(products): bomb-safe image processing + per-image error isolation; honest claim/resumability docs (SLICE-7 review)
ci / check (push) Has been cancelled
ci / check (pull_request) Has been cancelled
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 00:58:10 -07:00
ben.stull 757412ef2a docs(products): image pipeline ops + seams (DOC-1/DOC-4); overlay bucket config; v0.7.0
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 00:48:44 -07:00
ben.stull 0775537d4c test(e2e): e2e_image_outcomes — fixture image host, per-image outcomes + notice band (SD-0002 §6.8)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 00:41:39 -07:00
ben.stull d81215be1d feat(products-ui): run-detail images section + progress poll, image-problems notice band, history images column (SD-0002 §5.2/§5.5)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 00:37:45 -07:00
ben.stull 7f51f5242f feat(products): app-served image route — storefront-authorized, immutable cache (SD-0002 §6.4, INV-16)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 00:34:04 -07:00
ben.stull 5d5341b29b feat(products): confirm → fetching_images + post-commit fetch scheduling + startup recovery (SD-0002 §6.5.3/§6.9)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 00:30:08 -07:00
ben.stull 23267c4d4c feat(products): image-fetch phase — SSRF guard, bounds, renditions, resume (SD-0002 §6.5.4, §6.9, INV-18)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 00:26:26 -07:00
ben.stull 13e74f4c61 feat(products): image-phase repo helpers + run progress/outcomes/counts (SD-0002 §6.5.4)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 00:23:09 -07:00
ben.stull 984dc98dee feat(products): diff resolves hosted image URLs to existing records — INV-12 over images (SD-0002 §6.3)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 00:19:23 -07:00
ben.stull 906bc87c96 feat(products): export hosted detail URL for fetched images; snapshot carries status (SD-0002 §6.5.5, INV-16)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 00:15:47 -07:00
ben.stull da5177c950 feat(products): hosted-image URL build/parse helpers (SD-0002 §6.3.1)
DB-free host-agnostic helpers: image_url() builds the canonical hosted
Image Src for a fetched image; parse_image_id() recognizes one on re-import
via path-parse so exports round-trip across localhost/PPE/rebrand (INV-12).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 00:13:27 -07:00
ben.stull 0fc29a34dd feat(platform): objectstore port — local + GCS adapters, config (SD-0002 §6.2)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 00:12:24 -07:00
ben.stull f27a24353d feat(platform): images port — decode, resolution bar (Q-3), WebP renditions (SD-0002 §6.2)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 00:09:58 -07:00
ben.stull 385df8d728 Merge pull request 'SLICE-6: export & the round-trip lock — canonical serializer + streamed export (SD-0002 §7.2)' (#29) from worktree-slice-6-export-roundtrip into main
ci / check (push) Has been cancelled
2026-06-12 05:23:23 +00:00
ben.stull af72299a57 fix(products): serialize Variant Position explicitly — INV-12 round-trip bug
ci / check (push) Has been cancelled
ci / check (pull_request) Has been cancelled
A stored variant position is a CatalogVariant attribute, not a fields{} entry,
so the serializer emitted an empty Variant Position cell — which re-imports as
'reset to file order'. A non-sequential stored position (a merchant can import
explicit positions) then round-tripped to a spurious update, violating INV-12.
Emit str(variant.position) explicitly (like _write_image). The property-test
generator now assigns non-sequential positions to lock the regression, plus a
targeted test. Also wires isExportEnabled into ProductsPage (was dead code).
Both caught by the final code review.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 22:22:11 -07:00
ben.stull 6f22c8b146 chore: version 0.6.0 — SLICE-6 export & round-trip
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 22:14:30 -07:00
ben.stull 767011fbae docs(products): DOC-1 export ops + TEL-3, DOC-4 serializer/round-trip (SLICE-6)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 22:14:30 -07:00
ben.stull 8077f2e07b test(e2e): e2e_export_download + e2e_roundtrip_noop (SD-0002 §6.8, PUC-9/10)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 22:12:53 -07:00
ben.stull 0a85c4fef8 feat(frontend): Export status-filter menu on the Products page (SD-0002 §5.2, PUC-9)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 22:08:34 -07:00
ben.stull 6f213e1f02 feat(frontend): export URL helper + status-filter list (PUC-9)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 22:07:36 -07:00
ben.stull 155f9bd147 feat(api): GET /api/products/export — streamed canonical CSV, 409 empty_catalog (§6.4, PUC-9)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 22:05:55 -07:00
ben.stull 1c2a6af986 feat(products): streamed export service + TEL-3 + EmptyCatalog (PUC-9, §9.1) 2026-06-11 22:03:09 -07:00
ben.stull 5a97b9dc59 feat(products): status-filtered export_catalog snapshot query (PUC-9) 2026-06-11 22:01:11 -07:00
ben.stull fce0b5eaed test(products): decimal/int variant fields round-trip clean (INV-12 coverage) 2026-06-11 21:59:06 -07:00
ben.stull b40e4d30b5 test(products): INV-12 property test — export round-trips to a no-op (SD-0002 §6.8) 2026-06-11 21:58:47 -07:00
ben.stull 7652e92cbe feat(products): serialize multi-variant + interleaved images (§6.5.1 row grammar) 2026-06-11 21:58:22 -07:00
ben.stull 627e257d4c feat(products): canonical serializer skeleton — header + single product row (SD-0002 §6.5.5) 2026-06-11 21:57:48 -07:00
ben.stull 011f4d5dc1 Merge pull request 'SLICE-5: products import spine — canonical CSV → preview → confirm (SD-0002 §7.2)' (#26) from worktree-slice-5-import-spine into main
ci / check (push) Has been cancelled
2026-06-12 03:50:20 +00:00
ben.stull 2606fbf826 docs(products): operator guide (DOC-1) + domain notes (DOC-4); version 0.5.0
ci / check (push) Has been cancelled
ci / check (pull_request) Has been cancelled
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 17:00:13 -07:00
ben.stull ad738adbc0 test(e2e): SLICE-5 scenarios — preview/confirm, errors, rejection, cancel (§6.8)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:53:59 -07:00
ben.stull a58f42cf86 test(e2e): stand up Playwright — fresh-DB server harness + sign-up helper
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:50:59 -07:00
ben.stull d18a84e135 fix(products-ui): review pass — show-more race, keyboard history access, a11y glyphs/aria-live, label consistency
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:46:43 -07:00
ben.stull 87a56a66c4 feat(products-ui): import run detail (§5.5, PUC-4/5/8)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:35:49 -07:00
ben.stull 4141285b89 feat(products-ui): import preview — tiles, drill-in diffs, confirm/cancel (§5.4)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:34:33 -07:00
ben.stull 188494272a feat(products-ui): import upload screen (§5.3, PUC-2/5a)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:30:51 -07:00
ben.stull 26f84cb916 feat(products-ui): admin nav + Products page with import history (§5.2)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:29:20 -07:00
ben.stull b5dac8886f feat(products-ui): typed products API client + admin hash routing
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:25:37 -07:00
ben.stull 4dacc2dafd feat(products): /api/products/* BFF endpoints + sample.csv (DOC-3)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:18:34 -07:00
ben.stull a8538d3ecc fix(products): cleared Variant Position resolves to file order, not NULL
A blank Variant Position cell previewed position->null and aborted the
confirm transaction (variant.position is NOT NULL). Resolve the clear to
the variant's file order at diff time so preview and apply stay in
lockstep; carry file_order on VariantPlan instead of an identity-keyed
map; release the read snapshot before PreviewStale/NothingToApply.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:14:14 -07:00
ben.stull 0c7865e9e1 feat(products): confirm/apply in one transaction, runs, summary — INV-10/11/14 + TEL-2/6
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:02:27 -07:00
ben.stull fcbf1393f5 feat(products): import_validate → draft, preview records, discard + TEL-1
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 15:51:03 -07:00
ben.stull 138126ab17 feat(products): repo — catalog snapshot, draft/run SQL, upsert primitives
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 15:42:42 -07:00
ben.stull 667a462e0b fix(products): honest position compare for matched variants in diff
A Variant Position column previously produced a spurious update with a
false before:None (CatalogVariant keeps position as an attribute, not in
fields{}). Compare against the attribute, like images already do.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 15:37:59 -07:00
ben.stull 9bc6e4dbd2 feat(products): diff engine — add/update/unchanged/error + INV-11 fingerprint
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 15:28:03 -07:00
ben.stull 19ee695c20 feat(products): row validation — §6.5.1 rules + INV-15 sanitization
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 15:17:01 -07:00
ben.stull f64c3fddf9 feat(products): CSV codec — file-level parse + INV-18 caps (PUC-5a)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 14:58:53 -07:00
ben.stull c39bbd4728 feat(products): domain skeleton — errors, column registry, canonical row model
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 14:55:37 -07:00
ben.stull 0ee948b34d feat(products): SD-0002 §6.3 schema — migration 0002 + nh3/multipart deps (SLICE-5)
Also updates test_migrate_from_empty_applies_0001 → test_migrate_from_empty_applies_all
to assert both migrations apply on a fresh DB (mechanical consequence of adding 0002).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 14:52:18 -07:00
ben.stull 1267d4f29d docs: SLICE-5 implementation plan (SD-0002 §7.2 import spine)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 14:48:47 -07:00
ben.stull 6b405a0f2d Merge pull request 'fix(signin): key the resend cooldown to the address it was set for (#20)' (#22) from fix-20-signin-cooldown-keying into main
ci / check (push) Has been cancelled
2026-06-11 08:22:08 +00:00
ben.stull 118e925580 fix(signin): key the resend cooldown to the address it was set for (#20)
ci / check (push) Has been cancelled
ci / check (pull_request) Has been cancelled
The email-step Send button disabled on any running cooldown, so a merchant
who mistyped their address was locked out of sending to the corrected one
for the rest of the 60s window — a guard the server never had: request_code
enforces the cooldown per address (SD-0001 INV-3 -> PUC-2c).

Track which address the running cooldown belongs to (cooldownFor) and gate
the email-step button through cooldownAppliesTo(), which blocks only while
the countdown runs AND the input normalizes (strip+lowercase, mirroring
normalize_email / INV-2) to that address. Same address still shows the
honest 'Resend in Ns'; any other address sends immediately. The code-step
resend is unchanged. Verified in the live UI (wrong address -> Wrong
address? -> corrected address sends at once).

package-lock.json: sync recorded version with package.json (0.4.0).

Closes #20

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:21:23 -07:00
ben.stull 11acb3a5b1 Merge pull request 'docs: land session 0023 plan doc (stray untracked file)' (#18) from docs-archive-0023-plan into main
ci / check (push) Has been cancelled
2026-06-11 07:56:34 +00:00
ben.stull efc9c8edb9 docs: land session 0023's ui-designs-collection plan in-repo (was untracked; content archive already had it)
ci / check (pull_request) Has been cancelled
ci / check (push) Has been cancelled
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 00:56:31 -07:00
ben.stull f7b76d5370 Merge pull request 'SLICE-4: record the first PPE bootstrap rehearsal (PUC-11) — green' (#17) from slice-4-rehearsal-record into main
ci / check (push) Has been cancelled
2026-06-11 07:54:50 +00:00
ben.stull 4bb9763633 docs(slice-4): record the first PPE bootstrap rehearsal (PUC-11, 2026-06-11) — green
ci / check (pull_request) Has been cancelled
ci / check (push) Has been cancelled
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 00:54:47 -07:00
ben.stull a456d51f17 Merge pull request 'SLICE-4: deployment.toml — the ecomm PPE record' (#11) from slice-4-deployment-record into main
ci / check (push) Has been cancelled
2026-06-11 06:45:08 +00:00
ben.stull ac3c4ffe36 feat(slice-4): deployment.toml — the ecomm PPE record (launch-app §5.1; Cloud SQL via secret ref)
ci / check (push) Has been cancelled
ci / check (pull_request) Has been cancelled
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 23:45:05 -07:00
80 changed files with 6370 additions and 4168 deletions
+3 -12
View File
@@ -1,8 +1,8 @@
# Server-side pre-merge gate. Calls the same scripts/check.sh that runs locally, so # Server-side pre-merge gate. Calls the same scripts/check.sh that runs locally, so
# the local and server gates can never drift: import-linter (main > domains > # the local and server gates can never drift: import-linter (main > domains >
# platform), then backend pytest against a Postgres service, then the frontend # platform), then backend pytest against a Postgres service. The network-seed reset
# typecheck + build. Requires a registered Gitea Actions runner advertising the # stripped the SPA frontend, so the gate is backend-only now. Requires a registered
# `ubuntu-latest` label. # Gitea Actions runner advertising the `ubuntu-latest` label.
name: ci name: ci
on: on:
@@ -35,21 +35,12 @@ jobs:
with: with:
python-version: "3.13" python-version: "3.13"
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Install backend deps - name: Install backend deps
run: | run: |
python -m venv .venv python -m venv .venv
.venv/bin/python -m pip install --upgrade pip .venv/bin/python -m pip install --upgrade pip
.venv/bin/python -m pip install -r backend/requirements.txt .venv/bin/python -m pip install -r backend/requirements.txt
- name: Install frontend deps
run: |
cd frontend && npm ci
- name: Run the pre-merge gate - name: Run the pre-merge gate
env: env:
PYTHON: ${{ github.workspace }}/.venv/bin/python PYTHON: ${{ github.workspace }}/.venv/bin/python
+6 -3
View File
@@ -2,9 +2,12 @@
# wiggleverse-ecomm # wiggleverse-ecomm
The clean implementation of Wiggleverse ecomm — a multi-tenant, OHM-grounded Wiggleverse ecomm, pivoted to a network-service seed — a minimal, OHM-grounded
Shopify alternative. Greenfield: started fresh 2026-06-10, superseding the FastAPI service for a cross-maker verified referral network. Greenfield: started
first-pass reference prototype (`wiggleverse-ecomm-prototype`). fresh 2026-06-10, superseding the first-pass reference prototype
(`wiggleverse-ecomm-prototype`). The network-seed reset (v0.9.0) stripped the
storefront/products HTTP spine and the SPA; the seed is `/healthz` + `/api/auth/*`
(accounts) + the catalog-normalization library (no HTTP surface yet) + `platform/*`.
- **One Name:** `ecomm` (see `app.json`). - **One Name:** `ecomm` (see `app.json`).
- **Content/corpus repo:** `wiggleverse-ecomm-content` (specs, plans, roadmap, pin). - **Content/corpus repo:** `wiggleverse-ecomm-content` (specs, plans, roadmap, pin).
+6 -2
View File
@@ -1,7 +1,11 @@
# wiggleverse-ecomm # wiggleverse-ecomm
The clean implementation of **Wiggleverse ecomm** — a multi-tenant, OHM-grounded **Wiggleverse ecomm** is being pivoted to a **network-service seed**: a minimal,
Shopify alternative. OHM-grounded FastAPI service for a cross-maker verified referral network. The
storefront / products import-export spine and the SPA frontend were stripped in the
network-seed reset (v0.9.0); what remains is `/healthz`, the `/api/auth/*` identity
endpoints (the `accounts` domain), the catalog-normalization library kept as
importable modules with no HTTP surface yet, and the generic `platform/*` infra.
This repo is greenfield. It was stood up fresh on 2026-06-10, superseding the This repo is greenfield. It was stood up fresh on 2026-06-10, superseding the
first-pass **reference prototype** at first-pass **reference prototype** at
+1 -1
View File
@@ -1 +1 @@
0.4.0 0.9.0
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"schemaVersion": "1.1", "schemaVersion": "1.1",
"name": "ecomm", "name": "ecomm",
"title": "Wiggleverse ecomm — multi-tenant commerce", "title": "Wiggleverse ecomm — cross-maker verified referral network (service)",
"giteaHost": "ssh://git@git.wiggleverse.org:2222", "giteaHost": "ssh://git@git.wiggleverse.org:2222",
"repos": [ "repos": [
{ {
+4 -4
View File
@@ -1,6 +1,6 @@
"""domains layer — bounded contexts (accounts, storefronts). """domains layer — bounded contexts (accounts, products).
Empty in SLICE-1; the accounts domain lands in SLICE-2 and storefronts in SLICE-3. The accounts domain is the network-service seed's identity context. The products
The package exists now so the layer contract (.importlinter) has a target and later package is the catalog-normalization library (no HTTP surface yet). Domains import
slices add to a stable seam. Domains import only from app.platform, never upward. only from app.platform, never upward.
""" """
+68
View File
@@ -0,0 +1,68 @@
"""products domain — catalog-normalization library (no HTTP surface yet).
The network-seed reset stripped the storefront-scoped import/export/image spine
(service/repo/imagefetch and their endpoints). What remains is the pure, DB-free
catalog-normalization library: the canonical row model, the CSV codec, the Shopify
dialect adapter, validation, the diff engine, and serialization. These submodules are
importable as a library; nothing here owns persistence or an HTTP route.
"""
from __future__ import annotations
from pathlib import Path
from .codec import detect_dialect, parse_csv
from .diff import (
CatalogImage,
CatalogProduct,
CatalogVariant,
DiffResult,
ImagePlan,
ProductPlan,
VariantPlan,
compute_diff,
)
from .dialect_shopify import is_shopify_header, map_shopify_header
from .errors import (
DraftExpired,
DraftNotFound,
EmptyCatalog,
FileRejected,
NothingToApply,
PreviewStale,
ProductsError,
RunNotFound,
)
from .models import (
MAX_DATA_ROWS,
MAX_FILE_BYTES,
CanonicalImage,
CanonicalProduct,
CanonicalVariant,
ParsedFile,
Row,
RowError,
)
from .serialize import catalog_to_csv
from .validate import all_errors, build_products
# DOC-3: the downloadable worked-example CSV.
SAMPLE_CSV_PATH = Path(__file__).parent / "sample.csv"
# DOC-2: the column reference.
COLUMNS_MD_PATH = Path(__file__).parent / "columns.md"
__all__ = [
# errors
"ProductsError", "FileRejected", "DraftNotFound", "DraftExpired",
"PreviewStale", "NothingToApply", "RunNotFound", "EmptyCatalog",
# models + limits
"MAX_DATA_ROWS", "MAX_FILE_BYTES", "Row", "RowError", "ParsedFile",
"CanonicalProduct", "CanonicalVariant", "CanonicalImage",
# codec + dialect
"parse_csv", "detect_dialect", "is_shopify_header", "map_shopify_header",
# validate + diff + serialize
"build_products", "all_errors", "compute_diff", "catalog_to_csv",
"CatalogProduct", "CatalogVariant", "CatalogImage",
"ProductPlan", "VariantPlan", "ImagePlan", "DiffResult",
# docs
"SAMPLE_CSV_PATH", "COLUMNS_MD_PATH",
]
+86
View File
@@ -0,0 +1,86 @@
"""CSV codec — bytes → ParsedFile (SD-0002 §6.5.1). File-level gates only (PUC-5a);
row semantics live in validate.py. Dialect detection is the INV-17 seam (SLICE-8
adds Shopify)."""
from __future__ import annotations
import csv
import io
from .dialect_shopify import is_shopify_header, map_shopify_header
from .errors import FileRejected
from .models import KNOWN_COLUMNS, MAX_DATA_ROWS, MAX_FILE_BYTES, ParsedFile, Row
_REQUIRED_HEADER_COLUMNS = ("Handle", "Title")
def detect_dialect(header: list[str]) -> str:
"""The INV-17 seam: recognize Shopify's header set, else canonical (§6.5.1)."""
return "shopify" if is_shopify_header(header) else "canonical"
def parse_csv(data: bytes) -> ParsedFile:
if len(data) > MAX_FILE_BYTES:
raise FileRejected("file_too_large", "This file is larger than 10 MB.")
try:
text = data.decode("utf-8-sig")
except UnicodeDecodeError:
raise FileRejected("not_csv", "This file isn't readable as CSV.") from None
reader = csv.reader(io.StringIO(text))
try:
try:
raw_header = next(reader)
except StopIteration:
raise FileRejected("not_csv", "This file isn't readable as CSV.") from None
header = [h.strip() for h in raw_header]
dialect = detect_dialect(header)
# INV-17: normalize the header to canonical names at the boundary. mapped[i]
# is the canonical name for header[i] (or None when that column has no
# canonical home); unknown is the not-imported warning list. Canonical files
# map to themselves; unknown columns are warned exactly as before.
if dialect == "shopify":
mapped, unknown = map_shopify_header(header)
else:
mapped = [c if c in KNOWN_COLUMNS else None for c in header]
unknown = [c for c in header if c and c not in KNOWN_COLUMNS]
for col in _REQUIRED_HEADER_COLUMNS:
if col not in mapped:
raise FileRejected(
"missing_required_column",
f"This file is missing the required column '{col}'.",
)
# First occurrence of a duplicated canonical column wins.
col_index: dict[str, int] = {}
for i, name in enumerate(mapped):
if name and name not in col_index:
col_index[name] = i
# De-dup the warning list, order-preserving.
seen: set[str] = set()
unknown = [c for c in unknown if not (c in seen or seen.add(c))]
rows: list[Row] = []
for raw in reader:
if not any(cell.strip() for cell in raw):
continue
if len(rows) >= MAX_DATA_ROWS:
raise FileRejected(
"too_many_rows",
f"This file has more than {MAX_DATA_ROWS:,} rows — split it and import in parts.",
)
cells = {
c: (raw[col_index[c]].strip() if col_index[c] < len(raw) else "")
for c in col_index
}
# Shopify grams carry an implicit unit; canonical needs it explicit. In a
# Shopify file "Variant Weight" can only come from the Variant Grams rename
# (a canonical-named Variant Weight would have vetoed Shopify detection), so
# weight and unit move together: a value -> "g"; a cleared grams (present-but-
# empty) clears the unit too, never leaving a stale unit (§6.5.1).
if dialect == "shopify" and "Variant Weight" in cells:
cells["Variant Weight Unit"] = "g" if cells["Variant Weight"] else ""
rows.append(Row(line_number=reader.line_num, cells=cells))
except csv.Error:
raise FileRejected("not_csv", "This file isn't readable as CSV.") from None
return ParsedFile(dialect=dialect, header=header, unknown_columns=unknown, rows=rows)
+95
View File
@@ -0,0 +1,95 @@
# Product CSV — column reference
This is the complete reference for the product CSV you import into and export from
your store (SD-0002 §6.5.1). The format is **canonical** — a clean superset of the
Shopify product CSV — and a **Shopify product CSV** imports directly too: we detect
which one you uploaded and map it for you (see *Shopify dialect* below).
You don't pick a format. Upload your file; the preview tells you which format was
recognized and shows exactly what will change before anything is applied.
## File shape
- UTF-8 (a byte-order mark is tolerated), comma-delimited, RFC 4180 quoting.
- A header row is **required**. `Handle` and `Title` are the only always-required
columns.
- Up to 5,000 rows and 10 MB per file. Split larger catalogs and import in parts.
## Row grammar
Consecutive rows that share a `Handle` describe **one product**. The product's first
row carries the product-level fields (`Title` is required there). Each row may carry:
- a **variant** — its `Option n Value`s plus `Variant *` fields;
- an **image**`Image Src` with `Image Position` / `Image Alt Text`;
- or both.
An image-only row (just `Handle` + `Image *`) is valid — that's how a product carries
more images than it has variants.
## Updating: blank vs. absent
- A column **absent from your file** is left untouched on existing products.
- A cell that is **present but empty** clears that field (resets it to its default).
Every clear is shown explicitly in the preview, so this is deliberate, visible
behavior — never a silent surprise.
## Canonical columns
| Column | Level | Required | Notes |
| --- | --- | --- | --- |
| `Handle` | product | every row | Identity: lowercase letters, numbers, and dashes. |
| `Title` | product | first row of a product | |
| `Description` | product | — | HTML allowed; sanitized on import. |
| `Vendor` | product | — | Free text. |
| `Type` | product | — | `standalone` (default). `kit_virtual` / `kit_assembled` are reserved; any non-`standalone` value is a row error until kits ship. |
| `Google Product Category` | product | — | Taxonomy string. |
| `Tags` | product | — | Comma-separated within the cell. |
| `Status` | product | — | `draft` \| `active` \| `archived`; defaults to `active` on add. |
| `Published` | product | — | `TRUE` \| `FALSE`; defaults to `TRUE` on add. |
| `Option1 Name``Option3 Name` | product | with values | e.g. "Size". A value without its name is a row error. |
| `Option1 Value``Option3 Value` | variant | per variant | The variant's identity combination. |
| `Variant SKU` | variant | — | Indexed; not identity. |
| `Variant Barcode` | variant | — | |
| `Variant Price` | variant | — | Decimal; no currency symbol. |
| `Variant Cost` | variant | — | Decimal. |
| `Variant Weight` + `Variant Weight Unit` | variant | — | Decimal weight plus a unit string. |
| `Variant Volume` + `Variant Volume Unit` | variant | — | Decimal volume plus a unit string. |
| `Variant Tax ID 1` / `Variant Tax ID 2` | variant | — | Opaque references. |
| `Variant Inventory Tracker` | variant | — | |
| `Variant Inventory Qty` | variant | — | Integer ≥ 0. |
| `Variant Position` | variant | — | Display order; defaults to file order. |
| `Image Src` | image | — | URL (or a store-hosted URL on re-import). |
| `Image Position` | image | — | Integer order. |
| `Image Alt Text` | image | — | |
| `Variant Image` | variant | — | URL for this variant's specific image. |
| `Component 1 SKU` / `Component 1 Quantity``Component 10 …` | variant | — | Reserved for kits — a non-empty value is a row error today. |
## Shopify dialect
If you upload a **Shopify product CSV**, we recognize it by its header set and map it
to the canonical columns automatically. The preview labels it *"Shopify product
CSV — mapped"*.
**Mapped — renamed**
| Shopify column | Becomes |
| --- | --- |
| `Body (HTML)` | `Description` |
| `Product Category` | `Google Product Category` |
| `Cost per item` | `Variant Cost` |
| `Variant Grams` | `Variant Weight` (in grams) |
**Mapped — same name:** `Handle`, `Title`, `Vendor`, `Tags`, `Published`, `Status`,
`Option13 Name` / `Option13 Value`, `Variant SKU`, `Variant Barcode`,
`Variant Price`, `Variant Inventory Tracker`, `Variant Inventory Qty`,
`Variant Image`, `Image Src`, `Image Position`, `Image Alt Text`.
**Not imported — listed in the preview as a heads-up, never silently dropped:** the
Shopify free-text `Type` (canonical `Type` is structural, so we don't fold a category
string into it), `Variant Compare At Price`, `Variant Inventory Policy`,
`Variant Fulfillment Service`, `Variant Requires Shipping`, `Variant Taxable`,
`Variant Tax Code`, `Gift Card`, SEO fields, every `Google Shopping / …` field, and
market/region price columns. These have no canonical home yet; your other data still
imports.
@@ -0,0 +1,95 @@
"""Shopify product-CSV adapter (INV-17): detect by header signature, map to canonical.
The mapping is the §6.5.1 contract; the exhaustive table is pinned here and
mirrored by tests/test_products_dialect_shopify.py + e2e/fixtures/shopify-export.csv.
"""
from __future__ import annotations
from .models import KNOWN_COLUMNS
# Shopify header -> canonical header (renames only; direct same-name columns pass
# through via KNOWN_COLUMNS). Variant Grams carries a value transform (see codec).
SHOPIFY_RENAME: dict[str, str] = {
"Body (HTML)": "Description",
"Product Category": "Google Product Category",
"Cost per item": "Variant Cost",
"Variant Grams": "Variant Weight",
}
# Canonical-named columns Shopify uses differently — must NOT pass through.
# Type: Shopify's free-text type vs canonical's structural Type (§6.5.1, decision D).
# Variant Weight Unit: superseded by the Variant Grams -> Variant Weight transform.
SHOPIFY_DROP: frozenset[str] = frozenset({"Type", "Variant Weight Unit"})
# Columns whose presence proves the file is a Shopify export (canonical never has them).
_SIGNATURE: frozenset[str] = frozenset(
{
"Body (HTML)",
"Variant Grams",
"Cost per item",
"Variant Compare At Price",
"Variant Inventory Policy",
"Variant Fulfillment Service",
"Variant Requires Shipping",
"Variant Taxable",
"Gift Card",
"SEO Title",
"SEO Description",
}
)
# Canonical-distinctive columns — names Shopify renames away (so a real Shopify export
# never carries them) plus canonical-only columns Shopify has no equivalent for. Their
# presence proves the file is canonical and VETOES Shopify detection: detection must
# lean conservative, because under-detection is safe (Shopify-named columns are warned
# as not-imported) while over-detection corrupts (canonical Type / weight-unit are
# dropped). This closes the misdetection hazard — a canonical file that merely contains
# a stray signature-named column (e.g. `SEO Title`) is never misread as Shopify (§7.4).
_CANONICAL_SIGNATURE: frozenset[str] = frozenset(
{
"Description",
"Variant Cost",
"Variant Weight",
"Google Product Category",
"Variant Volume",
"Variant Volume Unit",
"Variant Tax ID 1",
"Variant Tax ID 2",
"Variant Position",
}
)
def is_shopify_header(header: list[str]) -> bool:
"""True iff the header carries a Shopify-only signature column AND no
canonical-distinctive column. A canonical signal vetoes Shopify detection so an
ambiguous or hybrid header is treated as canonical — where Shopify-named columns
are warned as not-imported rather than silently remapped (never misparses, §7.4)."""
cols = {h.strip() for h in header}
if cols & _CANONICAL_SIGNATURE:
return False
if cols & _SIGNATURE:
return True
return any(c.startswith("Google Shopping / ") for c in cols)
def map_shopify_header(header: list[str]) -> tuple[list[str | None], list[str]]:
"""Return (mapped, not_imported): mapped[i] is the canonical name for header[i],
or None when that Shopify column has no canonical home; not_imported lists the
original Shopify names (in order) that were dropped — the preview warning."""
mapped: list[str | None] = []
not_imported: list[str] = []
for raw in header:
col = raw.strip()
if col in SHOPIFY_RENAME:
mapped.append(SHOPIFY_RENAME[col])
elif col in SHOPIFY_DROP:
mapped.append(None)
not_imported.append(col)
elif col in KNOWN_COLUMNS:
mapped.append(col)
else:
mapped.append(None)
if col:
not_imported.append(col)
return mapped, not_imported
+350
View File
@@ -0,0 +1,350 @@
"""Diff engine — catalog × canonical products → apply plan + preview records (SD-0002 §6.5.2).
Classifies each canonical product against the storefront's current catalog as
add / update / unchanged / error. One walk produces two views of the same
computation: a typed apply *plan* carrying resolved native values (Decimal etc.)
for the confirm transaction, and JSON-ready preview records derived from that
walk (stored as draft JSONB, served verbatim to the SPA) — so what confirm
applies is exactly what preview showed (INV-11). A summary and a deterministic
fingerprint over the records detect catalog drift between preview and confirm.
Deliberately DB-free: the catalog snapshot dataclasses are defined here and the
repo layer builds them. Only fields present in the file's canonical fields{}
participate in a comparison — an absent column is untouched, never a change
(§6.5.1); a present-but-empty cell resolves to the field's CLEAR_DEFAULTS entry
— except a variant's position, whose default is the variant's 1-based file
order within its product ("defaults to file order"), resolved here at diff time.
Catalog variants/images absent from the file are likewise untouched (INV-10);
file variants match catalog variants by their option-value combination (INV-13).
"""
from __future__ import annotations
import hashlib
import json
from dataclasses import dataclass, field, replace
from decimal import Decimal
from . import hosted
from .models import CLEAR_DEFAULTS, CanonicalProduct, CanonicalVariant, RowError
@dataclass
class CatalogVariant:
id: int
options: tuple[str | None, str | None, str | None]
position: int
# sku, barcode, price (Decimal|None), cost, weight, weight_unit, volume,
# volume_unit, tax_id_1, tax_id_2, inventory_tracker, inventory_qty,
# variant_image (linked image source_url or None)
fields: dict[str, object]
@dataclass
class CatalogImage:
id: int
source_url: str
position: int
alt_text: str | None
status: str = "pending"
@dataclass
class CatalogProduct:
id: int
handle: str
title: str
option_names: tuple[str | None, str | None, str | None]
# title, description_html, vendor, product_type, google_product_category,
# tags (list[str]), status, published (bool)
fields: dict[str, object]
variants: list[CatalogVariant]
images: list[CatalogImage]
# ---------------------------------------------------------------------------
# Apply plan — the typed twin of the preview records. confirm_draft executes
# these; the values are resolved natives (Decimal, bool, list), never the
# json-safe strings the records carry for display.
# ---------------------------------------------------------------------------
@dataclass
class VariantPlan:
kind: str # "add" | "update"
canonical: CanonicalVariant
catalog_id: int | None # None for add
# 1-based index within the product's file variants — the position default.
file_order: int = 0
# field -> resolved after-value (update only); adds resolve from canonical.fields
changes: dict[str, object] = field(default_factory=dict)
@dataclass
class ImagePlan:
kind: str # "add" | "update"
source_url: str
position: int
alt_text: str | None
image_id: int | None # None for add
changes: dict[str, object] = field(default_factory=dict) # subset of {"position","alt_text"}
@dataclass
class ProductPlan:
kind: str # "add" | "update" | "unchanged" | "error"
canonical: CanonicalProduct
catalog: CatalogProduct | None
# field -> resolved after-value (update only; may include title/option*_name)
product_changes: dict[str, object] = field(default_factory=dict)
variant_plans: list[VariantPlan] = field(default_factory=list)
image_plans: list[ImagePlan] = field(default_factory=list)
@dataclass(frozen=True)
class DiffResult:
records: list[dict]
summary: dict
fingerprint: str
plan: list[ProductPlan]
# Option-name presence markers in fields{} (see validate.py); the values are
# compared via the option_names attribute, not through the generic field loop.
_OPTION_NAME_FIELDS = ("option1_name", "option2_name", "option3_name")
_SUMMARY_KEY = {"add": "adds", "update": "updates", "unchanged": "unchanged", "error": "errors"}
def compute_diff(catalog: dict[str, CatalogProduct], products: list[CanonicalProduct]) -> DiffResult:
records: list[dict] = []
plan: list[ProductPlan] = []
summary = {"adds": 0, "updates": 0, "unchanged": 0, "errors": 0}
for product in products:
current = catalog.get(product.handle)
_resolve_hosted_images(product, current)
if product.errors:
product_plan = ProductPlan(kind="error", canonical=product, catalog=current)
detail: dict = {"errors": [e.as_json() for e in product.errors]}
elif current is None:
product_plan = _add_plan(product)
detail = _add_detail(product_plan)
else:
product_plan, detail = _update_plan(product, current)
kind = product_plan.kind
plan.append(product_plan)
records.append(
{
"handle": product.handle,
"title": product.title or (current.title if current else ""),
"kind": kind,
"variant_count": len(current.variants) if kind == "unchanged" else len(product.variants),
"detail": detail,
}
)
summary[_SUMMARY_KEY[kind]] += 1
fingerprint = hashlib.sha256(
json.dumps(records, sort_keys=True, separators=(",", ":")).encode()
).hexdigest()
return DiffResult(records=records, summary=summary, fingerprint=fingerprint, plan=plan)
def _resolve_hosted_images(product: CanonicalProduct, current: CatalogProduct | None) -> None:
"""Recognize re-imported hosted image URLs (INV-12). A hosted URL is resolved
to this product's existing image with that id — re-keyed onto that image's
source_url so the by_src match treats it as the existing image (never an add,
never a fetch). A hosted URL whose id is not one of this product's images
(cross-storefront, deleted, or a brand-new product) is a row error."""
current_by_id = {i.id: i for i in current.images} if current else {}
resolved: list = []
for image in product.images:
hosted_id = hosted.parse_image_id(image.source_url)
if hosted_id is None:
resolved.append(image)
continue
match = current_by_id.get(hosted_id)
if match is None:
product.errors.append(
RowError(image.line_number, "Image Src",
f"image '{image.source_url}' refers to an unknown hosted id")
)
resolved.append(image)
continue
resolved.append(replace(image, source_url=match.source_url))
product.images = resolved
def _add_plan(product: CanonicalProduct) -> ProductPlan:
return ProductPlan(
kind="add",
canonical=product,
catalog=None,
variant_plans=[
VariantPlan(kind="add", canonical=v, catalog_id=None, file_order=order)
for order, v in enumerate(product.variants, start=1)
],
image_plans=[
ImagePlan(kind="add", source_url=i.source_url, position=i.position,
alt_text=i.alt_text, image_id=None)
for i in product.images
],
)
def _add_detail(plan: ProductPlan) -> dict:
# On add, absent fields fall back to their defaults for display where one
# exists — the detail shows what will actually be set.
set_fields = resolved_product_fields(plan.canonical)
for field_name, default in CLEAR_DEFAULTS.items():
set_fields.setdefault(field_name, default)
return {
"set": {f: _json_safe(v) for f, v in set_fields.items()},
"option_names": list(plan.canonical.option_names),
"variants": [
{"options": list(vp.canonical.options),
"set": _resolved_variant_fields(vp.canonical, vp.file_order)}
for vp in plan.variant_plans
],
"images": [
{"src": ip.source_url, "position": ip.position, "alt_text": ip.alt_text}
for ip in plan.image_plans
],
}
def _update_plan(product: CanonicalProduct, current: CatalogProduct) -> tuple[ProductPlan, dict]:
"""One walk, two outputs: the resolved-value plan entries and the json-safe
record detail entries are appended side by side, so they can never diverge."""
product_changes: dict[str, object] = {}
changes: list[dict] = []
# Title is always file-present (required header column); "" means the block
# already carries an error and never reaches here.
if product.title and product.title != current.title:
product_changes["title"] = product.title
changes.append(_change("title", current.title, product.title))
for field_name, resolved in resolved_product_fields(product).items():
before = current.fields.get(field_name)
if resolved != before:
product_changes[field_name] = resolved
changes.append(_change(field_name, before, resolved))
for slot in (1, 2, 3):
if f"option{slot}_name" not in product.fields:
continue
before, after = current.option_names[slot - 1], product.option_names[slot - 1]
if after != before:
product_changes[f"option{slot}_name"] = after
changes.append(_change(f"option{slot}_name", before, after))
variant_plans: list[VariantPlan] = []
variant_entries: list[dict] = []
by_options = {v.options: v for v in current.variants}
for file_order, variant in enumerate(product.variants, start=1):
match = by_options.get(variant.options)
if match is None:
variant_plans.append(
VariantPlan(kind="add", canonical=variant, catalog_id=None, file_order=file_order)
)
variant_entries.append(
{"options": list(variant.options), "kind": "add",
"set": _resolved_variant_fields(variant, file_order)}
)
continue
variant_changes: dict[str, object] = {}
variant_change_entries: list[dict] = []
for f, resolved in resolved_variant_fields(variant, file_order).items():
# position lives on the catalog variant as an attribute, not in
# fields{} — compare it explicitly (as images do for theirs).
before = match.position if f == "position" else match.fields.get(f)
if resolved != before:
variant_changes[f] = resolved
variant_change_entries.append(_change(f, before, resolved))
if variant_changes:
variant_plans.append(
VariantPlan(kind="update", canonical=variant, catalog_id=match.id,
file_order=file_order, changes=variant_changes)
)
variant_entries.append(
{"options": list(variant.options), "kind": "update", "changes": variant_change_entries}
)
image_plans: list[ImagePlan] = []
image_entries: list[dict] = []
by_src = {i.source_url: i for i in current.images}
for image in product.images:
match = by_src.get(image.source_url)
if match is None:
image_plans.append(
ImagePlan(kind="add", source_url=image.source_url, position=image.position,
alt_text=image.alt_text, image_id=None)
)
image_entries.append(
{"src": image.source_url, "kind": "add", "position": image.position, "alt_text": image.alt_text}
)
continue
image_changes: dict[str, object] = {}
image_change_entries: list[dict] = []
for f, before, after in (
("position", match.position, image.position),
("alt_text", match.alt_text, image.alt_text),
):
if after != before:
image_changes[f] = after
image_change_entries.append(_change(f, before, after))
if image_changes:
image_plans.append(
ImagePlan(kind="update", source_url=image.source_url, position=image.position,
alt_text=image.alt_text, image_id=match.id, changes=image_changes)
)
image_entries.append(
{"src": image.source_url, "kind": "update", "changes": image_change_entries}
)
detail: dict = {}
if changes:
detail["changes"] = changes
if variant_entries:
detail["variants"] = variant_entries
if image_entries:
detail["images"] = image_entries
kind = "update" if detail else "unchanged"
return (
ProductPlan(kind=kind, canonical=product, catalog=current, product_changes=product_changes,
variant_plans=variant_plans, image_plans=image_plans),
detail,
)
def resolved_fields(fields: dict[str, object]) -> dict[str, object]:
"""File-present fields with None (an explicit clear) resolved to the default."""
return {f: (v if v is not None else CLEAR_DEFAULTS.get(f)) for f, v in fields.items()}
def resolved_product_fields(product: CanonicalProduct) -> dict[str, object]:
"""Resolved product-level fields, minus the option-name presence markers."""
return {
f: v for f, v in resolved_fields(product.fields).items() if f not in _OPTION_NAME_FIELDS
}
def resolved_variant_fields(variant: CanonicalVariant, file_order: int) -> dict[str, object]:
"""File-present variant fields with clears resolved. A cleared position has
no CLEAR_DEFAULTS entry — it resets to the variant's 1-based file order
within its product ("defaults to file order"), never to NULL."""
resolved = resolved_fields(variant.fields)
if "position" in resolved and resolved["position"] is None:
resolved["position"] = file_order
return resolved
def _resolved_variant_fields(variant: CanonicalVariant, file_order: int) -> dict[str, object]:
return {f: _json_safe(v) for f, v in resolved_variant_fields(variant, file_order).items()}
def _change(field_name: str, before: object, after: object) -> dict:
return {"field": field_name, "before": _json_safe(before), "after": _json_safe(after)}
def _json_safe(value: object) -> object:
if isinstance(value, Decimal):
return str(value)
if isinstance(value, (tuple, list)):
return [_json_safe(v) for v in value]
return value
+41
View File
@@ -0,0 +1,41 @@
"""products domain errors (SD-0002 §6.4 error envelope codes)."""
from __future__ import annotations
class ProductsError(Exception):
"""Base for products-domain errors."""
class FileRejected(ProductsError):
"""PUC-5a: the whole file is unusable; no draft is created. `code` is the §6.4
error code (not_csv | missing_required_column | unknown_dialect | too_many_rows |
file_too_large)."""
def __init__(self, code: str, message: str):
super().__init__(message)
self.code = code
self.message = message
class DraftNotFound(ProductsError):
"""No such draft for this storefront (or already discarded)."""
class DraftExpired(ProductsError):
"""The draft's validity window passed (§6.3 ~1 h)."""
class PreviewStale(ProductsError):
"""INV-11: the catalog changed since validation — the previewed diff no longer holds."""
class NothingToApply(ProductsError):
"""PUC-10: no adds and no updates — confirming would be a no-op."""
class RunNotFound(ProductsError):
"""No such import run for this storefront."""
class EmptyCatalog(ProductsError):
"""PUC-9: nothing to export (no products, or none matching the status filter)."""
+30
View File
@@ -0,0 +1,30 @@
"""Hosted-image URL helpers (SD-0002 §6.3.1, INV-12).
Build the canonical hosted Image Src for a fetched image, and recognize one on
re-import. DB-free and host-agnostic: recognition is a path-parse, so an export
round-trips across localhost / PPE / the #23 rebrand. The diff resolves a parsed
id against the storefront-scoped catalog snapshot.
"""
from __future__ import annotations
import re
from urllib.parse import urlsplit
RENDITIONS = ("original", "thumb", "card", "detail")
EXPORT_RENDITION = "detail"
_PATH = re.compile(r"^/api/products/images/(\d+)/(original|thumb|card|detail)$")
def image_url(base_url: str, image_id: int, rendition: str = EXPORT_RENDITION) -> str:
"""Absolute when base_url is set, else a root-relative path."""
path = f"/api/products/images/{image_id}/{rendition}"
return f"{base_url.rstrip('/')}{path}" if base_url else path
def parse_image_id(url: str) -> int | None:
"""The image id if url is one of our hosted-image URLs (any host), else None."""
if not url:
return None
m = _PATH.match(urlsplit(url).path)
return int(m.group(1)) if m else None
+121
View File
@@ -0,0 +1,121 @@
"""Canonical row model + column registry — the one model every dialect maps to (INV-17)."""
from __future__ import annotations
from dataclasses import dataclass, field
# §6.5.1 canonical columns, by level. Header detection, unknown-column warnings, and
# validation all read from this registry.
PRODUCT_COLUMNS: dict[str, str] = {
# column -> product field name
"Title": "title",
"Description": "description_html",
"Vendor": "vendor",
"Type": "product_type",
"Google Product Category": "google_product_category",
"Tags": "tags",
"Status": "status",
"Published": "published",
"Option1 Name": "option1_name",
"Option2 Name": "option2_name",
"Option3 Name": "option3_name",
}
VARIANT_COLUMNS: dict[str, str] = {
"Variant SKU": "sku",
"Variant Barcode": "barcode",
"Variant Price": "price",
"Variant Cost": "cost",
"Variant Weight": "weight",
"Variant Weight Unit": "weight_unit",
"Variant Volume": "volume",
"Variant Volume Unit": "volume_unit",
"Variant Tax ID 1": "tax_id_1",
"Variant Tax ID 2": "tax_id_2",
"Variant Inventory Tracker": "inventory_tracker",
"Variant Inventory Qty": "inventory_qty",
"Variant Position": "position",
"Variant Image": "variant_image",
}
OPTION_VALUE_COLUMNS = ("Option1 Value", "Option2 Value", "Option3 Value")
IMAGE_COLUMNS = ("Image Src", "Image Position", "Image Alt Text")
COMPONENT_COLUMNS = tuple(
f"Component {i} {kind}" for i in range(1, 11) for kind in ("SKU", "Quantity")
)
KNOWN_COLUMNS = (
{"Handle"}
| set(PRODUCT_COLUMNS)
| set(VARIANT_COLUMNS)
| set(OPTION_VALUE_COLUMNS)
| set(IMAGE_COLUMNS)
| set(COMPONENT_COLUMNS)
)
# Clearing a field (present-but-empty cell, §6.5.1) resets it to its default.
CLEAR_DEFAULTS: dict[str, object] = {
"status": "active",
"published": True,
"product_type": "standalone",
"tags": [],
}
MAX_DATA_ROWS = 5_000 # INV-18
MAX_FILE_BYTES = 10 * 1024 * 1024 # INV-18
@dataclass(frozen=True)
class Row:
"""One CSV data row: 1-based file line number + the cells of known columns
present in the header (column name -> raw string, possibly empty)."""
line_number: int
cells: dict[str, str]
@dataclass(frozen=True)
class ParsedFile:
dialect: str
header: list[str]
unknown_columns: list[str]
rows: list[Row]
@dataclass(frozen=True)
class RowError:
line_number: int
column: str | None
message: str
def as_json(self) -> dict:
return {"line": self.line_number, "column": self.column, "message": self.message}
@dataclass
class CanonicalVariant:
line_number: int
options: tuple[str | None, str | None, str | None]
# field name -> normalized value; present only for columns in the file.
# value None == clear (reset to default/NULL).
fields: dict[str, object] = field(default_factory=dict)
@dataclass
class CanonicalImage:
line_number: int
source_url: str
position: int
alt_text: str | None
@dataclass
class CanonicalProduct:
first_line: int
handle: str
title: str # "" when missing (the block then carries an error)
option_names: tuple[str | None, str | None, str | None] = (None, None, None)
fields: dict[str, object] = field(default_factory=dict) # product-level, same semantics
variants: list[CanonicalVariant] = field(default_factory=list)
images: list[CanonicalImage] = field(default_factory=list)
errors: list[RowError] = field(default_factory=list)
@property
def valid(self) -> bool:
return not self.errors
+6
View File
@@ -0,0 +1,6 @@
Handle,Title,Description,Vendor,Type,Google Product Category,Tags,Status,Published,Option1 Name,Option1 Value,Option2 Name,Option2 Value,Variant SKU,Variant Price,Variant Inventory Qty,Image Src,Image Position,Image Alt Text
moon-mug,Moon Mug,"<p>A ceramic mug glazed in moonlight grey.</p>",Wiggle Goods,standalone,Home & Garden > Kitchen & Dining,"kitchen, mugs",active,TRUE,,,,,WG-MUG-001,18.00,40,https://images.example.com/moon-mug.jpg,1,Moon Mug on a desk
star-tee,Star Tee,"<p>Soft cotton tee with a hand-printed star.</p>",Wiggle Goods,standalone,Apparel & Accessories > Clothing,"apparel, tees",active,TRUE,Size,S,Color,Indigo,WG-TEE-S,24.00,12,https://images.example.com/star-tee.jpg,1,Star Tee flat lay
star-tee,,,,,,,,,,M,,Indigo,WG-TEE-M,24.00,18,,,
star-tee,,,,,,,,,,L,,Indigo,WG-TEE-L,26.00,9,,,
star-tee,,,,,,,,,,,,,,,,https://images.example.com/star-tee-back.jpg,2,Star Tee back print
1 Handle Title Description Vendor Type Google Product Category Tags Status Published Option1 Name Option1 Value Option2 Name Option2 Value Variant SKU Variant Price Variant Inventory Qty Image Src Image Position Image Alt Text
2 moon-mug Moon Mug <p>A ceramic mug glazed in moonlight grey.</p> Wiggle Goods standalone Home & Garden > Kitchen & Dining kitchen, mugs active TRUE WG-MUG-001 18.00 40 https://images.example.com/moon-mug.jpg 1 Moon Mug on a desk
3 star-tee Star Tee <p>Soft cotton tee with a hand-printed star.</p> Wiggle Goods standalone Apparel & Accessories > Clothing apparel, tees active TRUE Size S Color Indigo WG-TEE-S 24.00 12 https://images.example.com/star-tee.jpg 1 Star Tee flat lay
4 star-tee M Indigo WG-TEE-M 24.00 18
5 star-tee L Indigo WG-TEE-L 26.00 9
6 star-tee https://images.example.com/star-tee-back.jpg 2 Star Tee back print
+125
View File
@@ -0,0 +1,125 @@
"""Canonical serializer — CatalogProduct snapshot → canonical CSV (SD-0002 §6.5.5).
The export half of "one codec, two directions": this writes exactly the columns
codec.py/validate.py parse, in a row grammar (§6.5.1) the validator regroups
identically — so re-importing an unmodified export diffs to nothing (INV-12).
DB-free, like diff.py: it consumes the same CatalogProduct snapshot the diff
engine reads (repo.load_catalog), and the service streams the result.
"""
from __future__ import annotations
import csv
import io
from collections.abc import Iterable, Iterator
from decimal import Decimal
from .diff import CatalogProduct
from .hosted import EXPORT_RENDITION, image_url
from .models import (
IMAGE_COLUMNS,
OPTION_VALUE_COLUMNS,
PRODUCT_COLUMNS,
VARIANT_COLUMNS,
)
# Canonical column order: Handle, then product, option-value, variant, image
# columns. A superset of everything the parser knows (models.KNOWN_COLUMNS minus
# the #15-reserved Component columns, which export never emits).
HEADER: list[str] = [
"Handle",
*PRODUCT_COLUMNS,
*OPTION_VALUE_COLUMNS,
*VARIANT_COLUMNS,
*IMAGE_COLUMNS,
]
# field name -> column name, inverting the model registry (the parser reads
# column->field; the serializer writes field->column).
_PRODUCT_FIELD_TO_COL = {field: col for col, field in PRODUCT_COLUMNS.items()}
_VARIANT_FIELD_TO_COL = {field: col for col, field in VARIANT_COLUMNS.items()}
def _cell(value: object) -> str:
"""Serialize one value to its canonical cell text (the parse inverse)."""
if value is None:
return ""
if isinstance(value, bool):
return "TRUE" if value else "FALSE"
if isinstance(value, Decimal):
return str(value)
if isinstance(value, (list, tuple)):
return ", ".join(str(v) for v in value)
return str(value)
def catalog_to_csv(products: Iterable[CatalogProduct], base_url: str = "") -> Iterator[str]:
"""Stream canonical CSV text, header first, one product block at a time."""
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow(HEADER)
yield _drain(buf)
for product in products:
for row in _product_rows(product, base_url):
writer.writerow([row.get(col, "") for col in HEADER])
yield _drain(buf)
def _drain(buf: io.StringIO) -> str:
text = buf.getvalue()
buf.seek(0)
buf.truncate(0)
return text
def _product_rows(product: CatalogProduct, base_url: str = "") -> list[dict[str, str]]:
"""The product's CSV rows (§6.5.1 grammar): product fields + option names on
the first row; one variant per row; images interleaved; image-only rows when
a product has more images than variants."""
base = {"Handle": product.handle, "Title": product.title}
for field, col in _PRODUCT_FIELD_TO_COL.items():
if field in product.fields:
base[col] = _cell(product.fields[field])
for slot, name in enumerate(product.option_names, start=1):
if name:
base[f"Option{slot} Name"] = name
count = max(len(product.variants), len(product.images))
rows: list[dict[str, str]] = []
for i in range(count):
# Row 0 carries the product-level fields; later rows carry only Handle.
row = dict(base) if i == 0 else {"Handle": product.handle}
if i < len(product.variants):
_write_variant(row, product, product.variants[i])
if i < len(product.images):
_write_image(row, product.images[i], base_url)
rows.append(row)
return rows
def _write_image(row: dict[str, str], image, base_url: str) -> None:
# Fetched images export the platform-hosted URL (INV-12/16); everything
# else keeps the original source_url so nothing is lost (§6.5.5).
if image.status == "fetched":
row["Image Src"] = image_url(base_url, image.id, EXPORT_RENDITION)
else:
row["Image Src"] = image.source_url
row["Image Position"] = str(image.position)
if image.alt_text is not None:
row["Image Alt Text"] = image.alt_text
def _write_variant(row: dict[str, str], product: CatalogProduct, variant) -> None:
"""Fill a row's option-value + variant columns for one variant."""
for slot, value in enumerate(variant.options, start=1):
# Only emit an option value when the product actually has that option
# (a no-option product's single variant carries all-NULL options).
if product.option_names[slot - 1] and value is not None:
row[f"Option{slot} Value"] = value
# position is a CatalogVariant attribute, not a fields{} entry — emit it
# explicitly. An empty Variant Position cell re-imports as "reset to file
# order", so a non-sequential stored position would round-trip to an update
# (INV-12). (Like _write_image, which emits its position attribute.)
row["Variant Position"] = str(variant.position)
for field, col in _VARIANT_FIELD_TO_COL.items():
if field in variant.fields:
row[col] = _cell(variant.fields[field])
+299
View File
@@ -0,0 +1,299 @@
"""Row validation — ParsedFile rows → canonical products + row errors (SD-0002 §6.5.1).
The codec (codec.py) handles file-level gates; this module is the row-semantics
half of the PUC-5 import spine. It groups consecutive rows sharing a Handle into
product blocks (Shopify's grammar), normalizes product/variant/image fields, and
records every rule violation as a merchant-language RowError. Errors never raise:
an error poisons its whole product block (the product previews as kind="error"
and is excluded from apply) while parsing continues so the merchant gets a
complete accounting in one pass (BUC-1a). Description HTML is sanitized with nh3
on the way in (INV-15).
"""
from __future__ import annotations
import re
from decimal import Decimal, InvalidOperation
import nh3
from .models import (
COMPONENT_COLUMNS,
OPTION_VALUE_COLUMNS,
PRODUCT_COLUMNS,
VARIANT_COLUMNS,
CanonicalImage,
CanonicalProduct,
CanonicalVariant,
ParsedFile,
Row,
RowError,
)
_HANDLE_RE = re.compile(r"^[a-z0-9-]+$")
_STATUSES = {"draft", "active", "archived"}
# Title is handled as an attribute, not via fields{}. Option names live in BOTH
# .option_names (the values) and fields{} (the file-presence signal the diff
# needs: absent column == untouched, never a clear).
_ATTRIBUTE_COLUMNS = {"Title"}
# Sentinel: the cell failed normalization (the error is already recorded).
_INVALID = object()
def build_products(parsed: ParsedFile) -> list[CanonicalProduct]:
"""Group rows into product blocks and validate every §6.5.1 rule."""
products: list[CanonicalProduct] = []
closed_handles: set[str] = set()
block: list[Row] = []
def flush() -> None:
nonlocal block
if block:
closed_handles.add(block[0].cells["Handle"])
products.append(_build_block(block))
block = []
for row in parsed.rows:
handle = row.cells.get("Handle", "")
if block and handle == block[0].cells["Handle"]:
block.append(row)
continue
flush()
if not handle:
products.append(
_error_block(row, "(missing)", RowError(row.line_number, "Handle", "a row needs a Handle"))
)
elif not _HANDLE_RE.match(handle):
products.append(
_error_block(
row,
handle,
RowError(
row.line_number,
"Handle",
f"'{handle}' isn't a valid handle — lowercase letters, numbers, and dashes only",
),
)
)
elif handle in closed_handles:
products.append(
_error_block(
row,
handle,
RowError(
row.line_number,
"Handle",
f"rows for '{handle}' must be consecutive — it already appeared earlier in the file",
),
)
)
else:
block = [row]
flush()
return products
def all_errors(products: list[CanonicalProduct]) -> list[RowError]:
return [error for product in products for error in product.errors]
def _error_block(row: Row, handle: str, error: RowError) -> CanonicalProduct:
return CanonicalProduct(first_line=row.line_number, handle=handle, title="", errors=[error])
def _build_block(rows: list[Row]) -> CanonicalProduct:
first = rows[0]
handle = first.cells["Handle"]
errors: list[RowError] = []
title = first.cells.get("Title", "")
if not title:
errors.append(RowError(first.line_number, "Title", f"'{handle}' is missing its Title"))
option_names = tuple(first.cells.get(f"Option{n} Name") or None for n in (1, 2, 3))
has_options = any(option_names)
# Product-level fields come from the first row only; empty cell == clear (None).
fields: dict[str, object] = {}
for column, field_name in PRODUCT_COLUMNS.items():
if column in _ATTRIBUTE_COLUMNS or column not in first.cells:
continue
cell = first.cells[column]
if not cell:
fields[field_name] = None
continue
value = _product_value(first.line_number, column, field_name, cell, errors)
if value is not _INVALID:
fields[field_name] = value
variants: list[CanonicalVariant] = []
images: list[CanonicalImage] = []
seen_combos: set[tuple[str | None, str | None, str | None]] = set()
for index, row in enumerate(rows):
cells = row.cells
line = row.line_number
for column in COMPONENT_COLUMNS:
if cells.get(column):
errors.append(
RowError(line, column, "kits arrive in a coming release — leave the Component columns empty")
)
has_image = bool(cells.get("Image Src"))
if has_image:
_collect_image(row, images, errors)
# A row carries a variant iff any option value / Variant-* cell is filled;
# the first row of a no-option product always carries the single variant.
carries_variant = (
any(cells.get(c) for c in OPTION_VALUE_COLUMNS)
or any(cells.get(c) for c in VARIANT_COLUMNS)
or (index == 0 and not has_options)
)
if carries_variant:
options = tuple(cells.get(c) or None for c in OPTION_VALUE_COLUMNS)
for n in (1, 2, 3):
value, name = options[n - 1], option_names[n - 1]
if value and not name:
errors.append(
RowError(
line,
f"Option{n} Value",
f"Option{n} Value given but the product has no Option{n} Name",
)
)
elif name and not value:
errors.append(
RowError(
line,
f"Option{n} Value",
f"this variant is missing its Option{n} Value ('{name}')",
)
)
if not has_options:
if variants:
errors.append(RowError(line, None, "a product without options can have only one variant"))
elif options in seen_combos:
errors.append(
RowError(line, None, f"duplicate variant — '{handle}' already has a variant with these options")
)
seen_combos.add(options)
variant_fields: dict[str, object] = {}
for column, field_name in VARIANT_COLUMNS.items():
if column not in cells:
continue
cell = cells[column]
if not cell:
variant_fields[field_name] = None
continue
value = _variant_value(line, column, field_name, cell, errors)
if value is _INVALID:
continue
variant_fields[field_name] = value
if field_name == "variant_image" and cell not in {i.source_url for i in images}:
images.append(
CanonicalImage(line_number=line, source_url=cell, position=len(images) + 1, alt_text=None)
)
variants.append(CanonicalVariant(line_number=line, options=options, fields=variant_fields))
elif index > 0 and not has_image:
errors.append(RowError(line, None, "this row has no variant or image data"))
return CanonicalProduct(
first_line=first.line_number,
handle=handle,
title=title,
option_names=option_names,
fields=fields,
variants=variants,
images=images,
errors=errors,
)
def _collect_image(row: Row, images: list[CanonicalImage], errors: list[RowError]) -> None:
cells = row.cells
source_url = cells["Image Src"]
position_cell = cells.get("Image Position", "")
position: int | None = None
if position_cell:
try:
position = int(position_cell)
if position < 1:
raise ValueError
except ValueError:
position = None
errors.append(RowError(row.line_number, "Image Position", f"'{position_cell}' is not a position"))
# Dedupe by source URL within the block — first occurrence wins.
if source_url in {i.source_url for i in images}:
return
images.append(
CanonicalImage(
line_number=row.line_number,
source_url=source_url,
position=position if position is not None else len(images) + 1,
alt_text=cells.get("Image Alt Text") or None,
)
)
def _product_value(line: int, column: str, field_name: str, cell: str, errors: list[RowError]) -> object:
if field_name == "tags":
return [tag.strip() for tag in cell.split(",") if tag.strip()]
if field_name == "status":
status = cell.lower()
if status not in _STATUSES:
errors.append(RowError(line, column, f"'{cell}' is not a status — use draft, active, or archived"))
return _INVALID
return status
if field_name == "published":
flag = cell.upper()
if flag not in ("TRUE", "FALSE"):
errors.append(RowError(line, column, f"'{cell}' is not TRUE or FALSE"))
return _INVALID
return flag == "TRUE"
if field_name == "description_html":
return nh3.clean(cell)
if field_name == "product_type":
if cell != "standalone":
errors.append(RowError(line, column, "kits arrive in a coming release — Type must be 'standalone'"))
return _INVALID
return cell
return cell
def _variant_value(line: int, column: str, field_name: str, cell: str, errors: list[RowError]) -> object:
if field_name in ("price", "cost"):
return _decimal_or_error(line, column, cell, f"'{cell}' is not a price", errors)
if field_name in ("weight", "volume"):
return _decimal_or_error(line, column, cell, f"'{cell}' is not a number", errors)
if field_name == "inventory_qty":
try:
quantity = int(cell)
if quantity < 0:
raise ValueError
except ValueError:
errors.append(RowError(line, column, f"'{cell}' is not a whole number"))
return _INVALID
return quantity
if field_name == "position":
try:
position = int(cell)
if position < 1:
raise ValueError
except ValueError:
errors.append(RowError(line, column, f"'{cell}' is not a position"))
return _INVALID
return position
return cell
def _decimal_or_error(line: int, column: str, cell: str, message: str, errors: list[RowError]) -> object:
try:
value = Decimal(cell)
if not value.is_finite() or value < 0:
raise InvalidOperation
except InvalidOperation:
errors.append(RowError(line, column, message))
return _INVALID
return value
@@ -1,19 +0,0 @@
"""storefronts domain — storefront entity, membership, the one-storefront guard.
Owns INV-4 (one storefront per account is a service-layer rule) and INV-5 (tenant rows
carry storefront_id). Knows nothing about identity (that is the accounts domain). Imported
via this package surface only (§6.2).
"""
from __future__ import annotations
from .errors import AlreadyOwnsStorefront, StorefrontsError
from .models import Storefront
from .service import create_storefront, storefront_for
__all__ = [
"Storefront",
"StorefrontsError",
"AlreadyOwnsStorefront",
"create_storefront",
"storefront_for",
]
-10
View File
@@ -1,10 +0,0 @@
"""storefronts domain errors."""
from __future__ import annotations
class StorefrontsError(Exception):
"""Base for storefronts-domain errors."""
class AlreadyOwnsStorefront(StorefrontsError):
"""INV-4: the account already has its one storefront (PUC-7)."""
-10
View File
@@ -1,10 +0,0 @@
"""Storefront record — the §6.3 entity as the domain returns it."""
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class Storefront:
id: int
name: str
@@ -1,56 +0,0 @@
"""storefronts — the storefront + membership service (SD-0001 §6.5).
Owns the storefront entity, the account<->storefront membership (INV-5), the entry-routing
answer (storefront_for), and INV-4's one-storefront guard — the single deletable check that
makes one-per-account an MVP rule, not a schema law. Never mints identity: the BFF passes
account_id and email down.
"""
from __future__ import annotations
import psycopg
from .errors import AlreadyOwnsStorefront
from .models import Storefront
def _default_name(email: str) -> str:
"""§6.3: a blank name stores a generated default — never NULL (corpus 14.01.0026)."""
return f"{email.split('@', 1)[0]}'s storefront"
def storefront_for(conn: psycopg.Connection, account_id: int) -> Storefront | None:
"""The entry-routing answer (§6.5): which storefront, if any, this account has."""
row = conn.execute(
"SELECT s.id, s.name FROM storefront s"
" JOIN storefront_membership m ON m.storefront_id = s.id"
" WHERE m.account_id = %s ORDER BY m.created_at LIMIT 1",
(account_id,),
).fetchone()
return Storefront(id=row[0], name=row[1]) if row else None
def create_storefront(
conn: psycopg.Connection, account_id: int, email: str, name: str | None
) -> Storefront:
"""Create the account's one storefront + owner membership (PUC-4; INV-4, INV-5).
Guard + insert run as one atomic unit: a transaction-scoped advisory lock keyed by
account_id serializes concurrent creates for the same account, so the second of two
racing requests sees the first's committed membership and is refused (§6.5).
"""
conn.execute("SELECT pg_advisory_xact_lock(%s)", (account_id,))
existing = storefront_for(conn, account_id)
if existing is not None:
conn.rollback() # release the advisory lock; nothing was written
raise AlreadyOwnsStorefront()
final_name = (name or "").strip() or _default_name(email)
sf_id = conn.execute(
"INSERT INTO storefront (name) VALUES (%s) RETURNING id", (final_name,)
).fetchone()[0]
conn.execute(
"INSERT INTO storefront_membership (account_id, storefront_id, role)"
" VALUES (%s, %s, 'owner')",
(account_id, sf_id),
)
conn.commit()
return Storefront(id=sf_id, name=final_name)
+16 -52
View File
@@ -1,10 +1,14 @@
"""ecomm backend — FastAPI app factory + the REST BFF. """ecomm backend — FastAPI app factory + the network-service seed BFF.
SLICE-1 mounted /healthz; SLICE-2 adds the /api/auth/* identity endpoints (§6.4). The BFF This is the pivot to a network-service seed: a minimal FastAPI surface carrying only
translates HTTP <-> domain calls and owns no business logic (INV-6): every rule lives in /healthz and the /api/auth/* identity endpoints (§6.4), backed by the accounts domain.
the accounts domain. create_app() opens the pool, self-migrates (INV-1, INV-7), and builds The BFF translates HTTP <-> domain calls and owns no business logic (INV-6): every rule
the configured mailer (INV-8) at startup. SLICE-3 adds POST /api/storefronts and feeds the lives in the accounts domain. create_app() opens the pool, self-migrates (INV-1, INV-7),
_storefront_for seam from the storefronts domain. and builds the configured mailer (INV-8 — auth needs the mailer) at startup.
The catalog-normalization library (app.domains.products: codec/dialect/models/serialize/
validate/diff) is kept as importable modules but has no HTTP surface yet — the storefront
and products import/export/image spine was stripped in the network-seed reset.
""" """
from __future__ import annotations from __future__ import annotations
@@ -17,10 +21,9 @@ from typing import Any
import psycopg import psycopg
from fastapi import Depends, FastAPI, Response from fastapi import Depends, FastAPI, Response
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel from pydantic import BaseModel
from app.domains import accounts, storefronts from app.domains import accounts
from app.platform import config, db from app.platform import config, db
from app.platform import mailer as mailer_mod from app.platform import mailer as mailer_mod
from app.platform.deps import SESSION_COOKIE, get_conn, get_mailer, get_session from app.platform.deps import SESSION_COOKIE, get_conn, get_mailer, get_session
@@ -46,21 +49,11 @@ class VerifyBody(BaseModel):
code: str code: str
class CreateStorefrontBody(BaseModel):
name: str | None = None
def _error(status: int, code: str, message: str, **extra: Any) -> JSONResponse: def _error(status: int, code: str, message: str, **extra: Any) -> JSONResponse:
"""The shared §6.4 error envelope: {"error": {"code", "message", ...}}.""" """The shared §6.4 error envelope: {"error": {"code", "message", ...}}."""
return JSONResponse(status_code=status, content={"error": {"code": code, "message": message, **extra}}) return JSONResponse(status_code=status, content={"error": {"code": code, "message": message, **extra}})
def _storefront_for(conn: psycopg.Connection, account: accounts.Account) -> dict | None:
"""The entry-routing answer: which storefront, if any, this account has (§6.5)."""
sf = storefronts.storefront_for(conn, account.id)
return {"id": sf.id, "name": sf.name} if sf else None
def _ensure_app_logging() -> None: def _ensure_app_logging() -> None:
"""Surface the app's own `ecomm.*` INFO logs on stderr (idempotent). """Surface the app's own `ecomm.*` INFO logs on stderr (idempotent).
@@ -90,13 +83,13 @@ def _set_session_cookie(response: Response, account: accounts.Account) -> None:
) )
def create_app(database_url: str | None = None, static_dir: str | Path | None = None) -> FastAPI: def create_app(database_url: str | None = None) -> FastAPI:
_ensure_app_logging() _ensure_app_logging()
dsn = database_url or config.database_url() dsn = database_url or config.database_url()
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
app.state.pool = db.open_pool(dsn) app.state.pool = db.open_pool(dsn, max_size=10)
with app.state.pool.connection() as conn: with app.state.pool.connection() as conn:
db.migrate(conn) # self-migrate at startup (INV-1, INV-7) db.migrate(conn) # self-migrate at startup (INV-1, INV-7)
app.state.mailer = mailer_mod.build_mailer(config.mailer_kind()) # INV-8 app.state.mailer = mailer_mod.build_mailer(config.mailer_kind()) # INV-8
@@ -147,7 +140,7 @@ def create_app(database_url: str | None = None, static_dir: str | Path | None =
@app.post("/api/auth/verify") @app.post("/api/auth/verify")
def verify(body: VerifyBody, conn: psycopg.Connection = Depends(get_conn)): def verify(body: VerifyBody, conn: psycopg.Connection = Depends(get_conn)):
"""Verify a code, start a session, and answer entry routing (§6.4/§6.5).""" """Verify a code and start a session (§6.4)."""
try: try:
account, created = accounts.verify(conn, body.email, body.code) account, created = accounts.verify(conn, body.email, body.code)
except accounts.InvalidEmail: except accounts.InvalidEmail:
@@ -163,7 +156,6 @@ def create_app(database_url: str | None = None, static_dir: str | Path | None =
return _error(400, "code_exhausted", "Too many attempts — request a fresh code.") return _error(400, "code_exhausted", "Too many attempts — request a fresh code.")
payload = { payload = {
"account": {"email": account.email}, "account": {"email": account.email},
"storefront": _storefront_for(conn, account),
"created": created, "created": created,
} }
resp = JSONResponse(status_code=200, content=payload) resp = JSONResponse(status_code=200, content=payload)
@@ -172,13 +164,13 @@ def create_app(database_url: str | None = None, static_dir: str | Path | None =
@app.get("/api/auth/me") @app.get("/api/auth/me")
def me(conn: psycopg.Connection = Depends(get_conn), sess: dict | None = Depends(get_session)): def me(conn: psycopg.Connection = Depends(get_conn), sess: dict | None = Depends(get_session)):
"""The signed-in account + entry-routing answer, or 401 (§6.4/§6.5).""" """The signed-in account, or 401 (§6.4)."""
if sess is None: if sess is None:
return _error(401, "unauthenticated", "You are not signed in.") return _error(401, "unauthenticated", "You are not signed in.")
account = accounts.get_account(conn, sess["account_id"]) account = accounts.get_account(conn, sess["account_id"])
if account is None: if account is None:
return _error(401, "unauthenticated", "You are not signed in.") return _error(401, "unauthenticated", "You are not signed in.")
return {"account": {"email": account.email}, "storefront": _storefront_for(conn, account)} return {"account": {"email": account.email}}
@app.post("/api/auth/logout") @app.post("/api/auth/logout")
def logout(): def logout():
@@ -187,34 +179,6 @@ def create_app(database_url: str | None = None, static_dir: str | Path | None =
resp.delete_cookie(SESSION_COOKIE, path="/") resp.delete_cookie(SESSION_COOKIE, path="/")
return resp return resp
@app.post("/api/storefronts")
def create_storefront(
body: CreateStorefrontBody,
conn: psycopg.Connection = Depends(get_conn),
sess: dict | None = Depends(get_session),
):
"""Create the account's one storefront (§6.4; PUC-4, INV-4/PUC-7 on refusal)."""
if sess is None:
return _error(401, "unauthenticated", "You are not signed in.")
account = accounts.get_account(conn, sess["account_id"])
if account is None:
return _error(401, "unauthenticated", "You are not signed in.")
try:
sf = storefronts.create_storefront(conn, account.id, account.email, body.name)
except storefronts.AlreadyOwnsStorefront:
return _error(
409, "already_owns_storefront",
"Your account already has its storefront — ecomm is one storefront per account today.",
)
return JSONResponse(status_code=201, content={"id": sf.id, "name": sf.name})
# Deployed topology (launch-app SPEC §2): nginx proxies everything here, so the
# backend serves the built SPA. Mounted LAST so /healthz and /api/* win. In dev the
# dist dir doesn't exist (Vite serves the frontend) and the mount is skipped.
spa_dir = Path(static_dir) if static_dir is not None else _REPO_ROOT / "frontend" / "dist"
if (spa_dir / "index.html").is_file():
app.mount("/", StaticFiles(directory=spa_dir, html=True), name="spa")
return app return app
+28
View File
@@ -68,3 +68,31 @@ def smtp_from() -> str:
def smtp_starttls() -> bool: def smtp_starttls() -> bool:
"""STARTTLS on the relay connection (default on; disable only for odd relays).""" """STARTTLS on the relay connection (default on; disable only for odd relays)."""
return os.environ.get("ECOMM_SMTP_STARTTLS", "1").strip().lower() not in {"0", "false", "no", "off"} return os.environ.get("ECOMM_SMTP_STARTTLS", "1").strip().lower() not in {"0", "false", "no", "off"}
def objectstore_kind() -> str:
"""Which objectstore adapter to build: 'local' (dev/tests) or 'gcs' (deployed)."""
return os.environ.get("ECOMM_OBJECTSTORE_KIND") or "local"
def objectstore_bucket() -> str:
"""The GCS bucket name for the media objects (gcs adapter; deployment overlay)."""
return os.environ.get("ECOMM_OBJECTSTORE_BUCKET", "")
def objectstore_local_dir() -> str:
"""Base directory for the local-disk objectstore adapter (dev/tests)."""
return os.environ.get("ECOMM_OBJECTSTORE_DIR") or "/tmp/ecomm-objectstore"
def public_base_url() -> str:
"""Absolute origin for hosted image URLs in export (e.g. APP_URL); '' → relative."""
return (os.environ.get("APP_URL") or "").rstrip("/")
def image_fetch_allow_private() -> bool:
"""Allow image fetch from private/loopback hosts (dev/E2E fixture host only).
Default off — the SSRF guard rejects private ranges in deployed envs."""
return os.environ.get("ECOMM_IMAGE_FETCH_ALLOW_PRIVATE", "").strip().lower() in {
"1", "true", "yes", "on",
}
+77
View File
@@ -0,0 +1,77 @@
"""platform/images — pure image processing (SD-0002 §6.2).
Model-free, deterministic, no I/O of its own: decode bytes, enforce the
resolution bar (INV-18 / Q-3), and emit downscale-only WebP renditions. The
caller (the image-fetch phase) owns fetching, storage, and status.
"""
from __future__ import annotations
import io
from dataclasses import dataclass
from PIL import Image, UnidentifiedImageError
# Q-3 (SD-0002 §13): the minimum shorter-side dimension; below it an image is
# rejected_low_res. Rejects icons/thumbnails, accepts normal product photos.
MIN_IMAGE_SHORT_SIDE = 500
# Rendition longest-side caps (px); downscale-only — a smaller source is kept
# at its own size, never upscaled. Encoded WebP.
RENDITIONS = {"thumb": 160, "card": 480, "detail": 1600}
# Source formats we accept (Pillow format names).
_ACCEPTED = {"JPEG", "PNG", "WEBP"}
_WEBP_QUALITY = 82
@dataclass(frozen=True)
class Processed:
source_format: str
width: int
height: int
renditions: dict[str, bytes] # name -> WebP bytes
@dataclass(frozen=True)
class Rejected:
reason: str # "rejected_low_res" | "rejected_not_image"
def process(data: bytes) -> Processed | Rejected:
"""Decode + bar-check + renditions, or a typed rejection. Never raises on
bad input — undecodable/unsupported bytes are Rejected('rejected_not_image')."""
try:
with Image.open(io.BytesIO(data)) as im:
im.load()
source_format = im.format or ""
if source_format not in _ACCEPTED:
return Rejected(reason="rejected_not_image")
rgb = im.convert("RGB")
width, height = rgb.size
if min(width, height) < MIN_IMAGE_SHORT_SIDE:
return Rejected(reason="rejected_low_res")
renditions = {
name: _rendition(rgb, cap) for name, cap in RENDITIONS.items()
}
return Processed(
source_format=source_format,
width=width,
height=height,
renditions=renditions,
)
except (UnidentifiedImageError, OSError, ValueError, Image.DecompressionBombError):
return Rejected(reason="rejected_not_image")
def _rendition(rgb: Image.Image, cap: int) -> bytes:
longest = max(rgb.size)
if longest > cap:
scale = cap / longest
size = (max(1, round(rgb.width * scale)), max(1, round(rgb.height * scale)))
resized = rgb.resize(size, Image.LANCZOS)
else:
resized = rgb
buf = io.BytesIO()
resized.save(buf, format="WEBP", quality=_WEBP_QUALITY, method=6)
return buf.getvalue()
+103
View File
@@ -0,0 +1,103 @@
"""platform/objectstore — the media-blob port (SD-0002 §6.2, §6.3.1).
A tiny put/get/delete port with two adapters, mirroring the SD-0001 mailer
pattern: local-disk (dev/tests) and GCS (deployed). Owns no semantics — keys
and content types come from the caller (the image-fetch phase). Objects are
written once, never mutated (immutable, image-id-addressed).
"""
from __future__ import annotations
import os
from pathlib import Path
from typing import Protocol
from app.platform import config
class ObjectNotFound(Exception):
"""Raised by get() when the key has no object."""
class ObjectStore(Protocol):
def put(self, key: str, data: bytes, content_type: str) -> None: ...
def get(self, key: str) -> bytes: ...
def delete(self, key: str) -> None: ...
def build_objectstore(kind: str) -> ObjectStore:
"""Select the objectstore adapter by configured kind (config.objectstore_kind, INV-8)."""
if kind == "local":
return LocalObjectStore(config.objectstore_local_dir())
if kind == "gcs":
return GcsObjectStore(config.objectstore_bucket())
raise ValueError(f"unknown objectstore kind: {kind!r}")
def _safe_segments(key: str) -> tuple[str, ...]:
parts = tuple(p for p in key.split("/") if p)
if not parts or any(p in {".", ".."} for p in parts):
raise ValueError(f"unsafe objectstore key: {key!r}")
return parts
class LocalObjectStore:
"""Disk-backed adapter under a base dir; key path-segments become subdirs."""
def __init__(self, base_dir: str) -> None:
self._base = Path(base_dir)
def _path(self, key: str) -> Path:
return self._base.joinpath(*_safe_segments(key))
def put(self, key: str, data: bytes, content_type: str) -> None:
path = self._path(key)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data)
def get(self, key: str) -> bytes:
path = self._path(key)
try:
return path.read_bytes()
except FileNotFoundError as exc:
raise ObjectNotFound(key) from exc
def delete(self, key: str) -> None:
try:
os.remove(self._path(key))
except FileNotFoundError:
pass
class GcsObjectStore:
"""GCS adapter; auth via the VM service account ADC (no secret bytes).
google-cloud-storage is imported lazily so dev/test runs (local adapter)
don't require the package at import time.
"""
def __init__(self, bucket_name: str) -> None:
if not bucket_name:
raise ValueError("gcs objectstore requires ECOMM_OBJECTSTORE_BUCKET")
from google.cloud import storage # lazy
self._bucket = storage.Client().bucket(bucket_name)
def put(self, key: str, data: bytes, content_type: str) -> None:
_safe_segments(key)
self._bucket.blob(key).upload_from_string(data, content_type=content_type)
def get(self, key: str) -> bytes:
from google.cloud.exceptions import NotFound # lazy
try:
return self._bucket.blob(key).download_as_bytes()
except NotFound as exc:
raise ObjectNotFound(key) from exc
def delete(self, key: str) -> None:
from google.cloud.exceptions import NotFound # lazy
try:
self._bucket.blob(key).delete()
except NotFound:
pass
+13
View File
@@ -0,0 +1,13 @@
"""Structured log-event telemetry (SD-0002 §9.1). One JSON object per event on the
`ecomm.telemetry` logger — counts and durations only; never file names, URLs,
catalog content, or secret bytes (§6.3-handbook)."""
from __future__ import annotations
import json
import logging
_logger = logging.getLogger("ecomm.telemetry")
def emit(event: str, **fields: object) -> None:
_logger.info(json.dumps({"event": event, **fields}, sort_keys=True, default=str))
+130
View File
@@ -0,0 +1,130 @@
-- 0002_products.sql — SD-0002 §6.3 data model (SLICE-5). Forward-only (INV-7):
-- never edit once merged; add a new numbered migration.
-- product — one catalog product per (storefront, handle) (INV-13/14). Option *names*
-- live here; product_type's kit values are schema-open but a service-layer rule
-- rejects non-'standalone' until #15 (same pattern as INV-4).
CREATE TABLE product (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
storefront_id BIGINT NOT NULL REFERENCES storefront (id),
handle TEXT NOT NULL,
title TEXT NOT NULL,
description_html TEXT,
vendor TEXT,
product_type TEXT NOT NULL DEFAULT 'standalone'
CHECK (product_type IN ('standalone', 'kit_virtual', 'kit_assembled')),
google_product_category TEXT,
tags TEXT[] NOT NULL DEFAULT '{}',
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('draft', 'active', 'archived')),
published BOOLEAN NOT NULL DEFAULT TRUE,
option1_name TEXT,
option2_name TEXT,
option3_name TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX product_handle_key ON product (storefront_id, handle); -- INV-13
-- variant — one purchasable form, identified by its option-value combo (INV-13).
-- SKU is indexed data, never identity. image_id FK is added after product_image.
CREATE TABLE variant (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
product_id BIGINT NOT NULL REFERENCES product (id),
position INTEGER NOT NULL,
option1_value TEXT,
option2_value TEXT,
option3_value TEXT,
sku TEXT,
barcode TEXT,
price NUMERIC,
cost NUMERIC,
weight NUMERIC,
weight_unit TEXT,
volume NUMERIC,
volume_unit TEXT,
tax_id_1 TEXT,
tax_id_2 TEXT,
inventory_tracker TEXT,
inventory_qty INTEGER,
image_id BIGINT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Postgres 16 (compose + Cloud SQL pin): NULLS NOT DISTINCT makes the all-NULL
-- no-option combo unique too (INV-13).
CREATE UNIQUE INDEX variant_option_combo_key
ON variant (product_id, option1_value, option2_value, option3_value)
NULLS NOT DISTINCT;
CREATE INDEX variant_sku_idx ON variant (sku);
-- product_image — identity within a product is source_url (§6.3); bytes live in
-- object storage from SLICE-7 (keys nullable until fetched). status starts 'pending';
-- SLICE-5 stubs the fetch phase so rows simply stay pending.
CREATE TABLE product_image (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
product_id BIGINT NOT NULL REFERENCES product (id),
position INTEGER NOT NULL,
source_url TEXT NOT NULL,
alt_text TEXT,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'fetched', 'rejected_low_res', 'rejected_not_image', 'failed')),
failure_reason TEXT,
key_original TEXT,
key_thumb TEXT,
key_card TEXT,
key_detail TEXT,
import_run_id BIGINT,
fetched_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX product_image_src_key ON product_image (product_id, source_url);
ALTER TABLE variant
ADD CONSTRAINT variant_image_fk FOREIGN KEY (image_id) REFERENCES product_image (id);
-- import_draft — the preview's server side (INV-11). file_bytes is the SLICE-5
-- interim home for the upload (objectstore key from SLICE-7). Deleted outright on
-- cancel/expiry — drafts never appear in history (PUC-3a).
CREATE TABLE import_draft (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
storefront_id BIGINT NOT NULL REFERENCES storefront (id),
account_id BIGINT NOT NULL REFERENCES account (id),
file_name TEXT NOT NULL,
dialect TEXT NOT NULL,
file_bytes BYTEA NOT NULL,
summary JSONB NOT NULL,
records JSONB NOT NULL,
fingerprint TEXT NOT NULL,
unknown_columns TEXT[] NOT NULL DEFAULT '{}',
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- import_run — the durable record of one confirmed import (PUC-8); created only at
-- confirm (PUC-4).
CREATE TABLE import_run (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
storefront_id BIGINT NOT NULL REFERENCES storefront (id),
account_id BIGINT NOT NULL REFERENCES account (id),
file_name TEXT NOT NULL,
dialect TEXT NOT NULL,
products_added INTEGER NOT NULL,
products_updated INTEGER NOT NULL,
rows_errored INTEGER NOT NULL,
status TEXT NOT NULL
CHECK (status IN ('applying', 'fetching_images', 'complete', 'complete_with_problems')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
completed_at TIMESTAMPTZ
);
CREATE INDEX import_run_history_idx ON import_run (storefront_id, created_at DESC);
-- import_run_error — one row per rejected CSV row (PUC-5), merchant-language message.
CREATE TABLE import_run_error (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
run_id BIGINT NOT NULL REFERENCES import_run (id),
line_number INTEGER NOT NULL,
column_name TEXT,
message TEXT NOT NULL
);
ALTER TABLE product_image
ADD CONSTRAINT product_image_run_fk FOREIGN KEY (import_run_id) REFERENCES import_run (id);
+4
View File
@@ -5,3 +5,7 @@ psycopg[binary]>=3.1
psycopg-pool>=3.2 psycopg-pool>=3.2
pytest>=8.0 pytest>=8.0
import-linter>=2.0 import-linter>=2.0
nh3>=0.2
python-multipart>=0.0.9
Pillow>=11.0
google-cloud-storage>=2.18
+4
View File
@@ -0,0 +1,4 @@
Handle,Title,Body (HTML),Vendor,Product Category,Type,Tags,Published,Status,Option1 Name,Option1 Value,Variant SKU,Variant Grams,Variant Inventory Tracker,Variant Inventory Qty,Variant Inventory Policy,Variant Fulfillment Service,Variant Price,Variant Compare At Price,Variant Requires Shipping,Variant Taxable,Variant Barcode,Image Src,Image Position,Image Alt Text,Gift Card,SEO Title,SEO Description,Google Shopping / MPN,Variant Image,Variant Weight Unit,Cost per item,Status
star-tee,Star Tee,<p>Soft cotton tee.</p>,Wiggle Goods,Apparel & Accessories > Clothing,Shirts,"apparel, tees",TRUE,active,Size,S,WG-TEE-S,180,shopify,12,deny,manual,24.00,30.00,TRUE,TRUE,0001,https://img.example.com/star-tee.jpg,1,Star Tee,FALSE,Star Tee | Wiggle,Soft tee,MPN-1,https://img.example.com/star-s.jpg,g,11.00,active
star-tee,,,,,,,,,,M,WG-TEE-M,180,shopify,18,deny,manual,24.00,30.00,TRUE,TRUE,0002,,,,FALSE,,,,,g,11.00,
star-tee,,,,,,,,,,,,,,,,,,,,,,https://img.example.com/star-back.jpg,2,Star Tee back,,,,,,,,
1 Handle Title Body (HTML) Vendor Product Category Type Tags Published Status Option1 Name Option1 Value Variant SKU Variant Grams Variant Inventory Tracker Variant Inventory Qty Variant Inventory Policy Variant Fulfillment Service Variant Price Variant Compare At Price Variant Requires Shipping Variant Taxable Variant Barcode Image Src Image Position Image Alt Text Gift Card SEO Title SEO Description Google Shopping / MPN Variant Image Variant Weight Unit Cost per item Status
2 star-tee Star Tee <p>Soft cotton tee.</p> Wiggle Goods Apparel & Accessories > Clothing Shirts apparel, tees TRUE active Size S WG-TEE-S 180 shopify 12 deny manual 24.00 30.00 TRUE TRUE 0001 https://img.example.com/star-tee.jpg 1 Star Tee FALSE Star Tee | Wiggle Soft tee MPN-1 https://img.example.com/star-s.jpg g 11.00 active
3 star-tee M WG-TEE-M 180 shopify 18 deny manual 24.00 30.00 TRUE TRUE 0002 FALSE g 11.00
4 star-tee https://img.example.com/star-back.jpg 2 Star Tee back
+2 -2
View File
@@ -57,7 +57,7 @@ def test_verify_sets_cookie_and_returns_shape(fresh_db_url):
resp = client.post("/api/auth/verify", json={"email": "merchant@example.com", "code": code}) resp = client.post("/api/auth/verify", json={"email": "merchant@example.com", "code": code})
assert resp.status_code == 200 assert resp.status_code == 200
data = resp.json() data = resp.json()
assert data == {"account": {"email": "merchant@example.com"}, "storefront": None, "created": True} assert data == {"account": {"email": "merchant@example.com"}, "created": True}
assert "ecomm_session" in resp.cookies assert "ecomm_session" in resp.cookies
@@ -115,7 +115,7 @@ def test_me_returns_account_with_session(fresh_db_url):
client.post("/api/auth/verify", json={"email": "merchant@example.com", "code": _last_code(client)}) client.post("/api/auth/verify", json={"email": "merchant@example.com", "code": _last_code(client)})
resp = client.get("/api/auth/me") # TestClient carries the cookie set by verify resp = client.get("/api/auth/me") # TestClient carries the cookie set by verify
assert resp.status_code == 200 assert resp.status_code == 200
assert resp.json() == {"account": {"email": "merchant@example.com"}, "storefront": None} assert resp.json() == {"account": {"email": "merchant@example.com"}}
def test_puc_09_logout_clears_session(fresh_db_url): def test_puc_09_logout_clears_session(fresh_db_url):
+6 -13
View File
@@ -1,6 +1,7 @@
"""INV-1's enforcement (SD-0001 §6.8): from an empty database, one test walks the whole """INV-1's enforcement (SD-0001 §6.8): from an empty database, one test walks the whole
flow — request-code -> verify -> create-storefront -> /me — asserting no step needed surviving flow — request-code -> verify -> /me — asserting no step needed seeded state.
seeded state. Migration idempotence (the second INV-1 test) lives in test_migrations.py.""" The network-seed reset removed the create-storefront step from this flow. Migration
idempotence (the second INV-1 test) lives in test_migrations.py."""
import re import re
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
@@ -26,17 +27,9 @@ def test_inv_1_bootstrap_whole_flow_from_empty(fresh_db_url):
) )
assert verified.status_code == 200 assert verified.status_code == 200
assert verified.json()["created"] is True assert verified.json()["created"] is True
assert verified.json()["storefront"] is None # -> create-storefront (PUC-5) assert verified.json()["account"] == {"email": "first@example.com"}
# PUC-4: create the storefront (blank name -> generated default) # the admin answer — the signed-in account from /me alone (cookie set by verify)
created = client.post("/api/storefronts", json={})
assert created.status_code == 201
assert created.json()["name"] == "first's storefront"
# PUC-6/PUC-8: the admin answer — storefront + email from /me alone
me = client.get("/api/auth/me") me = client.get("/api/auth/me")
assert me.status_code == 200 assert me.status_code == 200
assert me.json() == { assert me.json() == {"account": {"email": "first@example.com"}}
"account": {"email": "first@example.com"},
"storefront": created.json(),
}
+24 -2
View File
@@ -1,4 +1,5 @@
import psycopg import psycopg
import pytest
from app.platform import db from app.platform import db
@@ -12,10 +13,10 @@ def _table_names(conn) -> set[str]:
return {r[0] for r in rows} return {r[0] for r in rows}
def test_migrate_from_empty_applies_0001(fresh_db_url): def test_migrate_from_empty_applies_all(fresh_db_url):
with psycopg.connect(fresh_db_url) as conn: with psycopg.connect(fresh_db_url) as conn:
applied = db.migrate(conn) applied = db.migrate(conn)
assert applied == ["0001_init.sql"] assert applied == ["0001_init.sql", "0002_products.sql"]
with psycopg.connect(fresh_db_url) as conn: with psycopg.connect(fresh_db_url) as conn:
assert _TABLES.issubset(_table_names(conn)) assert _TABLES.issubset(_table_names(conn))
@@ -41,3 +42,24 @@ def test_membership_has_no_unique_account_constraint(fresh_db_url):
).fetchall() ).fetchall()
defs = " ".join(r[0] for r in rows).lower() defs = " ".join(r[0] for r in rows).lower()
assert "unique" not in defs.replace("primary key", "") or "(account_id)" not in defs assert "unique" not in defs.replace("primary key", "") or "(account_id)" not in defs
def test_0002_products_tables_exist(fresh_db_url):
with psycopg.connect(fresh_db_url) as conn:
db.migrate(conn)
for table in ("product", "variant", "product_image", "import_draft", "import_run", "import_run_error"):
assert conn.execute("SELECT to_regclass(%s)", (f"public.{table}",)).fetchone()[0] == table
def test_0002_variant_option_combo_unique_treats_nulls_as_equal(fresh_db_url):
# INV-13: the no-option product's single variant has NULL option values; a second
# all-NULL combo must collide (NULLS NOT DISTINCT).
with psycopg.connect(fresh_db_url) as conn:
db.migrate(conn)
sf = conn.execute("INSERT INTO storefront (name) VALUES ('s') RETURNING id").fetchone()[0]
pid = conn.execute(
"INSERT INTO product (storefront_id, handle, title) VALUES (%s,'h','T') RETURNING id", (sf,)
).fetchone()[0]
conn.execute("INSERT INTO variant (product_id, position) VALUES (%s, 1)", (pid,))
with pytest.raises(psycopg.errors.UniqueViolation):
conn.execute("INSERT INTO variant (product_id, position) VALUES (%s, 2)", (pid,))
+65
View File
@@ -0,0 +1,65 @@
"""platform/images — pure decode + resolution bar + WebP renditions (SD-0002 §6.2)."""
import io
from PIL import Image
from app.platform import images
def _png(width: int, height: int, color=(120, 80, 200)) -> bytes:
buf = io.BytesIO()
Image.new("RGB", (width, height), color).save(buf, format="PNG")
return buf.getvalue()
def _jpeg(width: int, height: int) -> bytes:
buf = io.BytesIO()
Image.new("RGB", (width, height), (10, 200, 90)).save(buf, format="JPEG")
return buf.getvalue()
def test_good_image_produces_three_webp_renditions():
result = images.process(_png(1200, 900))
assert isinstance(result, images.Processed)
assert result.source_format == "PNG"
assert set(result.renditions) == {"thumb", "card", "detail"}
for name, blob in result.renditions.items():
with Image.open(io.BytesIO(blob)) as im:
assert im.format == "WEBP"
with Image.open(io.BytesIO(result.renditions["thumb"])) as im:
assert max(im.size) <= images.RENDITIONS["thumb"]
with Image.open(io.BytesIO(result.renditions["detail"])) as im:
assert max(im.size) <= images.RENDITIONS["detail"]
def test_downscale_only_never_upscales_small_source():
result = images.process(_png(520, 520))
with Image.open(io.BytesIO(result.renditions["detail"])) as im:
assert im.size == (520, 520)
def test_below_resolution_bar_rejected_low_res():
result = images.process(_png(300, 1200)) # shorter side 300 < 500
assert isinstance(result, images.Rejected)
assert result.reason == "rejected_low_res"
def test_not_an_image_rejected_not_image():
result = images.process(b"this is not an image")
assert isinstance(result, images.Rejected)
assert result.reason == "rejected_not_image"
def test_jpeg_source_format_preserved_in_result():
result = images.process(_jpeg(800, 800))
assert result.source_format == "JPEG"
def test_decompression_bomb_rejected_not_image(monkeypatch):
# A small file whose pixel count exceeds Pillow's bomb threshold must be a
# typed rejection, never an escaping exception.
from PIL import Image as PILImage
monkeypatch.setattr(PILImage, "MAX_IMAGE_PIXELS", 100) # 10x10 exceeds it
result = images.process(_png(800, 800))
assert isinstance(result, images.Rejected)
assert result.reason == "rejected_not_image"
@@ -0,0 +1,44 @@
"""platform/objectstore — local-disk adapter round-trip (SD-0002 §6.2/§6.3.1)."""
import pytest
from app.platform import objectstore
def test_local_put_get_round_trip(tmp_path):
store = objectstore.LocalObjectStore(str(tmp_path))
key = "storefronts/1/product-images/9/original"
store.put(key, b"\x89PNG-bytes", "image/png")
assert store.get(key) == b"\x89PNG-bytes"
def test_local_get_missing_raises_not_found(tmp_path):
store = objectstore.LocalObjectStore(str(tmp_path))
with pytest.raises(objectstore.ObjectNotFound):
store.get("nope/missing")
def test_local_delete_is_idempotent(tmp_path):
store = objectstore.LocalObjectStore(str(tmp_path))
store.put("a/b", b"x", "text/plain")
store.delete("a/b")
store.delete("a/b") # no error second time
with pytest.raises(objectstore.ObjectNotFound):
store.get("a/b")
def test_local_keys_cannot_escape_base_dir(tmp_path):
store = objectstore.LocalObjectStore(str(tmp_path))
with pytest.raises(ValueError):
store.put("../escape", b"x", "text/plain")
def test_build_objectstore_local(tmp_path, monkeypatch):
monkeypatch.setenv("ECOMM_OBJECTSTORE_KIND", "local")
monkeypatch.setenv("ECOMM_OBJECTSTORE_DIR", str(tmp_path))
store = objectstore.build_objectstore("local")
assert isinstance(store, objectstore.LocalObjectStore)
def test_build_objectstore_unknown_kind_raises():
with pytest.raises(ValueError):
objectstore.build_objectstore("s3")
+95
View File
@@ -0,0 +1,95 @@
"""§6.5.1 file-level codec — parse, caps, required columns (PUC-5a fixtures)."""
import pytest
from app.domains.products import FileRejected
from app.domains.products.codec import parse_csv
def _csv(*lines: str) -> bytes:
return ("\n".join(lines) + "\n").encode()
GOOD = _csv(
"Handle,Title,Variant Price,Bogus Column",
"moon-mug,Moon Mug,18.00,x",
"star-tee,Star Tee,24.00,y",
)
def test_parses_header_rows_and_unknown_columns():
parsed = parse_csv(GOOD)
assert parsed.dialect == "canonical"
assert parsed.unknown_columns == ["Bogus Column"]
assert [r.line_number for r in parsed.rows] == [2, 3]
assert parsed.rows[0].cells["Handle"] == "moon-mug"
assert parsed.rows[0].cells["Variant Price"] == "18.00"
assert "Bogus Column" not in parsed.rows[0].cells
def test_bom_tolerated():
parsed = parse_csv(b"\xef\xbb\xbf" + GOOD)
assert parsed.rows[0].cells["Handle"] == "moon-mug"
def test_quoted_cells_rfc4180():
parsed = parse_csv(_csv("Handle,Title,Tags", 'mug,"The ""Best"" Mug","a, b"'))
assert parsed.rows[0].cells["Title"] == 'The "Best" Mug'
assert parsed.rows[0].cells["Tags"] == "a, b"
def test_empty_rows_skipped_short_rows_padded():
parsed = parse_csv(_csv("Handle,Title,Vendor", "mug,Mug", "", ",,", "tee,Tee,Acme"))
assert [r.cells["Handle"] for r in parsed.rows] == ["mug", "tee"]
assert parsed.rows[0].cells["Vendor"] == ""
@pytest.mark.parametrize(
"data,code",
[
(b"\xff\xfe\x00garbage\x00", "not_csv"),
(b"", "not_csv"),
(_csv("Title,Vendor", "Mug,Acme"), "missing_required_column"),
(_csv("Handle,Vendor", "mug,Acme"), "missing_required_column"),
(
_csv("Handle,Title", *(f"h{i},T{i}" for i in range(5001))),
"too_many_rows",
),
(b"Handle,Title\n" + b"x" * (10 * 1024 * 1024), "file_too_large"),
],
)
def test_file_level_rejections(data, code):
with pytest.raises(FileRejected) as exc:
parse_csv(data)
assert exc.value.code == code
def test_missing_column_message_names_the_column():
with pytest.raises(FileRejected) as exc:
parse_csv(_csv("Handle,Vendor", "mug,Acme"))
assert "'Title'" in exc.value.message
def test_parse_detects_and_maps_shopify():
data = (
"Handle,Title,Body (HTML),Cost per item,Variant Price,Variant Grams,Type,Gift Card\n"
"mug,Moon Mug,<p>Grey</p>,9.50,18.00,300,Drinkware,false\n"
).encode("utf-8")
parsed = parse_csv(data)
assert parsed.dialect == "shopify"
cells = parsed.rows[0].cells
assert cells["Description"] == "<p>Grey</p>"
assert cells["Variant Cost"] == "9.50"
assert cells["Variant Weight"] == "300"
assert cells["Variant Weight Unit"] == "g" # synthesized
# Type (free-text) and Gift Card warned, never mapped:
assert "Type" in parsed.unknown_columns
assert "Gift Card" in parsed.unknown_columns
assert "product_type" not in str(cells) # Type never reached canonical
def test_parse_canonical_unchanged():
data = b"Handle,Title,Description,Variant Price\nmug,Moon Mug,Grey,18.00\n"
parsed = parse_csv(data)
assert parsed.dialect == "canonical"
assert parsed.rows[0].cells["Description"] == "Grey"
assert parsed.unknown_columns == []
@@ -0,0 +1,121 @@
"""Shopify dialect adapter — detection + the §6.5.1 mapping contract (SLICE-8)."""
from pathlib import Path
from app.domains.products.codec import parse_csv
from app.domains.products.dialect_shopify import is_shopify_header, map_shopify_header
_FIXTURE = Path(__file__).parent / "fixtures" / "shopify-export.csv"
def test_detects_shopify_by_signature_column():
assert is_shopify_header(["Handle", "Title", "Body (HTML)", "Variant Price"]) is True
assert is_shopify_header(["Handle", "Title", "Google Shopping / MPN"]) is True
def test_canonical_header_is_not_shopify():
assert is_shopify_header(["Handle", "Title", "Description", "Variant Cost"]) is False
def test_ambiguous_shared_only_header_defaults_canonical():
# Only columns common to both dialects -> not Shopify (safe default, never misparse).
assert (
is_shopify_header(["Handle", "Title", "Option1 Name", "Variant Price", "Image Src"])
is False
)
def test_map_renames_and_passes_through():
mapped, not_imported = map_shopify_header(
["Handle", "Body (HTML)", "Cost per item", "Variant Price"]
)
assert mapped == ["Handle", "Description", "Variant Cost", "Variant Price"]
assert not_imported == []
def test_map_drops_type_and_weight_unit_overrides():
mapped, not_imported = map_shopify_header(
["Handle", "Type", "Variant Grams", "Variant Weight Unit"]
)
assert mapped == ["Handle", None, "Variant Weight", None]
assert not_imported == ["Type", "Variant Weight Unit"]
def test_map_drops_shopify_only_and_market_columns():
mapped, not_imported = map_shopify_header(
["Handle", "Variant Compare At Price", "Gift Card", "Price / International"]
)
assert mapped == ["Handle", None, None, None]
assert not_imported == ["Variant Compare At Price", "Gift Card", "Price / International"]
def test_shopify_fixture_maps_exhaustively():
parsed = parse_csv(_FIXTURE.read_bytes())
assert parsed.dialect == "shopify"
first = parsed.rows[0].cells
# renamed
assert first["Description"] == "<p>Soft cotton tee.</p>"
assert first["Google Product Category"] == "Apparel & Accessories > Clothing"
assert first["Variant Cost"] == "11.00"
assert first["Variant Weight"] == "180"
assert first["Variant Weight Unit"] == "g"
# direct
assert first["Variant SKU"] == "WG-TEE-S"
assert first["Image Src"] == "https://img.example.com/star-tee.jpg"
# not imported — warned, none leaked into canonical cells
for col in (
"Type",
"Variant Compare At Price",
"Gift Card",
"SEO Title",
"Google Shopping / MPN",
"Variant Weight Unit",
"Variant Inventory Policy",
"Variant Fulfillment Service",
"Variant Requires Shipping",
"Variant Taxable",
):
assert col in parsed.unknown_columns, col
assert "Variant Compare At Price" not in first
# --- Detection robustness (review findings #1, #2): bias conservative ----------------
def test_canonical_distinctive_column_vetoes_shopify_detection():
# A canonical file carrying a stray Shopify-signature name (`SEO Title`) stays
# canonical because it also has canonical-distinctive columns — no misparse,
# no dropped Type, no corrupted weight unit (review finding #2).
header = ["Handle", "Title", "Type", "Variant Weight", "Variant Weight Unit", "SEO Title"]
assert is_shopify_header(header) is False
def test_dual_named_file_stays_canonical_and_warns_shopify_name():
# A malformed file with BOTH `Body (HTML)` and canonical `Description`: the
# canonical column vetoes detection, so `Body (HTML)` is honestly warned as an
# unknown column rather than silently shadowing `Description` (review finding #1).
data = (
b"Handle,Title,Body (HTML),Description,Variant Price\n"
b"mug,Moon Mug,FROM_BODY,FROM_DESC,18.00\n"
)
parsed = parse_csv(data)
assert parsed.dialect == "canonical"
assert parsed.rows[0].cells["Description"] == "FROM_DESC"
assert "Body (HTML)" in parsed.unknown_columns
def test_real_shopify_export_still_detected():
# The conservative veto must not break a genuine Shopify export (no canonical-
# distinctive columns present).
parsed = parse_csv(_FIXTURE.read_bytes())
assert parsed.dialect == "shopify"
def test_shopify_grams_clear_clears_weight_unit_together():
# An empty Variant Grams (a clear) clears the synthesized unit too — weight and
# unit move together, never a stale unit (review finding #3).
data = b"Handle,Title,Body (HTML),Variant Grams\nmug,Moon Mug,<p>x</p>,\n"
parsed = parse_csv(data)
assert parsed.dialect == "shopify"
cells = parsed.rows[0].cells
assert cells["Variant Weight"] == ""
assert cells["Variant Weight Unit"] == ""
+196
View File
@@ -0,0 +1,196 @@
"""Diff engine — classification, blank-vs-absent, option matching, fingerprint (§6.8)."""
from decimal import Decimal
from app.domains.products.codec import parse_csv
from app.domains.products.diff import (
CatalogImage, CatalogProduct, CatalogVariant, compute_diff,
)
from app.domains.products.validate import build_products
def _canon(*lines: str):
return build_products(parse_csv(("\n".join(lines) + "\n").encode()))
def _catalog_mug(**overrides):
fields = {
"title": "Moon Mug", "description_html": None, "vendor": "Acme",
"product_type": "standalone", "google_product_category": None,
"tags": ["kitchen"], "status": "active", "published": True,
} | overrides
return {
"moon-mug": CatalogProduct(
id=1, handle="moon-mug", title=fields["title"],
option_names=(None, None, None), fields=fields,
variants=[CatalogVariant(id=10, options=(None, None, None), position=1,
fields={"sku": "SKU-1", "barcode": None, "price": Decimal("18.00"),
"cost": None, "weight": None, "weight_unit": None,
"volume": None, "volume_unit": None, "tax_id_1": None,
"tax_id_2": None, "inventory_tracker": None,
"inventory_qty": 40, "variant_image": None})],
images=[CatalogImage(id=100, source_url="https://x/a.jpg", position=1, alt_text=None)],
)
}
HEADER = "Handle,Title,Vendor,Tags,Status,Variant SKU,Variant Price,Variant Inventory Qty,Image Src"
MUG_ROW = 'moon-mug,Moon Mug,Acme,kitchen,active,SKU-1,18.00,40,https://x/a.jpg'
def test_new_handle_classifies_add():
diff = compute_diff({}, _canon(HEADER, MUG_ROW))
[rec] = diff.records
assert rec["kind"] == "add" and rec["handle"] == "moon-mug" and rec["variant_count"] == 1
assert diff.summary == {"adds": 1, "updates": 0, "unchanged": 0, "errors": 0}
assert rec["detail"]["set"]["status"] == "active"
def test_identical_file_classifies_unchanged():
diff = compute_diff(_catalog_mug(), _canon(HEADER, MUG_ROW))
assert diff.records[0]["kind"] == "unchanged"
assert diff.summary["unchanged"] == 1
def test_changed_price_classifies_update_with_before_after():
row = MUG_ROW.replace("18.00", "21.00")
diff = compute_diff(_catalog_mug(), _canon(HEADER, row))
[rec] = diff.records
assert rec["kind"] == "update"
[vchange] = rec["detail"]["variants"]
assert {"field": "price", "before": "18.00", "after": "21.00"} in vchange["changes"]
def test_absent_column_untouched_empty_cell_clears():
# Vendor column absent: vendor stays Acme. Status present-but-empty: clears to default 'active' (already active -> no change).
diff = compute_diff(
_catalog_mug(),
_canon("Handle,Title,Status,Variant SKU,Variant Price,Variant Inventory Qty,Image Src",
"moon-mug,Moon Mug,,SKU-1,18.00,40,https://x/a.jpg"),
)
assert diff.records[0]["kind"] == "unchanged"
def test_empty_cell_clear_shows_in_diff():
# Vendor present-but-empty clears Acme -> None: an explicit, previewable change (§6.5.1).
diff = compute_diff(
_catalog_mug(),
_canon("Handle,Title,Vendor,Variant SKU,Variant Price,Variant Inventory Qty,Image Src",
"moon-mug,Moon Mug,,SKU-1,18.00,40,https://x/a.jpg"),
)
[rec] = diff.records
assert rec["kind"] == "update"
assert {"field": "vendor", "before": "Acme", "after": None} in rec["detail"]["changes"]
def test_new_option_combo_is_variant_add_existing_untouched():
catalog = _catalog_mug()
diff = compute_diff(
catalog,
_canon("Handle,Title,Option1 Name,Option1 Value,Variant Price",
"moon-mug,Moon Mug,Size,Large,25.00"),
)
[rec] = diff.records
assert rec["kind"] == "update"
kinds = [v.get("kind") for v in rec["detail"]["variants"]]
assert "add" in kinds
POSITION_HEADER = HEADER + ",Variant Position"
def test_matching_variant_position_column_classifies_unchanged():
# position is an attribute on CatalogVariant (not in fields{}); the compare
# must read it from there, not invent a before:None.
diff = compute_diff(_catalog_mug(), _canon(POSITION_HEADER, MUG_ROW + ",1"))
assert diff.records[0]["kind"] == "unchanged"
def test_changed_variant_position_reports_honest_before():
diff = compute_diff(_catalog_mug(), _canon(POSITION_HEADER, MUG_ROW + ",2"))
[rec] = diff.records
assert rec["kind"] == "update"
[ventry] = rec["detail"]["variants"]
assert {"field": "position", "before": 1, "after": 2} in ventry["changes"]
def test_blank_position_cell_resolves_to_file_order_unchanged():
# A present-but-empty Variant Position cell resets to file order (the spec's
# "defaults to file order"), never to NULL — here file order matches the
# catalog position, so nothing changes.
diff = compute_diff(_catalog_mug(), _canon(POSITION_HEADER, MUG_ROW + ","))
assert diff.records[0]["kind"] == "unchanged"
def test_blank_position_cell_updates_to_file_order():
catalog = _catalog_mug()
catalog["moon-mug"].variants[0].position = 2
diff = compute_diff(catalog, _canon(POSITION_HEADER, MUG_ROW + ","))
[rec] = diff.records
assert rec["kind"] == "update"
[ventry] = rec["detail"]["variants"]
assert {"field": "position", "before": 2, "after": 1} in ventry["changes"]
def _catalog_lamp_with_image():
# A well-formed single-variant product carrying one fetched image id=55.
fields = {
"title": "Lamp", "description_html": None, "vendor": "Acme",
"product_type": "standalone", "google_product_category": None,
"tags": ["home"], "status": "active", "published": True,
}
return {
"lamp": CatalogProduct(
id=1, handle="lamp", title="Lamp",
option_names=(None, None, None), fields=fields,
variants=[CatalogVariant(id=10, options=(None, None, None), position=1,
fields={"sku": "SKU-L", "barcode": None, "price": Decimal("30.00"),
"cost": None, "weight": None, "weight_unit": None,
"volume": None, "volume_unit": None, "tax_id_1": None,
"tax_id_2": None, "inventory_tracker": None,
"inventory_qty": 5, "variant_image": None})],
images=[CatalogImage(id=55, source_url="https://m.example/a.png", position=1,
alt_text=None, status="fetched")],
)
}
LAMP_HEADER = "Handle,Title,Vendor,Tags,Status,Variant SKU,Variant Price,Variant Inventory Qty,Image Src,Image Position"
LAMP_ROW_PREFIX = "lamp,Lamp,Acme,home,active,SKU-L,30.00,5,"
def test_hosted_url_for_existing_image_is_unchanged_not_add():
# Catalog product "lamp" has a fetched image id=55; canonical re-imports it
# as the hosted URL -> must be 'unchanged', never 'add'/'update'.
diff = compute_diff(
_catalog_lamp_with_image(),
_canon(LAMP_HEADER, LAMP_ROW_PREFIX + "/api/products/images/55/detail,1"),
)
kinds = {r["handle"]: r["kind"] for r in diff.records}
assert kinds["lamp"] == "unchanged"
def test_hosted_url_for_unknown_id_is_row_error():
# Catalog "lamp" has NO images; canonical references /images/999/detail -> error.
catalog = _catalog_lamp_with_image()
catalog["lamp"].images = []
diff = compute_diff(
catalog,
_canon(LAMP_HEADER, LAMP_ROW_PREFIX + "/api/products/images/999/detail,1"),
)
kinds = {r["handle"]: r["kind"] for r in diff.records}
assert kinds["lamp"] == "error"
def test_error_product_classifies_error():
diff = compute_diff({}, _canon("Handle,Title,Variant Price", "mug,Mug,nope"))
[rec] = diff.records
assert rec["kind"] == "error"
assert rec["detail"]["errors"][0]["column"] == "Variant Price"
def test_fingerprint_stable_and_drift_sensitive():
d1 = compute_diff(_catalog_mug(), _canon(HEADER, MUG_ROW))
d2 = compute_diff(_catalog_mug(), _canon(HEADER, MUG_ROW))
d3 = compute_diff(_catalog_mug(title="Renamed"), _canon(HEADER, MUG_ROW))
assert d1.fingerprint == d2.fingerprint
assert d1.fingerprint != d3.fingerprint
+249
View File
@@ -0,0 +1,249 @@
"""Canonical serializer + INV-12 round-trip lock (SD-0002 §6.5.5, §6.8)."""
import csv
import io
from app.domains.products import serialize
from app.domains.products.diff import CatalogImage, CatalogProduct, CatalogVariant
def _product(**kw) -> CatalogProduct:
"""A minimal no-option catalog product (one all-NULL variant)."""
base = dict(
id=1, handle="moon-mug", title="Moon Mug",
option_names=(None, None, None),
fields={
"title": "Moon Mug", "description_html": None, "vendor": "Acme",
"product_type": "standalone", "google_product_category": None,
"tags": [], "status": "active", "published": True,
},
variants=[CatalogVariant(
id=1, options=(None, None, None), position=1,
fields={"sku": "WG-MUG", "barcode": None, "price": None, "cost": None,
"weight": None, "weight_unit": None, "volume": None,
"volume_unit": None, "tax_id_1": None, "tax_id_2": None,
"inventory_tracker": None, "inventory_qty": None,
"variant_image": None})],
images=[],
)
base.update(kw)
return CatalogProduct(**base)
def _rows(products) -> list[dict]:
text = "".join(serialize.catalog_to_csv(products))
return list(csv.DictReader(io.StringIO(text)))
def test_header_is_full_canonical_set():
text = "".join(serialize.catalog_to_csv([_product()]))
header = next(csv.reader(io.StringIO(text)))
# Handle + Title first; every known column present exactly once.
assert header[0] == "Handle"
assert "Title" in header and "Variant SKU" in header and "Image Src" in header
assert len(header) == len(set(header))
def test_single_no_option_product_one_row():
rows = _rows([_product()])
assert len(rows) == 1
assert rows[0]["Handle"] == "moon-mug"
assert rows[0]["Title"] == "Moon Mug"
assert rows[0]["Variant SKU"] == "WG-MUG"
# A no-option product emits no option values.
assert rows[0]["Option1 Value"] == ""
def _star_tee() -> CatalogProduct:
"""A 3-variant, 2-image product (the sample.csv shape)."""
return CatalogProduct(
id=2, handle="star-tee", title="Star Tee",
option_names=("Size", "Color", None),
fields={"title": "Star Tee", "description_html": None, "vendor": "Acme",
"product_type": "standalone", "google_product_category": None,
"tags": ["apparel", "tees"], "status": "active", "published": True},
variants=[
CatalogVariant(id=10, options=("S", "Indigo", None), position=1,
fields={"sku": "WG-TEE-S", "variant_image": None}),
CatalogVariant(id=11, options=("M", "Indigo", None), position=2,
fields={"sku": "WG-TEE-M", "variant_image": None}),
CatalogVariant(id=12, options=("L", "Indigo", None), position=3,
fields={"sku": "WG-TEE-L", "variant_image": None}),
],
images=[
CatalogImage(id=1, source_url="https://x/a.jpg", position=1, alt_text="front"),
CatalogImage(id=2, source_url="https://x/b.jpg", position=2, alt_text="back"),
],
)
def test_multivariant_with_images_interleaves():
rows = _rows([_star_tee()])
assert len(rows) == 3 # max(3 variants, 2 images)
# Product-level fields only on the first row.
assert rows[0]["Title"] == "Star Tee" and rows[1]["Title"] == ""
assert rows[0]["Tags"] == "apparel, tees" and rows[1]["Tags"] == ""
# Option names on row 0; option values on every variant row.
assert rows[0]["Option1 Name"] == "Size" and rows[1]["Option1 Name"] == ""
assert [r["Option1 Value"] for r in rows] == ["S", "M", "L"]
assert [r["Variant SKU"] for r in rows] == ["WG-TEE-S", "WG-TEE-M", "WG-TEE-L"]
# Two images on the first two rows; third row has no image.
assert [r["Image Src"] for r in rows] == ["https://x/a.jpg", "https://x/b.jpg", ""]
assert [r["Image Position"] for r in rows] == ["1", "2", ""]
def test_more_images_than_variants_emits_image_only_rows():
p = _product(images=[
CatalogImage(id=1, source_url="https://x/a.jpg", position=1, alt_text=None),
CatalogImage(id=2, source_url="https://x/b.jpg", position=2, alt_text=None),
CatalogImage(id=3, source_url="https://x/c.jpg", position=3, alt_text=None),
])
rows = _rows([p])
assert len(rows) == 3 # 1 variant, 3 images
assert rows[0]["Variant SKU"] == "WG-MUG"
assert rows[1]["Variant SKU"] == "" and rows[1]["Handle"] == "moon-mug"
assert [r["Image Src"] for r in rows] == ["https://x/a.jpg", "https://x/b.jpg", "https://x/c.jpg"]
def test_fetched_image_serializes_hosted_detail_url():
product = CatalogProduct(
id=1, handle="lamp", title="Lamp", option_names=(None, None, None),
fields={"title": "Lamp", "status": "active"},
variants=[CatalogVariant(id=1, options=(None, None, None), position=1, fields={})],
images=[
CatalogImage(id=55, source_url="https://m.example/a.png", position=1,
alt_text=None, status="fetched"),
CatalogImage(id=56, source_url="https://m.example/b.png", position=2,
alt_text=None, status="failed"),
],
)
rows = list(serialize._product_rows(product, base_url="https://shop.test"))
srcs = [r.get("Image Src") for r in rows if r.get("Image Src")]
assert "https://shop.test/api/products/images/55/detail" in srcs # fetched -> hosted
assert "https://m.example/b.png" in srcs # failed -> source kept
import random
from app.domains.products import codec, diff, validate
def _gen_catalog(seed: int) -> dict:
"""A deterministic random text-field catalog, in load_catalog()'s shape."""
rng = random.Random(seed)
words = ["moon", "star", "river", "cloud", "ember", "fern", "slate", "wave"]
catalog: dict[str, CatalogProduct] = {}
n_products = rng.randint(1, 6)
for pid in range(1, n_products + 1):
handle = "-".join(rng.sample(words, rng.randint(1, 3))) + f"-{pid}"
if handle in catalog:
continue
has_opts = rng.random() < 0.6
option_names = ("Size", "Color", None) if has_opts else (None, None, None)
tags = rng.sample(["apparel", "kitchen", "sale", "new"], rng.randint(0, 3))
fields = {
"title": f"{handle.title()} Thing",
"description_html": rng.choice([None, "<p>hi</p>"]),
"vendor": rng.choice([None, "Acme", "Wiggle Goods"]),
"product_type": "standalone",
"google_product_category": rng.choice([None, "Home & Garden"]),
"tags": tags,
"status": rng.choice(["draft", "active", "archived"]),
"published": rng.choice([True, False]),
}
variants = []
if has_opts:
sizes = rng.sample(["S", "M", "L", "XL"], rng.randint(1, 4))
for vi, size in enumerate(sizes, start=1):
# Non-sequential positions (×10) so a serializer that drops the
# stored position and lets re-import default to file order is
# caught — a merchant can import explicit positions like 10/20.
variants.append(CatalogVariant(
id=pid * 100 + vi, options=(size, "Indigo", None), position=vi * 10,
fields={"sku": f"SKU-{pid}-{vi}", "variant_image": None}))
else:
variants.append(CatalogVariant(
id=pid * 100 + 1, options=(None, None, None), position=rng.choice([1, 5, 9]),
fields={"sku": f"SKU-{pid}", "variant_image": None}))
images = []
for ii in range(rng.randint(0, 3)):
# Mix fetched and non-fetched images: a fetched image exports its
# hosted /images/{id}/detail URL, which the diff pre-pass resolves
# back to the same id -> still a no-op (INV-12 over hosted images).
status = "fetched" if rng.random() < 0.5 else "pending"
images.append(CatalogImage(
id=pid * 10 + ii, source_url=f"https://img/{handle}-{ii}.jpg",
position=ii + 1, alt_text=rng.choice([None, f"alt {ii}"]),
status=status))
catalog[handle] = CatalogProduct(
id=pid, handle=handle, title=fields["title"], option_names=option_names,
fields=fields, variants=variants, images=images)
return catalog
def _roundtrip_diff(catalog: dict, base_url: str = "") -> diff.DiffResult:
"""export → bytes → import pipeline → diff against the same catalog."""
text = "".join(serialize.catalog_to_csv(catalog.values(), base_url=base_url))
parsed = codec.parse_csv(text.encode("utf-8"))
products = validate.build_products(parsed)
return diff.compute_diff(catalog, products)
def test_inv12_roundtrip_is_noop_over_generated_catalogs():
for seed in range(200):
catalog = _gen_catalog(seed)
# A fixed base_url so fetched images export an absolute hosted URL; the
# diff resolves it back by id -> the round-trip stays a no-op.
result = _roundtrip_diff(catalog, base_url="https://shop.test")
assert result.summary["adds"] == 0, f"seed {seed}: {result.summary}"
assert result.summary["updates"] == 0, f"seed {seed}: {result.summary}"
assert result.summary["errors"] == 0, f"seed {seed}: {result.summary}"
assert result.summary["unchanged"] == len(catalog), f"seed {seed}"
from decimal import Decimal
def test_decimal_and_int_variant_fields_roundtrip():
catalog = {
"priced": CatalogProduct(
id=1, handle="priced", title="Priced",
option_names=(None, None, None),
fields={"title": "Priced", "description_html": None, "vendor": None,
"product_type": "standalone", "google_product_category": None,
"tags": [], "status": "active", "published": True},
variants=[CatalogVariant(
id=1, options=(None, None, None), position=1,
fields={"sku": "P1", "price": Decimal("18.00"),
"cost": Decimal("9.5"), "weight": Decimal("0.250"),
"inventory_qty": 40, "variant_image": None})],
images=[],
)
}
result = _roundtrip_diff(catalog)
assert result.summary["unchanged"] == 1, result.records
assert result.summary["updates"] == 0
def test_nonsequential_variant_position_roundtrips():
"""A stored variant position that isn't its file order must survive export —
else re-import reads the empty cell as 'reset to file order' and the round-trip
spuriously updates (INV-12 regression: position is a CatalogVariant attribute,
not a fields{} entry, so the serializer must emit it explicitly)."""
catalog = {
"tee": CatalogProduct(
id=1, handle="tee", title="Tee", option_names=("Size", None, None),
fields={"title": "Tee", "description_html": None, "vendor": None,
"product_type": "standalone", "google_product_category": None,
"tags": [], "status": "active", "published": True},
variants=[
CatalogVariant(id=1, options=("S", None, None), position=10,
fields={"sku": "T-S", "variant_image": None}),
CatalogVariant(id=2, options=("M", None, None), position=20,
fields={"sku": "T-M", "variant_image": None}),
],
images=[],
)
}
result = _roundtrip_diff(catalog)
assert result.summary["unchanged"] == 1, result.records
assert result.summary["updates"] == 0
+164
View File
@@ -0,0 +1,164 @@
"""§6.5.1 row validation — every row-error rule has a fixture (SD-0002 §6.8)."""
from decimal import Decimal
import pytest
from app.domains.products.codec import parse_csv
from app.domains.products.validate import build_products
def _products(*lines: str):
return build_products(parse_csv(("\n".join(lines) + "\n").encode()))
def _errors(*lines: str):
return [e for p in _products(*lines) for e in p.errors]
def test_simple_product_parses_clean():
[p] = _products(
"Handle,Title,Vendor,Tags,Status,Published,Variant Price,Variant SKU",
"moon-mug,Moon Mug,Acme,\"kitchen, mugs\",active,TRUE,18.00,SKU-1",
)
assert p.valid and p.handle == "moon-mug" and p.title == "Moon Mug"
assert p.fields["tags"] == ["kitchen", "mugs"]
assert p.fields["status"] == "active" and p.fields["published"] is True
[v] = p.variants
assert v.options == (None, None, None)
assert v.fields["price"] == Decimal("18.00") and v.fields["sku"] == "SKU-1"
def test_option_product_groups_consecutive_rows():
[p] = _products(
"Handle,Title,Option1 Name,Option1 Value,Variant Price",
"tee,Tee,Size,S,24.00",
"tee,,,M,24.00",
"tee,,,L,26.00",
)
assert p.valid and p.option_names == ("Size", None, None)
assert [v.options[0] for v in p.variants] == ["S", "M", "L"]
def test_image_only_rows_and_dedupe():
[p] = _products(
"Handle,Title,Image Src,Image Position,Image Alt Text",
"mug,Mug,https://x/a.jpg,1,front",
"mug,,https://x/b.jpg,2,back",
"mug,,https://x/a.jpg,3,dupe",
)
assert p.valid
assert [(i.source_url, i.position) for i in p.images] == [
("https://x/a.jpg", 1), ("https://x/b.jpg", 2),
]
def test_variant_image_joins_product_images():
[p] = _products(
"Handle,Title,Variant Image",
"mug,Mug,https://x/v.jpg",
)
assert p.variants[0].fields["variant_image"] == "https://x/v.jpg"
assert [i.source_url for i in p.images] == ["https://x/v.jpg"]
def test_description_sanitized_inv15():
[p] = _products(
"Handle,Title,Description",
'mug,Mug,"<p onclick=\'x()\'>hi</p><script>evil()</script>"',
)
html = p.fields["description_html"]
assert "<p>" in html and "script" not in html and "onclick" not in html
def test_blank_cell_clears_absent_column_missing():
[p] = _products("Handle,Title,Vendor", "mug,Mug,")
assert p.fields["vendor"] is None # present-but-empty == clear
assert "status" not in p.fields # absent column == untouched
@pytest.mark.parametrize(
"header,row,column,fragment",
[
("Handle,Title", ",NoHandle", "Handle", "needs a Handle"),
("Handle,Title", "Bad_Handle!,T", "Handle", "isn't a valid handle"),
("Handle,Title", "mug,", "Title", "missing its Title"),
("Handle,Title,Type", "mug,Mug,kit_virtual", "Type", "kits arrive"),
("Handle,Title,Component 1 SKU", "mug,Mug,ABC", "Component 1 SKU", "kits arrive"),
("Handle,Title,Status", "mug,Mug,live", "Status", "is not a status"),
("Handle,Title,Published", "mug,Mug,YES", "Published", "is not TRUE or FALSE"),
("Handle,Title,Variant Price", 'mug,Mug,"12,50"', "Variant Price", "is not a price"),
("Handle,Title,Variant Cost", "mug,Mug,-3", "Variant Cost", "is not a price"),
("Handle,Title,Variant Weight", "mug,Mug,heavy", "Variant Weight", "is not a number"),
("Handle,Title,Variant Inventory Qty", "mug,Mug,3.5", "Variant Inventory Qty", "is not a whole number"),
("Handle,Title,Variant Position", "mug,Mug,0", "Variant Position", "is not a position"),
("Handle,Title,Option1 Value", "mug,Mug,Red", "Option1 Value", "no Option1 Name"),
],
)
def test_row_error_rules(header, row, column, fragment):
errors = _errors(header, row)
assert any(e.column == column and fragment in e.message for e in errors), errors
def test_missing_option_value_for_named_option():
errors = _errors(
"Handle,Title,Option1 Name,Option1 Value,Variant SKU",
"tee,Tee,Size,S,A",
"tee,,,,B",
)
assert any("missing its Option1 Value" in e.message and e.line_number == 3 for e in errors)
def test_duplicate_option_combo_is_error():
errors = _errors(
"Handle,Title,Option1 Name,Option1 Value",
"tee,Tee,Size,S",
"tee,,,S",
)
assert any("duplicate variant" in e.message for e in errors)
def test_second_variant_on_no_option_product_is_error():
errors = _errors(
"Handle,Title,Variant SKU",
"mug,Mug,A",
"mug,,B",
)
assert any("without options can have only one variant" in e.message for e in errors)
def test_non_consecutive_handle_is_error():
errors = _errors(
"Handle,Title",
"mug,Mug",
"tee,Tee",
"mug,",
)
assert any("must be consecutive" in e.message and e.line_number == 4 for e in errors)
def test_no_data_row_is_error():
errors = _errors(
"Handle,Title,Variant SKU,Image Src",
"mug,Mug,A,",
"mug,,,",
)
assert any("no variant or image data" in e.message for e in errors)
def test_errors_poison_their_product_only():
products = _products(
"Handle,Title,Variant Price",
"good-mug,Mug,10.00",
"bad-tee,Tee,not-a-price",
)
by_handle = {p.handle: p for p in products}
assert by_handle["good-mug"].valid
assert not by_handle["bad-tee"].valid
def test_all_errors_collected_not_first_only():
[p] = _products(
"Handle,Title,Status,Variant Price",
"mug,,bogus,abc",
)
assert len(p.errors) == 3 # missing Title + bad status + bad price
-24
View File
@@ -1,24 +0,0 @@
"""Deployed topology (launch-app SPEC §2): nginx proxies EVERYTHING to the backend, so
the backend must serve the built SPA (frontend/dist) itself. Dev is unaffected — Vite
serves the frontend and the default dist dir simply doesn't exist."""
from fastapi.testclient import TestClient
from app.main import create_app
def test_spa_served_when_dist_present(fresh_db_url, tmp_path):
(tmp_path / "index.html").write_text("<!doctype html><title>ecomm spa</title>")
with TestClient(create_app(database_url=fresh_db_url, static_dir=tmp_path)) as client:
root = client.get("/")
assert root.status_code == 200
assert "ecomm spa" in root.text
# API + health still win over the mount
assert client.get("/healthz").json()["status"] == "ok"
assert client.get("/api/auth/me").status_code == 401
def test_no_mount_when_dist_absent(fresh_db_url, tmp_path):
# An empty/missing dist (the dev case) must not 500 the app — / just 404s.
with TestClient(create_app(database_url=fresh_db_url, static_dir=tmp_path / "nope")) as client:
assert client.get("/").status_code == 404
assert client.get("/healthz").json()["status"] == "ok"
-154
View File
@@ -1,154 +0,0 @@
"""SLICE-3 storefront creation — service + endpoint scenario tests (SD-0001 §6.5 PUC-4/7)."""
import re
from contextlib import contextmanager
import psycopg
import pytest
from fastapi.testclient import TestClient
from app.main import create_app
from app.domains import storefronts
from app.platform import db
@pytest.fixture()
def migrated_conn(fresh_db_url):
with psycopg.connect(fresh_db_url) as conn:
db.migrate(conn)
yield conn
def _account_id(conn, email="merchant@example.com"):
return conn.execute(
"INSERT INTO account (email) VALUES (%s) RETURNING id", (email,)
).fetchone()[0]
def test_create_storefront_with_name(migrated_conn):
acct = _account_id(migrated_conn)
sf = storefronts.create_storefront(migrated_conn, acct, "merchant@example.com", "Ben's Bets")
assert sf.name == "Ben's Bets"
assert storefronts.storefront_for(migrated_conn, acct) == sf
def test_14_01_0026_blank_name_gets_generated_default(migrated_conn):
acct = _account_id(migrated_conn)
sf = storefronts.create_storefront(migrated_conn, acct, "merchant@example.com", None)
assert sf.name == "merchant's storefront"
def test_whitespace_name_treated_as_blank(migrated_conn):
acct = _account_id(migrated_conn)
sf = storefronts.create_storefront(migrated_conn, acct, "ben@bensbets.com", " ")
assert sf.name == "ben's storefront"
def test_second_create_refused_inv_4(migrated_conn):
acct = _account_id(migrated_conn)
storefronts.create_storefront(migrated_conn, acct, "merchant@example.com", "First")
with pytest.raises(storefronts.AlreadyOwnsStorefront):
storefronts.create_storefront(migrated_conn, acct, "merchant@example.com", "Second")
def test_membership_row_is_owner(migrated_conn):
acct = _account_id(migrated_conn)
sf = storefronts.create_storefront(migrated_conn, acct, "merchant@example.com", None)
role = migrated_conn.execute(
"SELECT role FROM storefront_membership WHERE account_id = %s AND storefront_id = %s",
(acct, sf.id),
).fetchone()[0]
assert role == "owner"
def test_storefront_for_none_when_absent(migrated_conn):
acct = _account_id(migrated_conn)
assert storefronts.storefront_for(migrated_conn, acct) is None
# ── endpoint scenario tests (§6.4 POST /api/storefronts; corpus 14.01.*) ──────────
@contextmanager
def _signed_in_client(fresh_db_url, email="merchant@example.com"):
with TestClient(create_app(database_url=fresh_db_url)) as client:
client.post("/api/auth/request-code", json={"email": email})
code = re.search(r"\b(\d{6})\b", client.app.state.mailer.outbox[-1].body).group(1)
client.post("/api/auth/verify", json={"email": email, "code": code})
yield client
def test_14_01_0013_create_storefront_with_name(fresh_db_url):
with _signed_in_client(fresh_db_url) as client:
resp = client.post("/api/storefronts", json={"name": "Ben's Bets"})
assert resp.status_code == 201
assert resp.json()["name"] == "Ben's Bets"
assert isinstance(resp.json()["id"], int)
def test_14_01_0015_0016_nothing_but_a_name_is_asked(fresh_db_url):
# No payment method, plan, or commitment: the request body needs nothing at all and
# the response carries only the storefront — no plan/trial/billing fields.
with _signed_in_client(fresh_db_url) as client:
resp = client.post("/api/storefronts", json={})
assert resp.status_code == 201
assert set(resp.json().keys()) == {"id", "name"}
def test_14_01_0025_create_lands_on_admin(fresh_db_url):
# After create, the entry-routing answer carries the storefront -> admin (PUC-6).
with _signed_in_client(fresh_db_url) as client:
created = client.post("/api/storefronts", json={"name": "Ben's Bets"}).json()
me = client.get("/api/auth/me").json()
assert me["storefront"] == created
def test_puc_05_returning_without_storefront_routes_to_create(fresh_db_url):
with _signed_in_client(fresh_db_url) as client:
me = client.get("/api/auth/me").json()
assert me["storefront"] is None
def test_14_01_0027_returning_with_storefront_straight_to_admin(fresh_db_url):
# PUC-6: a returning merchant's verify response already carries the storefront.
from datetime import datetime, timedelta, timezone
email = "returning@example.com"
with _signed_in_client(fresh_db_url, email) as client:
client.post("/api/storefronts", json={"name": "Ben's Bets"})
# fresh login: backdate the consumed code to clear the 60s resend cooldown
with psycopg.connect(fresh_db_url) as c:
c.execute(
"UPDATE auth_code SET created_at = %s WHERE email = %s",
(datetime.now(timezone.utc) - timedelta(minutes=2), email),
)
c.commit()
client.post("/api/auth/request-code", json={"email": email})
code = re.search(r"\b(\d{6})\b", client.app.state.mailer.outbox[-1].body).group(1)
resp = client.post("/api/auth/verify", json={"email": email, "code": code})
assert resp.status_code == 200
assert resp.json()["storefront"]["name"] == "Ben's Bets"
assert resp.json()["created"] is False
def test_puc_07_second_create_refused_409(fresh_db_url):
with _signed_in_client(fresh_db_url) as client:
client.post("/api/storefronts", json={"name": "First"})
resp = client.post("/api/storefronts", json={"name": "Second"})
assert resp.status_code == 409
assert resp.json()["error"]["code"] == "already_owns_storefront"
def test_puc_08_admin_shell_answer(fresh_db_url):
# The shell renders from /me alone: storefront name + signed-in email (§6.5 PUC-8).
with _signed_in_client(fresh_db_url) as client:
client.post("/api/storefronts", json={"name": "Ben's Bets"})
me = client.get("/api/auth/me").json()
assert me["account"]["email"] == "merchant@example.com"
assert me["storefront"]["name"] == "Ben's Bets"
def test_create_storefront_requires_session(fresh_db_url):
with TestClient(create_app(database_url=fresh_db_url)) as client:
resp = client.post("/api/storefronts", json={"name": "Nope"})
assert resp.status_code == 401
assert resp.json()["error"]["code"] == "unauthenticated"
@@ -1,64 +0,0 @@
"""INV-4's sharp edges (SD-0001 §6.8): concurrent second-storefront refusal, and the
membership schema staying many-capable (the rule is a deletable guard, not a schema law)."""
import threading
import psycopg
import pytest
from app.domains import storefronts
from app.platform import db
@pytest.fixture()
def migrated_url(fresh_db_url):
with psycopg.connect(fresh_db_url) as conn:
db.migrate(conn)
return fresh_db_url
def test_inv_4_concurrent_creates_exactly_one_wins(migrated_url):
with psycopg.connect(migrated_url) as setup:
acct = setup.execute(
"INSERT INTO account (email) VALUES ('racer@example.com') RETURNING id"
).fetchone()[0]
setup.commit()
barrier = threading.Barrier(2)
results: list[str] = []
def racer():
with psycopg.connect(migrated_url) as conn:
barrier.wait()
try:
storefronts.create_storefront(conn, acct, "racer@example.com", None)
results.append("created")
except storefronts.AlreadyOwnsStorefront:
results.append("refused")
threads = [threading.Thread(target=racer) for _ in range(2)]
for t in threads:
t.start()
for t in threads:
t.join()
assert sorted(results) == ["created", "refused"]
with psycopg.connect(migrated_url) as check:
count = check.execute(
"SELECT count(*) FROM storefront_membership WHERE account_id = %s", (acct,)
).fetchone()[0]
assert count == 1
def test_inv_4_schema_stays_many_capable(migrated_url):
# No UNIQUE(account_id) anywhere in the storefront path: the only unique constraint on
# storefront_membership is its composite PK, so many-per-account stays a deletable
# service rule (R-5 mitigation).
with psycopg.connect(migrated_url) as conn:
uniques = conn.execute(
"SELECT i.indkey::text FROM pg_index i"
" JOIN pg_class c ON c.oid = i.indrelid"
" WHERE c.relname = 'storefront_membership' AND (i.indisunique OR i.indisprimary)"
).fetchall()
# exactly one unique index (the composite PK over two columns)
assert len(uniques) == 1
assert len(uniques[0][0].split()) == 2
+45
View File
@@ -0,0 +1,45 @@
# deployment.toml — generated by define-deployment (launch-app SPEC §5.1, §6.6).
# Derived from the One Name 'ecomm' (§3.3); edit the toml, re-import to reconcile (§3.2.1).
[app]
name = "ecomm"
repo = "wiggleverse/wiggleverse-ecomm"
gitea_host = "https://git.wiggleverse.org"
version_source = { kind = "file", path = "VERSION" }
gitea_read_secret_ref = "wiggleverse-ecomm/ecomm-gitea-read-token"
[vm]
name = "ecomm-ppe"
zone = "us-central1-a"
project = "wiggleverse-ecomm"
machine_type = "e2-micro"
disk_gb = 10
service_user = "ecomm"
install_dir = "/opt/ecomm"
systemd_unit = "ecomm.service"
tunnel_through_iap = true
gcloud_config = "wiggleverse-ecomm"
[edge]
domain = "ecomm-ppe.wiggleverse.org"
# ecomm's health endpoint is /healthz (SD-0001 §6.4); body carries {status, version}.
health_url = "https://ecomm-ppe.wiggleverse.org/healthz"
# No DATABASE_PATH: ecomm runs Cloud SQL PostgreSQL (SD-0001 D-7/D-8) — the DSN is the
# ECOMM_DATABASE_URL secret reference below, minted by launch-app's provision-datastore.
[overlay] # non-secret env, plaintext (guide §8)
APP_URL = "https://ecomm-ppe.wiggleverse.org"
ECOMM_MAILER = "smtp"
ECOMM_COOKIE_SECURE = "1"
ECOMM_SMTP_HOST = "smtp.gmail.com"
ECOMM_SMTP_PORT = "587"
ECOMM_SMTP_USER = "ben.stull@wiggleverse.org"
ECOMM_SMTP_FROM = "ecomm <ben.stull@wiggleverse.org>"
# Objectstore (GCS) env removed in the network-seed reset — the seed no longer reads it.
# Re-add when network catalog-image storage lands; bucket wiggleverse-ecomm-ppe-media stays
# provisioned.
[secrets] # REFERENCES only — never bytes (§8.3)
ECOMM_SESSION_SECRET = "wiggleverse-ecomm/ecomm-ppe-session-secret"
ECOMM_DATABASE_URL = "wiggleverse-ecomm/ecomm-ppe-database-url"
ECOMM_SMTP_PASSWORD = "wiggleverse-ohm/ohm-rfc-app-smtp-password"
+12
View File
@@ -122,6 +122,18 @@ From empty persistence: the deploy migrates the schema at startup (INV-7); then
real sign-up → one-time code arriving by **real email** → create storefront → admin, real sign-up → one-time code arriving by **real email** → create storefront → admin,
through the public flows alone. Record each rehearsal here when it happens. through the public flows alone. Record each rehearsal here when it happens.
**Rehearsals:**
- **2026-06-11 — PPE first bootstrap (v0.4.0, session 0024): ✅** First-ever ecomm
deploy (`flotilla-core deploy ecomm`, 9/9 phases green, deploys.id=40) onto a
freshly provisioned environment — project `wiggleverse-ecomm`, Cloud SQL
`ecomm-ppe-pg` (provisioned by launch-app `provision-datastore`, first run), VM
`ecomm-ppe`. The app self-migrated the empty database at startup; the operator
then walked sign-up → real emailed code (Gmail relay) → create storefront →
honestly-empty admin in a browser, through the public flows alone. Findings
captured: OTC email branding (#16); identity should outgrow ecomm
(engineering#49/#50).
## Production ## Production
Lands with the prod stand-up — the **identical gesture** on a prod deployment record Lands with the prod stand-up — the **identical gesture** on a prod deployment record
+219
View File
@@ -0,0 +1,219 @@
# Operating ecomm
The framework-repo operator guide (SD-0002 DOC-1), started at SLICE-5. It covers
the app's operational surface — telemetry, runbooks, alert gestures, the E2E
gate. Per-deployment mechanics (deploy, secrets, VM access) live in the
deployment's flotilla docs and `deployment.toml`; environment bring-up is in
[`BOOTSTRAP.md`](./BOOTSTRAP.md).
## Products import/export ops (SD-0002, SLICE-5)
The §6.4 surface: a merchant uploads a catalog CSV (`POST
/api/products/imports`), the app validates it and stores an **import draft**
with a full preview (adds / updates / unchanged / errors), the merchant
confirms or cancels at the preview gate, and a confirm applies the previewed
diff as one **import run** recorded in the history (`/api/products/imports/runs`).
Caps and behavior to know (all enforced in code, not config):
- **Caps (INV-18):** ≤ 5,000 data rows and ≤ 10 MB per file —
`MAX_DATA_ROWS` / `MAX_FILE_BYTES` in `backend/app/domains/products/models.py`,
enforced in `backend/app/domains/products/codec.py` (file-level rejection)
plus a 413 `file_too_large` guard in the BFF (`backend/app/main.py`).
- **Draft expiry:** ~1 hour (`expires_at = now() + interval '1 hour'`). Cleanup
is a lazy sweep — expired drafts are deleted on the next upload and on any
access to an expired draft; there is no background job to babysit.
- **Upsert-only (INV-10):** an import adds and updates, never deletes. Catalog
products/variants/images absent from the file are untouched.
- **One-transaction apply (INV-11):** a confirm applies the whole previewed
diff in a single DB transaction — it lands completely or not at all.
### Telemetry
Structured JSON events on the `ecomm.telemetry` logger
(`backend/app/platform/telemetry.py`), one JSON object per line, emitted from
`backend/app/domains/products/service.py`. The app's `ecomm.*` log handler
writes to the process's stderr, which journald captures on the VM (and Cloud
Logging where the agent ships it). Events carry counts and durations only —
never file names, URLs, catalog content, or secret bytes.
| Event | Trigger | Payload fields |
| --- | --- | --- |
| TEL-1 `import_draft_created` | validation completes, draft stored | `storefront_id, dialect, row_count, adds, updates, unchanged, errors, unknown_columns_count, duration_ms` |
| TEL-2 `import_run_completed` | apply transaction commits | `run_id, storefront_id, added, updated, errored, duration_ms` |
| TEL-3 `catalog_exported` | export stream completes | `storefront_id, status_filter, product_count, duration_ms` |
| TEL-6 `import_apply_failed` | apply transaction aborts unexpectedly | `draft_id, storefront_id, error_class` |
| TEL-4 `image_phase_completed` | run's fetch task finishes | `run_id, fetched, rejected, failed, duration_ms` |
| TEL-5 `image_phase_recovered` | startup recovery resumes a run | `run_id, pending_resumed` |
### Export (PUC-9, SLICE-6)
`GET /api/products/export?status=all|active|draft|archived` streams the
storefront's catalog as a canonical-format CSV (one codec, two directions — the
same format the importer parses). It is **read-only** (no draft, no run) and
storefront-scoped (INV-14). An empty catalog — no products, or none matching the
status filter — returns `409 empty_catalog`; the Products page disables the
Export action with a note in that case. The round-trip is lossless (INV-12):
re-importing an unmodified export previews as all-unchanged with the import
action disabled (PUC-10). TEL-3 (`catalog_exported`) is emitted once the stream
completes — counts and duration only, never catalog content.
### Images (SLICE-7)
After a merchant confirms an import, the app transitions the run to
`fetching_images` and processes image URLs via a bounded thread pool
(4 workers). Each image goes through the SSRF guard, is decoded, and up to
four WebP renditions (`original`, `thumb`, `card`, `detail`) are written to
the objectstore under `product-images/`. Progress is tracked per run in
`image_progress` / `image_counts` / `image_outcomes` and visible in the
import history detail panel (PUC-8). TEL-4 (`image_phase_completed`) fires
when the phase finishes; startup recovery emits TEL-5 (`image_phase_recovered`)
when it resumes a stuck run.
#### `provision-bucket` — one-time gesture per environment (SLICE-7 PPE deploy)
Before the first SLICE-7 deploy, run the `provision-bucket` skill from the
engineering `launch-app` suite. This gesture:
1. Creates the GCS bucket `wiggleverse-ecomm-ppe-media` in the
`wiggleverse-ecomm` GCP project.
2. Grants the PPE VM's service account `roles/storage.objectAdmin` on the
bucket.
3. Sets an `import-drafts/` lifecycle rule (forward-compat for the draft-blob
migration deferred past SLICE-7).
After provisioning, `deployment.toml`'s `[overlay]` already carries:
```
ECOMM_OBJECTSTORE_KIND = "gcs"
ECOMM_OBJECTSTORE_BUCKET = "wiggleverse-ecomm-ppe-media"
```
so the next `flotilla deploy` picks them up automatically. GCS auth is the
VM service-account ADC — no credential bytes needed.
### RB-3 — image phase stuck
Triggered by ALR-3 (run in `fetching_images` > 2 h, or the same run recovered
≥ 3×).
1. **Identify the stuck run.** The import history (PUC-8, `GET
/api/products/imports/runs`) lists runs by status; look for a run with
`status="fetching_images"` that hasn't advanced.
```
journalctl -u ecomm.service | grep image_phase
```
2. **Restart to trigger recovery.** A process restart causes the startup
recovery scan to re-enqueue pending images for any run still in
`fetching_images` (TEL-5 confirms the resumption). Restarts are safe —
per-image claims are idempotent; already-fetched images are skipped.
```
systemctl restart ecomm.service
```
3. **If a specific image URL is the cause.** The `image_outcomes` field on
the run records per-image rejection reasons (SSRF block, resolution
rejection, network error, etc.). The merchant can update or remove the
offending URL and re-import.
4. **File a bug** on `wiggleverse/wiggleverse-ecomm` with the `run_id` and
the `image_outcomes` content from the log.
### ALR-3 — the log-based alert for stuck image phases
Run once per environment, at the SLICE-7 PPE deploy. Mirrors ALR-2's
approach — a log metric over the stuck/recovered signal, the existing email
channel, and an alert policy in project `wiggleverse-ecomm`.
```
# Select the deployment's gcloud config for this one process (handbook §8.4).
export CLOUDSDK_ACTIVE_CONFIG_NAME=wiggleverse-ecomm
# Log-based metric counting image-phase events (TEL-4/TEL-5 — stuck/recovered).
gcloud logging metrics create ecomm_image_phase_events \
--description="ecomm TEL-4/TEL-5 image phase completed/recovered (SD-0002 ALR-3)" \
--log-filter='resource.type="gce_instance" AND (jsonPayload.message:"image_phase_completed" OR jsonPayload.message:"image_phase_recovered" OR textPayload:"image_phase_completed" OR textPayload:"image_phase_recovered")'
```
Then attach an alert policy — **operator email channel, threshold any event >
0 within a 2-hour window, severity notify-only**. List channels first:
```
# Find the operator email channel's id for the policy.
gcloud beta monitoring channels list
```
Create the alert policy (Cloud Console or `gcloud alpha monitoring policies
create`); condition: `fetching_images` duration > 2 h **or** the same
`run_id` appears in TEL-5 ≥ 3 times.
### RB-2 — import apply failed
Triggered by ALR-2 (any TEL-6 event). The apply raised mid-transaction and
rolled back.
1. **Locate the failure.** On the VM, filter the journal for the event and note
the `draft_id`, `storefront_id`, and `error_class`:
```
journalctl -u ecomm.service | grep import_apply_failed
```
2. **Confirm the rollback held (INV-11).** The apply is one transaction, so a
failure leaves the catalog exactly as it was: the storefront's runs history
(`GET /api/products/imports/runs`) shows **no new run**, and the products
summary (`GET /api/products/summary`) shows an unchanged `product_count`.
3. **The merchant's draft is intact.** A failed apply does not consume the
draft — the merchant can retry confirm, or re-upload if the draft has since
expired (~1 h). Advise accordingly.
4. **File a bug** on `wiggleverse/wiggleverse-ecomm` with the `error_class`
and the surrounding log context (the traceback is in the app log next to
the event).
### ALR-2 — the log-based alert (one gesture per environment)
Run once per environment, at this slice's PPE deploy. This is an ad hoc op on
the existing GCP project (`wiggleverse-ecomm`), not a provisioning gesture.
```
# Select the deployment's gcloud config for this one process (handbook §8.4).
export CLOUDSDK_ACTIVE_CONFIG_NAME=wiggleverse-ecomm
# Log-based metric counting import-apply failures (TEL-6).
gcloud logging metrics create ecomm_import_apply_failed \
--description="ecomm TEL-6 import_apply_failed events (SD-0002 ALR-2)" \
--log-filter='resource.type="gce_instance" AND jsonPayload.message:"import_apply_failed" OR textPayload:"import_apply_failed"'
```
Then attach an alert policy to the metric — **operator email channel, threshold
any event > 0 in 5 minutes, severity notify-only** (pre-v1: no paging). The
policy is created in the Cloud Console or with
`gcloud alpha monitoring policies create`; the exact command depends on the
notification-channel id, so list channels first:
```
# Find the operator email channel's id for the policy.
gcloud beta monitoring channels list
```
### E2E browser suite
- Lives at `e2e/` — Playwright, Chromium, six scenarios (SLICE-5:
preview/confirm happy path, actionable errors, file rejection, cancel;
SLICE-6: `e2e_export_download`, `e2e_roundtrip_noop`).
- Run with `bash scripts/e2e.sh`. The harness boots a **fresh `ecomm_e2e`
database** against the local compose Postgres and serves the built SPA from
the backend on **:8765** (the deployed topology), so it needs the dev
Postgres up (`scripts/dev.sh`).
- **Not in `scripts/check.sh` / CI yet** — the Gitea runner has no browsers
(the §10.6 machinery gap). Run it locally before merge, and against PPE per
the §9 pipeline (the PPE browser run is still manual this slice).
## Cross-references
- SLO and alert definitions: SD-0002 §9–§10 (content repo,
`wiggleverse-ecomm-content/specs/SD-0002-products-bulk-csv-import-export.md`).
- Import/diff engine internals: [`products-domain.md`](./products-domain.md).
+275
View File
@@ -0,0 +1,275 @@
# products domain — developer notes
DOC-4 (SD-0002 §11): the import/export spine as built in SLICE-5, extended by
SLICE-68. Operator-facing material is in [`OPERATIONS.md`](./OPERATIONS.md);
the spec is SD-0002 in the content repo.
## Layout and pipeline
`backend/app/domains/products/` is layered like the rest of the app
(main → domains → platform, enforced by import-linter):
- `models.py` — canonical row model + the column registry.
- `codec.py` — bytes → `ParsedFile`; file-level gates only.
- `validate.py` — rows → `CanonicalProduct` blocks + per-row errors.
- `diff.py` — catalog × canonical products → apply plan + preview records.
- `serialize.py` — the export half: `CatalogProduct` snapshot → canonical CSV
(the inverse of `codec`/`validate`). DB-free, like `diff.py`.
- `repo.py` — SQL only: catalog snapshot, draft/run CRUD, apply primitives.
Never commits or rolls back.
- `service.py` — use-case orchestration; owns every transaction boundary and
emits the TEL events via `backend/app/platform/telemetry.py`.
```mermaid
flowchart LR
subgraph validate_path [import_validate]
A[parse_csv] --> B[build_products] --> D[compute_diff] --> E[(import_draft)]
C[load_catalog] --> D
end
subgraph confirm_path [confirm_draft]
E --> F[re-derive: parse → build → diff] --> G{fingerprint match?}
G -- yes --> H[one-transaction apply<br/>run + products + delete draft]
G -- no --> I[PreviewStale<br/>draft kept]
end
```
Confirm re-derives everything from the draft's stored `file_bytes` against the
live catalog, then checks the fingerprint — so what lands is exactly what the
preview showed, or the confirm refuses (`preview_stale`). A confirm with no
adds and no updates refuses with `nothing_to_apply`.
## Canonical model and blank-vs-absent
`models.py` is the one model every dialect maps to (INV-17). `KNOWN_COLUMNS`
is the registry header detection, unknown-column warnings, and validation all
read; `CLEAR_DEFAULTS` holds the reset values for clearable fields
(`status`, `published`, `product_type`, `tags`).
The §6.5.1 cell semantics, as implemented:
- **Absent column** → the field never enters `fields{}` → untouched by diff
and apply (never a change).
- **Present-but-empty cell** → `fields[name] = None` (an explicit clear) →
resolved at diff time to its `CLEAR_DEFAULTS` entry, or NULL where none
exists.
- **The position exception:** a cleared `Variant Position` has no
`CLEAR_DEFAULTS` entry — `diff.resolved_variant_fields` resolves it to the
variant's 1-based file order within its product, never to NULL.
Option names live both in `CanonicalProduct.option_names` (the values) and in
`fields{}` (the file-presence marker the diff needs for the absent-vs-clear
distinction).
## Dialects (INV-17, SLICE-8)
A file is normalized to canonical names **at the codec boundary**, so every stage
downstream of `parse_csv` (validate → diff → apply) is dialect-agnostic — it only
ever sees canonical cells. Two dialects exist: `canonical` and `shopify`.
`dialect_shopify.py` is the Shopify adapter and the source of truth for the
mapping (mirrored by `tests/test_products_dialect_shopify.py` and
`tests/fixtures/shopify-export.csv`):
- **Detection** — `is_shopify_header()` returns `shopify` iff the header carries a
Shopify-only *signature* column (`Body (HTML)`, `Variant Grams`, `Cost per item`,
`Gift Card`, a `Google Shopping / …` column, …) **and no canonical-distinctive
column**. A canonical-distinctive name — one Shopify renames away (`Description`,
`Variant Cost`, `Variant Weight`, `Google Product Category`) or a canonical-only
column Shopify lacks (`Variant Volume`, `Variant Tax ID 1/2`, `Variant Position`) —
**vetoes** Shopify detection. Detection is by signature, not an exact header set, so
extra market/region columns don't defeat it; the veto biases it conservative, because
under-detection is safe (Shopify-named columns are warned as not-imported) while
over-detection corrupts (canonical `Type` / `Variant Weight Unit` would be dropped).
So a hybrid or ambiguous header falls through to `canonical`, which maps shared
columns identically and warns the rest — it never misparses (§7.4).
- **Mapping** — `map_shopify_header(header)` returns `(mapped, not_imported)`:
`mapped[i]` is the canonical name for column `i` (or `None` when it has no
canonical home), and `not_imported` is the warning list surfaced as the draft's
`unknown_columns`. The rule per column, in order: in `SHOPIFY_RENAME`
canonical name; in `SHOPIFY_DROP` (`Type`, `Variant Weight Unit`) → not imported;
in `KNOWN_COLUMNS` → pass through; else → not imported (this last branch absorbs
the unbounded `… / <Market>` price columns without enumerating them).
- **The two name-collision overrides** are why `SHOPIFY_DROP` exists: Shopify's
`Type` is a free-text category, but canonical `Type` is *structural*
(`standalone`/kits), so it is warned rather than folded in; and Shopify's
`Variant Weight Unit` is dropped because the `Variant Grams``Variant Weight`
rename is the weight source.
- **The one value transform** lives in `parse_csv`: when a Shopify row has a mapped
`Variant Weight` (from `Variant Grams`), the codec synthesizes
`Variant Weight Unit = "g"`, since Shopify grams are unitless integers.
`detect_dialect()` in `codec.py` is the INV-17 seam; a new dialect is a new adapter
module plus a branch there, with no change to validate/diff/apply.
## Error granularity
`validate.py` never raises on a row problem: every violation is recorded as a
merchant-language `RowError` and **poisons its whole product block** — the
product previews as `kind="error"` and is excluded from apply, while parsing
continues so one pass yields a complete accounting (BUC-1a). On apply, error
rows are recorded per line in `import_run_error`; `rows_errored` on the run is
the **error-row count**, while the preview's errors tile counts error
**products** — the two numbers legitimately differ.
File-level problems (`not_csv`, `missing_required_column`, `too_many_rows`,
`file_too_large`) raise `FileRejected` in `codec.py` instead: no draft is
created. The BFF adds an early 413 for oversized uploads (`main.py`).
## INV-11 mechanics
`compute_diff` makes **one walk** that produces two views of the same
computation: the typed apply `plan` (resolved natives — `Decimal`, `bool`,
lists) that `confirm_draft` executes, and the JSON-safe preview `records`
stored as draft JSONB and served verbatim to the SPA. Because both derive from
the same walk they cannot diverge. The `fingerprint` is
`sha256(json.dumps(records, sort_keys=True, separators=(",", ":")))`; a
mismatch at confirm means the catalog drifted since preview → `PreviewStale`
(409 `preview_stale`, draft kept for re-validation). The whole apply — run row,
product/variant/image writes, error rows, draft delete — is one transaction;
any exception rolls it back and emits TEL-6.
## Export & the round-trip (SLICE-6)
`serialize.py` is the export half of "one codec, two directions": it turns the
`CatalogProduct` snapshot (the same one `repo.load_catalog` builds for the diff
engine) back into canonical CSV, writing exactly the columns `codec.py` /
`validate.py` parse, in the §6.5.1 row grammar — `HEADER` is the full canonical
column set; product fields + option names sit on the first row; one variant per
row; images interleave; a product with more images than variants emits
image-only rows.
`repo.export_catalog` returns the status-filtered snapshot list (sorted by
handle, deterministic); the `service.export_catalog` generator streams
`serialize.catalog_to_csv` over it and emits TEL-3 (`catalog_exported`) once the
stream is exhausted. The BFF wraps it in a `StreamingResponse`; an empty
(filtered) catalog raises `EmptyCatalog` **eagerly**`409 empty_catalog`
before any bytes stream.
**INV-12** (`diff(catalog, import(export(catalog))) = ∅`) is locked two ways: a
property test (`test_products_serialize.py`) runs the real
export→parse→diff loop over 200 generated **text-field** catalogs and asserts
every product is `unchanged`; the `e2e_roundtrip_noop` browser scenario does the
same through the UI (export download → re-upload → all-unchanged preview, import
disabled). Numeric/`Decimal` fields — the property test's deliberate blind spot
(string-form vs value-identity) — get explicit round-trip unit tests.
## Image pipeline (SLICE-7)
### `platform/objectstore`
A two-adapter port (`backend/app/platform/objectstore/`):
- `local.py` — writes blobs under a configurable local directory; used in
tests and local dev (`ECOMM_OBJECTSTORE_KIND=local`).
- `gcs.py` — wraps `google-cloud-storage`; bucket name comes from
`ECOMM_OBJECTSTORE_BUCKET` (`ECOMM_OBJECTSTORE_KIND=gcs`). Auth is the
VM's service-account ADC — no credential bytes in the overlay or secrets.
Object keys for product images follow the prefix `product-images/` (the
`provision-bucket` gesture also sets a lifecycle rule on `import-drafts/` for
forward-compat, even though the draft blob remains BYTEA this slice — see
seams below).
### `platform/images`
`backend/app/platform/images.py` decodes an uploaded or fetched image byte
string and produces up to four renditions stored in the objectstore:
- **`original`** — stored verbatim after the resolution bar passes.
- **`thumb`** — 150 px on the shorter side, WebP.
- **`card`** — 400 px on the shorter side, WebP.
- **`detail`** — 800 px on the shorter side, WebP.
**Resolution bar (`MIN_IMAGE_SHORT_SIDE = 500`):** images whose shorter side
is below 500 px are rejected (Q-3). This is the only hard gate; oversized
images are scaled down without rejection. All renditions share the same
objectstore key prefix (`product-images/<image_id>/`).
### `domains/products/imagefetch.py` — the fetch phase
After `confirm_draft` commits, the run enters `fetching_images` (if it has
any pending image URLs) and a `ThreadPoolExecutor(max_workers=4)` processes
them concurrently:
- **SSRF guard (INV-18, extended):** only `http`/`https` schemes are
permitted; the resolved IP must be public (RFC-1918, loopback, link-local,
and multicast ranges are blocked); redirects are followed only within the
same guard — a redirect to a private IP is rejected mid-chain.
- **Bounds:** ≤ 20 MB response body, ≤ 30 s per fetch.
- **Per-image presence/idempotency check (`claim_image_for_fetch`):** before
processing, each `CatalogImage` row is checked so an already-handled row is
skipped. This is a presence guard adequate for the **single-worker,
in-process** model — the deployed app runs one uvicorn worker, recovery runs
at startup over prior-process runs, and a fresh confirm always targets a new
run, so two phase invocations never process the same run's images
concurrently. It is **not** a cross-process lock. A true multi-worker claim
would need a distinct in-flight status transition (`pending → fetching` with
`RETURNING`, or `SELECT … FOR UPDATE SKIP LOCKED`) — that is the future seam
if the fetch phase ever moves multi-process or onto a queue (§6.9).
- **Startup recovery scan:** on process start the app scans for runs stuck in
`fetching_images` and re-enqueues their pending images (emits TEL-5). This
covers crash/restart scenarios without a separate scheduler. On a mid-fetch
process restart (e.g. a deploy) the phase is interrupted and the run is left
resumable: pending images stay `pending`, fetched ones stay `fetched`, and
the startup scan completes the run. Any pool-closed error in an in-flight
worker at shutdown is benign — the uncommitted image stays `pending` and is
re-fetched on resume. Mid-flight interruption is tolerated by construction
(§6.9).
- **Progress tracking:** the run row carries `image_progress`,
`image_counts`, and `image_outcomes` (updated per image); these fields feed
the run-detail UI panel.
On completion the phase sets the run status to `complete` and emits TEL-4.
### Image-serving route
`GET /api/products/images/{id}/{rendition}` — storefront-authorized,
streams the objectstore blob. The response sets
`Cache-Control: public, immutable, max-age=31536000` (the object key encodes
the image id, so the URL is stable for the lifetime of the image). INV-16:
an image belongs to exactly one storefront; the route enforces storefront
scope before reading from the objectstore.
### Hosted-URL recognition and INV-12 over images
`domains/products/hosted.py` provides a predicate that recognises whether an
image URL is already served by this app (i.e., a `GET /api/products/images/…`
URL at the current `APP_URL`). The diff pre-pass `_resolve_hosted_images`
uses it to convert hosted URLs back to their `CatalogImage` FK before hashing
the diff fingerprint — this closes the INV-12 round-trip guarantee over
image columns (a re-imported export previews as all-unchanged even when
images are hosted here).
## Named seams (what later slices replace)
- **`import_draft.file_bytes` remains BYTEA (deferred past SLICE-7).** Moving
the draft blob to object storage was scoped out of SLICE-7. The media
bucket holds only `product-images/` objects this slice; the `provision-bucket`
script sets the `import-drafts/` lifecycle rule for forward-compat so the
migration will be clean when it lands.
- **`codec.detect_dialect` → Shopify (SLICE-8).** Today it always returns
`"canonical"`; SLICE-8 recognizes Shopify's exact header set here (INV-17).
- **Run-status complete shortcut → shipped in SLICE-7.**
`confirm_draft` now inserts the run as `applying`, then transitions to
`fetching_images` (if the run has pending image URLs) or directly to
`complete`. The fetch phase fills `image_progress` / `image_counts` /
`image_outcomes` and sets `complete` when done.
## Test map
| File | Covers |
| --- | --- |
| `backend/tests/test_products_codec.py` | file-level gates: parse, caps (INV-18), required columns, dialect |
| `backend/tests/test_products_validate.py` | every §6.5.1 row-error rule, one fixture each |
| `backend/tests/test_products_diff.py` | classification, blank-vs-absent, option matching, fingerprint |
| `backend/tests/test_products_serialize.py` | serializer grammar + INV-12 property test (round-trip no-op over generated catalogs) + decimal round-trip |
| `backend/tests/test_products_export.py` | status-filtered snapshot, streamed export, EmptyCatalog, TEL-3 |
| `backend/tests/test_products_service.py` | draft lifecycle: validate/preview/discard, expiry, TEL-1 |
| `backend/tests/test_products_invariants.py` | INV-10 (never deletes), INV-14 (storefront isolation), apply transactionality, TEL-6 |
| `backend/tests/test_products_endpoints.py` | §6.4 API scenarios + auth/storefront gates |
| `e2e/tests/import-preview-confirm.spec.ts` | happy path: upload → preview → confirm → history |
| `e2e/tests/import-errors.spec.ts` | actionable row errors at preview and on the run report |
| `e2e/tests/import-file-rejected.spec.ts` | file-level rejection, picker stays live, no trace |
| `e2e/tests/import-cancel.spec.ts` | cancel at preview leaves no trace |
| `e2e/tests/export-download.spec.ts` | export downloads canonical CSV, status filter respected |
| `e2e/tests/roundtrip-noop.spec.ts` | export → re-import → all-unchanged, import disabled (PUC-10) |
@@ -0,0 +1,118 @@
# ui/designs Content-Repo Collection Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Establish `ui/designs/` as a standard content-repo collection (alongside `specs/` and `plans/`) — concretely in `wiggleverse-ecomm-content`, and centrally in the engineering repo's schema docs so it binds all `*-content` repos.
**Architecture:** Docs/convention change only, two repos, one PR each. The collection convention's canonical home is the engineering repo's `schemas/` docs (the `content` descriptor description strings in `app.schema.json` + `schemas/README.md` changelog), so the central change is a docs-only minor schema bump (1.2 → 1.3). No tooling changes: the spec-linkage gate, backfill verb, and GUIDE/TEMPLATE Design field are tracked separately as `wiggleverse-dev-claude-plugin#93`.
**Tech Stack:** Markdown, JSON Schema (description strings only), git + Gitea PRs over SSH.
**Anchor:** `wiggleverse/wiggleverse-ecomm#8` (type/task, ELIGIBLE R2b). Related: `wiggleverse/wiggleverse-dev-claude-plugin#93`.
---
### Task 1: `ui/designs/` collection in wiggleverse-ecomm-content
**Files:**
- Create: `/Users/benstull/git/wiggleverse.org/wiggleverse/wiggleverse-ecomm-content/ui/designs/README.md`
- Modify: `/Users/benstull/git/wiggleverse.org/wiggleverse/wiggleverse-ecomm-content/README.md` (layout table, lines 1014)
- [ ] **Step 1: Branch**
```bash
git -C /Users/benstull/git/wiggleverse.org/wiggleverse/wiggleverse-ecomm-content checkout -b ui-designs-collection
```
- [ ] **Step 2: Create the collection README**
`ui/designs/README.md`:
```markdown
# ui/designs — UI-design artifacts
Standard content-repo collection (alongside `specs/` and `plans/`) holding this
app's UI-design artifacts — primarily Claude Design outputs generated from a
Solution Design (rubric: `engineering/solution-design/claude-design-vs-code.md`).
A Solution Design with a UX-involving slice references its design artifact here
by path. The spec-linkage gate and the backfill gesture for adding that
reference once a design exists are tracked in
`wiggleverse/wiggleverse-dev-claude-plugin#93`.
Suggested layout: one subfolder per design, named for the spec/slice it serves,
e.g. `ui/designs/SD-0001-slice-3-storefront/`.
```
- [ ] **Step 3: Add the layout-table row**
In the top-level `README.md`, extend the table:
```markdown
| Path | Holds |
| --- | --- |
| `specs/` | reviewed Solution-Design specs (submitted at session finalize) |
| `plans/` | archived implementation plans |
| `ui/designs/` | UI-design artifacts (Claude Design outputs), referenced from specs |
```
- [ ] **Step 4: Commit, push, PR, merge**
```bash
git -C …/wiggleverse-ecomm-content add ui/designs/README.md README.md
git -C …/wiggleverse-ecomm-content commit -m "content: add ui/designs/ collection (ecomm#8)"
git -C …/wiggleverse-ecomm-content push -u origin ui-designs-collection
```
PR via Gitea API (default per-host token, NOT the issue-scoped one — TOKENS.md), then merge; body cites `wiggleverse/wiggleverse-ecomm#8` + plugin `#93`.
### Task 2: Standardize centrally in engineering schemas docs
**Files:**
- Modify: `/Users/benstull/git/wiggleverse.org/wiggleverse/engineering/schemas/app.schema.json` (lines 13, 94, 181, 184)
- Modify: `/Users/benstull/git/wiggleverse.org/wiggleverse/engineering/schemas/README.md` (repos[] bullets + changelog)
- [ ] **Step 1: Branch**
```bash
git -C /Users/benstull/git/wiggleverse.org/wiggleverse/engineering checkout -b ui-designs-collection
```
- [ ] **Step 2: Schema description strings + enum**
1. `schemaVersion.enum`: `["1.0", "1.1", "1.2"]``["1.0", "1.1", "1.2", "1.3"]`
2. `content` property description (line 94): "…where this app's reviewed specs/ and archived plans/ collections live…" → "…where this app's reviewed specs/, archived plans/, and ui/designs/ collections live…"
3. `$defs.content` description (line 181): "(reviewed specs/, archived plans/)" → "(reviewed specs/, archived plans/, ui/designs/ UI-design artifacts)"; and "The specs/ and plans/ collection subdirs are appended by the submit tooling" → "The specs/, plans/, and ui/designs/ collection subdirs are conventions (specs/ and plans/ are appended by the submit tooling; ui/designs/ holds Claude Design outputs referenced from specs)"
4. `$defs.content.subdir` description (line 184): "under which the specs/ and plans/ collections live" → "under which the specs/, plans/, and ui/designs/ collections live"
- [ ] **Step 3: schemas/README.md**
Changelog entry above 1.2:
```markdown
- **1.3** — docs-only: the content-repo collection convention gains a third
standard collection, `ui/designs/` — UI-design artifacts (Claude Design
outputs generated from a Solution Design), referenced from specs. Like
`specs/`/`plans/`, the subdir is a convention, not a schema field; no
validation change (the spec-linkage gate/backfill tooling is
wiggleverse-dev-claude-plugin#93). Existing files stay valid.
```
If the repos[] bullet list documents the `content` descriptor, name the three collections there too; if 1.2 never added a `content` bullet, add one.
- [ ] **Step 4: Validate JSON, commit, push, PR, merge**
```bash
python3 -m json.tool /Users/benstull/git/wiggleverse.org/wiggleverse/engineering/schemas/app.schema.json > /dev/null && echo OK
git -C …/engineering add schemas/app.schema.json schemas/README.md
git -C …/engineering commit -m "schemas: 1.3 — ui/designs/ standard content collection (ecomm#8, plugin#93)"
git -C …/engineering push -u origin ui-designs-collection
```
PR + merge (default token for `/pulls`).
### Task 3: Cross-link and close out
- [ ] **Step 1: Comment on plugin #93** noting the location is now standard (link both merged PRs) — its gate/backfill work can assume `ui/designs/` exists.
- [ ] **Step 2: Close ecomm#8** with a comment naming both merged PRs.
- [ ] **Step 3: Checkpoint the transcript** (`publish-transcript.sh` on the `--INPROGRESS` file).
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,721 @@
# SLICE-8 — Shopify dialect + public format docs — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Auto-detect a Shopify product-CSV export by its header set, map it to the canonical model at the codec boundary (INV-17), warn (not fail) on Shopify columns with no canonical home, and publish the public column reference (DOC-2) + finalized sample CSV (DOC-3) — completing PUC-6 (a Shopify export imports directly) and PUC-11 (learning the format).
**Architecture:** A Shopify file is recognized by *signature columns* it carries that canonical never does (`Body (HTML)`, `Variant Grams`, `Cost per item`, `Google Shopping / *`, …). Detected Shopify headers are rewritten to canonical names *before* the existing known/unknown split in `parse_csv`, so every downstream stage (validate → diff → apply) is already dialect-agnostic and unchanged (INV-17). Shopify columns with no canonical home — including two name-collision overrides (`Type` is Shopify's *free-text* type vs canonical's *structural* type; `Variant Weight Unit` is superseded by the grams→weight transform) — flow into the existing `unknown_columns` warning, which the preview UI already renders. The only value transform is `Variant Grams` → canonical `Variant Weight` (+ synthesized unit `g`). Frontend is already built for dialect display and the not-imported banner; SLICE-8 only sharpens the label and the upload-screen format help, and adds DOC-2.
**Tech Stack:** Python 3.13 / FastAPI backend (`backend/app/domains/products/`), Vitest + React/TS SPA (`frontend/src/`), Playwright E2E (`e2e/`). Tests: `pytest` (backend), `npm test` (frontend), `scripts/e2e.sh` (browser).
---
## The exhaustive Shopify → canonical mapping (the §6.5.1 fixture)
This table is the contract. It is pinned as a Python literal in Task 1 and as a CSV fixture in Task 3.
**Mapped — renamed** (Shopify header → canonical header):
| Shopify column | Canonical column | Note |
| --- | --- | --- |
| `Body (HTML)` | `Description` | HTML, sanitized downstream (INV-15) |
| `Product Category` | `Google Product Category` | taxonomy string |
| `Cost per item` | `Variant Cost` | decimal |
| `Variant Grams` | `Variant Weight` | value transform: grams numeric; unit `g` synthesized |
**Mapped — direct** (same name, already in `KNOWN_COLUMNS`, pass through): `Handle`, `Title`, `Vendor`, `Tags`, `Published`, `Status`, `Option1 Name`, `Option2 Name`, `Option3 Name`, `Option1 Value`, `Option2 Value`, `Option3 Value`, `Variant SKU`, `Variant Barcode`, `Variant Price`, `Variant Inventory Tracker`, `Variant Inventory Qty`, `Variant Image`, `Image Src`, `Image Position`, `Image Alt Text`.
**Not imported — warned** (no canonical home; surfaced in `unknown_columns`):
- **Name-collision overrides (must NOT pass through):**
- `Type` — Shopify free-text type; canonical `Type` is structural (`standalone`/kits, #15). Per decision D (spec §11): warned, never folded into canonical `Type` or `Tags`.
- `Variant Weight Unit` — superseded by the `Variant Grams` → weight transform (unit forced to `g`).
- **Shopify-only columns:** `Variant Compare At Price` (a.k.a. `Compare At Price`), `Variant Inventory Policy`, `Variant Fulfillment Service`, `Variant Requires Shipping`, `Variant Taxable`, `Variant Tax Code`, `Gift Card`, `SEO Title`, `SEO Description`, every `Google Shopping / *` column, and any market/region column (`Included / *`, `Price / *`, `Compare At Price / *`, `Price / International`, …).
**Algorithm (handles the unbounded market columns without enumerating them):** for each header column, in order —
1. in `SHOPIFY_RENAME` → its canonical name (mapped);
2. in `SHOPIFY_DROP` overrides (`{"Type", "Variant Weight Unit"}`) → not imported;
3. else in `KNOWN_COLUMNS` → pass through (mapped);
4. else → not imported.
**Detection signature** — a header is Shopify iff it contains ≥1 column canonical never has:
`{"Body (HTML)", "Variant Grams", "Cost per item", "Variant Compare At Price", "Variant Inventory Policy", "Variant Fulfillment Service", "Variant Requires Shipping", "Variant Taxable", "Gift Card", "SEO Title", "SEO Description"}` **or** any column starting `"Google Shopping / "`. Otherwise canonical (the safe default — shared columns map identically, so an ambiguous file never misparses; §7.4 risk row).
---
## File structure
- **Create** `backend/app/domains/products/dialect_shopify.py` — detection + header mapping + the pinned table. One responsibility: the Shopify adapter.
- **Modify** `backend/app/domains/products/codec.py``detect_dialect()` calls the adapter; `parse_csv()` rewrites the header before the known/unknown split and synthesizes the weight unit.
- **Create** `backend/tests/test_products_dialect_shopify.py` — adapter unit tests + the exhaustive-mapping fixture test.
- **Modify** `backend/tests/test_products_codec.py` — detection + end-to-end parse-as-shopify tests.
- **Modify** `backend/tests/test_products_invariants.py` — INV-10 over a partial Shopify file.
- **Create** `backend/app/domains/products/columns.md` — DOC-2 source (column reference, every column + dialect notes).
- **Modify** `backend/app/main.py` — add `GET /api/products/columns.md`.
- **Modify** `backend/tests/test_products_api.py` (or the existing API test module) — route test for DOC-2.
- **Modify** `frontend/src/productsApi.ts``dialectLabel("shopify")``"Shopify product CSV — mapped"`.
- **Modify** `frontend/src/screens/products/ImportUpload.tsx` — format help mentions Shopify + links DOC-2.
- **Modify** `frontend/src/screens/products/ProductsPage.tsx` — link DOC-2 beside the sample CSV.
- **Modify** `frontend/src/productsApi.test.ts` (or create) — `dialectLabel` cases.
- **Create** `e2e/fixtures/shopify-export.csv` — an unmodified-shape Shopify export fixture.
- **Create** `e2e/tests/import-shopify-dialect.spec.ts``e2e_import_shopify_dialect`.
- **Modify** `docs/products-domain.md` — DOC-4 dialect-adapter notes.
- **Modify** `backend/app/__init__.py` (or wherever `__version__`/`app.json` version lives) + `CHANGELOG`/version bump to v0.8.0.
---
### Task 1: Shopify dialect adapter module
**Files:**
- Create: `backend/app/domains/products/dialect_shopify.py`
- Test: `backend/tests/test_products_dialect_shopify.py`
- [ ] **Step 1: Write the failing tests**
```python
# backend/tests/test_products_dialect_shopify.py
from app.domains.products.dialect_shopify import is_shopify_header, map_shopify_header
def test_detects_shopify_by_signature_column():
assert is_shopify_header(["Handle", "Title", "Body (HTML)", "Variant Price"]) is True
assert is_shopify_header(["Handle", "Title", "Google Shopping / MPN"]) is True
def test_canonical_header_is_not_shopify():
assert is_shopify_header(["Handle", "Title", "Description", "Variant Cost"]) is False
def test_ambiguous_shared_only_header_defaults_canonical():
# Only columns common to both dialects -> not Shopify (safe default, never misparse).
assert is_shopify_header(["Handle", "Title", "Option1 Name", "Variant Price", "Image Src"]) is False
def test_map_renames_and_passes_through():
mapped, not_imported = map_shopify_header(
["Handle", "Body (HTML)", "Cost per item", "Variant Price"]
)
assert mapped == ["Handle", "Description", "Variant Cost", "Variant Price"]
assert not_imported == []
def test_map_drops_type_and_weight_unit_overrides():
mapped, not_imported = map_shopify_header(
["Handle", "Type", "Variant Grams", "Variant Weight Unit"]
)
assert mapped == ["Handle", None, "Variant Weight", None]
assert not_imported == ["Type", "Variant Weight Unit"]
def test_map_drops_shopify_only_and_market_columns():
mapped, not_imported = map_shopify_header(
["Handle", "Variant Compare At Price", "Gift Card", "Price / International"]
)
assert mapped == ["Handle", None, None, None]
assert not_imported == ["Variant Compare At Price", "Gift Card", "Price / International"]
```
- [ ] **Step 2: Run to verify it fails**
Run: `cd backend && python -m pytest tests/test_products_dialect_shopify.py -v`
Expected: FAIL — `ModuleNotFoundError: app.domains.products.dialect_shopify`
- [ ] **Step 3: Implement the adapter**
```python
# backend/app/domains/products/dialect_shopify.py
"""Shopify product-CSV adapter (INV-17): detect by header signature, map to canonical.
The mapping is the §6.5.1 contract; the exhaustive table is pinned here and
mirrored by tests/test_products_dialect_shopify.py + e2e/fixtures/shopify-export.csv.
"""
from __future__ import annotations
from .models import KNOWN_COLUMNS
# Shopify header -> canonical header (renames only; direct same-name columns pass
# through via KNOWN_COLUMNS). Variant Grams carries a value transform (see codec).
SHOPIFY_RENAME: dict[str, str] = {
"Body (HTML)": "Description",
"Product Category": "Google Product Category",
"Cost per item": "Variant Cost",
"Variant Grams": "Variant Weight",
}
# Canonical-named columns Shopify uses differently — must NOT pass through.
SHOPIFY_DROP: frozenset[str] = frozenset({"Type", "Variant Weight Unit"})
# Columns whose presence proves the file is a Shopify export (canonical never has them).
_SIGNATURE: frozenset[str] = frozenset({
"Body (HTML)", "Variant Grams", "Cost per item", "Variant Compare At Price",
"Variant Inventory Policy", "Variant Fulfillment Service",
"Variant Requires Shipping", "Variant Taxable", "Gift Card",
"SEO Title", "SEO Description",
})
def is_shopify_header(header: list[str]) -> bool:
cols = {h.strip() for h in header}
if cols & _SIGNATURE:
return True
return any(c.startswith("Google Shopping / ") for c in cols)
def map_shopify_header(header: list[str]) -> tuple[list[str | None], list[str]]:
"""Return (mapped, not_imported): mapped[i] is the canonical name for header[i],
or None when that Shopify column has no canonical home; not_imported lists the
original Shopify names (in order) that were dropped — the preview warning."""
mapped: list[str | None] = []
not_imported: list[str] = []
for raw in header:
col = raw.strip()
if col in SHOPIFY_RENAME:
mapped.append(SHOPIFY_RENAME[col])
elif col in SHOPIFY_DROP:
mapped.append(None)
not_imported.append(col)
elif col in KNOWN_COLUMNS:
mapped.append(col)
else:
mapped.append(None)
if col:
not_imported.append(col)
return mapped, not_imported
```
- [ ] **Step 4: Run to verify it passes**
Run: `cd backend && python -m pytest tests/test_products_dialect_shopify.py -v`
Expected: PASS (6 tests)
- [ ] **Step 5: Commit**
```bash
git add backend/app/domains/products/dialect_shopify.py backend/tests/test_products_dialect_shopify.py
git commit -m "feat(products): Shopify dialect adapter — header detection + canonical mapping (SD-0002 §6.5.1, SLICE-8)"
```
---
### Task 2: Wire the adapter into the codec
**Files:**
- Modify: `backend/app/domains/products/codec.py:15-66`
- Test: `backend/tests/test_products_codec.py`
- [ ] **Step 1: Write the failing tests** (append to `test_products_codec.py`)
```python
def test_parse_detects_and_maps_shopify():
data = (
"Handle,Title,Body (HTML),Cost per item,Variant Price,Variant Grams,Type,Gift Card\n"
"mug,Moon Mug,<p>Grey</p>,9.50,18.00,300,Drinkware,false\n"
).encode("utf-8")
parsed = parse_csv(data)
assert parsed.dialect == "shopify"
cells = parsed.rows[0].cells
assert cells["Description"] == "<p>Grey</p>"
assert cells["Variant Cost"] == "9.50"
assert cells["Variant Weight"] == "300"
assert cells["Variant Weight Unit"] == "g" # synthesized
# Type (free-text) and Gift Card warned, never mapped:
assert "Type" in parsed.unknown_columns
assert "Gift Card" in parsed.unknown_columns
assert "product_type" not in str(cells) # Type never reached canonical
def test_parse_canonical_unchanged():
data = b"Handle,Title,Description,Variant Price\nmug,Moon Mug,Grey,18.00\n"
parsed = parse_csv(data)
assert parsed.dialect == "canonical"
assert parsed.rows[0].cells["Description"] == "Grey"
assert parsed.unknown_columns == []
```
- [ ] **Step 2: Run to verify it fails**
Run: `cd backend && python -m pytest tests/test_products_codec.py -k "shopify or canonical_unchanged" -v`
Expected: FAIL — `parsed.dialect == "canonical"` (detection still stubbed) / `KeyError: 'Description'`
- [ ] **Step 3: Rewrite `detect_dialect` + `parse_csv`** in `codec.py`
Replace the `detect_dialect` stub and the header/cell-building block. New `codec.py` (lines from the import down through `parse_csv`):
```python
from .dialect_shopify import is_shopify_header, map_shopify_header
from .models import KNOWN_COLUMNS, MAX_DATA_ROWS, MAX_FILE_BYTES, ParsedFile, Row
_REQUIRED_HEADER_COLUMNS = ("Handle", "Title")
def detect_dialect(header: list[str]) -> str:
"""The INV-17 seam: recognize Shopify's header set, else canonical (§6.5.1)."""
return "shopify" if is_shopify_header(header) else "canonical"
def parse_csv(data: bytes) -> ParsedFile:
if len(data) > MAX_FILE_BYTES:
raise FileRejected("file_too_large", "This file is larger than 10 MB.")
try:
text = data.decode("utf-8-sig")
except UnicodeDecodeError:
raise FileRejected("not_csv", "This file isn't readable as CSV.") from None
reader = csv.reader(io.StringIO(text))
try:
try:
raw_header = next(reader)
except StopIteration:
raise FileRejected("not_csv", "This file isn't readable as CSV.") from None
header = [h.strip() for h in raw_header]
dialect = detect_dialect(header)
# INV-17: normalize the header to canonical names at the boundary. For Shopify,
# mapped[i] is the canonical name (or None when dropped); not_imported is the
# warning list. Canonical files map to themselves, unknowns warned as before.
if dialect == "shopify":
mapped, unknown = map_shopify_header(header)
else:
mapped = [c if c in KNOWN_COLUMNS else None for c in header]
unknown = [c for c in header if c and c not in KNOWN_COLUMNS]
for col in _REQUIRED_HEADER_COLUMNS:
if col not in (mapped):
raise FileRejected(
"missing_required_column",
f"This file is missing the required column '{col}'.",
)
# First occurrence of a duplicated canonical column wins.
col_index: dict[str, int] = {}
for i, name in enumerate(mapped):
if name and name not in col_index:
col_index[name] = i
# De-dup the warning list, order-preserving.
seen: set[str] = set()
unknown = [c for c in unknown if not (c in seen or seen.add(c))]
rows: list[Row] = []
for raw in reader:
if not any(cell.strip() for cell in raw):
continue
if len(rows) >= MAX_DATA_ROWS:
raise FileRejected(
"too_many_rows",
f"This file has more than {MAX_DATA_ROWS:,} rows — split it and import in parts.",
)
cells = {
c: (raw[col_index[c]].strip() if col_index[c] < len(raw) else "")
for c in col_index
}
# Shopify grams carry an implicit unit; canonical needs it explicit (§6.5.1).
if dialect == "shopify" and cells.get("Variant Weight"):
cells["Variant Weight Unit"] = "g"
rows.append(Row(line_number=reader.line_num, cells=cells))
except csv.Error:
raise FileRejected("not_csv", "This file isn't readable as CSV.") from None
return ParsedFile(dialect=dialect, header=header, unknown_columns=unknown, rows=rows)
```
Note: the required-column check now reads `mapped` (canonical names) so a Shopify file whose `Title`/`Handle` are present passes, and a file genuinely missing them still fails honestly. `Handle` and `Title` are direct pass-through names in both dialects, so `mapped` contains them when present.
- [ ] **Step 4: Run to verify it passes**
Run: `cd backend && python -m pytest tests/test_products_codec.py -v`
Expected: PASS (existing canonical tests + 2 new)
- [ ] **Step 5: Run the whole products suite (no regressions)**
Run: `cd backend && python -m pytest tests/ -q`
Expected: PASS — validate/diff/invariants unchanged (they consume canonical cells; INV-17 holds).
- [ ] **Step 6: Commit**
```bash
git add backend/app/domains/products/codec.py backend/tests/test_products_codec.py
git commit -m "feat(products): codec detects + maps Shopify dialect at the boundary (INV-17, SLICE-8)"
```
---
### Task 3: Exhaustive-mapping fixture test (the §6.5.1 pin)
**Files:**
- Create: `backend/tests/fixtures/shopify-export.csv`
- Test: `backend/tests/test_products_dialect_shopify.py` (append)
- [ ] **Step 1: Create a realistic full-width Shopify export fixture**
`backend/tests/fixtures/shopify-export.csv` — one product, two variants, one image-only row, exercising every mapped + several not-imported columns:
```csv
Handle,Title,Body (HTML),Vendor,Product Category,Type,Tags,Published,Status,Option1 Name,Option1 Value,Variant SKU,Variant Grams,Variant Inventory Tracker,Variant Inventory Qty,Variant Inventory Policy,Variant Fulfillment Service,Variant Price,Variant Compare At Price,Variant Requires Shipping,Variant Taxable,Variant Barcode,Image Src,Image Position,Image Alt Text,Gift Card,SEO Title,SEO Description,Google Shopping / MPN,Variant Image,Variant Weight Unit,Cost per item,Status
star-tee,Star Tee,<p>Soft cotton tee.</p>,Wiggle Goods,Apparel & Accessories > Clothing,Shirts,"apparel, tees",TRUE,active,Size,S,WG-TEE-S,180,shopify,12,deny,manual,24.00,30.00,TRUE,TRUE,0001,https://img.example.com/star-tee.jpg,1,Star Tee,FALSE,Star Tee | Wiggle,Soft tee,MPN-1,https://img.example.com/star-s.jpg,g,11.00,active
star-tee,,,,,,,,,,M,WG-TEE-M,180,shopify,18,deny,manual,24.00,30.00,TRUE,TRUE,0002,,,,FALSE,,,,,g,11.00,
star-tee,,,,,,,,,,,,,,,,,,,,,,https://img.example.com/star-back.jpg,2,Star Tee back,,,,,,,,
```
- [ ] **Step 2: Write the failing test**
```python
import csv as _csv
import io
from pathlib import Path
from app.domains.products.codec import parse_csv
_FIXTURE = Path(__file__).parent / "fixtures" / "shopify-export.csv"
def test_shopify_fixture_maps_exhaustively():
parsed = parse_csv(_FIXTURE.read_bytes())
assert parsed.dialect == "shopify"
first = parsed.rows[0].cells
# renamed
assert first["Description"] == "<p>Soft cotton tee.</p>"
assert first["Google Product Category"] == "Apparel & Accessories > Clothing"
assert first["Variant Cost"] == "11.00"
assert first["Variant Weight"] == "180"
assert first["Variant Weight Unit"] == "g"
# direct
assert first["Variant SKU"] == "WG-TEE-S"
assert first["Image Src"] == "https://img.example.com/star-tee.jpg"
# not imported — warned, none leaked into canonical cells
for col in ("Type", "Variant Compare At Price", "Gift Card", "SEO Title",
"Google Shopping / MPN", "Variant Weight Unit", "Variant Inventory Policy",
"Variant Fulfillment Service", "Variant Requires Shipping", "Variant Taxable"):
assert col in parsed.unknown_columns, col
assert "Variant Compare At Price" not in first
```
- [ ] **Step 3: Run — expect PASS** (codec from Task 2 already maps correctly)
Run: `cd backend && python -m pytest tests/test_products_dialect_shopify.py::test_shopify_fixture_maps_exhaustively -v`
Expected: PASS. If a column assertion fails, fix the mapping table in `dialect_shopify.py` to match this fixture — the fixture and table must agree.
- [ ] **Step 4: Commit**
```bash
git add backend/tests/fixtures/shopify-export.csv backend/tests/test_products_dialect_shopify.py
git commit -m "test(products): exhaustive Shopify->canonical mapping fixture (SD-0002 §6.5.1, SLICE-8)"
```
---
### Task 4: INV-10 over a partial Shopify import (never deletes)
**Files:**
- Test: `backend/tests/test_products_invariants.py` (append)
- [ ] **Step 1: Inspect the existing INV-10 test** to reuse its catalog/diff helpers
Run: `cd backend && grep -n "def test_inv10\|compute_diff\|build_products\|catalog" tests/test_products_invariants.py | head`
Expected: shows the helper pattern (catalog fixture → parse → build_products → compute_diff → assert no deletes in plan).
- [ ] **Step 2: Write the failing test** (mirror the existing INV-10 test, swapping in a Shopify file that omits an existing product)
```python
def test_inv10_shopify_partial_never_deletes():
# An existing catalog of two products; a Shopify file mentioning only one.
catalog = _catalog_with(["star-tee", "moon-mug"]) # existing helper
data = (
"Handle,Title,Body (HTML),Variant Price,Variant Grams\n"
"star-tee,Star Tee,<p>x</p>,24.00,180\n"
).encode("utf-8")
parsed = parse_csv(data)
assert parsed.dialect == "shopify"
products = build_products(parsed)
result = compute_diff(catalog, products)
# INV-10: nothing in the plan deletes; moon-mug (unmentioned) is untouched.
assert all(op.kind != "delete" for op in result.plan)
assert "moon-mug" not in {p.handle for p in products}
```
Adapt `_catalog_with` / `op.kind` / `result.plan` to the actual symbols found in Step 1.
- [ ] **Step 3: Run to verify it passes**
Run: `cd backend && python -m pytest tests/test_products_invariants.py -k inv10_shopify -v`
Expected: PASS (INV-10 is dialect-agnostic; this proves it over Shopify).
- [ ] **Step 4: Commit**
```bash
git add backend/tests/test_products_invariants.py
git commit -m "test(products): INV-10 holds over a partial Shopify import (SLICE-8)"
```
---
### Task 5: Frontend — Shopify label + format help
**Files:**
- Modify: `frontend/src/productsApi.ts:52-54`
- Modify: `frontend/src/screens/products/ImportUpload.tsx:53`
- Modify: `frontend/src/screens/products/ProductsPage.tsx:114`
- Test: `frontend/src/productsApi.test.ts`
- [ ] **Step 1: Write the failing test** (create or append `frontend/src/productsApi.test.ts`)
```ts
import { describe, expect, it } from "vitest";
import { dialectLabel } from "./productsApi";
describe("dialectLabel", () => {
it("labels canonical", () => expect(dialectLabel("canonical")).toBe("Canonical format"));
it("labels shopify as mapped", () =>
expect(dialectLabel("shopify")).toBe("Shopify product CSV — mapped"));
});
```
- [ ] **Step 2: Run to verify it fails**
Run: `cd frontend && npm test -- productsApi`
Expected: FAIL — receives `"shopify"`, expected `"Shopify product CSV — mapped"`.
- [ ] **Step 3: Update `dialectLabel`**
```ts
// frontend/src/productsApi.ts
export function dialectLabel(d: string): string {
if (d === "canonical") return "Canonical format";
if (d === "shopify") return "Shopify product CSV — mapped";
return d;
}
```
- [ ] **Step 4: Run to verify it passes**
Run: `cd frontend && npm test -- productsApi`
Expected: PASS
- [ ] **Step 5: Update the upload-screen format help**`ImportUpload.tsx:53`
Replace the line `Works with the canonical format.{" "}` and the following sample link block so it reads (PUC-11, §5.4 wireframe text):
```tsx
Works with the canonical format or a Shopify product CSV.{" "}
<a href="/api/products/sample.csv" download>
Download a sample
</a>{" "}
·{" "}
<a href="/api/products/columns.md" target="_blank" rel="noopener">
Column reference
</a>
```
- [ ] **Step 6: Add the column-reference link beside the sample on the Products page**`ProductsPage.tsx:114`
After the existing `<a href="/api/products/sample.csv" download>` element, add:
```tsx
{" · "}
<a href="/api/products/columns.md" target="_blank" rel="noopener">
Column reference
</a>
```
- [ ] **Step 7: Build + typecheck + test**
Run: `cd frontend && npm run build && npm test`
Expected: PASS (no TS errors, vitest green).
- [ ] **Step 8: Commit**
```bash
git add frontend/src/productsApi.ts frontend/src/productsApi.test.ts frontend/src/screens/products/ImportUpload.tsx frontend/src/screens/products/ProductsPage.tsx
git commit -m "feat(products-ui): Shopify 'mapped' label + format help links to column reference (PUC-6/PUC-11, SLICE-8)"
```
---
### Task 6: DOC-2 column reference + route; finalize DOC-3
**Files:**
- Create: `backend/app/domains/products/columns.md`
- Modify: `backend/app/main.py` (after the `sample.csv` route, ~line 451)
- Modify: `backend/app/domains/products/__init__.py` (export `COLUMNS_MD_PATH` next to `SAMPLE_CSV_PATH`)
- Test: the existing products API test module (find it in Step 4)
- [ ] **Step 1: Write the DOC-2 column reference**`backend/app/domains/products/columns.md`
A complete merchant-facing reference: a short intro (canonical = Shopify-flavored superset; auto-detection; blank-vs-absent semantics), then a table of **every canonical column** (name · level · required · accepted values), then a **Shopify dialect notes** section reproducing the Task-1 mapping table (renamed, direct, not-imported). Source the canonical column rows from spec §6.5.1 (the table at lines 852-873) and the dialect notes from §6.5.1 (lines 880-889). Keep it plain Markdown — no app-specific build step.
- [ ] **Step 2: Export the path**`backend/app/domains/products/__init__.py`
Add beside the existing `SAMPLE_CSV_PATH`:
```python
COLUMNS_MD_PATH = _HERE / "columns.md"
```
(Use the same `_HERE` / `Path(__file__).parent` idiom already present for `SAMPLE_CSV_PATH`.)
- [ ] **Step 3: Add the route**`backend/app/main.py`, immediately after `products_sample_csv`
```python
@app.get("/api/products/columns.md")
def products_columns_md():
"""The DOC-2 column reference. Documentation, so no auth gate (§6.4)."""
return PlainTextResponse(
products.COLUMNS_MD_PATH.read_text(),
media_type="text/markdown; charset=utf-8",
)
```
- [ ] **Step 4: Write the failing route test** — find the API test module first
Run: `cd backend && ls tests/ | grep -i "api\|main"`
Append to the module that tests the other unauthenticated doc routes:
```python
def test_columns_md_served(client):
resp = client.get("/api/products/columns.md")
assert resp.status_code == 200
assert "text/markdown" in resp.headers["content-type"]
body = resp.text
assert "Body (HTML)" in body # dialect notes present
assert "Handle" in body # canonical columns present
```
- [ ] **Step 5: Run to verify it passes**
Run: `cd backend && python -m pytest tests/ -k "columns_md or sample" -v`
Expected: PASS
- [ ] **Step 6: Verify DOC-3 sample.csv is final** — it already carries a variant product (`star-tee`, S/M/L) and image rows. Confirm it parses clean as canonical:
Run: `cd backend && python -c "from app.domains.products.codec import parse_csv; from app.domains.products import SAMPLE_CSV_PATH; p=parse_csv(SAMPLE_CSV_PATH.read_bytes()); print(p.dialect, len(p.rows), p.unknown_columns)"`
Expected: `canonical 5 []` (no unknown columns; 5 data rows). If `unknown_columns` is non-empty, fix the sample header to canonical names.
- [ ] **Step 7: Commit**
```bash
git add backend/app/domains/products/columns.md backend/app/domains/products/__init__.py backend/app/main.py backend/tests/
git commit -m "docs(products): DOC-2 column reference served at /api/products/columns.md (SLICE-8)"
```
---
### Task 7: DOC-4 dialect-adapter dev notes
**Files:**
- Modify: `docs/products-domain.md`
- [ ] **Step 1: Add a "Dialects (INV-17)" subsection** documenting: detection by signature columns; the `dialect_shopify` adapter; the rename/drop/passthrough algorithm; the grams→weight transform; that all downstream stages are dialect-agnostic. Point to `dialect_shopify.py` and the fixture as the source of truth (documentation-leads-automation, §4.1).
- [ ] **Step 2: Commit**
```bash
git add docs/products-domain.md
git commit -m "docs(products): DOC-4 dialect-adapter notes (SLICE-8)"
```
---
### Task 8: E2E — `e2e_import_shopify_dialect`
**Files:**
- Create: `e2e/fixtures/shopify-export.csv` (copy the Task-3 fixture)
- Create: `e2e/tests/import-shopify-dialect.spec.ts`
- [ ] **Step 1: Add the fixture** — same content as `backend/tests/fixtures/shopify-export.csv`.
- [ ] **Step 2: Read an existing import E2E spec** to reuse the sign-up + upload helpers
Run: `sed -n '1,60p' e2e/tests/import-preview-confirm.spec.ts`
Expected: shows the page-object / sign-up helper and the upload→preview→confirm flow to mirror.
- [ ] **Step 3: Write `e2e/tests/import-shopify-dialect.spec.ts`** (mirroring that helper)
```ts
import { test, expect } from "@playwright/test";
import { signUpAndOpenImport } from "./helpers"; // use the actual helper from Step 2
test("e2e_import_shopify_dialect: a Shopify export imports directly (PUC-6)", async ({ page }) => {
await signUpAndOpenImport(page);
await page.getByLabel(/choose|upload|file/i).setInputFiles("fixtures/shopify-export.csv");
// Preview shows the mapped banner + not-imported warning
await expect(page.getByText("Shopify product CSV — mapped")).toBeVisible();
await expect(page.getByText(/not imported/i)).toBeVisible();
await expect(page.getByText("Type")).toBeVisible(); // a warned column
// Confirm and land on the run detail
await page.getByRole("button", { name: /^Import \d/ }).click();
await expect(page).toHaveURL(/\/products\/imports\/runs\/\d+/);
await expect(page.getByText("Shopify product CSV — mapped")).toBeVisible();
});
```
Adapt selectors to the real upload control + helper names found in Step 2.
- [ ] **Step 4: Run the E2E suite**
Run: `./scripts/e2e.sh`
Expected: all specs PASS including `import-shopify-dialect` (7 prior + 1 new = 8).
- [ ] **Step 5: Commit**
```bash
git add e2e/fixtures/shopify-export.csv e2e/tests/import-shopify-dialect.spec.ts
git commit -m "test(e2e): e2e_import_shopify_dialect — Shopify export imports directly (PUC-6, SLICE-8)"
```
---
### Task 9: Traceability audit, version bump, full green
**Files:**
- Modify: version source (`backend/app/__init__.py` or `app.json`) + any `CHANGELOG`
- Read-only: spec §12 traceability matrix
- [ ] **Step 1: Audit §12 traceability** — confirm PUC-6, PUC-11, BUC-1 (Shopify), DOC-2, DOC-3 now have shipping code + tests. Note any gap in the transcript `## Deferred decisions`. (Spec audit is read-only; do not edit the content repo here — finalize submits the plan.)
- [ ] **Step 2: Bump the version to v0.8.0**
Run: `cd backend && grep -rn "0.7.0" app/__init__.py app.json 2>/dev/null`
Update the version string to `0.8.0` wherever the prior slice set it (mirror the SLICE-7 v0.7.0 bump).
- [ ] **Step 3: Full local gate**
Run: `./scripts/check.sh && ./scripts/e2e.sh` (or the repo's canonical localhost gate)
Expected: backend pytest, frontend build+vitest, import-linter, and Playwright all PASS.
- [ ] **Step 4: Commit**
```bash
git add -A
git commit -m "chore(products): SLICE-8 complete — bump v0.8.0; §12 traceability audited (SD-0002)"
```
---
## Post-plan (session flow, not subagent tasks)
After all tasks are green on the branch:
1. **PR → merge to `main`** (branch→PR→merge; §5.4).
2. **PPE deploy** via flotilla + E2E green on `https://ecomm-ppe.wiggleverse.org`; stamp the `release/<ts>` tag (§9.1). Prod promotion stays the operator's gate.
3. **Close story #13** (SLICE-5/6/7/8 all done) with a pointer to the merge + PPE release tag.
4. **Finalize** the session (`wgl-session-finalize`) — archives this plan to the content repo `plans/`, updates memory, publishes the transcript.
---
## Self-Review
**Spec coverage (SLICE-8 §7.2 DoD):**
- Header-set dialect detection → Task 1 (`is_shopify_header`) + Task 2 (`detect_dialect`). ✓
- Shopify→canonical mapping + exhaustive fixture → Task 1 (table) + Task 3 (fixture pin). ✓
- Preview "mapped" banner + not-imported warnings → Task 5 (label) + already-built `unknown_columns` banner (`ImportPreview.tsx:288`). ✓
- DOC-2 column reference (published) → Task 6. ✓
- DOC-3 final sample CSV → Task 6 Step 6 (verify; already has variants+images). ✓
- `e2e_import_shopify_dialect` green → Task 8. ✓
- BUC-1 acceptance for an unmodified Shopify export → Task 3 fixture + Task 8 E2E. ✓
- §12 traceability audited → Task 9. ✓
- INV-10 over Shopify → Task 4. ✓
**Placeholder scan:** every code step shows concrete code; selectors/helpers in Tasks 4/6/8 are explicitly flagged to adapt to symbols discovered in a named inspection step (not silent TBDs).
**Type consistency:** `is_shopify_header` / `map_shopify_header` signatures match between Task 1 (def), Task 2 (import + call), and Task 3 (test). `dialectLabel("shopify")` string is identical in Task 5 def, Task 5 test, and Task 8 E2E assertion (`"Shopify product CSV — mapped"`). `COLUMNS_MD_PATH` defined (Task 6 Step 2) before use (Task 6 Step 3).
**Deferred decisions (logged to transcript):**
- `Variant Grams``Variant Weight` value with unit forced to `g`; Shopify's own `Variant Weight Unit` display column is *not imported* (warned). Lossless in mass (grams is exact); chosen over carrying a possibly-inconsistent unit. Revisit if merchants need kg/lb display fidelity.
- DOC-2 served as raw Markdown (`text/markdown`), mirroring how DOC-3 `sample.csv` is served — not a styled HTML page. Satisfies "app-served, source in framework repo"; styled rendering is a future enhancement.
- Ambiguous header (only shared columns) defaults to **canonical** rather than a distinct `unknown_dialect` state — shared columns map identically so it never misparses (§7.4 intent); avoids a third dialect state the model doesn't carry.
-15
View File
@@ -1,15 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0E1230" />
<style>html { background: #0E1230; }</style>
<link rel="icon" type="image/svg+xml" href="/brand/mark-tile.svg" />
<title>ecomm</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
-2174
View File
File diff suppressed because it is too large Load Diff
-24
View File
@@ -1,24 +0,0 @@
{
"name": "wiggleverse-ecomm-frontend",
"private": true,
"version": "0.4.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"test": "vitest run"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.6.3",
"vite": "^5.4.11",
"vitest": "^2.1.8"
}
}
-8
View File
@@ -1,8 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="120" viewBox="0 0 120 120" fill="none" role="img" aria-label="Wiggleverse">
<title>Wiggleverse — mono gold</title>
<path d="M98 60 Q105 86 79 93 Q60 82 41 93 Q15 86 22 60 Q41 49 41 27 Q60 8 79 27 Q79 49 98 60 Z" stroke="#F4C76B" stroke-width="2.4" fill="none" stroke-linejoin="round"></path>
<g fill="#F4C76B">
<circle cx="98" cy="60" r="4.5"></circle><circle cx="79" cy="93" r="4.5"></circle><circle cx="41" cy="93" r="4.5"></circle>
<circle cx="22" cy="60" r="4.5"></circle><circle cx="41" cy="27" r="4.5"></circle><circle cx="79" cy="27" r="4.5"></circle>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 648 B

-9
View File
@@ -1,9 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="120" viewBox="0 0 120 120" fill="none" role="img" aria-label="Wiggleverse">
<title>Wiggleverse</title>
<rect width="120" height="120" rx="26" fill="#0E1230"></rect>
<path d="M98 60 Q105 86 79 93 Q60 82 41 93 Q15 86 22 60 Q41 49 41 27 Q60 8 79 27 Q79 49 98 60 Z" stroke="#F4C76B" stroke-width="5" fill="none" stroke-linejoin="round"></path>
<g fill="#EDEAFF">
<circle cx="98" cy="60" r="7"></circle><circle cx="79" cy="93" r="7"></circle><circle cx="41" cy="93" r="7"></circle>
<circle cx="22" cy="60" r="7"></circle><circle cx="41" cy="27" r="7"></circle><circle cx="79" cy="27" r="7"></circle>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 684 B

Binary file not shown.
Binary file not shown.
Binary file not shown.
-91
View File
@@ -1,91 +0,0 @@
// App shell — loads the session, applies the entry-routing rule (SD-0001 §6.5), and renders
// the matching screen. The rule is exhaustive: no state lands nowhere (BUC-4). The verify
// response carries the same shape as /me, so onAuthed feeds the session directly.
import { useEffect, useState } from "react";
import { getMe, type StorefrontResult, type VerifyResult } from "./api";
import { routeFor, type SessionState } from "./routing";
import Admin from "./screens/Admin";
import CreateStorefront from "./screens/CreateStorefront";
import Landing from "./screens/Landing";
import SignIn from "./screens/SignIn";
type Door = "signup" | "login";
type Welcome = "new" | "back" | null;
export default function App() {
const [session, setSession] = useState<SessionState | null>(null);
const [loading, setLoading] = useState(true);
const [door, setDoor] = useState<Door | null>(null);
const [welcome, setWelcome] = useState<Welcome>(null);
async function reload() {
setLoading(true);
setSession(await getMe());
setLoading(false);
}
useEffect(() => {
void reload();
}, []);
if (loading) {
return (
<div className="screen screen--plain">
<main className="screen__main">
<p className="note">Loading</p>
</main>
</div>
);
}
const route = routeFor(session ?? { account: null, storefront: null });
if (route === "landing") {
if (door) {
return (
<SignIn
door={door}
onBack={() => setDoor(null)}
onAuthed={(result: VerifyResult) => {
setWelcome(result.created ? "new" : "back");
setDoor(null);
setSession({ account: result.account, storefront: result.storefront });
}}
/>
);
}
return <Landing onGetStarted={() => setDoor("signup")} onLogIn={() => setDoor("login")} />;
}
const email = session!.account!.email;
function signedOut() {
setWelcome(null);
setDoor(null);
setSession(null);
}
if (route === "create-storefront") {
return (
<CreateStorefront
email={email}
welcome={welcome}
onCreated={(sf: StorefrontResult) => {
setWelcome(null);
setSession({ account: { email }, storefront: sf });
}}
onAlreadyOwns={() => void reload()}
onSignedOut={signedOut}
/>
);
}
return (
<Admin
storefrontName={session!.storefront!.name}
email={email}
welcome={welcome}
onSignedOut={signedOut}
/>
);
}
-79
View File
@@ -1,79 +0,0 @@
// Typed fetch wrappers for the /api/auth/* surface (SD-0001 §6.4). Same-origin via the Vite
// proxy; cookies carry the session. Errors return the §6.4 envelope; helpers normalize them.
import type { SessionState } from "./routing";
export interface ApiError {
code: string;
message: string;
retry_after_s?: number;
attempts_remaining?: number;
}
export interface VerifyResult {
account: { email: string };
storefront: { id: number; name: string } | null;
created: boolean;
}
async function errorOf(resp: Response): Promise<ApiError> {
try {
const body = await resp.json();
if (body && body.error) return body.error as ApiError;
} catch {
/* fall through */
}
return { code: "unexpected", message: "Something went wrong. Please try again." };
}
export async function getMe(): Promise<SessionState | null> {
const resp = await fetch("/api/auth/me", { credentials: "include" });
if (resp.status === 401) return null;
if (!resp.ok) return null;
return (await resp.json()) as SessionState;
}
export async function requestCode(email: string): Promise<ApiError | null> {
const resp = await fetch("/api/auth/request-code", {
method: "POST",
headers: { "content-type": "application/json" },
credentials: "include",
body: JSON.stringify({ email }),
});
return resp.ok ? null : await errorOf(resp);
}
export async function verifyCode(
email: string,
code: string,
): Promise<{ ok: true; result: VerifyResult } | { ok: false; error: ApiError }> {
const resp = await fetch("/api/auth/verify", {
method: "POST",
headers: { "content-type": "application/json" },
credentials: "include",
body: JSON.stringify({ email, code }),
});
if (resp.ok) return { ok: true, result: (await resp.json()) as VerifyResult };
return { ok: false, error: await errorOf(resp) };
}
export async function logout(): Promise<void> {
await fetch("/api/auth/logout", { method: "POST", credentials: "include" });
}
export interface StorefrontResult {
id: number;
name: string;
}
export async function createStorefront(
name: string,
): Promise<{ ok: true; storefront: StorefrontResult } | { ok: false; error: ApiError }> {
const resp = await fetch("/api/storefronts", {
method: "POST",
headers: { "content-type": "application/json" },
credentials: "include",
body: JSON.stringify(name.trim() ? { name: name.trim() } : {}),
});
if (resp.ok) return { ok: true, storefront: (await resp.json()) as StorefrontResult };
return { ok: false, error: await errorOf(resp) };
}
-10
View File
@@ -1,10 +0,0 @@
import "./styles/index.css";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);
-20
View File
@@ -1,20 +0,0 @@
import { describe, expect, it } from "vitest";
import { routeFor } from "./routing";
describe("entry routing (SD-0001 §6.5)", () => {
it("no session -> landing", () => {
expect(routeFor({ account: null, storefront: null })).toBe("landing");
});
it("session without storefront -> create-storefront", () => {
expect(routeFor({ account: { email: "m@example.com" }, storefront: null })).toBe(
"create-storefront",
);
});
it("session with storefront -> admin", () => {
expect(
routeFor({ account: { email: "m@example.com" }, storefront: { id: 1, name: "Shop" } }),
).toBe("admin");
});
});
-16
View File
@@ -1,16 +0,0 @@
// The single client-side entry-routing rule (SD-0001 §6.5). Exhaustive: every state lands
// somewhere (BUC-4 — never stranded). Fed by one server answer (GET /api/auth/me, or the
// verify response, which share this shape). storefront is always null in SLICE-2; SLICE-3
// makes "admin" reachable.
export interface SessionState {
account: { email: string } | null;
storefront: { id: number; name: string } | null;
}
export type Screen = "landing" | "create-storefront" | "admin";
export function routeFor(s: SessionState): Screen {
if (!s.account) return "landing";
if (!s.storefront) return "create-storefront";
return "admin";
}
-59
View File
@@ -1,59 +0,0 @@
// Admin shell (SD-0001 §5.4) — the storefront's stable home; honestly empty this release
// (PUC-8; PUC-9 sign-out). Renders from /me alone: storefront name + signed-in email. No
// zeroed metric tiles, no locked-feature teasers (OHM: Agency & Anti-Manipulation).
// Visuals per the ui/designs export (hf-admin).
import { logout } from "../api";
import { AccountChip, Banner, Eyebrow, Screen, TopBar } from "../ui/kit";
interface Props {
storefrontName: string;
email: string;
welcome: "new" | "back" | null;
onSignedOut: () => void;
}
export default function Admin({ storefrontName, email, welcome, onSignedOut }: Props) {
async function signOut() {
await logout();
onSignedOut();
}
return (
<Screen plain>
<TopBar
left={
<span className="storeid">
<img src="/brand/mark-tile.svg" width={24} height={24} alt="" />
<span className="storeid__col">
<span className="storeid__name">{storefrontName}</span>
<span className="storeid__sub">ecomm storefront</span>
</span>
</span>
}
right={<AccountChip email={email} onSignOut={signOut} />}
/>
<main className="screen__main">
<div className="empty">
{welcome && (
<div style={{ marginBottom: 24, width: "100%" }}>
<Banner tone="info" title={welcome === "new" ? "Welcome to ecomm" : "Welcome back"}>
{welcome === "new"
? "A new account was created for this email."
: "Signed in to your existing account."}
</Banner>
</div>
)}
<div className="empty__seal" aria-hidden="true">
<img src="/brand/mark-mono-gold.svg" width={36} height={36} alt="" />
</div>
<Eyebrow>Your storefront</Eyebrow>
<h1>{storefrontName}</h1>
<p className="empty__copy">
There's nothing to manage yet — and that's a finished state, not a missing one.
Catalog, orders, and settings will appear here as ecomm grows.
</p>
</div>
</main>
</Screen>
);
}
-92
View File
@@ -1,92 +0,0 @@
// Create storefront (SD-0001 §5.3) — a storefront-less Merchant establishes their one
// storefront (PUC-4/5; PUC-7 defense-in-depth on 409). Visuals per the ui/designs export
// (hf-storefront). The welcome banner carries PUC-3's honest copy from the verify step.
import { useState } from "react";
import { createStorefront, logout, type StorefrontResult } from "../api";
import { AccountChip, AuthCard, Banner, Eyebrow, Field, Footer, PrimaryButton, Screen, TopBar, Wordmark } from "../ui/kit";
interface Props {
email: string;
welcome: "new" | "back" | null;
onCreated: (storefront: StorefrontResult) => void;
onAlreadyOwns: () => void;
onSignedOut: () => void;
}
export default function CreateStorefront({ email, welcome, onCreated, onAlreadyOwns, onSignedOut }: Props) {
const [name, setName] = useState("");
const [busy, setBusy] = useState(false);
const [alreadyOwns, setAlreadyOwns] = useState(false);
const [error, setError] = useState<string | null>(null);
async function signOut() {
await logout();
onSignedOut();
}
async function submit(e: React.FormEvent) {
e.preventDefault();
setBusy(true);
setError(null);
const res = await createStorefront(name);
setBusy(false);
if (res.ok) {
onCreated(res.storefront);
return;
}
if (res.error.code === "already_owns_storefront") setAlreadyOwns(true);
else setError(res.error.message);
}
return (
<Screen horizon>
<TopBar left={<Wordmark size={22} />} right={<AccountChip email={email} onSignOut={signOut} />} />
<main className="screen__main">
<AuthCard>
<form onSubmit={submit} style={{ display: "contents" }}>
<div>
<Eyebrow>One storefront, fully yours</Eyebrow>
<h1 style={{ marginTop: 12 }}>Create your storefront</h1>
<p className="card__sub">
This is the one thing to do right now. It costs nothing and commits you to nothing.
</p>
</div>
{welcome && (
<Banner tone="info" title={welcome === "new" ? "Welcome to ecomm" : "Welcome back"}>
{welcome === "new"
? "A new account was created for this email."
: "Signed in to your existing account."}
</Banner>
)}
{alreadyOwns && (
<Banner title="Your account already has its storefront">
ecomm is one storefront per account today.{" "}
<button type="button" className="lk" onClick={onAlreadyOwns}>
Go to your admin
</button>
</Banner>
)}
{error && <Banner title="That didn't work">{error} Try again.</Banner>}
<Field
label="Storefront name"
optional
helper="You can leave this blank — we'll pick a placeholder name you can change any time."
inputProps={{
type: "text",
value: name,
placeholder: "e.g. Ben's Bets",
autoFocus: true,
onChange: (ev) => setName(ev.target.value),
}}
/>
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<PrimaryButton busy={busy}>{busy ? "Creating…" : "Create storefront"}</PrimaryButton>
<p className="note">You can rename, configure, or close your storefront whenever you like.</p>
</div>
</form>
</AuthCard>
</main>
<Footer />
</Screen>
);
}
-61
View File
@@ -1,61 +0,0 @@
// Landing (SD-0001 §5.1) — a Visitor learns what ecomm is and picks a door. Honest voice,
// no trial/pricing/fee (corpus 14.01.0011). Both doors open the same sign-in flow (§5.2);
// they differ only in framing. Visuals per the ui/designs export (hf-landing, Direction A).
import { Eyebrow, Footer, PrimaryButton, Screen, TopBar, Wordmark } from "../ui/kit";
interface Props {
onGetStarted: () => void;
onLogIn: () => void;
}
const PROMISES: Array<[string, string]> = [
["No trial clock", "Your storefront never expires or locks."],
["No plan wall", "We take only what it takes to run."],
["Your data, yours", "Nothing collected you didn't agree to give."],
];
export default function Landing({ onGetStarted, onLogIn }: Props) {
return (
<Screen horizon>
<TopBar
left={<Wordmark />}
right={
<button type="button" className="lk lk--nav" onClick={onLogIn}>
Log in
</button>
}
/>
<main className="screen__main">
<div className="hero">
<Eyebrow>One storefront, fully yours</Eyebrow>
<h1>
Sell online,
<br />
<em>honestly.</em>
</h1>
<p className="hero__lead">
Claim the one storefront that's yours on a platform that takes only what it takes
to run — no trial countdown, no plan wall, no data you didn't agree to give.
</p>
<div className="hero__actions">
<PrimaryButton type="button" onClick={onGetStarted}>
Create your storefront
</PrimaryButton>
<button type="button" className="lk lk--mute" onClick={onLogIn}>
Already selling with us? <span style={{ color: "var(--wv-lilac)" }}>Log in</span>
</button>
</div>
<div className="promises">
{PROMISES.map(([title, copy]) => (
<div key={title}>
<div className="promises__title">{title}</div>
<p>{copy}</p>
</div>
))}
</div>
</div>
</main>
<Footer />
</Screen>
);
}
-188
View File
@@ -1,188 +0,0 @@
// Sign in (SD-0001 §5.2) — the single email + one-time-code flow behind both doors. Step 1
// requests a code; step 2 verifies it. Honest copy for every §5.2 state; storefront routing
// is handled by App on success. Visuals per the ui/designs export (hf-signin).
import { useEffect, useState } from "react";
import { requestCode, verifyCode, type ApiError, type VerifyResult } from "../api";
import CodeInput from "../ui/CodeInput";
import { AuthCard, Banner, Field, Footer, PrimaryButton, Screen, TopBar, Wordmark } from "../ui/kit";
type Door = "signup" | "login";
interface Props {
door: Door;
onAuthed: (result: VerifyResult) => void;
onBack: () => void;
}
function useCooldown(): [number, (s: number) => void] {
const [left, setLeft] = useState(0);
const ticking = left > 0;
useEffect(() => {
if (!ticking) return;
const t = setInterval(() => setLeft((v) => Math.max(0, v - 1)), 1000);
return () => clearInterval(t);
}, [ticking]);
return [left, setLeft];
}
export default function SignIn({ door, onAuthed, onBack }: Props) {
const [step, setStep] = useState<"email" | "code">("email");
const [email, setEmail] = useState("");
const [code, setCode] = useState("");
const [busy, setBusy] = useState(false);
const [fieldError, setFieldError] = useState<string | null>(null);
const [codeError, setCodeError] = useState<string | null>(null);
const [bannerError, setBannerError] = useState<ApiError | null>(null);
const [cooldown, setCooldown] = useCooldown();
function applyError(err: ApiError) {
setFieldError(null);
setCodeError(null);
setBannerError(null);
if (err.code === "invalid_email") setFieldError(err.message);
else if (err.code === "resend_cooldown") setCooldown(err.retry_after_s ?? 60);
else if (err.code === "code_mismatch" || err.code === "code_expired") setCodeError(err.message);
else setBannerError(err); // delivery_failed, code_exhausted, unexpected
}
async function sendCode(): Promise<boolean> {
setBusy(true);
setFieldError(null);
setBannerError(null);
const err = await requestCode(email);
setBusy(false);
if (err) {
applyError(err);
return err.code === "resend_cooldown"; // a cooldown still means a code is out there
}
setCooldown(60);
return true;
}
async function submitEmail(e: React.FormEvent) {
e.preventDefault();
if (await sendCode()) {
setCode("");
setCodeError(null);
setStep("code");
}
}
async function resend() {
setCodeError(null);
setCode("");
await sendCode();
}
async function submitCode(e: React.FormEvent) {
e.preventDefault();
setBusy(true);
setCodeError(null);
setBannerError(null);
const res = await verifyCode(email, code);
setBusy(false);
if (!res.ok) {
applyError(res.error);
return;
}
onAuthed(res.result);
}
const heading = door === "signup" ? "Create your storefront" : "Log in";
const sub =
door === "signup"
? "Enter your email to begin. There's no password to create."
: "Enter your email and we'll send a one-time code to sign in.";
return (
<Screen horizon>
<TopBar
left={<Wordmark size={22} />}
right={
<button type="button" className="lk lk--mute" onClick={onBack}>
Back
</button>
}
/>
<main className="screen__main">
<AuthCard>
{step === "email" ? (
<form onSubmit={submitEmail} style={{ display: "contents" }}>
<div>
<h1>{heading}</h1>
<p className="card__sub">{sub}</p>
</div>
{bannerError && (
<Banner title="We couldn't send the code">
The email didn't go out. Try again in a moment — nothing was lost.
</Banner>
)}
<Field
label="Email"
error={fieldError}
inputProps={{
type: "email",
value: email,
placeholder: "you@example.com",
autoFocus: true,
required: true,
autoComplete: "email",
onChange: (ev) => setEmail(ev.target.value),
}}
/>
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<PrimaryButton busy={busy} disabled={cooldown > 0}>
{busy ? "Sending…" : cooldown > 0 ? `Resend in ${cooldown}s` : "Send code"}
</PrimaryButton>
<p className="note">
We'll email you a one-time code. That's all we need no password, ever.
</p>
</div>
</form>
) : (
<form onSubmit={submitCode} style={{ display: "contents" }}>
<div>
<h1>Check your email</h1>
<p className="card__sub">
We sent a 6-digit code to <span style={{ color: "var(--wv-starlight)" }}>{email}</span>.{" "}
<button type="button" className="lk" onClick={() => setStep("email")}>
Wrong address?
</button>
</p>
</div>
{bannerError && (
<Banner title={bannerError.code === "code_exhausted" ? "Too many attempts" : "Something went wrong"}>
{bannerError.code === "code_exhausted"
? "That code is no longer valid. Request a fresh one to keep going."
: bannerError.message}
</Banner>
)}
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<CodeInput value={code} onChange={setCode} error={!!codeError} />
{codeError && (
<p className="note note--attn" role="alert">
{codeError}
</p>
)}
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<PrimaryButton busy={busy}>{busy ? "Checking…" : "Continue"}</PrimaryButton>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<p className="note">Good for 10 minutes.</p>
{cooldown > 0 ? (
<span className="note">Resend in {cooldown}s</span>
) : (
<button type="button" className="lk" onClick={resend}>
Resend code
</button>
)}
</div>
</div>
</form>
)}
</AuthCard>
</main>
<Footer />
</Screen>
);
}
-394
View File
@@ -1,394 +0,0 @@
/* ecomm app chrome — the hf-kit primitives (Direction A · Quiet centered) as CSS.
Depth = layered surfaces + hairlines; hover = small lift, no new shadows. */
* { box-sizing: border-box; }
html, body, #root { height: 100%; }
body {
margin: 0;
background: var(--wv-midnight);
color: var(--wv-starlight);
font-family: var(--wv-font-body);
-webkit-font-smoothing: antialiased;
}
/* ── screen ground: barely-there starfield + optional gold horizon ─────────── */
.screen {
min-height: 100%;
display: flex;
flex-direction: column;
position: relative;
background:
radial-gradient(1.3px 1.3px at 16% 22%, rgba(237, 234, 255, .34), transparent),
radial-gradient(1.2px 1.2px at 78% 14%, rgba(237, 234, 255, .22), transparent),
radial-gradient(1.1px 1.1px at 88% 46%, rgba(155, 140, 255, .30), transparent),
radial-gradient(1.3px 1.3px at 30% 72%, rgba(237, 234, 255, .24), transparent),
radial-gradient(1.1px 1.1px at 62% 86%, rgba(237, 234, 255, .18), transparent),
radial-gradient(1.2px 1.2px at 7% 56%, rgba(155, 140, 255, .26), transparent),
var(--wv-midnight);
}
.screen--horizon {
background:
radial-gradient(120% 70% at 50% 142%, rgba(244, 199, 107, .16) 0%, rgba(244, 199, 107, .05) 38%, transparent 64%),
radial-gradient(1.3px 1.3px at 16% 22%, rgba(237, 234, 255, .34), transparent),
radial-gradient(1.2px 1.2px at 78% 14%, rgba(237, 234, 255, .22), transparent),
radial-gradient(1.1px 1.1px at 88% 46%, rgba(155, 140, 255, .30), transparent),
radial-gradient(1.3px 1.3px at 30% 72%, rgba(237, 234, 255, .24), transparent),
radial-gradient(1.1px 1.1px at 62% 86%, rgba(237, 234, 255, .18), transparent),
radial-gradient(1.2px 1.2px at 7% 56%, rgba(155, 140, 255, .26), transparent),
var(--wv-midnight);
}
.screen--plain { background: var(--wv-midnight); }
.screen__main {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 32px 24px;
}
/* ── top bar: glass chrome ──────────────────────────────────────────────────── */
.topbar {
flex: 0 0 auto;
height: 68px;
padding: 0 36px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
border-bottom: 1px solid var(--border-soft);
background: var(--glass-sky);
backdrop-filter: blur(var(--glass-blur));
position: relative;
z-index: 5;
}
.topbar__side { display: flex; align-items: center; gap: 16px; min-width: 0; }
/* ── wordmark ───────────────────────────────────────────────────────────────── */
.wordmark { display: inline-flex; align-items: center; gap: 10px; }
.wordmark img { display: block; border-radius: 6px; }
.wordmark__name {
font-family: var(--wv-font-display);
font-weight: var(--weight-bold);
font-size: 22px;
letter-spacing: var(--tracking-display);
color: var(--wv-starlight);
}
/* ── links & text buttons ───────────────────────────────────────────────────── */
.lk {
color: var(--wv-lilac);
text-decoration: none;
background: none;
border: none;
padding: 0;
font: inherit;
cursor: pointer;
transition: color var(--dur-fast) var(--ease);
}
.lk:hover { color: var(--wv-gold); }
.lk--mute { color: var(--text-on-dark-mute); }
.lk--nav {
font-family: var(--wv-font-display);
font-weight: var(--weight-medium);
font-size: 14px;
color: var(--wv-starlight);
}
.signout {
font-family: var(--wv-font-display);
font-weight: var(--weight-medium);
font-size: 14px;
color: var(--wv-starlight);
background: transparent;
border: none;
cursor: pointer;
padding: 0;
transition: color var(--dur-fast) var(--ease);
}
.signout:hover { color: var(--wv-gold); }
/* ── account chip (top-bar right, signed in) ────────────────────────────────── */
.chip { display: flex; align-items: center; gap: 14px; min-width: 0; }
.chip__avatar {
width: 30px;
height: 30px;
border-radius: 15px;
background: var(--wv-lilac-16);
border: 1px solid var(--border-card);
display: flex;
align-items: center;
justify-content: center;
font-family: var(--wv-font-display);
font-weight: var(--weight-bold);
font-size: 13px;
flex: 0 0 auto;
}
.chip__email {
font-size: 14px;
color: var(--text-on-dark-soft);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chip__divider { width: 1px; height: 18px; background: var(--border-soft); flex: 0 0 auto; }
/* ── eyebrow ────────────────────────────────────────────────────────────────── */
.eyebrow {
font-family: var(--wv-font-display);
font-weight: var(--weight-medium);
font-size: var(--text-eyebrow);
letter-spacing: var(--tracking-eyebrow);
text-transform: uppercase;
color: var(--wv-lilac);
margin: 0;
}
/* ── auth card — the one surface with a real shadow ─────────────────────────── */
.card {
width: 440px;
max-width: 100%;
background: var(--surface-raised);
border: 1px solid var(--border-card);
border-radius: var(--radius-card);
padding: 40px 40px 34px;
display: flex;
flex-direction: column;
gap: 22px;
box-shadow: var(--shadow-soft);
position: relative;
z-index: 2;
}
.card h1 {
font-family: var(--wv-font-display);
font-weight: var(--weight-bold);
letter-spacing: var(--tracking-display);
font-size: 27px;
line-height: 1.08;
margin: 0 0 9px;
}
.card__sub { font-size: 14.5px; line-height: 1.55; color: var(--text-on-dark-soft); margin: 0; }
/* ── fields ─────────────────────────────────────────────────────────────────── */
.field { display: flex; flex-direction: column; gap: 8px; }
.field__label {
font-weight: var(--weight-medium);
font-size: 13.5px;
color: var(--text-on-dark-soft);
display: flex;
justify-content: space-between;
align-items: baseline;
}
.field__optional { font-size: 12.5px; color: var(--text-on-dark-mute); font-weight: var(--weight-regular); }
.field__input {
height: 52px;
border-radius: 10px;
padding: 0 16px;
background: rgba(237, 234, 255, .035);
border: 1.5px solid var(--border-soft);
font-family: var(--wv-font-body);
font-size: 16px;
color: var(--wv-starlight);
width: 100%;
transition: border-color var(--dur-fast) var(--ease), box-shadow var(--dur-fast) var(--ease);
}
.field__input::placeholder { color: var(--text-on-dark-mute); }
.field__input:hover { border-color: var(--border-strong); }
.field__input:focus {
outline: none;
border-color: var(--wv-gold);
box-shadow: 0 0 0 3px var(--wv-gold-28);
background: rgba(237, 234, 255, .06);
}
.field__input--error { border-color: var(--wv-gold); }
.note { font-size: 13px; line-height: 1.45; color: var(--text-on-dark-mute); margin: 0; }
.note--attn { color: var(--wv-gold); display: flex; gap: 7px; align-items: baseline; }
.note--attn::before { content: "◆"; font-size: 11px; }
/* ── primary button: gold pill ──────────────────────────────────────────────── */
.btn-primary {
display: inline-flex;
align-items: center;
justify-content: center;
gap: .4em;
width: 100%;
font-family: var(--wv-font-display);
font-weight: var(--weight-medium);
font-size: 15.5px;
line-height: 1;
padding: .85rem 1.4rem;
border-radius: var(--radius-pill);
border: var(--btn-border-w) solid transparent;
background: var(--cta);
color: var(--cta-text);
cursor: pointer;
transition: transform var(--dur-fast) var(--ease), background var(--dur-fast) var(--ease);
}
.btn-primary:hover:not(:disabled) { background: var(--cta-hover); transform: var(--lift-1); }
.btn-primary:disabled { opacity: .5; cursor: not-allowed; }
.btn-primary--auto { width: auto; }
.btn-primary:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; }
/* ── banner: gold attention / lilac info (no red in the palette) ────────────── */
.banner {
display: flex;
gap: 12px;
padding: 14px 16px;
border-radius: 12px;
align-items: flex-start;
font-size: 14px;
line-height: 1.5;
color: var(--text-on-dark-soft);
text-align: left;
}
.banner--attn { background: rgba(244, 199, 107, .10); border: 1px solid var(--wv-gold-40); }
.banner--info { background: var(--wv-lilac-08); border: 1px solid var(--wv-lilac-18); }
.banner__icon { font-size: 13px; line-height: 22px; flex: 0 0 auto; }
.banner--attn .banner__icon { color: var(--wv-gold); }
.banner--info .banner__icon { color: var(--wv-lilac); }
.banner__title { color: var(--wv-starlight); font-weight: var(--weight-semibold); margin-bottom: 2px; }
/* ── code input: 6 cells over one invisible input ───────────────────────────── */
.code { position: relative; display: flex; gap: 11px; justify-content: center; }
.code__hidden {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
opacity: 0;
border: none;
font-size: 16px;
cursor: pointer;
}
.code__cell {
width: 52px;
height: 64px;
border-radius: 11px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(237, 234, 255, .035);
border: 1.5px solid var(--border-soft);
font-family: var(--wv-font-display);
font-weight: var(--weight-medium);
font-size: 26px;
transition: border-color var(--dur-fast) var(--ease), box-shadow var(--dur-fast) var(--ease);
}
.code--focus .code__cell--active {
border-color: var(--wv-gold);
box-shadow: 0 0 0 3px var(--wv-gold-28);
}
.code--error .code__cell { border-color: var(--wv-gold); }
/* ── footer ─────────────────────────────────────────────────────────────────── */
.footer {
flex: 0 0 auto;
padding: 20px 36px;
border-top: 1px solid var(--border-soft);
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
font-size: 12.5px;
color: var(--text-on-dark-mute);
background: rgba(9, 12, 34, .4);
}
.footer__id { display: inline-flex; align-items: center; gap: 9px; }
.footer__id img { opacity: .85; }
/* ── landing ────────────────────────────────────────────────────────────────── */
.hero { max-width: 680px; display: flex; flex-direction: column; align-items: center; text-align: center; }
.hero h1 {
font-family: var(--wv-font-display);
font-weight: var(--weight-bold);
letter-spacing: -0.02em;
line-height: 1.02;
font-size: clamp(36px, 6.5vw, 62px);
margin: 20px 0 22px;
}
.hero h1 em { font-style: normal; color: var(--wv-gold); }
.hero__lead {
font-size: clamp(15.5px, 1.8vw, 18.5px);
line-height: 1.6;
color: var(--text-on-dark-soft);
max-width: 520px;
margin: 0 0 32px;
text-wrap: pretty;
}
.hero__actions { display: flex; gap: 14px; align-items: center; flex-wrap: wrap; justify-content: center; }
.hero__actions .btn-primary { width: auto; }
.promises {
display: grid;
grid-template-columns: repeat(3, 1fr);
width: 100%;
margin-top: 44px;
border-top: 1px solid var(--border-soft);
padding-top: 24px;
text-align: left;
}
.promises > div { padding: 0 22px; }
.promises > div + div { border-left: 1px solid var(--border-soft); }
.promises__title {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 5px;
font-family: var(--wv-font-display);
font-weight: var(--weight-medium);
font-size: 14.5px;
}
.promises__title::before { content: "◆"; color: var(--wv-gold); font-size: 10px; }
.promises p { font-size: 13px; line-height: 1.5; color: var(--text-on-dark-mute); margin: 0; }
/* ── admin shell ────────────────────────────────────────────────────────────── */
.storeid { display: inline-flex; align-items: center; gap: 11px; min-width: 0; }
.storeid img { display: block; border-radius: 7px; flex: 0 0 auto; }
.storeid__col { display: inline-flex; flex-direction: column; line-height: 1.12; min-width: 0; }
.storeid__name {
font-family: var(--wv-font-display);
font-weight: var(--weight-bold);
font-size: 17px;
letter-spacing: -0.01em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.storeid__sub { font-size: 11px; color: var(--text-on-dark-mute); }
.empty { display: flex; flex-direction: column; align-items: center; text-align: center; max-width: 560px; }
.empty__seal {
width: 76px;
height: 76px;
border-radius: 50%;
border: 1px solid var(--border-card);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 26px;
background: var(--wv-lilac-08);
}
.empty h1 {
font-family: var(--wv-font-display);
font-weight: var(--weight-bold);
letter-spacing: var(--tracking-display);
line-height: 1.04;
font-size: clamp(30px, 4.5vw, 42px);
margin: 12px 0 16px;
}
.empty__copy { font-size: 17px; line-height: 1.6; color: var(--text-on-dark-soft); max-width: 460px; margin: 0; text-wrap: pretty; }
/* ── small screens ──────────────────────────────────────────────────────────── */
@media (max-width: 720px) {
.topbar { padding: 0 20px; }
.footer { padding: 16px 20px; flex-direction: column; }
.card { padding: 28px 22px 24px; }
.promises { grid-template-columns: 1fr; gap: 14px; }
.promises > div { padding: 0; }
.promises > div + div { border-left: none; }
.code__cell { width: 44px; height: 56px; font-size: 22px; }
}
-37
View File
@@ -1,37 +0,0 @@
/* Wiggleverse webfonts (from the design-system export) — Space Grotesk (display) +
Inter (body/UI). Fraunces (human register) deliberately omitted: no screen uses it yet. */
@font-face {
font-family: "Space Grotesk";
font-style: normal;
font-weight: 500;
font-display: swap;
src: url("/fonts/space-grotesk-v22-latin-500.woff2") format("woff2");
}
@font-face {
font-family: "Space Grotesk";
font-style: normal;
font-weight: 700;
font-display: swap;
src: url("/fonts/space-grotesk-v22-latin-700.woff2") format("woff2");
}
@font-face {
font-family: "Inter";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("/fonts/inter-v20-latin-regular.woff2") format("woff2");
}
@font-face {
font-family: "Inter";
font-style: normal;
font-weight: 500;
font-display: swap;
src: url("/fonts/inter-v20-latin-500.woff2") format("woff2");
}
@font-face {
font-family: "Inter";
font-style: normal;
font-weight: 600;
font-display: swap;
src: url("/fonts/inter-v20-latin-600.woff2") format("woff2");
}
-7
View File
@@ -1,7 +0,0 @@
/* Global style entry point — Wiggleverse design-system tokens (copied from the
ui/designs export) + the ecomm app chrome built on them. */
@import "./fonts.css";
@import "./tokens-colors.css";
@import "./tokens-typography.css";
@import "./tokens-spacing.css";
@import "./app.css";
-60
View File
@@ -1,60 +0,0 @@
/* Wiggleverse — Color tokens
Source of truth: wiggleverse-www/assets/tokens.css (brand BRAND.md §89).
Dark "sky" is the primary ground; Paper is for long reading. No-center motif. */
:root {
/* ---- Brand palette (base values) ---- */
--wv-midnight: #0E1230; /* sky / ground — primary dark background */
--wv-indigo: #1C2150; /* raised surfaces on dark (cards, mobile nav) */
--wv-indigo-2: #232A63; /* hover state for raised surfaces */
--wv-lilac: #9B8CFF; /* accent — "the bonds between us"; links, nodes */
--wv-violet: #7C6FE0; /* secondary links / strokes (on light) */
--wv-gold: #F4C76B; /* warmth / horizon / primary CTAs */
--wv-gold-hi: #F7D488; /* gold hover */
--wv-starlight:#EDEAFF; /* nodes / text on dark */
--wv-paper: #F6F4FB; /* light-mode background */
--wv-ink: #3B2F7A; /* text on light */
--wv-night: #090C22; /* footer / deepest ground */
/* CTA text-on-gold (very dark gold-brown, not pure black) */
--wv-gold-ink: #2A2003;
--wv-gold-ink-soft: #6B4E10; /* "soon" tag text on gold tint */
/* ---- Alpha derivations (lilac / gold / starlight washes) ---- */
--wv-lilac-08: rgba(155, 140, 255, .08);
--wv-lilac-12: rgba(155, 140, 255, .12);
--wv-lilac-16: rgba(155, 140, 255, .16);
--wv-lilac-18: rgba(155, 140, 255, .18);
--wv-lilac-32: rgba(155, 140, 255, .32);
--wv-gold-28: rgba(244, 199, 107, .28);
--wv-gold-40: rgba(244, 199, 107, .40);
--wv-starlight-85: rgba(237, 234, 255, .85);
--wv-starlight-78: rgba(237, 234, 255, .78);
--wv-starlight-60: rgba(237, 234, 255, .60);
--wv-starlight-55: rgba(237, 234, 255, .55);
/* ---- Semantic aliases ---- */
--surface-sky: var(--wv-midnight); /* page ground (dark) */
--surface-raised: var(--wv-indigo); /* cards / panels on dark */
--surface-raised-hi: var(--wv-indigo-2); /* raised hover */
--surface-paper: var(--wv-paper); /* long-reading light sections */
--surface-card-light:#FFFFFF; /* build-cards on paper */
--surface-footer: var(--wv-night);
--text-on-dark: var(--wv-starlight);
--text-on-dark-soft: var(--wv-starlight-78);
--text-on-dark-mute: var(--wv-starlight-60);
--text-on-light: var(--wv-ink);
--text-on-light-soft:#4B4170;
--accent: var(--wv-lilac); /* links + nodes on dark */
--accent-on-light: var(--wv-violet); /* links + strokes on light */
--cta: var(--wv-gold); /* primary action / horizon */
--cta-hover: var(--wv-gold-hi);
--cta-text: var(--wv-gold-ink);
--border-soft: var(--wv-lilac-16); /* hairlines on dark */
--border-card: var(--wv-lilac-18);
--border-strong: var(--wv-lilac-32);
--focus-ring: var(--wv-gold);
}
-55
View File
@@ -1,55 +0,0 @@
/* Wiggleverse — Spacing, radius, shadow, layout & motion tokens
Derived from the marketing-site CSS. The brand has almost no shadow system —
depth is carried by surface color + hairline borders, not drop shadows. */
:root {
/* ---- Spacing scale (rem) ---- */
--space-0: 0;
--space-1: .25rem;
--space-2: .5rem;
--space-3: .7rem;
--space-4: 1rem;
--space-5: 1.2rem;
--space-6: 1.6rem;
--space-8: 2rem;
--space-10: 2.5rem;
--space-12: 3rem;
/* Section rhythm — fluid vertical padding for page bands */
--section-pad: clamp(3rem, 7vw, 5.5rem);
--page-hero-pad: clamp(2.8rem, 6vw, 4.5rem);
/* ---- Layout ---- */
--wrap-max: 1080px; /* content column */
--wrap-gutter: 92vw; /* width: min(--wrap-max, --wrap-gutter) */
--measure-prose: 68ch; /* reading measure for prose blocks */
/* ---- Radii ---- */
--radius-card: 14px; /* path-cards, build-cards */
--radius-panel: 12px; /* principle tiles */
--radius-sm: 4px; /* focus ring rounding */
--radius-pill: 999px; /* buttons, tags, notices */
--radius-mark: 26px; /* favicon tile rounding */
/* ---- Borders ---- */
--border-hair: 1px; /* default hairline */
--border-card-w: 1px;
--btn-border-w: 1.5px; /* ghost button / focus weight */
/* ---- Elevation ---- *
* The system avoids drop shadows. "Lift" on hover is a -1 to -3px translateY,
* not a shadow. These tokens exist for the rare card that needs real elevation. */
--shadow-none: none;
--shadow-soft: 0 8px 24px rgba(9, 12, 34, .28);
--lift-1: translateY(-1px); /* @kind other */ /* buttons */
--lift-3: translateY(-3px); /* @kind other */ /* cards */
/* ---- Glass (sticky header) ---- */
--glass-sky: rgba(14, 18, 48, .82);
--glass-blur: 10px;
/* ---- Motion ---- */
--ease: ease; /* @kind other */
--dur-fast: .15s; /* @kind other */ /* hover / press transitions */
--dur-mid: .25s; /* @kind other */ /* mobile nav reveal */
}
-53
View File
@@ -1,53 +0,0 @@
/* Wiggleverse — Typography tokens
Two registers, one meaning (BRAND.md):
• Machine register — Space Grotesk (display) + Inter (body/UI): precise, structural.
• Human register — Fraunces, ITALIC ONLY: warm, literary, used sparingly
for the lines that carry conscience ("the soul").
@font-face rules live in assets/fonts/fonts.css. */
:root {
/* ---- Families ---- */
--wv-font-display: 'Space Grotesk', system-ui, sans-serif; /* headings, wordmark */
--wv-font-body: 'Inter', system-ui, sans-serif; /* body / UI */
--wv-font-human: 'Fraunces', Georgia, serif; /* pull-quotes — italic only */
/* semantic aliases */
--font-display: var(--wv-font-display);
--font-body: var(--wv-font-body);
--font-soul: var(--wv-font-human);
/* ---- Weights ---- */
--weight-regular: 400; /* Inter body */
--weight-medium: 500; /* nav, eyebrows, buttons, Space Grotesk text */
--weight-semibold:600; /* Inter emphasis, ledger totals */
--weight-bold: 700; /* Space Grotesk headings, wordmark */
--weight-soul: 500; /* Fraunces italic pull-quotes */
/* ---- Fluid display sizes (clamp: min, vw, max) ---- */
--text-h1: clamp(2.1rem, 5.2vw, 3.6rem);
--text-h2: clamp(1.6rem, 3.4vw, 2.4rem);
--text-h3: 1.2rem;
--text-lead: clamp(1.05rem, 1.8vw, 1.3rem); /* intro paragraph */
--text-soul: clamp(1.2rem, 2.4vw, 1.7rem); /* hero pull-quote */
/* ---- Body / UI scale ---- */
--text-body: 1rem; /* 16px base */
--text-small: .95rem;
--text-fine: .92rem;
--text-eyebrow: .8rem; /* uppercase label */
--text-tag: .72rem; /* pill tags */
/* ---- Line heights ---- */
--leading-tight: 1.12; /* headings */
--leading-body: 1.6; /* paragraphs */
/* ---- Letter spacing ---- */
--tracking-display: -0.015em; /* headings + wordmark draw in slightly */
--tracking-eyebrow: 0.12em; /* uppercase eyebrows open up */
--tracking-tag: 0.08em;
--tracking-notice: 0.06em;
/* ---- Measure ---- */
--measure-lead: 60ch;
--measure-prose:68ch;
}
-46
View File
@@ -1,46 +0,0 @@
// Six-cell one-time-code input: one real <input> (invisible, full-bleed) carries the value
// and the OTC autofill semantics; the cells are presentation. Click anywhere to focus;
// paste works because the input is real.
import { useState } from "react";
const LENGTH = 6;
export default function CodeInput({
value,
onChange,
error = false,
}: {
value: string;
onChange: (code: string) => void;
error?: boolean;
}) {
const [focused, setFocused] = useState(false);
const digits = value.slice(0, LENGTH).split("");
const active = Math.min(digits.length, LENGTH - 1);
return (
<div className={`code${focused ? " code--focus" : ""}${error ? " code--error" : ""}`}>
<input
className="code__hidden"
value={value}
inputMode="numeric"
autoComplete="one-time-code"
aria-label="One-time code"
autoFocus
maxLength={LENGTH}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
onChange={(ev) => onChange(ev.target.value.replace(/\D/g, "").slice(0, LENGTH))}
/>
{Array.from({ length: LENGTH }, (_, i) => (
<span
key={i}
aria-hidden="true"
className={`code__cell${i === active && focused ? " code__cell--active" : ""}`}
>
{digits[i] ?? ""}
</span>
))}
</div>
);
}
-150
View File
@@ -1,150 +0,0 @@
// The ecomm UI kit — React faces of the app.css primitives, matching the Claude Design
// export (ui/designs/ecomm-login-and-create-storefront-designs, hf-kit). Presentation
// only: no business rules live here (§6.2 — the SPA renders what the BFF returns).
import { useId } from "react";
import type { ReactNode } from "react";
export function Screen({
children,
horizon = false,
plain = false,
}: {
children: ReactNode;
horizon?: boolean;
plain?: boolean;
}) {
const cls = plain ? "screen screen--plain" : horizon ? "screen screen--horizon" : "screen";
return <div className={cls}>{children}</div>;
}
export function TopBar({ left, right }: { left: ReactNode; right?: ReactNode }) {
return (
<header className="topbar">
<div className="topbar__side">{left}</div>
<div className="topbar__side">{right}</div>
</header>
);
}
export function Wordmark({ size = 26 }: { size?: number }) {
return (
<span className="wordmark">
<img src="/brand/mark-tile.svg" width={size} height={size} alt="" />
<span className="wordmark__name" style={{ fontSize: size * 0.86 }}>
ecomm
</span>
</span>
);
}
export function Footer() {
return (
<footer className="footer">
<span className="footer__id">
<img src="/brand/mark-mono-gold.svg" width={16} height={16} alt="" />
ecomm · a Wiggleverse line
</span>
</footer>
);
}
export function AuthCard({ children }: { children: ReactNode }) {
return <div className="card">{children}</div>;
}
export function Eyebrow({ children }: { children: ReactNode }) {
return <p className="eyebrow">{children}</p>;
}
export function Banner({
tone = "attn",
title,
children,
}: {
tone?: "attn" | "info";
title?: string;
children: ReactNode;
}) {
return (
<div className={`banner banner--${tone}`} role={tone === "attn" ? "alert" : "status"}>
<span className="banner__icon" aria-hidden="true">
{tone === "attn" ? "◆" : "◇"}
</span>
<div>
{title && <div className="banner__title">{title}</div>}
{children}
</div>
</div>
);
}
export function PrimaryButton({
children,
busy = false,
disabled = false,
type = "submit",
onClick,
}: {
children: ReactNode;
busy?: boolean;
disabled?: boolean;
type?: "submit" | "button";
onClick?: () => void;
}) {
return (
<button className="btn-primary" type={type} disabled={disabled || busy} onClick={onClick}>
{children}
</button>
);
}
export function Field({
label,
optional = false,
error,
helper,
inputProps,
}: {
label: string;
optional?: boolean;
error?: string | null;
helper?: string;
inputProps: React.InputHTMLAttributes<HTMLInputElement>;
}) {
const id = useId();
return (
<div className="field">
<label className="field__label" htmlFor={id}>
<span>{label}</span>
{optional && <span className="field__optional">optional</span>}
</label>
<input
id={id}
className={`field__input${error ? " field__input--error" : ""}`}
aria-invalid={!!error}
{...inputProps}
/>
{error && (
<p className="note note--attn" role="alert">
{error}
</p>
)}
{helper && !error && <p className="note">{helper}</p>}
</div>
);
}
export function AccountChip({ email, onSignOut }: { email: string; onSignOut: () => void }) {
return (
<div className="chip">
<span className="chip__avatar" aria-hidden="true">
{email[0]?.toUpperCase()}
</span>
<span className="chip__email">{email}</span>
<span className="chip__divider" aria-hidden="true" />
<button type="button" className="signout" onClick={onSignOut}>
Sign out
</button>
</div>
);
}
-17
View File
@@ -1,17 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true
},
"include": ["src"]
}
-24
View File
@@ -1,24 +0,0 @@
/// <reference types="vitest/config" />
import react from "@vitejs/plugin-react";
import { defineConfig } from "vitest/config";
// The SPA talks to the FastAPI backend on :8000 via a same-origin proxy, so the
// browser makes no cross-origin calls in dev. Override the target with
// VITE_API_TARGET when :8000 is occupied. Screen-shaped /api/* endpoints arrive in
// SLICE-2/3 (SD-0001 §6.4).
const apiTarget = process.env.VITE_API_TARGET || "http://localhost:8000";
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
"/api": apiTarget,
},
},
// Tests live alongside source; scoping the include to src/ keeps the runner out of
// node_modules (and any stray local backup dirs) without naming them.
test: {
include: ["src/**/*.test.{ts,tsx}"],
},
});
+2 -7
View File
@@ -2,7 +2,8 @@
# The single pre-merge gate for wiggleverse-ecomm (SD-0001 §6.8). Runs, in order: # The single pre-merge gate for wiggleverse-ecomm (SD-0001 §6.8). Runs, in order:
# 1. the import-linter layering contract (main > domains > platform) # 1. the import-linter layering contract (main > domains > platform)
# 2. the backend test suite (pytest, against Postgres) # 2. the backend test suite (pytest, against Postgres)
# 3. the frontend typecheck + production build # The network-seed reset stripped the SPA frontend, so the frontend typecheck/build
# and vitest steps are gone — this is a backend-only gate now.
# CI (.gitea/workflows/ci.yml) calls this exact script, so local and server gates # CI (.gitea/workflows/ci.yml) calls this exact script, so local and server gates
# cannot drift. Exits non-zero on the first failure. # cannot drift. Exits non-zero on the first failure.
# #
@@ -31,10 +32,4 @@ echo "==> import boundaries (lint-imports)"
echo "==> backend tests (pytest)" echo "==> backend tests (pytest)"
( cd "$repo_root/backend" && "$PY" -m pytest -q ) ( cd "$repo_root/backend" && "$PY" -m pytest -q )
echo "==> frontend typecheck + build"
( cd "$repo_root/frontend" && npm run build )
echo "==> frontend unit tests (vitest)"
( cd "$repo_root/frontend" && npm test )
echo "==> all gates green" echo "==> all gates green"
+10 -43
View File
@@ -1,12 +1,14 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Local bring-up for ecomm (SD-0001 PUC-10). From a clean checkout this: # Local bring-up for the ecomm network-service seed (SD-0001 PUC-10). From a clean
# checkout this:
# 1. starts the dev Postgres container and waits for it to be healthy (owns its # 1. starts the dev Postgres container and waits for it to be healthy (owns its
# lifecycle — Docker is the one prerequisite, docs/BOOTSTRAP.md); # lifecycle — Docker is the one prerequisite, docs/BOOTSTRAP.md);
# 2. ensures the backend venv + deps and the frontend node_modules; # 2. ensures the backend venv + deps;
# 3. runs the FastAPI backend (:8000, self-migrating an empty DB) and the Vite dev # 3. runs the FastAPI backend (:8000, self-migrating an empty DB).
# server (:5173, proxying /api -> :8000) together. # Ctrl-C stops the backend; the container keeps running (stop it with
# Ctrl-C stops both processes; the container keeps running (stop it with
# `docker compose down`, or `docker compose down -v` to reset to empty). # `docker compose down`, or `docker compose down -v` to reset to empty).
# The storefront SPA was removed in the network-seed reset — there is no frontend dev
# server yet (the network admin UI is a future build).
set -euo pipefail set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
@@ -24,41 +26,6 @@ if [ ! -x "$repo_root/.venv/bin/python" ]; then
"$repo_root/.venv/bin/python" -m pip install -r "$repo_root/backend/requirements.txt" "$repo_root/.venv/bin/python" -m pip install -r "$repo_root/backend/requirements.txt"
fi fi
if [ ! -d "$repo_root/frontend/node_modules" ]; then echo "==> starting backend (:8000) — Ctrl-C to stop"
echo "==> installing frontend deps" # exec hands the terminal (and Ctrl-C) straight to uvicorn; no child-process juggling.
( cd "$repo_root/frontend" && npm install ) exec "$repo_root/.venv/bin/python" -m uvicorn app.main:app --app-dir "$repo_root/backend" --port 8000 --reload
fi
echo "==> starting backend (:8000) and Vite (:5173) — Ctrl-C to stop"
"$repo_root/.venv/bin/python" -m uvicorn app.main:app --app-dir "$repo_root/backend" --port 8000 --reload &
backend_pid=$!
( cd "$repo_root/frontend" && npm run dev ) &
frontend_pid=$!
# Kill a process and its whole descendant tree (children first), portably across
# macOS and Linux — npm spawns vite as a grandchild, so killing the captured pid
# alone would orphan it on :5173. `pgrep -P` (list children) exists on both.
kill_tree() {
local pid=$1 child
for child in $(pgrep -P "$pid" 2>/dev/null); do
kill_tree "$child"
done
kill "$pid" 2>/dev/null || true
}
cleanup() {
trap - INT TERM
echo
echo "==> stopping backend and Vite"
kill_tree "$backend_pid"
kill_tree "$frontend_pid"
wait "$backend_pid" "$frontend_pid" 2>/dev/null || true
}
trap cleanup INT TERM
# Wait until either process exits, then tear the other down. `wait -n` would be
# tidier but is unsupported on macOS's bundled bash 3.2, so poll portably.
while kill -0 "$backend_pid" 2>/dev/null && kill -0 "$frontend_pid" 2>/dev/null; do
sleep 1
done
cleanup