@mercurjs/docs 2.2.0-rc.1 → 2.2.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,115 @@
1
+ ---
2
+ title: "Best Practices Overview"
3
+ description: "How to build with Mercur — the layered architecture, the non-negotiable rules, and where each piece of logic belongs."
4
+ ---
5
+
6
+ This section is a practical guide for developing on Mercur, written for both **human developers** and **AI coding agents**. It captures the conventions the codebase already follows so that new code reads as if it belongs, stays testable, and survives upgrades of the underlying Medusa framework.
7
+
8
+ <Note>
9
+ Mercur is a Medusa plugin. Every rule here is either a Medusa framework requirement or a Mercur convention that keeps the marketplace layer consistent. When Medusa's docs and this guide agree, follow both; when in doubt, mirror an existing module, workflow, or route in `packages/core`.
10
+ </Note>
11
+
12
+ ## The layered architecture
13
+
14
+ Every feature in Mercur flows through the same four layers, top to bottom. Data and requests move **down**; results move back **up**. A layer may only talk to the layer directly beneath it.
15
+
16
+ ```mermaid
17
+ graph TD
18
+ F["Frontend<br/>(Admin · Vendor panels, Storefront)"]
19
+ A["API Route<br/>(/admin/* · /vendor/* · /store/*)"]
20
+ W["Workflow<br/>(orchestration + compensation)"]
21
+ M["Module<br/>(thin data access / CRUD)"]
22
+ DB[(PostgreSQL)]
23
+
24
+ F -->|"typed SDK request"| A
25
+ A -->|"run(workflow)"| W
26
+ W -->|"module service calls (steps)"| M
27
+ M --> DB
28
+ ```
29
+
30
+ <CardGroup cols={2}>
31
+ <Card title="Module" icon="cube">
32
+ Owns one domain's data. Thin CRUD only — no orchestration, no cross-module calls.
33
+ </Card>
34
+ <Card title="Workflow" icon="diagram-project">
35
+ Orchestrates a business operation across modules, step by step, with automatic rollback (compensation) on failure.
36
+ </Card>
37
+ <Card title="API Route" icon="plug">
38
+ A thin HTTP adapter: validate input, run a workflow (or query for reads), shape the response.
39
+ </Card>
40
+ <Card title="Frontend" icon="window">
41
+ Admin/Vendor panels and storefront. Talks to the API only through the typed SDK — never raw `fetch`.
42
+ </Card>
43
+ </CardGroup>
44
+
45
+ Why this shape matters:
46
+
47
+ - **Testability** — business logic lives in workflows, which can be run in isolation without an HTTP request.
48
+ - **Reusability** — a workflow can be called from a route, a subscriber, or a scheduled job.
49
+ - **Upgrade safety** — modules stay thin, so Medusa framework upgrades rarely touch your logic.
50
+ - **Rollback** — because mutations are workflow steps, a failure halfway through automatically undoes the earlier steps.
51
+
52
+ ## The non-negotiables
53
+
54
+ These are hard rules. Breaking one produces code that looks like it works but silently violates the architecture — no rollback, broken upgrades, or data written outside a workflow.
55
+
56
+ <Warning>
57
+ **All mutations go through a workflow.** Never write to the database directly from an API route, a subscriber, or a scheduled job. Reads may query directly; writes must run a workflow so they get validation, compensation, and event emission.
58
+ </Warning>
59
+
60
+ <Warning>
61
+ **Only `GET`, `POST`, and `DELETE`.** Mercur (following Medusa) does not use `PUT` or `PATCH`. Updates are modeled as `POST` to the resource. Keep every route to these three verbs.
62
+ </Warning>
63
+
64
+ <Warning>
65
+ **No cross-module service calls.** A module service must never import or call another module's service. Modules are isolated. Cross-module reads happen through **Query** (the graph); cross-module relationships are declared with **module links**; cross-module writes are coordinated in a **workflow**.
66
+ </Warning>
67
+
68
+ Two more that follow from the above:
69
+
70
+ - **Modules are thin.** A module service is CRUD plus small, self-contained helpers. If a method touches more than one module's data, it belongs in a workflow, not the service.
71
+ - **One mutation per step.** Each workflow step performs a single mutation and defines how to compensate it. This is what makes rollback reliable.
72
+
73
+ ## Logic-placement cheat sheet
74
+
75
+ When you're about to write a piece of logic, find the concern in this table before you decide where the code goes. The **"Never put it in"** column is the part people get wrong.
76
+
77
+ | Concern | Put it in | Never put it in |
78
+ | --- | --- | --- |
79
+ | **Input shape / type validation** | API route (Zod schema on the request) | Module service, workflow |
80
+ | **Ownership / scoping** (e.g. this seller may only see its own orders) | API middleware (filters) + workflow guard | The frontend alone |
81
+ | **Business orchestration** (multi-step, multi-module operations) | Workflow (steps + compensation) | API route handler, module service |
82
+ | **A single data mutation** | Workflow step (module service call inside it) | API route, subscriber, job |
83
+ | **Cross-module reads** | Query (`query.graph`) | Direct service-to-service imports |
84
+ | **Cross-module relationships** | Module link (`defineLink`) | A foreign key inside one module's model |
85
+ | **Reacting to something that happened** (emails, sync, indexing) | Subscriber (listens to an event, runs a workflow) | Inline inside the route that caused it |
86
+ | **Periodic / time-based work** (polling, daily settlement) | Scheduled job (runs a workflow) | A subscriber, a route |
87
+ | **Emitting domain events** | Workflow step (`emitEventStep`) | Module service, subscriber |
88
+ | **Response shaping / field selection** | API route `queryConfig` (fields) | The module service |
89
+ | **Presentation, composition, UX** | Frontend (panels / storefront) | The API or any backend layer |
90
+
91
+ <Tip>
92
+ A quick mental test: *"Does this change data?"* → it must run inside a workflow. *"Does this react to a change?"* → it's a subscriber. *"Does this run on a schedule?"* → it's a job. *"Is this just reading and shaping data for a screen?"* → it's a route + Query. Everything else is either module CRUD or frontend.
93
+ </Tip>
94
+
95
+ ## How to read the rest of this section
96
+
97
+ Each page that follows drills into one layer and its rules:
98
+
99
+ <CardGroup cols={2}>
100
+ <Card title="Modules" href="/rc/resources/best-practices/modules" icon="cube">
101
+ Thin CRUD, naming, and what must never live in a service.
102
+ </Card>
103
+ <Card title="Module links" href="/rc/resources/best-practices/module-links" icon="link">
104
+ `defineLink`, link direction, and filtering by links.
105
+ </Card>
106
+ <Card title="Workflows" href="/rc/resources/best-practices/workflows" icon="diagram-project">
107
+ Composition constraints, steps, compensation, and the query engine.
108
+ </Card>
109
+ <Card title="API routes" href="/rc/resources/best-practices/api-routes" icon="plug">
110
+ Thin adapters, Zod validation, middlewares as filters, `queryConfig`.
111
+ </Card>
112
+ <Card title="Subscribers & jobs" href="/rc/resources/best-practices/subscribers-and-jobs" icon="clock">
113
+ Event-driven side effects and scheduled work done safely.
114
+ </Card>
115
+ </CardGroup>
@@ -0,0 +1,133 @@
1
+ ---
2
+ title: "Subscribers & jobs"
3
+ description: "React to events and run periodic work safely — fetch from { id }, mutate via workflows, log-don't-throw, idempotency and loop guards, cron jobs."
4
+ ---
5
+
6
+ Subscribers and scheduled jobs are the two ways work happens *outside* a request. Both follow the same core rule as everything else: **they never mutate directly — they run a [workflow](/rc/resources/best-practices/workflows).**
7
+
8
+ ## Subscribers — react to events
9
+
10
+ A subscriber listens for a domain event (emitted by a workflow via `emitEventStep`) and runs an asynchronous side effect: send a notification, sync a search index, create a link. It lives in `src/subscribers/` and exports a handler plus a `config` naming the event.
11
+
12
+ ```ts title="src/subscribers/brand-created.ts"
13
+ import { SubscriberArgs, SubscriberConfig } from "@medusajs/framework"
14
+
15
+ export default async function brandCreatedHandler({
16
+ event,
17
+ container,
18
+ }: SubscriberArgs<{ id: string }>) {
19
+ const { id } = event.data
20
+ // ...fetch, then run a workflow
21
+ }
22
+
23
+ export const config: SubscriberConfig = {
24
+ event: "brand.created",
25
+ }
26
+ ```
27
+
28
+ ### Fetch full data from `{ id }`
29
+
30
+ <Warning>
31
+ Event payloads carry **ids, not entities.** A subscriber receives `{ id }` (sometimes a couple of ids) and must fetch the full record it needs via Query. Never rely on a fat event payload — it goes stale and couples the emitter to every consumer's needs.
32
+ </Warning>
33
+
34
+ ```ts
35
+ const query = container.resolve(ContainerRegistrationKeys.QUERY)
36
+ const { data: [brand] } = await query.graph({
37
+ entity: "brand",
38
+ fields: ["id", "name", "products.*"],
39
+ filters: { id: event.data.id },
40
+ })
41
+ ```
42
+
43
+ ### Mutate via workflows, never directly
44
+
45
+ If the subscriber needs to change data, it runs a workflow — same as a route would. The subscriber is the trigger; the workflow is the work.
46
+
47
+ ```ts
48
+ await createBrandNotificationWorkflow(container).run({
49
+ input: { brand_id: brand.id },
50
+ })
51
+ ```
52
+
53
+ ### Log, don't throw
54
+
55
+ <Warning>
56
+ A subscriber runs detached from the request. Throwing doesn't surface to a user — it just fails silently or spams retries. **Catch errors and log them** (resolve the `logger`), then decide explicitly whether to rethrow for a retry or swallow.
57
+ </Warning>
58
+
59
+ ```ts
60
+ const logger = container.resolve("logger")
61
+ try {
62
+ await doWork()
63
+ } catch (e) {
64
+ logger.error(`brand-created subscriber failed for ${event.data.id}: ${e}`)
65
+ }
66
+ ```
67
+
68
+ ### Idempotency and loop guards
69
+
70
+ Events can be delivered more than once, and a subscriber that mutates data can re-trigger the very event it listens to. Two defences:
71
+
72
+ - **Idempotency** — make the handler safe to run twice. Check current state before acting (e.g. "is this product already linked to a brand?" before creating the link), or clear the marker that triggered the work so a redelivered event finds nothing left to do.
73
+ - **Loop guards** — if handling event X causes a mutation that emits X again, gate on a condition that becomes false after the first run, or key off a marker you set. Never emit the same event unconditionally from its own subscriber.
74
+
75
+ <Tip>
76
+ A good idempotency check reads the current state through Query first and returns early if the work is already done. This makes redelivery harmless and removes the need for exactly-once guarantees.
77
+ </Tip>
78
+
79
+ ## Scheduled jobs — periodic work
80
+
81
+ A scheduled job runs on a cron interval to do time-based work: poll for records that became ready, reconcile drifted counters, emit a "settle now" event. It lives in `src/jobs/`, exports a handler taking the container, and a `config` with a `name` and a cron `schedule`.
82
+
83
+ ```ts title="src/jobs/deactivate-stale-brands.ts"
84
+ import { MedusaContainer } from "@medusajs/framework/types"
85
+
86
+ export default async function deactivateStaleBrands(container: MedusaContainer) {
87
+ const logger = container.resolve("logger")
88
+ const query = container.resolve(ContainerRegistrationKeys.QUERY)
89
+
90
+ const { data: stale } = await query.graph({
91
+ entity: "brand",
92
+ fields: ["id"],
93
+ filters: { is_active: true /* + your staleness condition */ },
94
+ })
95
+
96
+ // pass all ids at once — the workflow handles the batch, not the job
97
+ await deactivateBrandsWorkflow(container).run({
98
+ input: { ids: stale.map((b) => b.id) },
99
+ })
100
+
101
+ logger.info(`deactivated ${stale.length} stale brands`)
102
+ }
103
+
104
+ export const config = {
105
+ name: "deactivate-stale-brands",
106
+ schedule: "0 1 * * *", // daily at 01:00 UTC
107
+ }
108
+ ```
109
+
110
+ ### When to use a job vs a subscriber
111
+
112
+ | Trigger | Use |
113
+ | --- | --- |
114
+ | "Something happened" (a workflow emitted an event) | **Subscriber** |
115
+ | "It's time" / "poll for anything that became ready" | **Scheduled job** |
116
+
117
+ A time-based pipeline often combines both: a daily job finds records that became eligible and emits an event (say `brand.review_due`), and a *subscriber* turns each event into a workflow run. Polling for "what's ready" is the job; reacting to each item is the subscriber.
118
+
119
+ ### Job best practices
120
+
121
+ - **Do the work in batches** and bound result sets — a job that `SELECT`s an unbounded table will eventually time out. Page through with `LIMIT`/`OFFSET` or a cursor.
122
+ - **Idempotent by design** — a job re-runs on every tick; it must only act on records still needing action (filter on the not-yet-processed state).
123
+ - **Mutations run workflows**, reads run Query — same as everywhere.
124
+ - **Log a summary** each run (how many processed) so drift is visible.
125
+
126
+ ## Checklist
127
+
128
+ - Subscriber `config.event` names a real emitted event; handler fetches full data from `{ id }` via Query.
129
+ - Subscriber mutations run a workflow; errors are caught and logged, not thrown blindly.
130
+ - Handler is idempotent and can't retrigger its own event without a guard.
131
+ - Job exports `{ name, schedule }`; cron is correct (UTC).
132
+ - Job filters to records still needing work, batches large sets, and logs a summary.
133
+ - Neither a subscriber nor a job writes to the database outside a workflow.
@@ -0,0 +1,120 @@
1
+ ---
2
+ title: "Types & augmentation"
3
+ description: "Type the panels against your own backend extensions — augment framework DTOs with declaration merging so every SDK endpoint carries your custom data."
4
+ ---
5
+
6
+ The panels are fully typed against the API through `@mercurjs/types` and the typed SDK. When you extend the backend — adding [custom fields](/rc/resources/best-practices/custom-fields), a [linked module](/rc/resources/best-practices/module-links), an extra field on a DTO — those additions aren't in the shipped types yet. You close the gap in the **frontend** with a small declaration-merging `.d.ts` file. Do that once and *every* SDK call that returns the entity carries your field, typed.
7
+
8
+ <Warning>
9
+ **Never use `any` to paper over a missing field.** Casting a response to `any` (or `as { custom_fields: … }` at each call site) throws away type-checking and has to be repeated everywhere. Augment the type once instead.
10
+ </Warning>
11
+
12
+ ## The scenario: a custom field, typed end-to-end
13
+
14
+ Say you added a custom field on the backend — for example `is_featured` on `product` (see [Custom fields](/rc/resources/best-practices/custom-fields)). The value now comes back from the API, but the panel's `ProductDTO` doesn't know about it, so `product.is_featured` is a type error.
15
+
16
+ Fix it in the panel with a declaration-merging file.
17
+
18
+ ### Why merging works here
19
+
20
+ The `ProductDTO` the SDK returns ultimately resolves to Medusa's upstream `ProductDTO`, which is declared as an **`interface`** in `@medusajs/types`. Interfaces are open, so you can **merge into it** with `declare module "@medusajs/types"`.
21
+
22
+ Because everything downstream — `@mercurjs/types`, the SDK response wrappers (`AdminProductResponse`, list responses), and the panel hooks — refers back to that same interface, your added members appear in all of them at once. You augment in one place and every product-returning endpoint is typed.
23
+
24
+ ### Add the `.d.ts` in the panel
25
+
26
+ Drop a declaration file anywhere under the panel's `src/` (it's picked up by the app's `tsconfig`):
27
+
28
+ ```ts title="apps/vendor/src/types/custom-fields.d.ts"
29
+ import "@medusajs/types"
30
+
31
+ declare module "@medusajs/types" {
32
+ interface ProductDTO {
33
+ custom_fields?: {
34
+ is_featured?: boolean
35
+ }
36
+ }
37
+ }
38
+ ```
39
+
40
+ <Warning>
41
+ Two rules or the augmentation silently does nothing:
42
+
43
+ - The module name in `declare module "..."` must be the package that declares the interface you're merging into — here `@medusajs/types`, the owner of `UpstreamProductDTO`, **not** `@mercurjs/types` (which only aliases it).
44
+ - The file must be a module. Add an `import "@medusajs/types"` (or a trailing `export {}`) so TypeScript treats it as one.
45
+ </Warning>
46
+
47
+ ### Now the whole SDK is typed
48
+
49
+ With that one file in place, no cast is needed anywhere:
50
+
51
+ ```ts title="Every product endpoint carries the field"
52
+ const { products } = await sdk.vendor.products.query()
53
+ products[0].custom_fields?.is_featured // ✅ typed, no cast
54
+
55
+ const { product } = await sdk.vendor.products.$id.query({ $id: id })
56
+ product.custom_fields?.is_featured // ✅ typed everywhere ProductDTO flows
57
+ ```
58
+
59
+ <Tip>
60
+ Types and runtime are separate concerns: this `.d.ts` makes the field *typed*, but it only *arrives* if the fetch asks for it. Let the [custom-fields `link` / registry merge](/rc/resources/best-practices/custom-fields#the-extension-api-link-property) add the fields to the built-in panel fetches rather than hand-adding `+field.*` — the vendor product query in particular rejects arbitrary `*`-relation overrides.
61
+ </Tip>
62
+
63
+ ## Linked data resolves the same way
64
+
65
+ The augmentation isn't limited to a custom field's own value — it's how you make **linked-module data** typed too. When a [custom-fields config](/rc/resources/best-practices/custom-fields#the-extension-api-link-property) declares a `link`, the panel fetches that module's data alongside the entity ([SPEC-021](/rc/references/panel-extension-api)): `link: "brand"` merges `brand.*` into the built-in product fetch for you — no hand-written field list.
66
+
67
+ Pair that one config line with a matching augmentation, and the linked data is both **present at runtime** and **typed everywhere `ProductDTO` is imported**:
68
+
69
+ ```ts title="src/custom-fields/product.tsx — declare the link (runtime)"
70
+ export default defineCustomFieldsConfig({
71
+ model: "product",
72
+ link: "brand", // brand.* is fetched with every product
73
+ list: {
74
+ columns: [
75
+ { id: "brand_name", header: "Brand", component: ({ row }) => row.brand?.name },
76
+ ],
77
+ },
78
+ })
79
+ ```
80
+
81
+ ```ts title="src/types/brand.d.ts — declare the shape (types)"
82
+ import "@medusajs/types"
83
+
84
+ declare module "@medusajs/types" {
85
+ interface ProductDTO {
86
+ brand?: { id: string; name: string }
87
+ }
88
+ }
89
+ ```
90
+
91
+ Now any code that imports `ProductDTO` — a page, a hook, a column renderer — sees `product.brand` resolved, with the data already fetched by the `link`:
92
+
93
+ ```ts
94
+ const { product } = await sdk.vendor.products.$id.query({ $id: id })
95
+ product.brand?.name // ✅ present (via link) and typed (via augmentation)
96
+ ```
97
+
98
+ <Tip>
99
+ The `link` does the fetching, the `.d.ts` does the typing — you write each once, per model, and every product-returning endpoint in the panel is covered. This is the payoff of augmentation: register the relationship in one place, consume it as a plain typed property everywhere.
100
+ </Tip>
101
+
102
+ ## Where each type goes
103
+
104
+ | You're adding… | Put it in |
105
+ | --- | --- |
106
+ | A field your API now returns on an entity | A panel `.d.ts` merging into the framework DTO (`declare module "@medusajs/types"`) |
107
+ | A one-off request/response shape for a custom route | Infer it from the Zod validator (`z.infer<typeof Schema>`) and export it |
108
+ | A brand-new Mercur/domain DTO | The domain folder in `@mercurjs/types`, re-exported from `index.ts` |
109
+
110
+ <Warning>
111
+ DTOs and enums the platform already ships (`ProductDTO`, `SellerStatus`, `MercurModules`, `HttpTypes`) are imported from `@mercurjs/types` — never redeclared. In the dashboards, `HttpTypes` comes from `@mercurjs/types` too, which is what keeps request/response types aligned with Mercur's extended routes.
112
+ </Warning>
113
+
114
+ ## Checklist
115
+
116
+ - No `any`, and no per-call-site casts for extended data.
117
+ - Backend additions typed in the panel via a `.d.ts` merging into the **framework** interface that owns the DTO.
118
+ - Augmentation files name the correct package and are real modules (`import`/`export {}`).
119
+ - Extended fields are requested with `+field.*` so they actually arrive.
120
+ - Shipped types imported from `@mercurjs/types`; one-off shapes inferred from Zod.
@@ -0,0 +1,173 @@
1
+ ---
2
+ title: "Workflows"
3
+ description: "Where all business logic and mutations live — composition-function constraints, one-mutation-per-step with compensation, reusing built-in steps, and the query engine."
4
+ ---
5
+
6
+ A workflow is the orchestration layer: it coordinates a business operation across one or more modules as a series of **steps**, with automatic rollback (**compensation**) if any step fails. Every mutation in Mercur runs inside a workflow — this is the single most important rule in the [architecture](/rc/resources/best-practices/overview).
7
+
8
+ <Warning>
9
+ **All mutations go through a workflow.** API routes, subscribers, and scheduled jobs never write to the database directly — they *run a workflow*. That is what gives every mutation validation, event emission, and rollback.
10
+ </Warning>
11
+
12
+ <Note>
13
+ Workflows are a [Medusa framework primitive](https://docs.medusajs.com/learn/fundamentals/workflows). This page focuses on the constraints and conventions that trip people (and agents) up.
14
+ </Note>
15
+
16
+ ## The composition function is not normal JavaScript
17
+
18
+ The function you pass to `createWorkflow` is a **composition function**. It runs once at build time to wire steps together — it does **not** execute your business logic at request time. Because of that, it has hard constraints:
19
+
20
+ <Warning>
21
+ Inside a `createWorkflow` composition function you must **not**:
22
+
23
+ - use `async` / `await`
24
+ - use arrow functions for the composition body (use a named `function`)
25
+ - use `if` / `else`, `for`, `while`, or `try/catch`
26
+ - use `new Date()`, `Math.random()`, or any non-deterministic call
27
+ - access properties of a step's output directly (e.g. `result.id`)
28
+
29
+ These run at composition time, not execution time, so they either do nothing useful or break replay/rollback.
30
+ </Warning>
31
+
32
+ Anything that looks like normal logic goes into a **step** (for side effects) or a **`transform`** (for shaping data between steps):
33
+
34
+ ```ts title="src/workflows/create-brands.ts"
35
+ import {
36
+ createWorkflow,
37
+ transform,
38
+ WorkflowResponse,
39
+ } from "@medusajs/framework/workflows-sdk"
40
+
41
+ import { createBrandsStep } from "./steps"
42
+
43
+ export const createBrandsWorkflow = createWorkflow(
44
+ "create-brands",
45
+ function (input: CreateBrandsWorkflowInput) {
46
+ // ✅ shape data with transform, not inline expressions
47
+ const toCreate = transform(input, ({ brands }) =>
48
+ brands.map((b) => ({ ...b, is_active: b.is_active ?? true }))
49
+ )
50
+
51
+ const brands = createBrandsStep(toCreate)
52
+
53
+ return new WorkflowResponse(brands)
54
+ }
55
+ )
56
+ ```
57
+
58
+ <Tip>
59
+ Need a conditional or a computed value? Use `transform` to derive data, `when` to run a step conditionally, and put date/random/id generation **inside a step**. Never branch in the composition body itself.
60
+ </Tip>
61
+
62
+ ## One mutation per step + compensation
63
+
64
+ A step is the unit of work and the unit of rollback. The rule: **each step performs a single mutation and defines how to undo it.** `createStep` takes an invoke function and a compensation function; the invoke returns a `StepResponse` whose second argument is the data the compensation needs.
65
+
66
+ ```ts title="src/workflows/steps/create-brands.ts"
67
+ import { StepResponse, createStep } from "@medusajs/framework/workflows-sdk"
68
+ import BrandModuleService from "../../modules/brand/service"
69
+ import { BRAND_MODULE } from "../../modules/brand"
70
+
71
+ export const createBrandsStep = createStep(
72
+ "create-brands",
73
+ async (data: CreateBrandInput[], { container }) => {
74
+ const service = container.resolve<BrandModuleService>(BRAND_MODULE)
75
+ const brands = await service.createBrands(data)
76
+ // 2nd arg → passed to the compensation function below
77
+ return new StepResponse(brands, brands.map((b) => b.id))
78
+ },
79
+ async (ids: string[] | undefined, { container }) => {
80
+ if (!ids?.length) {
81
+ return
82
+ }
83
+ const service = container.resolve<BrandModuleService>(BRAND_MODULE)
84
+ await service.deleteBrands(ids)
85
+ }
86
+ )
87
+ ```
88
+
89
+ If a later step in the workflow throws, Medusa runs the compensation functions of the already-completed steps in reverse — the `createBrands` above is undone by `deleteBrands`. Splitting mutations one-per-step is what makes this reliable: a step that does two writes can only half-compensate.
90
+
91
+ ## Reuse built-in steps
92
+
93
+ Don't hand-roll what the framework already ships. Medusa's `core-flows` exports composable steps you should reuse instead of writing your own:
94
+
95
+ | Need | Built-in step |
96
+ | --- | --- |
97
+ | Create/remove module links | `createRemoteLinkStep` / `dismissRemoteLinkStep` |
98
+ | Emit a domain event | `emitEventStep` |
99
+ | Call another workflow as a step | `otherWorkflow.runAsStep({ input })` |
100
+
101
+ ```ts title="Composing built-in steps + a sub-workflow"
102
+ import { emitEventStep } from "@medusajs/medusa/core-flows"
103
+
104
+ export const createBrandsWorkflow = createWorkflow(
105
+ "create-brands",
106
+ function (input: CreateBrandsWorkflowInput) {
107
+ const brands = createBrandsStep(input.brands)
108
+
109
+ // reuse a whole workflow as one step
110
+ notifyOwnersWorkflow.runAsStep({ input: { brands } })
111
+
112
+ // reuse the built-in event step
113
+ emitEventStep({ eventName: "brand.created", data: { ids: brands } })
114
+
115
+ return new WorkflowResponse(brands)
116
+ }
117
+ )
118
+ ```
119
+
120
+ <Tip>
121
+ Prefer `runAsStep` over duplicating logic. If two workflows need the same sequence, extract it into its own workflow and call it as a step from both — you get one place to maintain and correct compensation for free.
122
+ </Tip>
123
+
124
+ ## Hooks — let others extend your workflow
125
+
126
+ Expose extension points with `createHook` so consumers can inject behaviour (validation, side effects) without forking the workflow. Add a `validate` hook before the mutation and a `brandsCreated` hook after it:
127
+
128
+ ```ts
129
+ const validate = createHook("validate", { input })
130
+
131
+ const brands = createBrandsStep(input.brands)
132
+
133
+ const brandsCreated = createHook("brandsCreated", {
134
+ brands,
135
+ additional_data: input.additional_data,
136
+ })
137
+
138
+ return new WorkflowResponse(brands, {
139
+ hooks: [validate, brandsCreated],
140
+ })
141
+ ```
142
+
143
+ Consumers register a handler on the hook to run custom logic at that point. This is the sanctioned way to extend a workflow — see [Extend a workflow](/rc/resources/customization/extend-a-workflow).
144
+
145
+ ## The query engine
146
+
147
+ Reads inside a step (and anywhere else) go through **Query** — the graph engine that resolves data across modules and links. Resolve it from the container and call `query.graph`:
148
+
149
+ ```ts title="Reading across modules inside a step"
150
+ import { ContainerRegistrationKeys } from "@medusajs/framework/utils"
151
+
152
+ const query = container.resolve(ContainerRegistrationKeys.QUERY)
153
+
154
+ const { data: brands } = await query.graph({
155
+ entity: "brand",
156
+ fields: ["id", "name", "products.*"], // follows the product ↔ brand link
157
+ filters: { is_active: true },
158
+ })
159
+ ```
160
+
161
+ <Warning>
162
+ Query is for **reads**. Never try to mutate through it, and never resolve another module's service inside a step to read its data — go through Query so module isolation and links are respected.
163
+ </Warning>
164
+
165
+ ## Checklist for a workflow
166
+
167
+ - Composition function is a named `function`, with no `async`/`await`, `if`, loops, `try/catch`, `new Date()`, or step-output property access.
168
+ - Data shaping between steps uses `transform`; conditional steps use `when`.
169
+ - Each step does exactly one mutation and defines a compensation function.
170
+ - `StepResponse` passes the compensation the data it needs to undo the work.
171
+ - Built-in steps (`createRemoteLinkStep`, `emitEventStep`) and `runAsStep` are reused instead of reimplemented.
172
+ - Reads use `query.graph`; no cross-module service calls.
173
+ - Extension points are exposed as hooks, not by forking.
package/llms.txt CHANGED
@@ -28,6 +28,15 @@ package (`node_modules/@mercurjs/docs/`).
28
28
  - [LLMs](content/resources/ai/llms.mdx) — Feed Mercur documentation directly to LLMs for accurate, context-aware assistance.
29
29
  - [MCP Server](content/resources/ai/mcp.mdx) — Connect your AI environment to Mercur documentation via Model Context Protocol.
30
30
  - [Building with AI](content/resources/ai/overview.mdx) — Mercur ships version-matched docs inside your project so AI coding agents build from accurate APIs instead of stale training data.
31
+ - [API routes](content/resources/best-practices/api-routes.mdx) — Thin HTTP adapters — typed request/response generics, Zod validation with createFindParams, filterable fields, middlewares as filters, and queryConfig.
32
+ - [Custom fields](content/resources/best-practices/custom-fields.mdx) — The full loop — add a custom field in core, render it in the panels with defineCustomFieldsConfig, pull linked data with the link property, and type it end-to-end.
33
+ - [Frontend](content/resources/best-practices/frontend.mdx) — Build panel UI the right way — @medusajs/ui usage, custom-field extensions (forms, tables, read-only section fields), and new pages, with the correct imports.
34
+ - [Module links](content/resources/best-practices/module-links.mdx) — Relate modules without coupling them — defineLink, the link-direction rule, built-in link steps, and filtering by links.
35
+ - [Modules](content/resources/best-practices/modules.mdx) — Keep modules thin — data access and CRUD only. Naming, decorators, and what must never live in a module service.
36
+ - [Best Practices Overview](content/resources/best-practices/overview.mdx) — How to build with Mercur — the layered architecture, the non-negotiable rules, and where each piece of logic belongs.
37
+ - [Subscribers & jobs](content/resources/best-practices/subscribers-and-jobs.mdx) — React to events and run periodic work safely — fetch from { id }, mutate via workflows, log-don't-throw, idempotency and loop guards, cron jobs.
38
+ - [Types & augmentation](content/resources/best-practices/types.mdx) — Type the panels against your own backend extensions — augment framework DTOs with declaration merging so every SDK endpoint carries your custom data.
39
+ - [Workflows](content/resources/best-practices/workflows.mdx) — Where all business logic and mutations live — composition-function constraints, one-mutation-per-step with compensation, reusing built-in steps, and the query engine.
31
40
  - [Custom Fields](content/resources/customization/custom-fields.mdx) — Extend any Medusa entity with additional fields without modifying core code.
32
41
  - [Extend a workflow](content/resources/customization/extend-a-workflow.mdx) — Inject custom logic into an existing Mercur workflow through hooks — without rewriting it.
33
42
  - [Extending Panels](content/resources/customization/extending-panels.mdx) — Add pages, inject widgets, customize navigation, and extend forms and tables in the admin and vendor panels.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mercurjs/docs",
3
- "version": "2.2.0-rc.1",
3
+ "version": "2.2.0-rc.2",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/mercurjs/mercur",