Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
491 changes: 182 additions & 309 deletions DEPLOYMENT.md

Large diffs are not rendered by default.

90 changes: 39 additions & 51 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,22 +8,25 @@ Open source (MIT), version 0.0.1. The WooCommerce-equivalent for

![The Otta storefront: a product listing with three sample products, each showing generated coil artwork, a title, a description, a price, and whether it is in stock — the first is sold out, its price struck through](./docs/storefront.png)

<sub>The reference storefront running locally, with prices and stock served by the commerce
service — this is what the [quick start](#quick-start-local-2-minutes) below gives you.</sub>
<sub>The reference storefront running locally, with prices and stock served in-process by the
Otta plugin — this is what the [quick start](#quick-start-local-2-minutes) below gives you.</sub>

## What this is

Otta turns an EmDash site into a store. It ships as three parts:

1. **Otta plugin** — a sandbox-clean EmDash plugin: storefront routes, content-sync
hooks, cart/checkout orchestration, an admin console (pricing & inventory, orders,
reports, settings), and x402 gating for digital goods. Talks to the commerce service
over HTTP only (`network:request` + `allowedHosts`). The CMS owns content; every
commercial field lives in the commerce service and is edited in the admin console.
2. **Otta commerce service** — a standalone Node/Hono + Postgres service that owns all
money and stock truth: catalog, inventory, cart, checkout, orders, customers,
payments, tax, shipping, discounts, entitlements, reporting, and webhooks.
3. **The reference site** (`sites/staging`) — a default EmDash site with the plugin already
Otta turns an EmDash site into a store. It is **one deployable**, and it ships as two parts:

1. **Otta plugin** — a sandbox-clean EmDash plugin that owns all money and stock truth
**in-process**: catalog, inventory, cart, checkout, orders, customers, payments, tax,
shipping, discounts, entitlements, reporting, and webhooks, plus storefront routes,
content-sync hooks and an admin console (pricing & inventory, orders, reports,
settings) and x402 gating for digital goods. Commerce state lives in the host's
per-plugin document store (`ctx.storage`) via the `@otta-sh/store-emdash` adapter — no
separate service, no second database. Its only outbound egress is `ctx.http.fetch`,
gated by `network:request` + `allowedHosts`. The CMS owns content; every commercial
field lives in the plugin's store and is edited in the admin console
([ADR-0018](./adr/0018-plugin-owns-commerce-truth-in-process.md),
[ADR-0020](./adr/0020-one-deployable-plugin-owns-commerce-truth.md)).
2. **The reference site** (`sites/staging`) — a default EmDash site with the plugin already
registered, so there's something to actually run. It's the storefront in the screenshot
above and what the [quick start](#quick-start-local-2-minutes) boots: product listing
pages, cart, and the admin console. Treat it as the worked example to copy from when
Expand Down Expand Up @@ -59,8 +62,7 @@ commerce fields it does not touch — so give them some:
# 2. Price, stock and activate the demo products (second terminal).
# It reads the products' real ids from the CMS (matching the seed's slugs),
# then prices and stocks each one through the SITE's own admin API — the same
# route the Pricing & inventory page uses, so it needs no service URL and no
# service token of its own.
# route the Pricing & inventory page uses, so the site URL is all it needs.
SITE_URL=http://localhost:4321 \
pnpm dlx tsx@4 sites/staging/scripts/seed-demo-commerce.ts
```
Expand All @@ -73,56 +75,42 @@ description and images.
One thing to know: this storefront covers **catalog + cart only** — see [Status](#status).

To deploy this for free on Cloudflare Workers, follow
[`DEPLOYMENT.md`](./DEPLOYMENT.md) §3.

## Why two parts

**EmDash's plugin sandbox has no atomic write, compare-and-set, or transaction.** Plugins
get no direct database access — everything crosses a capability-scoped RPC bridge as JSON
copies — and `ctx.storage` is an unconditional upsert whose declared unique indexes are
silently downgraded. Any read-then-write spans two bridge calls and can interleave, so the
sandbox can't express a guarded update, a uniqueness constraint, or a multi-document
commit. Those are the ordinary building blocks of an order pipeline, so for now the
transactional database sits off to the side, in the commerce service.

The gap is closing. [emdash-cms/emdash#2169](https://github.com/emdash-cms/emdash/pull/2169)
(ours, currently a draft) adds `ctx.storage.<collection>.updateIf(id, { where, set?,
delta? })` — a guarded `UPDATE … RETURNING` run inside the sandbox. Two sibling primitives,
not yet proposed upstream, cover the rest: an atomic `insert` that classifies unique
violations, and `ctx.storage.batch([...])` for all-or-nothing multi-collection writes.
With all three, a `@otta-sh/store-emdash` adapter already passes the domain's full
`InventoryStore` contract in-process. Once orders, payments, webhooks, and reporting
follow, the split becomes a deployment choice rather than a correctness requirement.
[`DEPLOYMENT.md`](./DEPLOYMENT.md) §2.

## Architecture (summary)

- **Product model = hybrid.** Content (title, description, images, SEO, taxonomies)
lives in a native EmDash `products` collection; commercial data (price, SKU, stock,
tax, shipping) lives in the commerce service. Link key = the CMS content `id`.
- **Separate databases.** Commerce Postgres is independent of the EmDash content DB
(independent scaling; no cross-DB joins — joined in app code at render time).
- **Ports and adapters.** `@otta-sh/domain` is pure (no IO); every store is a Kysely
adapter dialect-parameterized over better-sqlite3 (dev) and Postgres (CI/prod). The
REST API in `@otta-sh/service` mirrors the domain ports 1:1, and the same client-side
contract suite runs over the wire so the HTTP format can't drift from the port.
tax, shipping) lives in the plugin's own document store. Link key = the CMS content `id`.
- **One database.** Commerce truth and CMS content share the site's single D1 database:
content lives in the CMS's own tables, commerce lives in the host's per-plugin document
store (`ctx.storage`), namespaced by plugin id and collection. They are not joined in
SQL — the hybrid product model is joined in app code at render time.
- **Ports and adapters.** `@otta-sh/domain` is pure (no IO); the stores that implement its
ports live in `@otta-sh/store-emdash`, which writes one document per aggregate to the
host's per-plugin document store (`ctx.storage`) by compare-and-set — D1 in dev and in
production, with a dialect harness that runs the same adapters against SQLite and
Postgres in CI. The plugin composes those stores in-process, and the domain's contract
suites are the spec they are held to ([ADR-0019](./adr/0019-commerce-aggregates-are-one-document-each.md)).
- **Pluggable payments.** Stripe (async webhook) and x402 (HTTP-402 at the page layer)
behind one `PaymentGateway` interface.
- **Deployment.** Runs on Cloudflare Workers via Hyperdrive over Neon Postgres, with
cron sweeps for cart/reservation expiry. First-party sites may register the plugin
trusted (in-process) to stay on the Workers free plan — the plugin still passes the
full workerd sandbox suite on every CI run, which is the binding contract (ADR-0006).
- **Deployment.** One Worker and one D1 database: the EmDash site with the plugin
registered trusted (in-process), on the Cloudflare Workers **free** plan, with cron
sweeps for cart/reservation expiry. The plugin still passes the full workerd sandbox
suite on every CI run, which is the binding contract (ADR-0006).
Step-by-step bootstrap guide: [`DEPLOYMENT.md`](./DEPLOYMENT.md).

## Repository layout

| Package | What it is |
|---|---|
| `@otta-sh/domain` | Pure ports, use-cases, branded money types, contract-test suites. No IO. |
| `@otta-sh/service` | Thin Hono REST API + Cloudflare Worker entry mirroring the domain ports. |
| `@otta-sh/store-postgres` | Kysely store adapters (better-sqlite3 local, `pg` CI/prod) + forward-only migrations. |
| `@otta-sh/store-emdash` | Store adapters over the host's per-plugin document store — one document per aggregate, compare-and-set writes. |
| `@otta-sh/payments-stripe` | Stripe `PaymentGateway` adapter (async-webhook, raw-body HMAC). |
| `@otta-sh/payments-x402` | x402 `PaymentGateway` adapter (synchronous page-gate, facilitator-verified). |
| `@otta-sh/plugin` | The EmDash plugin: storefront routes, admin console, content-sync hooks. |
| `@otta-sh/plugin` | The EmDash plugin: commerce composition, storefront routes, admin console, content-sync hooks. |
| `@otta-sh/admin-presentation` | Pure admin presentation primitives (money, dates, short ids, status vocabulary) shared by both console surfaces. No IO. |
| `@otta-sh/admin-react` | The React admin console on the `otta-console` native descriptor (ADR-0014) — Orders and Pricing & inventory. |
| `sites/staging` | Staging storefront + admin — EmDash on Cloudflare Workers, plugin registered trusted. |

Design decisions live in [`adr/`](./adr/); development practices in
Expand All @@ -149,7 +137,7 @@ process, so it verifies the SQL is correct, not that it's race-safe under conten
**v0.0.1** — first open-source release. The `@otta-sh/*` packages are all at `0.0.1` and are
not published to npm yet; consume them from the workspace.

The commerce **service** is feature-complete (Phases 0–7 merged): catalog, inventory,
The commerce **layer** is feature-complete (Phases 0–7 merged): catalog, inventory,
cart, checkout, orders, customers with magic-link auth, Stripe + x402 payments, tax,
shipping, discounts, entitlements, reporting, and settings.

Expand All @@ -158,7 +146,7 @@ only**. The checkout / payment / download pages
([#27](https://github.com/UrumiAI/otta.sh/issues/27)) and the customer account pages are
not built yet — so today you get a browsable catalog and carts with real inventory
holds, but completing a purchase end-to-end means building those pages or driving the
service API directly.
plugin's own commerce routes directly.

## License

Expand Down
13 changes: 12 additions & 1 deletion adr/0002-adapter-based-split.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,19 @@
# 0002. Adapter-based split: the plugin↔authority boundary is a deployment choice

- Status: accepted
- Status: accepted, **partially superseded** 2026-09-20 by
[ADR-0020](./0020-one-deployable-plugin-owns-commerce-truth.md) — the **plugin/service split
only**: the separate commerce service is removed, Otta is one deployable, and the five "a
service may remain preferable" reasons in the Context below are answered there as rejected
(pre-launch, no users). The **ports-and-adapters discipline this record established is not
superseded — it is reaffirmed**, and it is what made the deletion safe. Read the Decision's
five numbered clauses as standing; read every sentence promising a service as historical.
- Date: 2026-07-10
- Refines: ADR-0001 (does not supersede)
- Refined by: [ADR-0018](./0018-plugin-owns-commerce-truth-in-process.md) (the plugin may own
commerce truth in-process) and
[ADR-0019](./0019-commerce-aggregates-are-one-document-each.md) (the document model that
spends the storage seam, and that answers and reverses this record's single-statement
conditional-`UPDATE` premise)

## Context

Expand Down
13 changes: 9 additions & 4 deletions adr/0018-plugin-owns-commerce-truth-in-process.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,17 @@
duration of the vendored host build only**, described below.
- Refines: [ADR-0002](./0002-adapter-based-split.md) — the ports-and-adapters seams it designed
are what make this possible, and nothing about them changes. This record does **not** answer
ADR-0002's five "a service may remain" reasons and does not retire the service: that is
**ADR-0020**'s job, and until it is written those reasons stand as written.
ADR-0002's five "a service may remain" reasons and does not itself retire the service: that
was [ADR-0020](./0020-one-deployable-plugin-owns-commerce-truth.md)'s job, and it has since
been written — it answers all five as **rejected** (pre-launch, with no users) and removes
the service. Where this record leaves those reasons standing, read that as true of its own
date; ADR-0020 is where they were settled.
- Relates to: [ADR-0013](./0013-product-title-is-cms-owned.md) (unchanged by this record; see
the closing note)
- Forward references: **ADR-0019** (the storage document model for commerce aggregates) and
**ADR-0020** (one deployable) — both **to be written**.
- Forward references, **both since written**:
[ADR-0019](./0019-commerce-aggregates-are-one-document-each.md) (the storage document model
for commerce aggregates) and
[ADR-0020](./0020-one-deployable-plugin-owns-commerce-truth.md) (one deployable).

## Context

Expand Down
4 changes: 3 additions & 1 deletion adr/0019-commerce-aggregates-are-one-document-each.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@
respected: a title still has one home, and the commerce document still only caches it.
[ADR-0017](./0017-list-refresh-semantics.md) — unchanged; this record narrows what a list can
*search*, not how it refreshes.
- Forward reference: **ADR-0020** (one deployable — the commerce service is removed), to be written.
- Forward reference, **since written**: [ADR-0020](./0020-one-deployable-plugin-owns-commerce-truth.md)
(one deployable — the commerce service is removed, and ADR-0002's five "a service may remain" reasons
are answered there as rejected).
- Amends: **nothing.**

## Context
Expand Down
Loading
Loading