This is the EverShop core repository - a 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 (support@evershop.io).
- Runtime: Node.js >= 20, Express, React 17 with SSR + hydration
- Database: PostgreSQL (no other DBs supported; SQL is plain Postgres)
- GraphQL: schema assembled at startup from per-module
.graphqlfiles - 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
- Query builder:
@evershop/postgres-query-builder(separate workspace package atpackages/postgres-query-builder/)
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.
npm run compile # SWC: src/ -> dist/ (required before tests and runtime)
npm run compile:db # SWC: postgres-query-builder src/ -> dist/
npm run build # webpack production build (reads from dist/)
npm run dev # dev server with HMR (reads from dist/)
npm run start # production start (reads from dist/)
npm run lint # eslint --fix on packages/
npm test # Jest unit tests
npm run test:e2e # Playwright E2E tests (in tests/e2e/)- Tests run from
dist/, notsrc/.jest.config.jssetstestMatch: ["**/dist/**/tests/**/unit/**/*.test.[jt]s"]and ignoressrc/entirely. You must runnpm run compile(andnpm run compile:dbfor query-builder tests) beforenpm testor zero tests will execute. - Jest maps
@evershop/postgres-query-buildertopackages/postgres-query-builder/dist/index.js- that dist must exist. - Run a single test file:
npm test -- <path-relative-to-dist>(e.g.npm test -- modules/oms/tests/unit/status.test.js). - E2E tests live in
tests/e2e/(separate package with its ownpackage-lock.json). Run vianpm run test:e2efrom the repo root.
- CI (
.github/workflows/build_test.yml):npm install->npm run compile->npm run compile:db->npm test. CI does not run lint. Tested on Node 20 and 22. huskypre-commit (.husky/pre-commit): lint is commented out (#FORCE_COLOR=1 npm run lint). Do not assume lint runs automatically on commit.eslint.config.jsignores tests, extensions, dist, themes, public, media.no-consoleiserror.import/orderiswarnwithnewlines-between: never.
- Uses the
confignpm package. SeeconfigExample.textfor the full shape (shop settings, OMS status definitions, PSO mapping, carriers, theme config). - OMS order/payment/shipment statuses and the payment-shipment -> order (
psoMapping) are config-driven, not hardcoded. Seemodules/oms/services/statusManager.ts. - Environment:
.envfile (DB connection, etc.).tests/e2e/.env.exampleshows E2E requirements.
- Migrations:
Version-X.Y.Z.ts(hyphen, not underscore) in<module>/migration/ - Routes: folder name = route ID, alphabetic only (a-z, A-Z),
route.jsondeclares the route - Middleware: lowercase first letter, bracket-syntax ordering
[after]name[before].ts - Master components: uppercase first letter,
.tsx, optionalexport 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 inextensions/(project root, created on demand) - Module ID: must be unique across the system
The hook system and registry are locked after every module's bootstrap.ts runs. Calling addProcessor, hookBefore, hookAfter, registerWidget, registerJob, registerPaymentMethod, etc. from inside a middleware or request handler throws. Always register from bootstrap.ts.
Defined in packages/evershop/package.json exports. The most common ones:
import { select, insert, update, del, insertOnUpdate, startTransaction } 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';Module-level service entrypoints: @evershop/evershop/oms/services, @evershop/evershop/checkout/services, @evershop/evershop/catalog/services, etc.
import { Request, Response } from 'express'- wrong. UseEvershopRequest/EvershopResponsefrom@evershop/evershop/types/requestandtypes/response.module.exports- wrong. ESM. Useexport default.- MySQL syntax - wrong. PostgreSQL. Use
IDENTITYorSERIAL, double-quoted identifiers,JSONB,gen_random_uuid(). migrations/(plural) folder - wrong. Singularmigration/.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 -
thisis bound via.call(), arrow functions can't access it. - Adding a hook or processor from a middleware - locked after bootstrap, throws.
- New
.jsfiles in new code paths. The codebase has mixed.jsand.tsbecause the migration to TypeScript is incremental, but new authorship is.ts(or.tsxfor React). Editing an existing.jskeeps it.jsfor small changes; full rewrites are a good moment to switch.
These compile cleanly and fail later. Each has bitten the codebase.
- API handler that sends a response with a 2-arg
(request, response)signature ->ERR_HTTP_HEADERS_SENT. The framework inspectsfunction.length; a 2-arg handler is treated as passive, so it auto-callsnext()after your function resolves andapiResponsetries to send headers again. If you callresponse.json()/response.send()/response.redirect(), declare the thirdnextparameter even if you never call it - the 3-arg signature disables auto-next. - Chaining
.where()or.orderBy()directly off.on()in a query-builder JOIN ->where is not a functionat runtime..on()returns aNode;.where()and.orderBy()live onQuery/SelectQuery, notNode. Hold the query handle in a variable and call.where()/.orderBy()on it separately. - Passing
(column, alias)to the top-levelselect(...)-> silent column rename ->column "X" does not existat runtime. The top-levelselect(...)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. Useselect().from(table).select(col, alias)instead. - Passing
{ isSQL: true, value: '...' }to.given()for raw SQL in UPDATE/INSERT ->invalid input syntax for type ...at runtime.UpdateQuery.given/InsertQuery.givencalltoString(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 toconnection.query()with bind parameters for just the user values. Canonical pattern:modules/oms/services/updateShipmentStatus.ts:78-98. .execute(connection)/.load(connection)on a freshgetConnection()PoolClient beforestartTransaction(connection)->Release called on client which has already been released to the poolat runtime. The query-builder's internalrelease()only short-circuits whenconnection.INTRANSACTION === true, a flag exclusively set bystartTransaction. Pre-tx reads on a freshly-acquired PoolClient auto-release it back to the pool, and the nextstartTransaction(connection)operates on a detached client. Rule: either callstartTransactionIMMEDIATELY aftergetConnection, or run pre-tx reads on the sharedpool(whichrelease()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 inmodules/oms/services/createShipment.ts:477-557.- 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 auseEffectand the next render reaches it, React errors. Move every hook above any conditional return. .admin.graphqltype referenced from a non-admin.graphqlfile -> "Unknown type X" at storefront schema build.buildStoreFrontSchemafilters 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.graphqltoo. 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 bymodules/checkout/. When dropping a column,grep -rn "table\\.column" packages/evershop/srcacross the whole tree, not just the owning module. hookable()keys hooks by the wrapped function's.name, so a…Impldeclaration silently kills its public hooks.hookable(fooImpl)registers under'fooImpl', but ahookBeforeFoohelper that callshookBefore('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() {…}(thecheckout.ts:10idiom -const _checkout = async function checkout(). A plainfunction fooImpl() {}declaration sets.name = 'fooImpl'and breaks it. Guard test:modules/oms/tests/unit/hookNameAlignment.test.js.- A widget
settingComponentthat reads a list setting aswatch('settings.x') ?? initialworks in the page-builder drawer but throwsitems.map is not a functionon the legacy/admin/widgets/editpage. 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 hiddendefaultValue={JSON.stringify(...)}input - and??only guards null, so the string reachesRepeatableAccordion. Read list settings withuseArraySetting('settings.x', initial)and mutate viaasArray(getValues('settings.x'), initial)(both from@components/common/page-builder), or hold the array withuseFieldArraylikeSlideshowSetting.
Choose the right search tool for the task rather than always defaulting to one:
- CodeSemanticSearch - for "how/where/what" questions, exploring unfamiliar code, finding code by meaning when you don't know the exact symbol name. Reuse the user's exact wording when possible.
- CodeGraphSearch - for tracing call chains, dependencies, impact analysis ("what breaks if I change X?"), and execution paths. Use
graph_depth=1for direct callers/callees,2for transitive context (default),3for broad impact analysis. Extract symbols from the query first - if none match, it degrades to semantic matching. - grep - for exact symbol lookups, exact string/pattern matching, counting occurrences, and searching for specific imports/exports when you know the name. Also the fallback when semantic/graph search returns no results.
- glob - for finding files by name pattern (e.g.,
**/*.graphql,src/**/*.ts) or locating files by naming convention. Use overlsfor file discovery. - read - for reading known file paths or specific line ranges. Prefer over re-reading chunks already returned by search tools.
- Combine tools when needed:
globto locate files ->grepfor exact symbols inside them ->CodeSemanticSearchfor behavior questions ->CodeGraphSearchfor impact/dependency tracing. - Break down large questions into smaller, focused searches. Don't combine multiple intents (e.g., "what is X and how does it dispatch events?") in one query - split them and run in parallel.
- Avoid single-word queries (e.g., just
AuthService) for semantic/graph search - use a full question instead (e.g., "How does AuthService authenticate users?").
- The published package is built from
src/todist/via SWC (npm run compile). Runtime loads.jsfromdist/. When editing, edit.tsinsrc/. - Never bypass
huskyhooks (--no-verify) or skip type/lint failures without an explicit go-ahead. packages/postgres-query-builder/is a separate workspace package. Its source is a single file:src/index.ts. It exportsselect,insert,update,del,startTransaction, etc. The evershop package re-exports these fromlib/postgres/query.ts.