SD-0001 v0.3.0: PostgreSQL everywhere (Cloud SQL deployed, Docker container in dev) #1
@@ -9,7 +9,7 @@ status: graduated
|
||||
| **Author(s)** | Claude (session ecomm-0020), for Ben Stull |
|
||||
| **Reviewers / approvers** | Ben Stull |
|
||||
| **Status** | `approved` |
|
||||
| **Version** | v0.2.0 |
|
||||
| **Version** | v0.3.0 |
|
||||
| **Source artifacts** | BDD corpus: [research/shopify](https://git.wiggleverse.org/wiggleverse/wiggleverse-ecomm-prototype-content/src/branch/main/research/shopify) (esp. [flows/first-run-experience.md](https://git.wiggleverse.org/wiggleverse/wiggleverse-ecomm-prototype-content/src/branch/main/research/shopify/flows/first-run-experience.md)) · Prototype: [wiggleverse-ecomm-prototype](https://git.wiggleverse.org/wiggleverse/wiggleverse-ecomm-prototype) (R01–R07) · Reference: Shopify first-run experience · Supersedes: — |
|
||||
|
||||
**Change log**
|
||||
@@ -18,6 +18,7 @@ status: graduated
|
||||
| --- | --- | --- | --- |
|
||||
| 2026-06-10 | v0.1.0 | Initial draft (session ecomm-0020) | Claude |
|
||||
| 2026-06-10 | v0.2.0 | Self-review fixes; status → `approved` under the session's autonomous posture (handbook §6.5) — operator amendments welcome as v0.2.x | Claude |
|
||||
| 2026-06-10 | v0.3.0 | Datastore: **PostgreSQL everywhere** (Cloud SQL in PPE/Prod; single pinned Docker container in dev) replaces SQLite — D-7/D-8; retires R-3, adds R-6; §§2, 6.2–6.9, 7.2, 7.4, 9 touched | Ben + Claude |
|
||||
|
||||
---
|
||||
|
||||
@@ -247,10 +248,13 @@ shape and inverts its *disqualifying* assumption:
|
||||
|
||||
- **Carry forward** (validated by R01–R07 of the prototype): a four-layer
|
||||
Python backend (`entrypoint → api → domains → platform`) with the layer
|
||||
contract mechanically enforced; SQLite (WAL) with forward-only numbered
|
||||
migrations and no ORM; a React/Vite SPA admin talking to a screen-shaped
|
||||
REST BFF; scenario-bound end-to-end tests (one test per BDD scenario ID);
|
||||
OHM concepts cited inline next to the rules they ground.
|
||||
contract mechanically enforced; relational persistence through forward-only
|
||||
numbered migrations and no ORM — with one deliberate engine swap from the
|
||||
prototype's SQLite to **PostgreSQL in every environment** (Cloud SQL when
|
||||
deployed, a Docker container in dev; rationale in §6.7/D-7); a React/Vite
|
||||
SPA admin talking to a screen-shaped REST BFF; scenario-bound end-to-end
|
||||
tests (one test per BDD scenario ID); OHM concepts cited inline next to the
|
||||
rules they ground.
|
||||
- **Invert** (the prototype's bootstrap was the wrong way round for a real
|
||||
product): **no seeded tenant and no admission gate.** The prototype was born
|
||||
with one hard-coded store and one invited owner; ecomm is born *empty*.
|
||||
@@ -592,12 +596,12 @@ flowchart LR
|
||||
SF[storefronts<br/>storefront · membership]
|
||||
end
|
||||
subgraph platform
|
||||
DB[db<br/>SQLite WAL + migrations]
|
||||
DB[db<br/>Postgres + migrations]
|
||||
MAIL[mailer<br/>port: log / SMTP]
|
||||
DEPS[deps<br/>request wiring]
|
||||
end
|
||||
end
|
||||
STORE[(SQLite file)]
|
||||
STORE[(PostgreSQL<br/>Cloud SQL · dev: Docker)]
|
||||
RELAY[SMTP relay<br/>deployed envs only]
|
||||
|
||||
SPA -->|JSON /api/*| BFF
|
||||
@@ -628,8 +632,11 @@ imports flow only downward; domains are imported via their package surface
|
||||
account↔storefront membership (INV-4, INV-5), including the entry-routing
|
||||
answer ("which storefront, if any, does this account have?"). Must never
|
||||
mint identity.
|
||||
- **`platform/db`** — SQLite WAL connection + forward-only migration runner
|
||||
(INV-7); owns the schema lifecycle, no business rules.
|
||||
- **`platform/db`** — PostgreSQL connections (psycopg, small pool) + the
|
||||
forward-only migration runner (INV-7); owns the schema lifecycle, no
|
||||
business rules. The engine is PostgreSQL in **every** environment: Cloud
|
||||
SQL in PPE/Prod, a single pinned-version Docker container in dev and tests
|
||||
(D-7) — one engine, one driver, full dev/prod parity.
|
||||
- **`platform/mailer`** — a one-method port (`send(to, subject, body)`) with
|
||||
two adapters: `LogMailer` (localhost/dev and tests — the message lands in
|
||||
the app log / a test inbox) and `SmtpMailer` (PPE/Prod — relay coordinates
|
||||
@@ -647,10 +654,10 @@ management arrives.
|
||||
|
||||
| Entity | Owned by | Key fields | System of record |
|
||||
| --- | --- | --- | --- |
|
||||
| `account` | accounts | `id`, `email` (unique, normalized — INV-2), `created_at` | SQLite |
|
||||
| `auth_code` | accounts | `id`, `email`, `code_hash`, `expires_at`, `attempts`, `consumed_at`, `created_at` (INV-3) | SQLite |
|
||||
| `storefront` | storefronts | `id`, `name`, `created_at` | SQLite |
|
||||
| `storefront_membership` | storefronts | `account_id` (FK), `storefront_id` (FK), `role` (MVP: always `'owner'`), `created_at`; PK `(account_id, storefront_id)` | SQLite |
|
||||
| `account` | accounts | `id`, `email` (unique, normalized — INV-2), `created_at` | PostgreSQL |
|
||||
| `auth_code` | accounts | `id`, `email`, `code_hash`, `expires_at`, `attempts`, `consumed_at`, `created_at` (INV-3) | PostgreSQL |
|
||||
| `storefront` | storefronts | `id`, `name`, `created_at` | PostgreSQL |
|
||||
| `storefront_membership` | storefronts | `account_id` (FK), `storefront_id` (FK), `role` (MVP: always `'owner'`), `created_at`; PK `(account_id, storefront_id)` | PostgreSQL |
|
||||
|
||||
Notes:
|
||||
|
||||
@@ -712,7 +719,7 @@ sequenceDiagram
|
||||
participant ACC as accounts
|
||||
participant SF as storefronts
|
||||
participant M as mailer (port)
|
||||
participant DB as SQLite
|
||||
participant DB as PostgreSQL
|
||||
|
||||
V->>SPA: enter email, "Send code"
|
||||
SPA->>BFF: POST /api/auth/request-code {email}
|
||||
@@ -748,7 +755,7 @@ sequenceDiagram
|
||||
participant SPA as Admin SPA
|
||||
participant BFF as REST BFF
|
||||
participant SF as storefronts
|
||||
participant DB as SQLite
|
||||
participant DB as PostgreSQL
|
||||
|
||||
Mer->>SPA: name (optional), "Create storefront"
|
||||
SPA->>BFF: POST /api/storefronts {name?}
|
||||
@@ -787,11 +794,11 @@ Idempotent; no server state to destroy (sessions are cookie-borne, §6.2).
|
||||
sequenceDiagram
|
||||
participant Op as Operator / Developer
|
||||
participant App as ecomm process
|
||||
participant DB as SQLite (empty file or absent)
|
||||
participant DB as PostgreSQL (empty database)
|
||||
actor Mer as First merchant
|
||||
|
||||
Op->>App: start (dev script · flotilla deploy)
|
||||
App->>DB: open/create; apply pending migrations in order (INV-7)
|
||||
App->>DB: connect; apply pending migrations in order (INV-7)
|
||||
App-->>Op: /healthz 200 (migrations current)
|
||||
Mer->>App: sign up → create storefront → admin (the ordinary PUC-2/4/6 flows)
|
||||
Note over App,Mer: first rows created through the product alone (INV-1)
|
||||
@@ -799,14 +806,22 @@ sequenceDiagram
|
||||
|
||||
- **Implementation:** identical in all three environments; only configuration
|
||||
differs (INV-8): **localhost** — `scripts/dev.sh` from a clean checkout
|
||||
(venv + deps, backend on :8000, Vite on :5173 proxying `/api`); mailer is
|
||||
`LogMailer`, so the one-time code appears in the backend log (the "local
|
||||
dev channel" of PUC-10 — auth itself is not stubbed; the same issue/verify
|
||||
path runs). **PPE/Prod** — provisioned and deployed through
|
||||
flotilla/launch-app (operator gesture, handbook §8.5); mailer is
|
||||
`SmtpMailer` with relay coordinates and secrets resolved from Secret
|
||||
Manager via deployment config; migrations apply at startup under
|
||||
flotilla's fail-stop deploy, gated by `/healthz`. The bring-up runbook
|
||||
(documented prerequisite: Docker): it first brings up the dev datastore —
|
||||
a **single PostgreSQL container** (one-service Docker Compose, pinned to
|
||||
the Cloud SQL major version, named volume, healthcheck; the app itself
|
||||
stays native for the fast dev loop) — waits for healthy, then starts the
|
||||
backend (venv + deps, :8000) and Vite (:5173 proxying `/api`);
|
||||
`docker compose down -v` is the one-command reset-to-empty gesture for
|
||||
rehearsing the bootstrap. Mailer is `LogMailer`, so the one-time code
|
||||
appears in the backend log (the "local dev channel" of PUC-10 — auth
|
||||
itself is not stubbed; the same issue/verify path runs). **PPE/Prod** —
|
||||
provisioned and deployed through flotilla/launch-app (operator gesture,
|
||||
handbook §8.5); persistence is **Cloud SQL for PostgreSQL**, provisioned
|
||||
per environment as part of that same gesture, with the connection URL and
|
||||
credentials resolved from Secret Manager via deployment config (INV-8);
|
||||
mailer is `SmtpMailer` with relay coordinates and secrets resolved the
|
||||
same way; migrations apply at startup under flotilla's fail-stop deploy,
|
||||
gated by `/healthz`. The bring-up runbook
|
||||
(per-environment: prerequisites, the one gesture, how to watch `/healthz`)
|
||||
ships in-repo as `docs/BOOTSTRAP.md` — the documented gesture BUC-5
|
||||
demands.
|
||||
@@ -823,16 +838,17 @@ sequenceDiagram
|
||||
- Data classification: emails are personal data — the only personal data
|
||||
held (§6.3); code hashes are short-lived secrets; both excluded from
|
||||
logs in deployed environments.
|
||||
- Secrets are references, never bytes (handbook §6.3): session secret and
|
||||
SMTP credentials live in Secret Manager, named by deployment config
|
||||
(INV-8).
|
||||
- Secrets are references, never bytes (handbook §6.3): session secret,
|
||||
database credentials, and SMTP credentials live in Secret Manager, named
|
||||
by deployment config (INV-8).
|
||||
- **Performance & scale:** MVP traffic is near-zero; the bar is honesty, not
|
||||
throughput — interactive responses well under a second on the standard
|
||||
single-VM deployment. SQLite/WAL with per-request connections is far above
|
||||
this bar; revisit at real scale (§7.4 R-3).
|
||||
single-VM deployment. PostgreSQL with a small connection pool is far above
|
||||
this bar.
|
||||
- **Availability & resilience:** one VM, one process (standard flotilla
|
||||
stack); deploys are fail-stop with `/healthz` gating; brief deploy-window
|
||||
unavailability is accepted pre-v1.
|
||||
stack) against managed Cloud SQL (automated backups + point-in-time
|
||||
recovery); deploys are fail-stop with `/healthz` gating; brief
|
||||
deploy-window unavailability is accepted pre-v1.
|
||||
- **Observability:** structured request logs (no emails/codes in deployed
|
||||
logs); `/healthz` reporting migration currency; auth events (code issued /
|
||||
verify ok / verify failed) logged with hashed identifiers — enough to see
|
||||
@@ -847,7 +863,7 @@ sequenceDiagram
|
||||
| Decision | Chosen | Alternatives considered | Why chosen |
|
||||
| --- | --- | --- | --- |
|
||||
| Overall shape | Carry forward the prototype's 4-layer modular monolith + React SPA | Heavier re-architecture (Postgres, services, SSR); resume prototype code; new stack | Proven by R01–R07 against this exact domain; smallest bootstrap surface (§1.6 outcome 3); rebuild wants clean *code*, not unproven *shape* (§2) |
|
||||
| Datastore | SQLite (WAL), no ORM, forward-only `.sql` migrations | Postgres (managed or on-VM) | Single-VM flotilla standard; zero standing service to provision per environment — bootstrap stays one gesture; prototype precedent. Revisit at scale (R-3) |
|
||||
| Datastore | **PostgreSQL everywhere** (Cloud SQL in PPE/Prod; single pinned Docker container in dev/tests), psycopg, no ORM, forward-only `.sql` migrations | SQLite everywhere (prototype precedent, v0.1–v0.2 of this spec); SQLite-dev + Postgres-deployed bridge with dual-engine CI; SQLite + Litestream | ecomm's destination workload is transactional, money-bearing, multi-tenant — row locking (`FOR UPDATE`/`SKIP LOCKED`) arrives with the first inventory/checkout Feature, which SQLite cannot express; pre-data is the cheapest moment to swap (zero rows, ~10 queries); no ORM means dialect drift compounds per Feature, so the bridge was a planned demolition; Docker is already assumed by the standard local test tier, so dev parity costs one compose file (D-7) |
|
||||
| Tenancy | One shared DB; tenant rows carry `storefront_id` (INV-5); ownership via membership relation | DB-per-storefront; hard 1-1 FK on account | Membership keeps the many-per-account door open (Feature #1 constraint) and is the staff-model seam (13.15.\*); DB-per-tenant multiplies the bootstrap story for no MVP gain |
|
||||
| One-storefront rule | Service-layer guard + no UX affordance (INV-4) | UX-only; schema `UNIQUE(account_id)` | UX-only leaves the API dishonest (data could contradict the promise); schema-level closes the door Feature #1 says to keep open. The guard is one deletable check |
|
||||
| Auth mechanism | Email + one-time code, passwordless | Passwords (+ reset/verification machinery); OAuth (Google/Apple) first | Corpus's primary path (14.01.0003–0004); prototype-proven; collapses the account-lifecycle question — no passwords to reset, verification inherent. OAuth deferred, addable under INV-2 |
|
||||
@@ -863,7 +879,9 @@ sequenceDiagram
|
||||
The prototype's discipline, inherited:
|
||||
|
||||
- **Scenario-bound end-to-end tests** drive the app through its HTTP surface
|
||||
(FastAPI TestClient on a fresh temp DB). Where a corpus scenario covers the
|
||||
(FastAPI TestClient on a fresh per-test PostgreSQL database — created from
|
||||
a template against the dev container, so every test runs the engine Prod
|
||||
runs). Where a corpus scenario covers the
|
||||
behavior, the test name embeds its ID (`test_14_01_0003_email_code_sent`);
|
||||
PUCs without a corpus ID bind to the PUC (`test_puc_05_...`). One test per
|
||||
scenario/PUC, including every unhappy path in §4.
|
||||
@@ -878,7 +896,8 @@ The prototype's discipline, inherited:
|
||||
(§6.5) unit-tested; component/E2E browser tests deferred until there is UI
|
||||
beyond forms.
|
||||
- **Gate:** `scripts/check.sh` = `lint-imports` (layer contract, INV-6) +
|
||||
`pytest` + frontend typecheck/build — locally pre-merge and in CI. "Tested"
|
||||
`pytest` (against the compose Postgres locally; a Postgres service in CI)
|
||||
+ frontend typecheck/build — locally pre-merge and in CI. "Tested"
|
||||
for a slice (§7.2) means its scenarios are green in this gate.
|
||||
- **PPE tier:** the §7.2 SLICE-4 rehearsal *is* the deployed-tier test — the
|
||||
product flows walked against PPE with real mail before Prod exists.
|
||||
@@ -892,9 +911,9 @@ The prototype's discipline, inherited:
|
||||
- **Migration fails at startup** → process refuses to start (INV-7);
|
||||
flotilla's fail-stop deploy keeps the prior version serving; operator fixes
|
||||
forward (no down-migrations — MVP migrations are additive).
|
||||
- **DB file lost/corrupted** → restore from VM disk snapshot (the standard
|
||||
flotilla VM's recovery story); accepted pre-v1 bar — an explicit backup
|
||||
cadence is deferred and logged (§9 Q-2).
|
||||
- **Database loss/corruption** → Cloud SQL automated backups +
|
||||
point-in-time recovery (enabled as part of SLICE-4 provisioning; D-8). In
|
||||
dev, the named volume is disposable by design (`docker compose down -v`).
|
||||
- **Session-secret leak** → rotate the secret in Secret Manager + redeploy;
|
||||
all sessions invalidate at once (accepted blast radius at MVP scale,
|
||||
§6.2).
|
||||
@@ -927,10 +946,12 @@ Slice tasks live in each slice's plan, not here.
|
||||
|
||||
- **Depends on:** —
|
||||
- **Ships:** repo scaffold (backend 4-layer skeleton + import-linter
|
||||
contract, frontend Vite/React shell, `scripts/check.sh`, CI); `platform/db`
|
||||
with the migration runner (INV-7) and migration `0001` (the §6.3 schema);
|
||||
`/healthz`; `scripts/dev.sh`; first cut of `docs/BOOTSTRAP.md` (localhost
|
||||
section).
|
||||
contract, frontend Vite/React shell, `scripts/check.sh`, CI); the dev
|
||||
datastore compose file (single pinned PostgreSQL service, named volume);
|
||||
`platform/db` (psycopg + pool) with the migration runner (INV-7) and
|
||||
migration `0001` (the §6.3 schema); `/healthz`; `scripts/dev.sh` (owns the
|
||||
container lifecycle); first cut of `docs/BOOTSTRAP.md` (localhost section,
|
||||
Docker prerequisite).
|
||||
- **Definition of done:** clean checkout → `scripts/dev.sh` → app up on an
|
||||
empty, self-migrated DB; `/healthz` green; check gate green in CI; the
|
||||
bootstrap test skeleton runs migrate-from-empty + idempotent re-migrate
|
||||
@@ -962,8 +983,9 @@ Slice tasks live in each slice's plan, not here.
|
||||
|
||||
- **Depends on:** SLICE-3; **operator gestures:** PPE + Prod provisioning via
|
||||
launch-app/flotilla (handbook §8.5 — the session hands the operator the
|
||||
commands and waits), SMTP relay choice + credentials into Secret Manager
|
||||
(§9 Q-1).
|
||||
commands and waits), **Cloud SQL for PostgreSQL per environment** (with
|
||||
automated backups + PITR enabled, D-8) and its credentials into Secret
|
||||
Manager, SMTP relay choice + credentials into Secret Manager (§9 Q-1).
|
||||
- **Ships:** `SmtpMailer` + config wiring (INV-8), `deployment.toml`,
|
||||
deployed-environment hardening from §6.6 (Secure cookies, log hygiene),
|
||||
`docs/BOOTSTRAP.md` completed for PPE/Prod.
|
||||
@@ -984,7 +1006,8 @@ the bootstrap state; rollback is flotilla redeploy of the prior version
|
||||
| --- | --- | --- |
|
||||
| R-1: First-ever ecomm deploy — flotilla provisioning/deploy path unexercised for this app | M / H | Isolate in SLICE-4 with all product code already green; PPE before Prod; operator runs provisioning (handbook §8.5); wiggle-snip's deployment.toml as template |
|
||||
| R-2: OTC email deliverability (spam-foldering, relay limits) kills real sign-ups | M / H | Relay is deployment config (swap without code, INV-8); rehearsal on PPE with real mailboxes before Prod; honest `delivery_failed` surfacing (INV-9) makes failures visible, not silent |
|
||||
| R-3: SQLite write concurrency at real scale | L (MVP) / M | WAL + short transactions now; INV-5/INV-6 keep the storage seam clean for a Postgres move; revisit at the first scale-bearing Feature |
|
||||
| R-3: ~~SQLite write concurrency at real scale~~ | — | Retired v0.3.0 — superseded by D-7 (PostgreSQL everywhere); the risk no longer exists |
|
||||
| R-6: Docker-in-dev prerequisite adds bring-up friction (PUC-10 is no longer zero-dependency) | L / L | `scripts/dev.sh` owns the container lifecycle end to end; Docker is already required by the standard local test tier; `docs/BOOTSTRAP.md` names it as the one prerequisite |
|
||||
| R-4: Scope creep into admin features ("just one settings field…") | M / M | PUC-8's honestly-empty rule + §1.7 scope; anything beyond the shell is a new Feature |
|
||||
| R-5: The 1-1 storefront rule calcifies into hidden assumptions | L / M | INV-4 names the single deletable guard; membership schema + tests assert the many-capable shape stays intact |
|
||||
|
||||
@@ -1009,19 +1032,20 @@ PUC-2a/b (failed proof ≠ admission).
|
||||
| # | Question | Owner | Blocks |
|
||||
| --- | --- | --- | --- |
|
||||
| Q-1 | Which SMTP relay/provider serves PPE and Prod? (Deployment-shape choice — config + Secret Manager entry, not code; the app speaks SMTP either way.) | Operator (Ben) | SLICE-4 deploy config only |
|
||||
| Q-2 | Backup cadence for the Prod SQLite file beyond VM disk snapshots (and when does it graduate from "accepted pre-v1 bar", §6.9)? | Operator (Ben) | nothing in this Feature; revisit before real merchant data accrues |
|
||||
| Q-3 | Account deletion / data export (OHM: Exit & Portability) — deliberately deferred from the MVP's honest bar; which Feature picks it up? | ecomm team | nothing here; must be captured as a Feature before public launch |
|
||||
|
||||
**Resolved**
|
||||
|
||||
| # | Decision | Resolution | Date |
|
||||
| --- | --- | --- | --- |
|
||||
| D-1 | Architecture for the clean rebuild | Carry forward the prototype's proven shape (4-layer monolith, SQLite/WAL, React SPA, REST BFF), invert seed + admission gate, defer GraphQL/OAuth — §2, §6.7 | 2026-06-10 |
|
||||
| D-1 | Architecture for the clean rebuild | Carry forward the prototype's proven shape (4-layer monolith, React SPA, REST BFF; datastore engine since revised by D-7), invert seed + admission gate, defer GraphQL/OAuth — §2, §6.7 | 2026-06-10 |
|
||||
| D-2 | One vs many storefronts per account | UX + service-layer 1-1 (INV-4) over a many-capable membership schema; conscious divergence from corpus 14.01.0012/0014, per Feature #1 | 2026-06-10 |
|
||||
| D-3 | Auth bar for the MVP | Passwordless email + OTC; no passwords ⇒ no reset flow; OTC ⇒ verification inherent; OAuth/magic-link deferred under the email-canonical rule (INV-2) | 2026-06-10 |
|
||||
| D-4 | Bootstrap mechanism | No seed anywhere; empty persistence is a working state (INV-1); migrations self-apply at startup (INV-7) | 2026-06-10 |
|
||||
| D-5 | Canonical noun | "Storefront" (charter + Feature #1), with corpus "store" mapped in §10 | 2026-06-10 |
|
||||
| D-6 | Corpus first-run steps not adopted | Onboarding questionnaire / POS opt-in (14.01.0017–0024) and multi-store selection (14.01.0012/0014) are out; merchant lands straight on the admin | 2026-06-10 |
|
||||
| D-7 | Datastore engine (revisits D-1's SQLite) | **PostgreSQL in every environment**: Cloud SQL (PPE/Prod), single pinned Docker container (dev/tests; app stays native — the container is the datastore only). Chosen pre-data over SQLite-everywhere and over a SQLite-dev/Postgres-deployed bridge — see §6.7 | 2026-06-10 |
|
||||
| D-8 | Q-2 (Prod backup cadence) | Resolved by D-7: Cloud SQL automated backups + point-in-time recovery, enabled at SLICE-4 provisioning | 2026-06-10 |
|
||||
|
||||
## 10. Glossary & References
|
||||
|
||||
|
||||
Reference in New Issue
Block a user