@mercurjs/docs 2.2.0-canary.51 → 2.2.0-canary.53
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.
- package/content/resources/best-practices/api-routes.mdx +306 -0
- package/content/resources/best-practices/custom-fields.mdx +241 -0
- package/content/resources/best-practices/frontend.mdx +286 -0
- package/content/resources/best-practices/module-links.mdx +157 -0
- package/content/resources/best-practices/modules.mdx +144 -0
- package/content/resources/best-practices/overview.mdx +115 -0
- package/content/resources/best-practices/subscribers-and-jobs.mdx +133 -0
- package/content/resources/best-practices/types.mdx +120 -0
- package/content/resources/best-practices/workflows.mdx +173 -0
- package/content/resources/tutorials/add-order-detail-button.mdx +113 -0
- package/llms.txt +10 -0
- package/package.json +1 -1
|
@@ -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.
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Add a button to order details"
|
|
3
|
+
description: "Drop a 'Copy link' button onto the vendor order detail page with a widget — no forking, no page override."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
The order detail page is a built-in panel screen you don't own. To add a small piece of UI to it — a button, a badge, a note — you don't copy the page. You drop a **widget** at one of its zones, and the SDK renders your component there while the rest of the page keeps working exactly as shipped.
|
|
7
|
+
|
|
8
|
+
This tutorial adds a **"Copy link"** button to the order summary section that copies a link to the order.
|
|
9
|
+
|
|
10
|
+
<Info>
|
|
11
|
+
**Additive, not a replacement.** A widget layers your component onto a built-in page at a documented zone. Reach for it first whenever you just want to *add* something to an existing screen.
|
|
12
|
+
</Info>
|
|
13
|
+
|
|
14
|
+
## Add the button
|
|
15
|
+
|
|
16
|
+
<Steps>
|
|
17
|
+
<Step title="Create the widget file">
|
|
18
|
+
Drop a file under `src/widgets/`. Export the component as the **default** and a `config` built with `defineWidgetConfig`. Target `orders.detail.summary.after` — your component renders in the order summary section footer and receives the loaded order as `data`.
|
|
19
|
+
|
|
20
|
+
```tsx apps/vendor/src/widgets/order-copy-link.tsx
|
|
21
|
+
import { defineWidgetConfig } from "@mercurjs/dashboard-sdk"
|
|
22
|
+
import type { HttpTypes } from "@medusajs/types"
|
|
23
|
+
import { Button, Container, toast } from "@medusajs/ui"
|
|
24
|
+
|
|
25
|
+
export const config = defineWidgetConfig({
|
|
26
|
+
zone: "orders.detail.summary.after",
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
const OrderCopyLink = ({ data: order }: { data?: HttpTypes.AdminOrder }) => {
|
|
30
|
+
if (!order) return null
|
|
31
|
+
|
|
32
|
+
const orderLink = `${window.location.origin}/orders/${order.id}`
|
|
33
|
+
|
|
34
|
+
const handleCopy = async () => {
|
|
35
|
+
try {
|
|
36
|
+
await navigator.clipboard.writeText(orderLink)
|
|
37
|
+
toast.success("Link copied")
|
|
38
|
+
} catch {
|
|
39
|
+
toast.error("Couldn't copy the link")
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<Container className="flex items-center justify-end p-3">
|
|
45
|
+
<Button size="small" variant="secondary" onClick={handleCopy}>
|
|
46
|
+
Copy link
|
|
47
|
+
</Button>
|
|
48
|
+
</Container>
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export default OrderCopyLink
|
|
53
|
+
```
|
|
54
|
+
</Step>
|
|
55
|
+
<Step title="Understand the zone id">
|
|
56
|
+
A zone id is `<domain>.<view>.<slot>.<placement>`. The last segment is the placement:
|
|
57
|
+
|
|
58
|
+
| Placement | Effect |
|
|
59
|
+
|-----------|--------|
|
|
60
|
+
| `before` | Renders before the built-in content of the zone |
|
|
61
|
+
| `after` | Renders after the built-in content |
|
|
62
|
+
|
|
63
|
+
Multiple `before` / `after` widgets on the same zone stack in registration order.
|
|
64
|
+
</Step>
|
|
65
|
+
<Step title="Reload the panel">
|
|
66
|
+
Start the project (`bun run dev`) and open any order in the vendor portal. Widget files hot-reload — the "Copy link" button appears in the summary section footer, and the rest of the page is untouched.
|
|
67
|
+
</Step>
|
|
68
|
+
</Steps>
|
|
69
|
+
|
|
70
|
+
## Order detail zones
|
|
71
|
+
|
|
72
|
+
Zones mounted on the vendor order detail page:
|
|
73
|
+
|
|
74
|
+
| Zone | Where it renders |
|
|
75
|
+
|------|------------------|
|
|
76
|
+
| `orders.detail.summary.before` / `.after` | Inside the order summary section (around its footer) |
|
|
77
|
+
| `orders.detail.main.before` / `.after` | Around the main column (summary, payment, fulfillment) |
|
|
78
|
+
| `orders.detail.side.before` / `.after` | Around the sidebar (customer, activity) |
|
|
79
|
+
|
|
80
|
+
Each is passed the loaded `order` as `data`. The full, valid set is typed as `WidgetZoneId` and generated from the panel's own zone hosts — let your editor autocomplete `zone:` to see every option. A zone no page renders can't be targeted and won't type-check.
|
|
81
|
+
|
|
82
|
+
## Verify
|
|
83
|
+
|
|
84
|
+
1. Open an order — the "Copy link" button renders in the summary section footer.
|
|
85
|
+
2. Click it — the link is copied and a toast appears.
|
|
86
|
+
3. Change the zone to `orders.detail.side.before` and reload — the button moves to the top of the sidebar.
|
|
87
|
+
4. Set `zone: "not.a.zone"` — `tsc` (`bun run lint`) fails with a "not assignable to `WidgetZoneId`" error.
|
|
88
|
+
5. Delete the file — the button disappears; nothing else changed.
|
|
89
|
+
|
|
90
|
+
## FAQ
|
|
91
|
+
|
|
92
|
+
<AccordionGroup>
|
|
93
|
+
<Accordion title="What can I read from the order?">
|
|
94
|
+
The zone passes the loaded order as `data` (`HttpTypes.AdminOrder`) — id, display id, totals, items, `payment_collections`, customer, and more. Build the link (or any UI) from it.
|
|
95
|
+
</Accordion>
|
|
96
|
+
<Accordion title="Can a block ship this instead of the host app?">
|
|
97
|
+
Yes. Put the same file in a [block](/rc/learn/blocks)'s `vendor_ui` entry under `src/widgets/`; installing the block adds the button with no wiring.
|
|
98
|
+
</Accordion>
|
|
99
|
+
<Accordion title="Can I render more than a button?">
|
|
100
|
+
The zone renders any React component — a badge, an action menu, a whole section. You have the full order in `data`.
|
|
101
|
+
</Accordion>
|
|
102
|
+
</AccordionGroup>
|
|
103
|
+
|
|
104
|
+
## Next steps
|
|
105
|
+
|
|
106
|
+
<CardGroup cols={2}>
|
|
107
|
+
<Card title="Add a widget" href="/rc/resources/tutorials/add-a-widget">
|
|
108
|
+
The general widget model and the full list of zones.
|
|
109
|
+
</Card>
|
|
110
|
+
<Card title="Extend forms and tables" href="/rc/resources/tutorials/extend-forms-and-tables">
|
|
111
|
+
Add validated fields and columns with defineCustomFieldsConfig.
|
|
112
|
+
</Card>
|
|
113
|
+
</CardGroup>
|