RuleStack

Configs

Stacks

Compare

Diff

RuleStack

Configs

Stacks

Compare

Diff

Read API

RuleStack

Configs

Stacks

Compare

Diff

Read API

Configs/CLAUDE.md/spree/spree

CLAUDE.md

CLAUDE.md
CLAUDE.mdroot

Quality

84/100

Scores the file, not the repository.

Length

6,714 words

47 headings · 24 code blocks

Repository

16k

— · pushed 0 days ago

Last changed

3 days ago

First indexed 3 days ago.
spree/spree/CLAUDE.mdRawGitHub
1# Spree Commerce — Development Rules
2 
3## Plans & Architecture Decisions
4 
5All feature plans live in `docs/plans/` using the template at `docs/plans/_template.md`. Never create plans elsewhere.
6 
7When proposing significant architectural changes:
81. Check existing plans in `docs/plans/` for conflicts
92. Create or update a plan using the template before implementing
103. Pay special attention to "Constraints on Current Work" sections — these apply even when you're not implementing that plan directly
11 
12Use `/project:create-plan` and `/project:update-plan` for plan management.
13 
14Active plans (6.0 target, work pending):
15- `6.0-multi-vendor-marketplace.md` — Open-source the marketplace core (Vendor, OrderGroup-based order splitting, commission engine with EU commission taxation, `VendorTransfer`/`VendorPayout` ledger + pluggable `PayoutProvider`) per spree/spree#13323. 6.0 headline feature; rebuilds the legacy Enterprise multi-vendor module as native models on the Cart/Order split. Basic Stripe Connect payouts (Express onboarding + on-fulfillment transfers) ship OSS in the monorepo, alongside the Stripe core gateway pulled in from the standalone `spree_stripe` repo (payment-sessions classes only, likely `spree/core` — decisions.md 2026-07-15); Enterprise keeps refund clawbacks/netting, reconciliation, KYC ops, DAC7 payout reports, facilitator taxes + Shopify/WooCommerce vendor apps.
16- `6.0-cart-order-split.md` — Cart/Order model separation, dual-FK owner everywhere (`cart_id`/`order_id` exactly-one, `#owner` as method — NOT polymorphic), idempotent copy-on-completion (`Order#cart_id` unique, order-before-payment), Order state machine removed (Checkout::Requirements for steps; payment/fulfillment statuses derived-then-persisted via one recompute service). Expanded 2026-07-27; gates split-adjustments, fulfillment, multi-vendor
17- `6.0-admin-api.md` — Admin REST API conventions, auth, endpoint list (~300 endpoints)
18- `6.0-admin-spa.md` — React admin architecture, extension points, table registry, i18n + server-error mapping
19- `6.0-product-types.md` — Prototype → ProductType rename, MetafieldDefinition schema enforcement
20- `6.0-remove-master-variant.md` — Eliminate is_master, add default_variant_id FK on Product
21- `6.0-split-adjustments.md` — Replace polymorphic Adjustment with TaxLine, Discount, Fee
22- `6.0-typed-stock-movements.md` — Replace generic StockMovement with typed kinds + concrete FKs
23- `6.0-normalize-state-to-status.md` — Rename state → status on Payment, Shipment, InventoryUnit, ReturnAuthorization, GiftCard
24- `6.0-fulfillment-and-delivery.md` — Shipment→Fulfillment, ShippingMethod→DeliveryMethod, drop ShippingCategory, FulfillmentProvider strategy, pickup (merchant StockLocation) + pickup_point (third-party PickupPointProvider)
25- `6.0-returns-exchanges-claims.md` — First-class Return, Exchange, Claim models replacing ReturnAuthorization/Reimbursement chain
26- `6.0-platform-auth.md` — Drop Devise, own auth stack, User→Customer/Staff rename (RefreshToken shipped in 5.4)
27- `6.0-tax-provider.md` — Per-Market TaxProvider, replaces TaxRate.adjust + Calculator; TaxRate gets direct country/state FKs (tax decoupled from Zone; Zone model dropped in 6.0 via `6.0-delivery-zones.md`)
28- `6.0-delivery-rate-provider.md` — Per-DeliveryMethod DeliveryRateProvider wrapping Stock::Estimator (calculators stay — 2026-07-27 reversal), `store_id` on ShippingMethod, enriched DeliveryRate (carrier/service/date/metadata)
29- `6.0-delivery-zones.md` — Zone → DeliveryZone with postal-code-range/prefix members (country-scoped); owns the full ~183-reference Zone consumer inventory; `Spree::Zone` dropped entirely by end of 6.0 (2026-07-27 — 6.0 is the breaking-change window)
30- `6.0-rich-text-descriptions.md` — Drop ActionText **entirely** at 6.0 (incl. CustomFields::RichText values + Order/User internal notes; gem dependency removed), store sanitized HTML in text columns, serve `field` + `field_html`, **write via `field_html`** (read/write symmetry). description_html serializer shipped 5.4; sanitizer shipped in **5.6.2** with a permissive-but-safe configurable allowlist + `sanitize_rich_text` step in the 5.5→5.6 upgrade manifest (decisions.md 2026-07-27/28), tightened to the Tiptap set at 6.0
31- `6.0-inventory-operations.md` — StockTransfer lifecycle (draft → ready_to_ship → in_transit → received with partial receive), new `Spree::PurchaseOrder` + `Spree::Supplier` (renamed from Vendor — `Spree::Vendor` is the marketplace seller, see decisions.md 2026-07-14) replacing today's "external receive" hack, variant + stock-location stock history panels. Consumes the typed-movement primitives from `6.0-typed-stock-movements.md`.
32- `6.0-replace-taxons-with-categories.md` — Split Taxon into Category (hierarchy) + Collection (flat/rule-based). `Spree::Category < Spree::Taxon` alias + Category API surface shipped in 5.5; table rename + Collection pending.
33- `6.0-store-scoped-custom-field-definitions.md` — Add `store_id` + persisted `filter_key` to `Spree::MetafieldDefinition` (today global and computed), making uniqueness `(store_id, resource_type, filter_key)` with a DB index. Deferred from the 5.6 custom-field search/sort/filter work (schema change, not patch-safe). **Until then:** `filter_key` is a computed method — no Ransack predicates or `where`/`order` against it; definitions are global; uniqueness rests on a `CONCAT` validation with no index behind it.
34 
35Multi-version plans (some phases shipped, some pending):
36- `5.4-store-api-naming-standardization.md` — Standardize API naming against industry (address fields, discounts, customer_note, label, brand/last4, etc.). 5.4 model/API aliases shipped; 6.0 column/table renames pending.
37- `5.4-6.0-eu-legal-compliance.md` — GDPR (data export/anonymization, consent timestamps), Omnibus (PriceHistory, lowest-in-30-days), Consumer Rights (withdrawal period). 5.4 PriceHistory + `prior_price` shipped; GDPR endpoints + withdrawal period still pending.
38- `5.4-6.0-custom-fields-rename.md` — Rename Metafields → Custom Fields. 5.4 API bridge + 5.5 `Spree::CustomField`/`CustomFieldDefinition` constant aliases shipped; 6.0 model/table rename pending.
39- `5.4-6.0-product-media-system.md` — Product-level media gallery. 5.5 data model (spree_variant_media, media_type, focal_point, external_video_url) shipped; admin UIs in progress; 6.0 cleanup pending.
40- `5.5-6.0-order-cancellation-and-approval.md` — First-class `OrderCancellation` + `OrderApproval` models. 5.5 models + migrations shipped; 6.0 drops denormalized columns.
41- `5.5-6.0-display-on-to-boolean.md` — Collapse `display_on` tri-state to a single `storefront_visible` boolean. 5.5 bridge (`storefront_visible` accessor + Ransacker on `Spree::DisplayOn`) shipped; 6.0 schema rename pending.
42- `6.0-order-routing.md` — Two-tier extension: pluggable `Spree::OrderRouting::Strategy::Base` + STI subclasses of `Spree::OrderRoutingRule`. Phase 1 (5.5) shipped: `Channel`, `OrderRoutingRule`, strategy base + Rules + Reducer + Legacy, `preferred_stock_location_id` + `channel_id` on Order. Phase 2+ (6.0) layers Catalog/Company on top via `6.0-channels-catalogs-b2b.md`.
43- `6.0-channels-catalogs-b2b.md` — Channel + ProductPublication (replaces StoreProduct) + single-owner Product (`belongs_to :store`) + Publishing card (legacy admin + SPA) + `Channel#default` boolean shipped in 5.5. Channel-level gated storefront access (`storefront_access` enum + channel-owned `guest_checkout`, both with store fallback, enforced in the v3 Store API) targeted for 5.6. Catalog, Company/CompanyLocation/CompanyContact for B2B **deferred to 6.1** (the B2B release — see `docs/plans/decisions.md` 2026-06-16; filename keeps `6.0-` prefix for stable cross-refs). Multi-store catalogs (historic `Product has_many :stores`) move to the `spree_multi_store` extension.
44- `5.6-6.0-single-store-promotions-payment-methods.md` — Migrate `Spree::Promotion` + `Spree::PaymentMethod` from multi-store (`has_many :stores` via `spree_promotions_stores` / `spree_payment_methods_stores` join tables) to single-owner `belongs_to :store`, mirroring the 5.5 single-owner Product migration. 5.6 (implemented): `store_id` FK + required-store presence via `Spree::SingleStoreResource`, backfill rake task (loud per-record deprecation on shared records), shared `LegacyMultiStoreSupport` deprecation bridge, deletes the `ResourceController` `store_ids=` seam, deprecates `StoreScopedResource`; multi-store sharing moves to the `spree_multi_store` extension (join tables left intact). 6.0 cleanup: enforce `null: false`, drop join tables + bridges. Paired with `6.0-channels-catalogs-b2b.md`.
45- `5.6-project-layout-and-dashboard.md` — React Dashboard Developer Preview packaging + `backend/` → `api/` project layout. Implemented: `<Dashboard />` shell export from `@spree/dashboard` (source-only, relative imports only), monorepo-canonical `packages/dashboard-starter` thin host (embedded standalone into the `@spree/cli` tarball at build time via `scripts/sync-dashboard-starter.mjs` — no template repo; create-spree-app delegates to the project-local `spree add dashboard`), `spree add dashboard` + create-spree-app dashboard phase (opt-in via `--react-dashboard` while WIP — not prompted; env carries only `VITE_API_PROXY_TARGET` — never secret keys, and never `VITE_SPREE_API_URL`, which would flip the SDK to absolute cross-origin URLs and break dev on CORS), npm release job for `@spree/dashboard{,-ui,-core}` (0.x → `next` tag). Pending: layout rename + `detectApiDir` dual-layout CLI, `spree upgrade layout`; optional public template repo at 6.0 GA.
46- `5.6-dashboard-typed-plugin-routes.md` — Plugin file routes compiled into the host's TanStack route tree: `spree.dashboard.routes` marker + virtual-route-config composition in `@spree/dashboard/vite`, `createDashboardRouter` + `<Dashboard router>` ownership inversion, typed cast-free links, cross-package collision pre-flight with package-named errors. Runtime route registry stays for dynamic/in-app cases (catch-all is lowest priority). Implemented; published-tarball spike passed.
47 
48Pending design work (drafts, no implementation yet):
49- `5.7-payment-method-rules.md` — `Spree::PaymentMethodRule` STI on PaymentMethod (Channel / Market / OrderTotal / CustomerGroup rules), mirroring the PromotionRule/PriceRule/OrderRoutingRule pattern. Enforced solely through `Order#collect_frontend_payment_methods` (listing + `Payments::Create` + payment sessions all flow through it); admin/backoffice bypasses; no rules = available everywhere. Dashboard-only management (nested Admin API CRUD + `/payment_method_rules/types` discovery). Supersedes the "no distribution concept" rationale in `5.6-6.0-single-store-promotions-payment-methods.md`; per-channel provider credentials (multiple Stripe accounts, multi-entity setups) explicitly deferred for grooming — see decisions.md 2026-07-23.
50- `5.7-channel-markets.md` — Optional Channel→Markets allowlist (`spree_channel_markets` join, empty = all markets). Enforced in market resolution (`set_market_from_country` + channel-aware `Spree::Current.market` fallback), channel-filtered Store API `/store/markets`, and order-level `market must be served by channel` validation. Composes with `MarketRule` from the payment-method-rules plan.
51- `5.6-admin-spa-csv-import.md` — Universal dashboard CSV import over the existing `Spree::Import` pipeline (implemented). Admin API v3 surface (create via direct-upload signed blob, `complete_mapping`, `retry_failed_rows`, nested failed-rows index, write-scope gating), `client.imports` SDK resource, dashboard-core `ImportButton` (per-context `<Can>` gating, upload Sheet) + full-window wizard dialog driven by an `?import=` search param, with history under `/settings/imports` (new `audit` settings-nav group). Status via API polling — explicitly no ActionCable/Turbo Streams in the SPA; legacy per-row live feed replaced by polled counters + paginated failed-rows table.
52- `5.5-6.0-resource-translations-api.md` — Admin API v3 translation management + React dashboard for all `Spree.translatable_resources`. Hybrid: embedded `translations` object on resource update + generic dedicated `…/:id/translations` endpoint (one registry-driven controller), self-describing field discovery, advisory server-side staleness. Canonical `{ locale → { field → value } }` shape (consistent with metafield-translations). Cross-record bulk = CSV import/export generalized across the registry (NOT a JSON bulk endpoint — no competitor ships one). Phase 1 (5.5) API; Phase 2 (6.0) coverage read + CSV generalization + staleness + centralized SPA page; Phase 3 folds in metafields.
53- `5.4-centralized-translations-admin.md` — Centralized Translations admin page under Products, overview grid + bulk CSV import/export
54- `5.4-metafield-translations.md` — Translate MetafieldDefinition names + Metafield text values (ShortText, LongText, RichText) via Mobility translation tables
55- `5.5-admin-api-cli.md` — `spree api` command group in `@spree/cli` (gh-api-style generic verbs + schema introspection + layered auth, CLI-first ahead of MCP servers; core patch: `SCOPES` on `spree:cli:create_api_key`, promotions scopes)
56 
57Shipped plans:
58- `5.4-store-api-bridges.md` — Bridge 6.0 naming into 5.4 Store API (PR #13782)
59- `5.4-spree-starter-and-create-spree-app.md` — Replace monorepo server/ with spree-starter template repo
60- `5.4-option-type-enhancements.md` — `kind` (dropdown/color_swatch/buttons) on OptionType + `color_code` on OptionValue
61- `5.4-search-provider.md` — Pluggable SearchProvider interface (Database + Meilisearch); PgSearch + `add_search_scope` removed (6.0 MetafieldDefinition faceting still pending)
62- `5.4-disjunctive-option-faceting.md` — Per-option-type filter params with disjunctive facet counts (`FiltersAggregator` for DB, `merge_disjunctive_facets` for Meilisearch)
63- `6.0-stock-reservations.md` — Time-limited stock reservations during checkout (PR #13978; Cart/Order split integration + `allocated_count` term still pending for 6.0)
64- `5.5-admin-api-key-scopes.md` — granular `read_*`/`write_*` scopes on `Spree::ApiKey` for app authorization
65- `5.5-admin-auth-cookie-refresh.md` — Admin SPA refresh token in httpOnly cookie, access token in memory, server-side logout
66- `5.5-admin-customers-api.md` — Admin Customers + nested addresses/credit_cards/store_credits + CustomerGroups
67- `5.5-admin-spa-csv-export.md` — Admin API ExportsController + admin-sdk + `useExport` + toolbar export button
68- `5.5-agent-skills.md` — `spree/agent-skills` standalone repo: 25 Claude Code skills + `spree-expert` subagent + safety hooks, distributed via `npx skills add spree/agent-skills`
69 
70## Monorepo Structure
71 
72| Directory | Description |
73|---|---|
74| `spree/core` | Ruby gem — models, services, business logic (`spree_core`) |
75| `spree/api` | Ruby gem — Store & Admin REST APIs (`spree_api`) |
76| `spree/emails` | Ruby gem — transactional emails (optional). Rebuilt + modernized in 5.6. The default email stack for installations without a storefront app (e.g. mobile apps); headless storefronts may instead own consumer emails via webhooks. |
77| `spree/dashboard` | Ruby gem (`spree_dashboard`, optional) — hosts a built React Dashboard at `/dashboard` from `Spree::Dashboard.dist_path` / `SPREE_DASHBOARD_DIST_PATH` (single-node topology). Successor slot to `spree_admin` at 6.0. |
78| `packages/dashboard` | `@spree/dashboard` — React SPA admin dashboard (Spree 6.0, replaces `spree/admin`). The deployable app shell, routes, schemas, resource hooks, locales. |
79| `packages/dashboard-ui` | `@spree/dashboard-ui` — design system. Shadcn primitives + headless composed components + tokens. Source-only; consumer compiles via Vite/Tailwind. **Components are headless: data comes via props, no provider/hook imports.** |
80| `packages/dashboard-core` | `@spree/dashboard-core` — framework. Registries (table, nav, slot, settings-nav), providers (auth, permission, store, theme), generic infra hooks, admin SDK client singleton, `defineDashboardPlugin` facade. The extension API for plugin authors. |
81| `packages/dashboard-starter` | `@spree/dashboard-starter` — thin host app consuming `<Dashboard />` from `@spree/dashboard`; canonical source of the `spree/dashboard-starter` template repo (synced on release). Doubles as the in-repo consumer test for the plugin pipeline. |
82| `packages/sdk` | `@spree/sdk` — TypeScript Store API client |
83| `packages/admin-sdk` | `@spree/admin-sdk` — TypeScript Admin API client (Developer Preview) |
84| `packages/sdk-core` | `@spree/sdk-core` — shared HTTP/retry/error layer (private internal) |
85| `packages/cli` | `@spree/cli` — Docker-based project management CLI |
86| `packages/create-spree-app` | `create-spree-app` — project scaffolding |
87| `server/` | Rails app cloned from `spree/spree-starter` (.gitignored, run `pnpm server:setup`) |
88 
89## Development Server (`server/`)
90 
91One-time bootstrap (Docker required, no host Ruby): `pnpm install && pnpm server:setup`. It clones spree-starter into `server/`, boots the edge stack (monorepo gems bind-mounted via a compose overlay), and prepares + seeds the DB. Idempotent — but re-running it is a **full reset** that wipes the DB and volumes.
92 
93Day-to-day from the repo root: `pnpm server:dev` (foreground — streams web logs; jobs run in-process via Solid Queue; Ctrl+C stops it, postgres stays warm) / `server:stop` (full teardown) / `server:restart` / `server:logs` / `server:console` / `server:seed` / `server:load_sample_data`. CLI commands run from `server/`: `pnpm exec spree <cmd>` (`spree migrate`, `spree console`, `spree generate model …`). `spree dev` and `spree build` refuse to run in `server/` (SPREE_PATH guard) — use the `pnpm server:*` scripts instead.
94 
95| What changed | What to run |
96|---|---|
97| Ruby code in `spree/*` gems | Nothing — bind-mounted, reloads on next request |
98| Hosted React Dashboard at `/dashboard` (single-node test) | `pnpm server:dashboard` — rebuilds `packages/dashboard-starter/dist` with `VITE_BASE_PATH=/dashboard/`; served immediately through the monorepo mount (no restart). For dashboard *development* keep using Vite on :5173 (`cd packages/dashboard && pnpm dev`). |
99| Tailwind classes in `spree/admin` templates/helpers/JS | Nothing — a watcher in the web container rebuilds the admin CSS within ~15s. If changes still don't reach the browser, delete `server/public/assets/.manifest.json` (stale precompile output that freezes asset serving) and restart web — `pnpm server:dev` boots handle this automatically. |
100| New migration in a gem | Nothing — the next `pnpm server:dev` boot runs `spree:install:migrations db:prepare` (or `cd server && pnpm exec spree migrate` while running) |
101| Gem dependencies (gemspec / Gemfile / starter `Gemfile.lock` drift after a pull) | Nothing — the next `pnpm server:dev` boot self-heals (`bundle check || bundle install` into the `bundle_cache` volume); while running: `cd server && pnpm exec spree bundle install` |
102| Compose files / `server/.env` | `pnpm server:dev` (force-recreates web + worker) |
103| `server/Dockerfile` / `.ruby-version` / starter update that breaks the image build (frozen-lockfile error) | `pnpm server:build`, then `pnpm server:dev` — the build script swaps the edge PATH lock for a RubyGems-resolved one and the next boot swaps it back |
104| Working on the Meilisearch search provider | Off by default (DB provider) — `SPREE_MEILISEARCH=1 pnpm server:dev` chains `scripts/docker-compose.meilisearch.yml` (service + `MEILISEARCH_URL`), then `cd server && pnpm exec spree rake spree:search:reindex` once. Booting without the flag reverts web to DB search and removes the meilisearch container; the index volume stays. Image bump ("database version … is incompatible"): `pnpm server:stop && docker volume rm server_meilisearch_data`, flagged boot, reindex |
105| Broken beyond repair | `pnpm server:setup` (full reset — wipes DB + volumes) |
106 
107Backend: http://localhost:3000, admin at `/admin`, hosted React Dashboard at `/dashboard` (`spree@example.com` / `spree123`). Native no-Docker path: `pnpm server:create`, then `cd server && bin/setup && bin/dev`.
108 
109---
110 
111## General rules
112 
113- ONLY comment complex or non-obvious methods/code, do not comment every method or class, DON'T create comments noise
114- Commit message body: be precise, DON'T include implementation detail, focus on the "what" and "why", not the "how"
115- If n-commits are needed for a single logical change, use `git commit --fixup` for the follow-ups and `git rebase -i --autosquash` to combine into a single commit before merging
116- Documentation also needs to follow the same principles — focus on the "what" and "why", not the "how". Don't include implementation details in docs. Docs should explain the feature, its purpose, and how to use it, but not how it's implemented internally.
117- NEVER commit anything to main branch, always use feature/fix/chore branches for development
118 
119## Backend (Ruby)
120 
121### Architecture Principles
122 
123- All code namespaced under `Spree::` module
124- Follow Rails conventions and the Rails Security Guide
125- RESTful routes and action names
126- CanCanCan for authorization: listings use `accessible_by(current_ability, :show)`, other actions use `authorize!`
127- Always use scope fetching for security (e.g. `current_store.orders` not `Spree::Order`)
128- Ransack for filtering/searching, Pagy for pagination
129- Use services only when necessary — prefer standard Rails models and concerns
130- DO NOT call `Spree::User` directly, use `Spree.user_class`; same for `Spree.admin_user_class`
131- DO NOT put logic into controllers or serializers - this should live in models and services
132- ALWAYS use Yard comments for classes and public methods, with `@param` and `@return` types
133- DO NOT generate too much comment noise, be very strict and selective about what gets a comment — only non-obvious public methods, never private methods or internal helpers
134 
135### Code Organization
136 
137All backend code lives inside `spree/` engine directories following Rails conventions:
138 
139- `app/models/spree/`, `app/controllers/spree/`, `app/services/spree/`, `app/serializers/spree/`, `app/subscribers/spree/`, `app/mailers/spree/`, `app/jobs/spree/`, `app/helpers/spree/`, `app/presenters/spree/`
140- File naming matches class: `spree/product.rb` → `Spree::Product`
141- Split large models into concerns, organized by topic
142 
143### Spree::Current
144 
145Per-request context available in models, controllers, jobs, and services:
146 
147- `Spree::Current.store` — current store
148- `Spree::Current.currency` — current currency
149- `Spree::Current.locale` — current locale
150 
151### Models
152 
153- ALWAYS Inherit from `Spree.base_class`
154- ALWAYS pass `class_name` and `dependent` on associations; use `dependent: :destroy_async` for high-fanout associations to offload deletion to a background job
155- Include `Spree::Metafields` for custom fields support (see docs/plans/5.4-6.0-custom-fields-rename.md)
156- Include `Spree::Metadata` for JSON metadata support
157- ALWAYS Use string columns instead of enums
158- State machines: use `state_machines-activerecord` gem, default column `status` (legacy uses `state`, see docs/plans/6.0-normalize-state-to-status.md)
159- NEVER cast IDs to integer — always treat as strings (UUID support)
160- Uniqueness validations: ALWAYS use `scope: spree_base_uniqueness_scope`, should be also enforced by database index
161- If needed use paranoia gem for soft delete support (via `acts_as_paranoid`)
162- For configuration / options always use [Model Preferences](docs/developer/customization/model-preferences.mdx)
163- NEVER hardcode table names, always use `Model.table_name` in models, queries, scopes, etc.
164- ALWAYS use Arel, scopes and ActiveRecord helpers to build queries, only use raw SQL if cannot use Arel
165 
166```ruby
167class Spree::Product < Spree.base_class
168 include Spree::Metafields
169 include Spree::Metadata
170 
171 acts_as_paranoid
172 
173 has_many :variants, class_name: 'Spree::Variant', dependent: :destroy
174 scope :available, -> { where(available_on: ..Time.current) }
175 
176 validates :name, presence: true
177 validates :slug, presence: true, uniqueness: { scope: spree_base_uniqueness_scope }
178end
179```
180 
181### Migrations
182 
183- Target version: `ActiveRecord::Migration[7.2]` (Rails 7.2 support)
184- No foreign key constraints
185- No default values
186- Always add `null: false` on required columns
187- One migration per feature when possible
188- Data transformations go in rake tasks, never in migrations
189- Soft delete: use `paranoia` gem, add `deleted_at` column yourself
190- JSON columns must work across PostgreSQL, MySQL, and SQLite. PostgreSQL supports `t.jsonb` (binary, indexable); MySQL and SQLite do not — only `t.json`. Guard with `respond_to?`:
191 
192```ruby
193# JSON column — works on PostgreSQL, MySQL, SQLite
194if t.respond_to?(:jsonb)
195 t.jsonb :metadata
196else
197 t.json :metadata
198end
199```
200 
201```ruby
202class CreateSpreeMetafields < ActiveRecord::Migration[7.2]
203 def change
204 create_table :spree_metafields do |t|
205 t.string :key, null: false
206 t.text :value, null: false
207 t.string :kind, null: false
208 t.string :visibility, null: false
209 t.references :resource, polymorphic: true, null: false
210 t.timestamps
211 end
212 
213 add_index :spree_metafields, [:resource_type, :resource_id, :key, :visibility],
214 name: 'index_spree_metafields_on_resource_and_key_and_visibility'
215 end
216end
217```
218 
219### API Controllers
220 
221The Store API (customer-facing) and Admin API (back-office) are two halves of the same v3 API and should follow the same conventions. The differences are in **what data is exposed**, **who can call it**, and **which actions are enabled by default** — not in routing style, parameter shape, or response format.
222 
223#### Hierarchy
224 
225- **Base:** `Spree::Api::V3::ResourceController` — pagination (Pagy), Ransack, CanCanCan, prefixed ID lookups, HTTP caching
226- **Store API:** `Spree::Api::V3::Store::ResourceController` — publishable API key auth, **read-only by default**; opt into `create`/`update`/`destroy` per resource where it makes sense (carts, customers, addresses)
227- **Admin API:** `Spree::Api::V3::Admin::ResourceController` — secret API key auth (with scopes) **or** JWT auth (with CanCanCan), **full CRUD by default** (`index`, `show`, `create`, `update`, `destroy`); subclasses don't need to redeclare actions unless restricting
228 
229#### Key overridable methods
230 
231`model_class`, `serializer_class` (use `Spree.api.serializer_name`), `scope` (call `super` and chain), `find_resource`, `permitted_params`, `collection_includes`
232 
233#### Flat request/response structure
234 
235API v3 uses flat params — no nested Rails-style wrapping. **For new controllers, prefer enumerating attributes directly with `params.permit(...)`** rather than reaching into `Spree::PermittedAttributes`. Existing controllers that use the global allowlist remain valid until migrated as part of the 6.0 transition.
236 
237```ruby
238# ✅ Flat params
239def permitted_params
240 params.permit(:name, :description, :slug)
241end
242 
243# ❌ Nested params — not used in API v3
244def permitted_params
245 params.require(:product).permit(:name, :description, :slug)
246end
247```
248 
249**Read and write attribute names must match.** Whatever a serializer exposes (`label`, `status`, `customer_note`) is what the controller's `permitted_params` must accept on write — no "we expose `label` but accept `presentation`" mismatches. This is non-negotiable for v3: clients should not have to translate field names between read and write. When the underlying column has a legacy name, define a writer alias on the **model** (`def label=(value); self.presentation = value; end` — pair it with the matching reader) and permit the public name in the controller. The model owns the bridge, never the client. Example: `Spree::OptionType#label` / `label=` aliases — the serializer returns `label`, the controller permits `:label`, and the model translates to the underlying `presentation` column.
250 
251```ruby
252module Spree::Api::V3::Store
253 class ProductsController < ResourceController
254 protected
255 
256 def model_class
257 Spree::Product
258 end
259 
260 def serializer_class
261 Spree.api.product_serializer
262 end
263 
264 def scope
265 super.active(Spree::Current.currency)
266 end
267 end
268end
269```
270 
271```ruby
272# Admin counterpart — gets full CRUD for free from the base class
273module Spree::Api::V3::Admin
274 class ProductsController < ResourceController
275 protected
276 
277 def model_class
278 Spree::Product
279 end
280 
281 def serializer_class
282 Spree.api.admin_product_serializer
283 end
284 
285 # No need to declare index/show/create/update/destroy — inherited.
286 # Only override scope/find_resource/permitted_params when behavior differs.
287 end
288end
289```
290 
291### Prefixed IDs
292 
293All API v3 uses Stripe-style prefixed IDs (e.g. `prod_86Rf07xd4z`, `variant_k5nR8xLq`):
294 
295- Always return prefixed IDs in responses — never expose raw IDs
296- Always accept prefixed IDs in request params
297- `BaseSerializer` auto-converts the primary `id`; for associations use `object.association&.prefixed_id`
298- Controllers use `find_by_prefix_id!` (automatic in base `ResourceController`)
299- Event payloads also use prefixed IDs
300 
301```ruby
302# ✅ Serializer
303attribute :variant_id do |line_item|
304 line_item.variant&.prefixed_id
305end
306 
307# ❌ Exposes raw ID
308attribute :variant_id
309```
310 
311### Serializers (Alba)
312 
313Located in `api/app/serializers/spree/api/v3/`. Store and Admin APIs have separate serializers; **Admin always extends Store** so changes to public fields propagate automatically.
314 
315#### What goes where
316 
317The Store API is a customer-facing surface. The Admin API is a back-office surface. Two rules govern which serializer an attribute belongs to:
318 
319**Store serializer (customer-visible):**
320- Public product/category/cart/order data the customer sees in the storefront
321- Computed display values (`display_total`, `purchasable`, `in_stock`)
322- Customer-facing pricing (`price`, `compare_at_price`, `prior_price` for EU Omnibus)
323- **No timestamps** (`created_at`, `updated_at`, `deleted_at`) — these leak operational info and aren't useful to customers
324- **No internal state** — never expose `cost_price`, internal status flags, soft-delete columns, audit logs, internal notes, private metadata, or admin-only relations (vendors, fulfillment providers)
325 
326**Admin serializer (back-office):**
327- Always include `created_at`, `updated_at`, and `deleted_at` (when paranoid)
328- Cost price, margins, internal notes, private metadata
329- Internal status, audit fields (`approved_by_id`, `cancelled_by_id`)
330- Operational relations (stock movements, fulfillment providers, internal customer tags)
331- Anything an admin needs to see but a customer must not
332 
333```ruby
334# Store serializer — customer-facing, no timestamps, no back-office data
335module Spree::Api::V3
336 class ProductSerializer < BaseSerializer
337 typelize purchasable: :boolean, in_stock: :boolean, price: 'number | null'
338 attributes :id, :name, :description, :slug, :price
339 end
340end
341 
342# Admin serializer — extends store, adds back-office attributes + timestamps
343module Spree::Api::V3::Admin
344 class ProductSerializer < V3::ProductSerializer
345 typelize cost_price: 'number | null', private_metadata: 'Record<string, unknown> | null'
346 attributes :status, :cost_price, :private_metadata, :created_at, :updated_at, :deleted_at
347 end
348end
349```
350 
351- `typelize attr: :type` for computed/delegated attribute types
352- Never use `typelize_from` — it connects to the database
353- Customize via inheritance + `Spree.api.product_serializer = 'MyApp::ProductSerializer'`
354 
355### Events System
356 
357```ruby
358order.publish_event('order.completed')
359```
360 
361Subscribers go in `app/subscribers/spree/`:
362 
363```ruby
364module Spree
365 class OrderCompletedSubscriber < Spree::Subscriber
366 subscribes_to 'order.completed'
367 
368 def handle(event)
369 order = Spree::Order.find_by_prefix_id(event.payload['id'])
370 return unless order
371 ExternalService.notify_order_placed(order)
372 end
373 end
374end
375```
376 
377For new models, add `publishes_lifecycle_events` concern and create an event serializer.
378 
379### API Authentication
380 
381Four credential types, each with its own header and authorization model:
382 
383- **Publishable keys** (`pk_xxx`) — Store API, `X-Spree-API-Key` header. Identifies the store; permits public/guest endpoints. Safe to expose in client-side code.
384- **Secret keys** (`sk_xxx`) — Admin API, `X-Spree-API-Key` header. **Server-to-server only.** Each key carries a list of [granular scopes](docs/plans/5.5-admin-api-key-scopes.md) (`read_products`, `write_orders`, etc.) that gate which endpoints it can hit. Authorization is scope-based, not CanCanCan-based.
385- **JWT tokens** — user auth, `Authorization: Bearer <token>` header. Used by both Store API (logged-in customer) and Admin API (logged-in admin user). Admin JWT auth uses **CanCanCan abilities** for authorization, not scopes — this is what the admin SPA uses.
386- **Guest cart tokens** — `X-Spree-Token` header. Authorizes operations on a specific guest cart.
387 
388Admin API authorization summary:
389- Secret API key + scopes → for apps and integrations (audit-friendly, fine-grained)
390- JWT + CanCanCan → for human admin users (role-based)
391 
392Both code paths converge at the same controllers; the controller checks permissions appropriately based on which credential authenticated the request.
393 
394### Dependencies System
395 
396Register swappable services in `Spree::Dependencies`:
397 
398```ruby
399Spree::Dependencies.cart_add_item_service = 'Spree::Cart::AddItem'
400```
401 
402### Security
403 
404- CanCanCan permission checks on all actions
405- Use Rails [`params.permit`](https://api.rubyonrails.org/classes/ActionController/Parameters.html) to whitelist parameters in controllers
406- Use `Spree.user_class` / `Spree.admin_user_class` — never reference user models directly
407- Declare Ransack allowlists on **models** via `whitelisted_ransackable_attributes`, `whitelisted_ransackable_associations`, and `whitelisted_ransackable_scopes` to control which attributes, associations, and scopes are queryable from API requests
408 
409### Performance
410 
411- Use `includes`/`preload` to avoid N+1 queries (`ar_lazy_preload` gem also active)
412- Use `Rails.cache` for expensive operations; use `cache_key_with_version` for custom keys
413- Proper database indexing
414 
415### I18n
416 
417- Use `Spree.t` for translations
418- Keep translations in `config/locales/en.yml` — no duplication across files
419 
420### Documentation
421 
422- Re-generate OpenAPI spec after API changes: `bundle exec rake rswag:specs:swaggerize`
423- OpenAPI spec: `docs/api-reference/store.yaml` (generated from `spree/api/spec/integration`)
424- Update developer docs in `docs/developer/` when relevant
425- DO NOT edit the OpenAPI specs manually, it is generated from the integration tests. If you need to change the spec, change the integration tests instead and run swaggerize to regenerate the spec.
426 
427---
428 
429## Frontend (TypeScript)
430 
431### Workspace Setup
432 
433Managed with **pnpm** workspace + **Turbo** for task orchestration. All packages use **Tsup** for building and **Vitest** for testing.
434 
435```bash
436pnpm install # install all workspace deps
437pnpm build # build all packages (Turbo-cached)
438pnpm test # run all package tests
439pnpm typecheck # TypeScript validation across all packages
440pnpm lint # Biome lint across all packages
441pnpm lint:fix # Biome lint + auto-fix
442pnpm format # Biome format-write
443```
444 
445**Linting:** All TypeScript packages use [Biome](https://biomejs.dev/) (replaces ESLint + Prettier). Root config at `biome.json`; per-package configs extend it via `"extends": ["../../biome.json"]` and set `"root": false`. CI runs `pnpm turbo lint` on every PR touching `packages/**`.
446 
447### @spree/sdk — Store API Client
448 
449TypeScript SDK for the customer-facing Store API v3.
450 
451**Structure:**
452- `src/client.ts` — `createClient()` factory, `ClientConfig` interface
453- `src/store-client.ts` — all REST endpoints as resource classes (`client.products.list()`, `client.carts.create()`, etc.)
454- `src/types/generated/` — auto-generated TypeScript types from Alba serializers
455- `src/zod/generated/` — auto-generated Zod schemas for runtime validation
456 
457**Patterns:**
458- Flat resource pattern: `client.products.list()`, `client.carts.items.create()`
459- Auth modes: publishable key (guest), JWT (customer)
460- Automatic retry with exponential backoff
461- `SpreeError` class with code, status, details
462- Ransack query params transformed via `transformListParams()` in sdk-core
463 
464**Testing:** Vitest + MSW (Mock Service Worker) for HTTP mocking. Tests in `tests/`.
465 
466```bash
467cd packages/sdk
468pnpm build # tsup build (CJS + ESM)
469pnpm test # vitest
470pnpm generate:zod # regenerate Zod schemas from TS types
471pnpm typecheck
472```
473 
474### @spree/admin-sdk — Admin API Client
475 
476Same patterns as `@spree/sdk` but for the Admin API. Supports both secret key (server-to-server) and JWT (admin SPA) authentication. Published under the `next` dist-tag during the Spree 6.0 Developer Preview.
477 
478### @spree/dashboard — Admin UI (React SPA)
479 
480The Spree 6.0 admin dashboard — a Vite-built React SPA that replaces the legacy Rails `spree/admin` engine entirely. Tech stack: Vite, TanStack Router (file-based, type-safe), TanStack Query, React Hook Form + Zod, shadcn/ui + Base UI + Tailwind, Biome, Vitest. All API calls go through `@spree/admin-sdk`. See [`packages/dashboard/README.md`](packages/dashboard/README.md) and `docs/plans/6.0-admin-spa.md` for the full architecture (auth, permissions, multi-store, extension points, the three-package split).
481 
482**Package boundary rules** (see `docs/plans/6.0-admin-spa.md` → "Package Split"):
483- `@spree/dashboard-ui` — primitives + headless compounds. Components accept data via props, never import providers or hooks.
484- `@spree/dashboard-core` — registries, providers, generic infra hooks, admin SDK client singleton, `defineDashboardPlugin`.
485- `@spree/dashboard` — routes, resource hooks (`use-orders`, `use-products`, …), Zod schemas, locales, app shell.
486 
487The split lets plugin authors register UI via `defineDashboardPlugin` from `@spree/dashboard-core/plugin`, build new pages with `@spree/dashboard-ui` primitives, and reuse the same providers/hooks. It also lets app developers compose custom dashboards (e.g. vendor panels) from the same packages.
488 
489**Running the admin UI locally:**
490 
491```bash
492# 1. Boot a Spree backend (one terminal, from monorepo root)
493pnpm server:setup # one-time bootstrap (see "Development Server" above)
494pnpm server:dev # foreground; streams logs — Rails on http://localhost:3000
495 
496# 2. Boot the admin (separate terminal, from monorepo root)
497pnpm turbo dev --filter=@spree/dashboard-starter # http://localhost:5173 (proxies /api/* to :3000)
498```
499 
500The starter is the canonical host — the same app `spree add dashboard` scaffolds — so local dev exercises the real consumer path (shell + plugin pipeline) while still hot-reloading `@spree/dashboard`/`-core`/`-ui` source through the workspace. `turbo dev` (not a bare `pnpm dev` inside the package) matters on a fresh clone: the starter's `vite.config.ts` resolves the compiled Node-side Vite entries (`@spree/dashboard/vite`, `@spree/dashboard-core/vite`) from `dist/`, and turbo's `^build` dependency produces them. After any full `pnpm build`, `cd packages/dashboard-starter && pnpm dev` works too.
501 
502`VITE_API_PROXY_TARGET` overrides the backend the dev proxy targets (default `http://localhost:3000`); don't use `VITE_SPREE_API_URL` in dev — it flips the SDK to absolute cross-origin URLs, bypassing the proxy. Sign in with the seed admin user (`spree@example.com` / `spree123` — override at seed time with `ADMIN_EMAIL` / `ADMIN_PASSWORD`; see `spree/core/app/services/spree/seeds/admin_user.rb`).
503 
504**When implementing a new admin feature:**
505 
5061. **The Admin API is the only data source.** Never reach into Rails models or import server-rendered HTML. If a needed endpoint or attribute is missing, add it to `spree/api` first (see backend conventions above), regenerate types via the [Type Generation Pipeline](#type-generation-pipeline), then consume it from the SPA.
5072. **Look at the legacy Rails admin in `spree/admin/`** for what the feature does today (data shape, business rules, edge cases) — but don't port the UX 1:1. The SPA can do better than Turbo-era full-page reloads where it meaningfully improves the experience.
5083. **Follow `docs/plans/6.0-admin-spa.md`** for the three extension points (table registry, navigation registry, component injection) and the shadcn copy-paste ownership model.
5094. **Wrap SDK calls in custom hooks** under `src/hooks/` (e.g. `useOrders`, `useProduct`) — never call `adminClient` directly from components.
510 
511**Translations.** Every user-visible string in `@spree/dashboard` goes through i18next — page titles, headings, table column labels, button labels, empty states, toast messages, confirm dialog copy, select option labels, badges, status text, tooltips, helper text. Never hardcode English (or any language) into JSX, into table column definitions, or into dropdown option arrays. Keys live in `packages/dashboard/src/locales/en.json` (app-specific copy) or `packages/dashboard-core/src/locales/en.json` (cross-cutting: `admin.common.*`, `admin.fields.<attribute>.<facet>`). Reach for `i18n.t(...)` at module load (table definitions) and `useTranslation().t(...)` inside components. **Schemas in `src/schemas/` hold canonical values only — never label strings.** Build `{ value, label }` pairs at render time inside the component by mapping the canonical value list against translation keys. Same goes for the legacy Rails admin: every label/hint goes through `Spree.t` / `I18n.t` against `spree/admin/config/locales/en.yml`; run `bundle exec i18n-tasks normalize` after adding keys. When adding a new translation key, ALWAYS add it to the all languages files in `packages/dashboard/src/locales/` and `packages/dashboard-core/src/locales/`.
512 
513**Forms.** Raw React Hook Form with `<Field>` / `<Input>` / `<FieldError>` blocks. Drive each input explicitly with `form.register(...)` or a `<Controller>` for custom widgets so the form reads top-to-bottom. Wrap RHF's `handleSubmit` with a try/catch that calls `mapSpreeErrorsToForm` (`@/lib/form-errors`) to route 422 responses onto `form.formState.errors`: flat attribute keys become field errors with `aria-invalid` + `<FieldError>`; `:base` and nested keys land on `errors.root.message` so render a destructive banner at the top of the form.
514 
515```tsx
516async function handleSubmit(values: FormValues) {
517 try {
518 await onSubmit(values)
519 } catch (err) {
520 if (!mapSpreeErrorsToForm(err, form.setError)) throw err
521 }
522}
523```
524 
525- **Labels/placeholders/help** come from `packages/dashboard/src/locales/en.json` under `admin.fields.<resource>.<attribute>.{label,placeholder,help}` with cross-resource fallback `admin.fields.<attribute>.<facet>`. Dev mode logs missing keys to the console.
526- **Client validation** lives in the Zod schema (`zodResolver`).
527- **Mutation hooks built on `useResourceMutation` suppress their own toast for 422 responses** — the form already shows the inline message. Non-validation errors (network, 5xx, gateway) still toast. For a plain `useMutation` you want a fallback toast on, layer the catch: try `mapSpreeErrorsToForm` first, re-throw `SpreeError`, otherwise `toast.error(...)`.
528 
529**Form schemas** live in `packages/dashboard/src/schemas/<resource>.ts` when shared across 2+ files or non-trivial (~30+ lines, nested sub-schemas, companion constants); inline is fine for short single-file forms. The schema file owns the Zod schema, its inferred `FormValues` type, defaults, dropdown option arrays, and regex constants. **Don't add form↔API mappers to paper over field renames** — if you find yourself translating `ot.label → form.presentation`, fix the API instead (read/write symmetry, see "API Controllers" above). Mappers are only for pure frontend state (upload progress, transient UI bookkeeping).
530 
531**Base UI `<Select>` does not auto-render labels.** Unlike Radix, Base UI's `<Select.Value />` renders the raw selected `value` (the slug, the ISO code, the prefixed ID) instead of the matching `<SelectItem>`'s children. Two fixes:
532 
5331. **Static option labels** — pass an `items` array; Base UI resolves the trigger label automatically:
534```tsx
535 <Select items={KIND_OPTIONS} value={...} onValueChange={...}>
536 <SelectTrigger><SelectValue /></SelectTrigger>
537 <SelectContent>
538 {KIND_OPTIONS.map((o) => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
539 </SelectContent>
540 </Select>
541```
5422. **Dynamic option labels** — use the children render-prop:
543```tsx
544 <SelectValue>{(value) => roles.find((r) => r.id === value)?.name ?? (value as string)}</SelectValue>
545```
546 
547For free-text **searchable** pickers, use `<Combobox>` instead — see `components/spree/country-state-fields.tsx`.
548 
549**`acts_as_list` ⇒ drag-and-drop reorder, never a numeric position input.** When a model uses `acts_as_list`, both top-level list tables and nested collection editors must reorder via dnd-kit:
550 
5511. **Top-level resource tables**: pass `reorder={{ onReorder: (id, position) => adminClient.X.update(id, { position }) }}` to `<ResourceTable>` — it owns the `DndContext` + `SortableContext` internally, optimistic with rollback. Reference: `routes/_authenticated/$storeId/settings/payment-methods.tsx`.
5522. **Nested collection editors** (e.g. `option_values[]` on an option-type sheet): wrap `useFieldArray` rows in `DndContext` + `SortableContext`, give each row a `<GripVerticalIcon>` grip with `{...attributes} {...listeners}` from `useSortable`, and on drag end call `valuesArray.move(from, to)` and rewrite each row's `position` to its new index. The position field is **not rendered**; it's a computed output. Reference: `routes/_authenticated/$storeId/products/options.tsx` (vertical), `routes/_authenticated/$storeId/products/$productId.tsx` (product media grid).
553 
554Use `verticalListSortingStrategy` for rows/lists, `rectSortingStrategy` for grids. Always pair `PointerSensor` (with `activationConstraint: { distance: 5 }` so row clicks don't hijack as drags) with `KeyboardSensor` + `sortableKeyboardCoordinates` for accessibility.
555 
556**`<StoreDatePicker>` is the only correct way to render a date/datetime field.** Never use `<Input type="date">` (native styling breaks the design system) or the bare `<DatePicker>` in `components/ui/` (skips the store timezone). `@/components/spree/store-date-picker` reads the store's IANA timezone from `<StoreProvider>` so every datetime in the SPA means the same thing for every admin. Modes:
557 
558- **Date-only** (default): emits `yyyy-MM-dd` strings (timezone-agnostic). Persist as-is — backend `date` columns accept these directly via Ransack.
559- **Datetime** (`includeTime`): the user picks a wall-clock time in the store's timezone; the picker emits the corresponding UTC ISO string and reinterprets it on read.
560 
561Wire through `<Controller>` in forms; pass `value`/`onChange` directly in filter panels. **Inside a `<Sheet>`, pass `inline`** — the default Popover path hits the portal bug below.
562 
563**Base UI `<Popover>` is unreliable inside a `<Sheet>`'s portal tree.** Symptom: the trigger gets `aria-expanded="true"` and `data-popup-open=""` on click, but no `[data-slot="popover-content"]` ever appears in the DOM. Happens in deeply-nested portal trees (Sheet → SortableContext → TableRow → Popover). Fix: render the panel inline with `absolute top-full left-0 z-50` + a `document.pointerdown` click-outside listener + Escape-to-close. A portal is only needed to escape an `overflow: hidden` ancestor; for table cells and form fields, inline is fine. Reference: `components/spree/color-picker.tsx`, plus `<StoreDatePicker inline>` above.
564 
565### @spree/sdk-core — Shared HTTP Layer
566 
567Private package providing `createRequestFn()`, `SpreeError`, retry logic, and Ransack param transformation. Used internally by both SDKs.
568 
569### Type Generation Pipeline
570 
571When changing Alba serializers, run the full pipeline:
572 
573```bash
574cd spree/api && bundle exec rake typelizer:generate # 1. TS types from serializers
575cd packages/sdk && pnpm generate:zod # 2. Zod schemas from TS types
576cd spree/api && bundle exec rspec spec/integration/ # 3. Integration tests
577bundle exec rake rswag:specs:swaggerize # 4. OpenAPI spec
578cd packages/sdk && pnpm test # 5. SDK tests
579```
580 
581- TypeScript types → `packages/sdk/src/types/generated/` (Store) and `packages/admin-sdk/src/types/generated/` (Admin)
582- Zod schemas → `packages/sdk/src/zod/generated/`
583- Store types: `StoreProduct`, `StoreOrder`, etc. Admin types: `AdminProduct`, `AdminOrder`, etc.
584 
585A **Lefthook pre-commit hook** (`lefthook.yml`) regenerates types and Zod schemas automatically whenever `spree/api/app/serializers/**/*.rb` files are committed, then re-stages the generated output. You don't need to run steps 1 and 2 manually if you're committing serializer changes — the hook handles it. Steps 3–5 (integration tests, OpenAPI regen, SDK tests) still need to run locally before pushing.
586 
587### Changesets & Versioning
588 
589Published packages use **Changesets** for versioning — one workspace-wide instance. Place changeset files in the root `.changeset/` directory (`pnpm changeset`), never in per-package directories. The dashboard packages (`@spree/dashboard`, `@spree/dashboard-core`, `@spree/dashboard-ui`) are a `fixed` group and always release together under one version; `@spree/admin-sdk` versions independently. Two release trains: `pnpm version:preview` cuts the Developer Preview packages while holding back `@spree/sdk` (stable, tracks Spree releases); `pnpm changeset version` includes it. `--ignore` defers changesets, it never discards them.
590 
591---
592 
593## Testing
594 
595Always run tests before committing changes.
596 
597### Backend (Ruby — RSpec)
598 
599Each engine has its own test suite:
600 
601```bash
602cd spree && bundle install # shared deps
603cd core && bundle install # engine deps
604bundle exec rake test_app # create dummy Rails app (skip if already exists)
605bundle exec rspec # run full suite
606bundle exec rspec spec/models/spree/state_spec.rb # single file
607bundle exec rspec spec/models/spree/state_spec.rb:7 # single test
608```
609 
610Default DB is SQLite3. For PostgreSQL:
611 
612```bash
613DB=postgres DB_USERNAME=postgres DB_PASSWORD=password DB_HOST=localhost bundle exec rake test_app
614```
615 
616**Parallel runs:**
617 
618```bash
619bundle exec rake parallel_setup # create worker DBs
620bundle exec parallel_rspec spec # run in parallel
621bundle exec parallel_rspec -n 4 spec # with worker count
622```
623 
624Re-run `parallel_setup` after schema changes.
625 
626**Test guidelines:**
627- RSpec + Factory Bot
628- Prefer `build` over `create` for speed
629- Factories live in `lib/spree/testing_support/factories/`
630- ALWAYS use factories in tests, never call `Model#create` directly
631- ALWAYS run parallel tests if running full test suite, if there are any failures repeat the failed examples seperately and confirm they really fail before investigating
632- Pragmatic — no tests for standard Rails validations, only custom ones
633- Controller specs: always add `render_views`, use `stub_authorization!` for auth
634- Use controller specs for testing edge cases, API integration tests are only for happy path/simple 422 failures to generate OpenAPI examples; otherwise they get too brittle and high-maintenance
635- Time-based tests: use `Timecop`
636- Don't over-engineer or repeat tests
637 
638### Frontend (TypeScript — Vitest)
639 
640```bash
641cd packages/sdk && pnpm test # SDK tests (uses MSW for HTTP mocking)
642```
643 
644### Admin SPA E2E (Playwright)
645 
646End-to-end tests for `packages/dashboard` live in `packages/dashboard/e2e/`. The global setup boots a real Rails test server (port 3010) + Vite (port 5174) once and seeds the DB; specs then exercise the SPA through a browser against that stack. Locally Vite runs in dev mode; CI builds first and serves the bundle (`E2E_PREVIEW=1` → `vite preview`) because every test's fresh browser context re-downloads all dev-mode modules. CI also splits the suite across shard jobs (`--shard=n/m`), each with its own isolated Rails + SQLite + Vite stack — specs must stay self-contained (seed via global-setup fixtures or create your own records) and must not depend on records another spec file leaves behind.
647 
648```bash
649cd packages/dashboard && pnpm test:e2e # full suite
650cd packages/dashboard && pnpm test:e2e:ui # Playwright UI mode (debug)
651```
652 
653The `login(page)` helper authenticates through the API (one POST plants the refresh cookie; the SPA's boot-time silent refresh does the rest) — only `auth.spec.ts` drives the login form itself.
654 
655**Write UI-only assertions, like Capybara.** Drive the test through user-visible actions (fill labels, click buttons, find by role) and assert on visible UI. **Do not** reach for `page.waitForResponse(/api/...)` to wait for backend completion — it leaks API shape into tests and makes refactors painful. Playwright's `await expect(...).toBeVisible()` auto-polls until the condition is met (same as Capybara's `default_max_wait_time`), which covers virtually all cases.
656 
657```ts
658// ✅ Capybara-style: drive the UI, assert on the UI.
659await page.getByLabel(/^label$/i).fill('Color')
660await page.getByRole('button', { name: /create option type/i }).click()
661await expect(page.getByRole('button', { name: 'color' })).toBeVisible({ timeout: 15_000 })
662 
663// ❌ Avoid: couples the test to API shape, brittle on refactor.
664await Promise.all([
665 page.waitForResponse((res) => /\/api\/v3\/admin\/option_types/.test(res.url()) && res.status() === 201),
666 page.getByRole('button', { name: /create option type/i }).click(),
667])
668```
669 
670The narrow exceptions where API-level waits are justified:
671- **No UI feedback** — a mutation kicks off background work (e.g., a webhook fire-and-forget) and there's nothing visible to assert against.
672- **Optimistic UI** — success state appears in the DOM before the API confirms; a UI-only assertion can't distinguish "rendered and persisted" from "rendered but later failed."
673 
674Both are rare in the admin SPA, which renders success states only after mutations resolve.
675 
676**Conventions:**
677- Use `Date.now()` suffixes on names so leftover rows from earlier specs don't collide (the suite runs serially — `fullyParallel: false, workers: 1`).
678- Disambiguate duplicate button names (e.g., a "Delete" in the sheet footer + another in a confirm dialog) by scoping: `page.getByRole('dialog').getByRole('button', { name: /^delete$/i })`.
679- Reference: `e2e/option-types.spec.ts`, `e2e/invitation-acceptance.spec.ts`.
680 

Commands it names

  • pnpm install
  • pnpm build
  • pnpm test
  • pnpm typecheck
  • pnpm lint
  • pnpm lint:fix
  • pnpm format
  • pnpm generate:zod
  • pnpm server:setup
  • pnpm server:dev
  • pnpm turbo dev --filter=@spree/dashboard-starter
  • bundle exec rake rswag:specs:swaggerize
  • bundle exec rake test_app
  • bundle exec rspec
  • bundle exec rspec spec/models/spree/state_spec.rb
  • bundle exec rspec spec/models/spree/state_spec.rb:7
  • bundle exec rake parallel_setup
  • bundle exec parallel_rspec spec
  • bundle exec parallel_rspec -n 4 spec
  • npx skills add spree/agent-skills
  • pnpm install && pnpm server:setup
  • pnpm exec spree <cmd>
  • pnpm server:*
  • pnpm server:dashboard
  • bundle check || bundle install
  • pnpm server:build
  • pnpm server:stop && docker volume rm server_meilisearch_data
  • pnpm server:create
  • git commit --fixup
  • git rebase -i --autosquash
  • biome.json
  • pnpm turbo lint
  • turbo dev
  • pnpm dev
  • bundle exec i18n-tasks normalize
  • pnpm changeset
  • pnpm version:preview
  • pnpm changeset version

Sections

  • Spree Commerce — Development Rules
  • Plans & Architecture Decisions
  • Monorepo Structure
  • Development Server (`server/`)
  • General rules
  • Backend (Ruby)
  • Architecture Principles
  • Code Organization
  • Spree::Current
  • Models
  • Migrations
  • JSON column — works on PostgreSQL, MySQL, SQLite
  • API Controllers
  • ✅ Flat params
  • ❌ Nested params — not used in API v3
  • Admin counterpart — gets full CRUD for free from the base class
  • Prefixed IDs
  • ✅ Serializer
  • ❌ Exposes raw ID
  • Serializers (Alba)
  • Store serializer — customer-facing, no timestamps, no back-office data
  • Admin serializer — extends store, adds back-office attributes + timestamps
  • Events System
  • API Authentication
  • Dependencies System
  • Security
  • Performance
  • I18n
  • Documentation
  • Frontend (TypeScript)
  • Workspace Setup
  • @spree/sdk — Store API Client
  • @spree/admin-sdk — Admin API Client
  • @spree/dashboard — Admin UI (React SPA)
  • 1. Boot a Spree backend (one terminal, from monorepo root)
  • 2. Boot the admin (separate terminal, from monorepo root)
  • @spree/sdk-core — Shared HTTP Layer
  • Type Generation Pipeline
  • Changesets & Versioning
  • Testing
  • Backend (Ruby — RSpec)
  • Frontend (TypeScript — Vitest)
  • Admin SPA E2E (Playwright)

What it covers

setuptestlint-formatcode-stylearchitecturetypestesting-strategygit-prsecuritydependenciesapiuiperformancedeploymentmonorepodo-notdocs

Stack — with the evidence

turborepo

(1.00)

monorepo

(1.00)

biome

(1.00)

node

(0.95)

vitest

(0.95)

pnpm

(0.85)

ruby

(0.80)

react

(0.70)

tailwind

(0.70)

vite

(0.70)

playwright

(0.70)

typescript

(0.60)

javascript

(0.60)

github-actions

(0.60)

Format

CLAUDE.md

Claude Code's memory file. Shaped like AGENTS.md but with two things it lacks: @path imports, so shared rules live in one place, and a user-scope layer that follows the developer across repos rather than shipping with the code.

What the corpus says about it

Repository

Owner
spree
Language
—
License
—
Archived
no

All configs in this repo

Similar configs

Same format, overlapping stack, ranked by quality.

Same format, overlapping stack, ranked by quality
RepositoryFormatStackCoversScoreChanged
dotCMS/corecore-web/CLAUDE.md · 949CLAUDE.mdjavanode+13teststylearchtesting-strategy+3100/1003 days ago
dotCMS/corecore-web/libs/sdk/react/CLAUDE.md · 949CLAUDE.mdtypescriptjava+10setupbuildtestlint-format+997/1003 days ago
khrnchn/sedekah-jeCLAUDE.md · 89CLAUDE.mdtypescriptnextjs+12testlint-formatstylearch+697/1003 days ago
carrot-foundation/middle-earthCLAUDE.md · 0CLAUDE.mdtypescriptnode+12setupbuildtestlint-format+697/1003 days ago
modelcontextprotocol/serversCLAUDE.md · 89kCLAUDE.mdtypescriptnode+8setupbuildtestlint-format+697/1003 days ago
oven-sh/buntest/CLAUDE.md · 95kCLAUDE.mdtypescriptjavascript+14teststyletesting-strategydo-not97/1003 days ago
BattlefieldNoob/Project-ShishaCLAUDE.md · 0CLAUDE.mdtypescriptnode+6setupbuildtestlint-format+497/1002 days ago
MetaMask/metamask-design-systemCLAUDE.md · 34CLAUDE.mdtypescriptnode+12buildtestlint-formatstyle+597/1003 days ago
RuleStack

Built by

Kynth Studio

Directory

Configs
Stacks
Compare formats
Diff two configs
Best AGENTS.md examples

Formats

AGENTS.md
CLAUDE.md
Cursor rules
Copilot instructions

Reference

Read API
Corpus health
Privacy Policy
Terms

RuleStack

RuleStack

Built by

Kynth Studio

Directory

Configs
Stacks
Compare formats
Diff two configs
Best AGENTS.md examples

Formats

AGENTS.md
CLAUDE.md
Cursor rules
Copilot instructions

Reference

Read API
Corpus health
Privacy Policy
Terms

RuleStack

RuleStack

Built by

Kynth Studio

Directory

Configs
Stacks
Compare formats
Diff two configs
Best AGENTS.md examples

Formats

AGENTS.md
CLAUDE.md
Cursor rules
Copilot instructions

Reference

Read API
Corpus health
Privacy Policy
Terms

RuleStack