ποΈ Typescript E-commerce Platform
# EverShop Core β Project Instructions for Claude
This is the **EverShop core repository** β the codebase for the modular monolith eCommerce platform built on Express + React (SSR) + PostgreSQL + GraphQL. The package source lives at `packages/evershop/src/` and is published as `@evershop/evershop`.
The author and maintainer is The Nguyen (`[email protected]`). Assume deep familiarity with the codebase.
## Read the wiki first
A curated wiki for this codebase lives in `wiki/`. **Before answering architectural questions or making non-trivial changes, read the relevant wiki page(s)**, not just the docs in `../docs/` or the source code. The wiki is hand-curated to be the fastest source of truth for "how does this actually work" questions.
- `wiki/index.md` β catalog of all pages with one-line summaries. Read this first to find the right page.
- `wiki/log.md` β chronological record of ingests, queries, and audits.
- `wiki/<topic>.md` β the content pages.
If a page doesn't exist for the topic you need, that's a signal to **ingest** (see Operations below).
## Operations
### Query
When the user asks a question that the wiki could answer:
1. Open `wiki/index.md` and find candidate pages.
2. Read those pages.
3. Answer using the wiki content. Cite pages by filename (e.g., "see `wiki/widgets.md`") so the user can jump in.
4. If the wiki content is **outdated relative to the code** (rename, removal, signature change), update the wiki page in the same response and append a note to `wiki/log.md`.
### Ingest
When the user explains something non-obvious about the codebase, or when you investigate an unfamiliar subsystem and the answer is worth keeping:
1. Discuss the takeaways with the user inline (don't go silent for minutes writing files).
2. Decide whether the knowledge belongs on an existing page or warrants a new one. Prefer updating existing pages β a page with five short sections beats five tiny pages.
3. Write/update the page using the conventions below.
4. Update `wiki/index.md` if a new page was added or a description changed.
5. Append a one-line entry to `wiki/log.md` with prefix `## [YYYY-MM-DD] ingest | <topic>`.
### Lint
When asked to audit the wiki, or when you notice drift while answering questions:
- Verify file paths and line references still resolve (they decay as files move).
- Check that named functions/flags/files referenced in the wiki still exist (`grep`, `Read`).
- Reconcile contradictions between pages.
- Flag pages that haven't been touched in a long time *and* describe an area that has clearly evolved (use `git log --oneline -- <path>` to spot churn).
- Append a `## [YYYY-MM-DD] lint | <scope>` entry to `wiki/log.md` summarizing what was checked and what changed.
## Page conventions
Every page follows this shape:
```md
# <Title>
**TL;DR.** One paragraph. The thing the user came here to learn, said directly.
## <Section>
Body. Code snippets go in fenced blocks with the language tag. File references use `relative/path:line` format so the user can click into them.
## See also
- [Other page](other-page.md) β one-line hook
```
Specifics:
- **File paths.** Always relative to the package root (`packages/evershop/src/...`) unless the file lives outside that tree. Add `:line` suffix when pointing at a specific implementation.
- **Code samples.** Prefer real, compilable snippets pulled from the codebase over invented examples. If you must invent, mark it clearly.
- **No marketing.** No "powerful", "robust", "comprehensive". Just what it is and how it works.
- **Be willing to contradict the public docs.** If `../docs/` is wrong, the wiki should say so and explain the actual current behavior.
- **Date format.** ISO `YYYY-MM-DD` everywhere.
## EverShop quick reference
For deep understanding, read the wiki pages. This section is the cheat sheet.
### Stack
- **Runtime:** Node.js β₯ 20, Express, React 18 with SSR + hydration
- **Database:** PostgreSQL 13+ (no other DBs supported; SQL is plain Postgres)
- **GraphQL:** schema assembled at startup from per-module `.graphql` files
- **Bundler:** webpack 5 with SWC for transforms (no Babel)
- **Forms:** react-hook-form (wrapped by `components/common/form/Form.tsx`)
- **Styling:** Tailwind v4 + PostCSS + custom plugins
### Application type
Multi-page application (MPA) β each route gets its own bundle and a full HTML response. Hydrated on the client. Not a SPA β there is no client-side router.
### File / folder conventions
- **Migrations:** `Version-X.Y.Z.ts` (hyphen, not underscore) in `<module>/migration/`
- **Routes:** folder name = route ID, alphabetic only (a-z, A-Z), `route.json` declares the route
- **Middleware:** lowercase first letter, bracket-syntax ordering `[after]name[before].ts`
- **Master components:** uppercase first letter, `.tsx`, optional `export const layout = { areaId, sortOrder }`
- **Shared between routes:** folder named `routeA+routeB/` (e.g. `productEdit+productNew/`)
- **Site-wide middleware:** `pages/admin/all/`, `pages/frontStore/all/`, `pages/global/`, `api/global/`
- **Subscribers:** `subscribers/<event_name>/<handler>.ts`
- **Modules:** core in `packages/evershop/src/modules/`, user extensions in `extensions/` (project root)
- **Module ID:** must be unique across the system
### Bootstrap is a hard wall
The hook system and registry are **locked** after every module's `bootstrap.ts` runs. Calling `addProcessor`, `hookBefore`, `hookAfter`, `registerWidget`, `registerJob`, `registerEmailService`, `registerPaymentMethod`, etc. from inside a middleware or request handler **throws**. Always register from `bootstrap.ts`.
### Public import paths
See `wiki/reference.md` for the full table. The most common ones:
```ts
import { select, insert, update, del, insertOnUpdate } from '@evershop/evershop/lib/postgres/query';
import { pool, getConnection } from '@evershop/evershop/lib/postgres';
import { addProcessor, getValue } from '@evershop/evershop/lib/util/registry';
import { hookBefore, hookAfter, hookable } from '@evershop/evershop/lib/util/hookable';
import { emit } from '@evershop/evershop/lib/event';
import { createSubscriber } from '@evershop/evershop/lib/event/subscriber';
import { buildUrl, buildAbsoluteUrl } from '@evershop/evershop/lib/router';
import { registerWidget } from '@evershop/evershop/lib/widget';
import { setContextValue, getContextValue } from '@evershop/evershop/graphql/services';
```
### Common pitfalls
- `import { Request, Response } from 'express'` β wrong. Use `EvershopRequest`/`EvershopResponse` from `@evershop/evershop/types/request` and `types/response`.
- `module.exports` β wrong. ESM. Use `export default`.
- MySQL syntax β wrong. PostgreSQL. Use `IDENTITY` or `SERIAL`, double-quoted identifiers, `JSONB`, `gen_random_uuid()`.
- `migrations/` (plural) folder β wrong. Singular `migration/`.
- `Version_1.0.0.ts` (underscore) β wrong. Hyphen: `Version-1.0.0.ts`.
- `pages/frontend/` β wrong. `pages/frontStore/`.
- Arrow functions in hookable / processor callbacks when context is needed β `this` is bound via `.call()`, arrow functions can't access it.
- Adding a hook or processor from a middleware β locked after bootstrap, throws.
### Common mistakes
The pitfalls above are syntactic β caught by lint or first compile. The ones below are runtime or integration traps. They compile cleanly and fail later. Each has bitten the codebase; the wiki pages explain *why* and show the fix.
- **API handler that sends a response with a 2-arg `(request, response)` signature β `ERR_HTTP_HEADERS_SENT`.** The framework inspects `function.length`; a 2-arg handler is treated as passive, so it auto-calls `next()` after your function resolves and `apiResponse` tries to send headers again. If you call `response.json()` / `response.send()` / `response.redirect()`, declare the third `next` parameter even if you never call it β the 3-arg signature disables auto-next. See [wiki/middleware-system.md β Active vs passive middleware](wiki/middleware-system.md#active-vs-passive-middleware-2-arg-vs-3-arg).
- **Chaining `.where()` or `.orderBy()` directly off `.on()` in a query-builder JOIN β `where is not a function` at runtime.** `.on()` returns a `Node`; `.where()` and `.orderBy()` live on `Query`/`SelectQuery`, not `Node`. Hold the query handle in a variable and call `.where()` / `.orderBy()` on it separately. The `.where().and().execute()` chain works fine (Node has `.and()` and `.execute()`) β the trap is only joins. See [wiki/database.md β What chains on what](wiki/database.md#what-chains-on-what-the-gotcha).
- **Passing `(column, alias)` to the top-level `select(...)` β silent column rename β `column "X" does not exist` at runtime.** The top-level `select(...)` is *variadic over columns* β `select('foo.uuid', 'method_uuid')` treats both strings as columns, not as `(column, alias)`. Only the chained `.select(col, alias)` form supports aliasing. Use `select().from(table).select(col, alias)` instead. See [wiki/database.md β `select(...)` is variadic](wiki/database.md#select-is-variadic--it-does-not-take-column-alias).
- **Passing `{ isSQL: true, value: '...' }` to `.given()` for raw SQL in UPDATE/INSERT β `invalid input syntax for type ...` at runtime.** `UpdateQuery.given` / `InsertQuery.given` call `toString(value)` on every entry, which JSON-stringifies object values. The `{isSQL, value}` raw-escape convention is **only** honored inside `.where()` / `Leaf` / `RawLeaf`, not for SET / VALUES. When you need raw SQL on the write side (`COALESCE(col, NOW())`, `col + 1`, `gen_random_uuid()`), drop to `connection.query()` with bind parameters for just the user values. Canonical pattern: `oms/services/updateShipmentStatus.ts:79-100`.
- **`.execute(connection)` / `.load(connection)` on a fresh `getConnection()` PoolClient before `startTransaction(connection)` β `Release called on client which has already been released to the pool` at runtime.** The query-builder's internal `release()` only short-circuits when `connection.INTRANSACTION === true`, a flag exclusively set by `startTransaction`. Pre-tx reads on a freshly-acquired PoolClient auto-release it back to the pool, and the next `startTransaction(connection)` operates on a detached client. Rule: either call `startTransaction` IMMEDIATELY after `getConnection`, or run pre-tx reads on the shared `pool` (which `release()` ignores) and acquire the dedicated PoolClient only at the top of the tx. The canonical "delayed tx" pattern (necessary when an external network call has to run between read and write β e.g. `carrier.createLabel()`) is in `oms/services/createShipment.ts:330-410`.
- **Hook called after a conditional early return in a React component β `Rendered more hooks than during the previous render`.** All hooks must run in the same order on every render. If one render takes an early return before a `useEffect` and the next render reaches it, React errors. Move every hook above any conditional return.
- **`.admin.graphql` type referenced from a non-admin `.graphql` file β "Unknown type X" at storefront schema build.** `buildStoreFrontSchema` filters out `.admin.graphql`; types defined there aren't visible to the storefront schema. Either move the type to a non-admin file or mark the referencing file `.admin.graphql` too. The two schemas build separately β admin sees both, storefront sees only non-admin.
- **Dropping a DB column without grepping across modules.** EverShop modules share tables. A resolver in `modules/base/` may read a column owned by `modules/checkout/`. When dropping a column, `grep -rn "table\\.column" packages/evershop/src` across the whole tree, not just the owning module.
- **New `.js` files in new code paths.** The codebase has mixed `.js` and `.ts` because the migration to TypeScript is incremental, but **new authorship is `.ts`** (or `.tsx` for React). Editing an existing `.js` keeps it `.js` for small changes; full rewrites are a good moment to switch. See [wiki/module-structure.md β TypeScript by default](wiki/module-structure.md#typescript-by-default).
- **`hookable()` keys hooks by the wrapped function's `.name`, so a `β¦Impl` declaration silently kills its public hooks.** `hookable(fooImpl)` registers under `'fooImpl'`, but a `hookBeforeFoo` helper that calls `hookBefore('foo', β¦)` registers under `'foo'` β they never meet, the hook never fires, and nothing errors (the wrapped function still runs, the transaction still commits). Wrap a **named function expression** whose intrinsic name *is* the hook key, even if the binding differs: `const fooImpl = async function foo() {β¦}` (the `checkout.ts:10` idiom β `const _checkout = async function checkout(`). A plain `function fooImpl() {}` declaration sets `.name = 'fooImpl'` and breaks it. See [wiki/hooks.md β Common pitfalls](wiki/hooks.md#common-pitfalls); guard test `modules/oms/tests/unit/hookNameAlignment.test.js`.
- **A widget `settingComponent` that reads a list setting as `watch('settings.x') ?? initial` works in the page-builder drawer but throws `items.map is not a function` on the legacy `/admin/widgets/edit` page.** The two surfaces seed settings differently: the drawer's page-level form holds real arrays/objects, but the legacy `<Form>` seeds list fields as a JSON **string** via a hidden `defaultValue={JSON.stringify(...)}` input β and `??` only guards null, so the string reaches `RepeatableAccordion` and would also fail the widget's AJV array schema on save (settings are never parsed in the save path). Read list settings with `useArraySetting('settings.x', initial)` and mutate via `asArray(getValues('settings.x'), initial)` (both from `@components/common/page-builder`), or hold the array with `useFieldArray` like `SlideshowSetting`. See [wiki/page-builder.md β Widget settings run on two surfaces](wiki/page-builder.md#widget-settings-run-on-two-surfaces-list-field-trap).
## Doing work in this repo
- The published package is built from `src/` to `dist/` via SWC (`npm run compile`). Runtime loads `.js` from `dist/`. When editing, edit `.ts` in `src/`.
- Tests run with Jest: `npm test` from the repo root.
- Lint with `npm run lint`.
- Dev server: `npm run dev` (uses `webpack-dev-middleware` + HMR).
- Build for production: `npm run build` then `npm run start`.
- Never bypass `husky` hooks (`--no-verify`) or skip type/lint failures without an explicit go-ahead.