@hartl-services/medusa-payload-sync 0.1.0
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/.medusa/server/src/admin/index.js +26 -0
- package/.medusa/server/src/admin/index.mjs +27 -0
- package/.medusa/server/src/modules/payload-sync/domain/contracts.js +35 -0
- package/.medusa/server/src/modules/payload-sync/domain/errors.js +54 -0
- package/.medusa/server/src/modules/payload-sync/domain/options.js +43 -0
- package/.medusa/server/src/modules/payload-sync/domain/reconciliation.js +31 -0
- package/.medusa/server/src/modules/payload-sync/domain/status.js +7 -0
- package/.medusa/server/src/modules/payload-sync/index.js +15 -0
- package/.medusa/server/src/modules/payload-sync/loaders/register-payload-client.js +108 -0
- package/.medusa/server/src/modules/payload-sync/service.js +213 -0
- package/.medusa/server/src/modules/payload-sync/services/payload-rest-client.js +326 -0
- package/.medusa/server/src/scripts/reconcile-payload-catalog.js +160 -0
- package/.medusa/server/src/subscribers/payload-sync-product-deleted.js +18 -0
- package/.medusa/server/src/subscribers/payload-sync-product-upserted.js +18 -0
- package/.medusa/server/src/subscribers/payload-sync-variant-deleted.js +18 -0
- package/.medusa/server/src/subscribers/payload-sync-variant-upserted.js +21 -0
- package/.medusa/server/src/types/index.js +18 -0
- package/.medusa/server/src/types/public.js +3 -0
- package/.medusa/server/src/workflows/payload-sync/authoritative-reconciliation.js +357 -0
- package/.medusa/server/src/workflows/payload-sync/external-step-failure.js +14 -0
- package/.medusa/server/src/workflows/payload-sync/reconcile-catalog.js +103 -0
- package/.medusa/server/src/workflows/payload-sync/sync-product.js +78 -0
- package/.medusa/server/src/workflows/payload-sync/sync-variant.js +129 -0
- package/.medusa/server/src/workflows/payload-sync/unpublish-product.js +36 -0
- package/.medusa/server/src/workflows/payload-sync/unpublish-variant.js +36 -0
- package/LICENSE +21 -0
- package/README.md +507 -0
- package/package.json +52 -0
package/README.md
ADDED
|
@@ -0,0 +1,507 @@
|
|
|
1
|
+
# Medusa Payload Sync
|
|
2
|
+
|
|
3
|
+
This Medusa shop component mirrors the small set of Medusa product
|
|
4
|
+
and variant facts needed by Payload projects. Medusa remains the source of truth
|
|
5
|
+
for catalog and commerce data. Payload remains the source of truth for editorial
|
|
6
|
+
content.
|
|
7
|
+
|
|
8
|
+
The plugin writes only two hidden, protected mirror collections. Editorial
|
|
9
|
+
documents live in separate, project-defined collections, so publishing a Medusa
|
|
10
|
+
title or handle can never publish an editorial draft.
|
|
11
|
+
|
|
12
|
+
## Support
|
|
13
|
+
|
|
14
|
+
- Medusa: `^2.19.0`
|
|
15
|
+
- Payload: `3.x` REST API
|
|
16
|
+
- Node.js: `>=20`
|
|
17
|
+
- Package manager: `pnpm` (`pnpm@11.5.3` in this workspace)
|
|
18
|
+
|
|
19
|
+
All Medusa 2.x peer dependencies accept `^2.19.0`; development dependencies
|
|
20
|
+
remain pinned to `2.19.0`.
|
|
21
|
+
|
|
22
|
+
## Installation in a shop
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
pnpm add @hartl-services/medusa-payload-sync
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
// medusa-config.ts
|
|
30
|
+
const plugins = [
|
|
31
|
+
{
|
|
32
|
+
resolve: "@hartl-services/medusa-payload-sync",
|
|
33
|
+
options: {
|
|
34
|
+
payloadUrl: process.env.PAYLOAD_URL,
|
|
35
|
+
payloadApiKey: process.env.PAYLOAD_SERVICE_API_KEY,
|
|
36
|
+
// optional, defaults to "users"
|
|
37
|
+
payloadAuthCollection: process.env.PAYLOAD_AUTH_COLLECTION,
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
// ...other plugins
|
|
41
|
+
];
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Required options and environment variables:
|
|
45
|
+
|
|
46
|
+
- `payloadUrl` — Payload origin, `http`/`https` (`PAYLOAD_URL`).
|
|
47
|
+
- `payloadApiKey` — API key of the dedicated Payload service user
|
|
48
|
+
(`PAYLOAD_SERVICE_API_KEY`).
|
|
49
|
+
|
|
50
|
+
Everything else (`payloadAuthCollection`, `collections.product`,
|
|
51
|
+
`collections.variant`, `requestTimeoutMs`, `backfillBatchSize`,
|
|
52
|
+
`backfillBatchDelayMs`) is optional; see
|
|
53
|
+
[Options and exact defaults](#options-and-exact-defaults) below. Keep
|
|
54
|
+
`payloadApiKey` out of any public/browser environment-variable prefix and
|
|
55
|
+
never forward it to a Storefront or Admin client. Also complete
|
|
56
|
+
[Configure Payload authentication](#configure-payload-authentication) below,
|
|
57
|
+
which the plugin itself does not automate.
|
|
58
|
+
|
|
59
|
+
## Configure Medusa
|
|
60
|
+
|
|
61
|
+
This repository's demo/test host, `apps/backend`, configures Payload
|
|
62
|
+
Sync as a local workspace dependency directly in `apps/backend/medusa-config.ts`,
|
|
63
|
+
following the same direct `medusa-config.ts` entry shown above; it does not
|
|
64
|
+
ship a Payload runtime package.
|
|
65
|
+
|
|
66
|
+
Feature selection and non-secret defaults belong directly in the plugin's
|
|
67
|
+
options in `apps/backend/medusa-config.ts`. It is enabled by being listed
|
|
68
|
+
there, not by a separate runtime feature flag. `medusa-config.ts` selects the
|
|
69
|
+
default Payload auth collection `users`, overridable via
|
|
70
|
+
`PAYLOAD_AUTH_COLLECTION`.
|
|
71
|
+
|
|
72
|
+
Dokploy must provide the server-only runtime values `PAYLOAD_URL` and
|
|
73
|
+
`PAYLOAD_SERVICE_API_KEY`; `PAYLOAD_AUTH_COLLECTION` can override the
|
|
74
|
+
default. `apps/backend/medusa-config.ts` validates and passes those values to
|
|
75
|
+
the local plugin. Never use a public/browser environment-variable prefix for
|
|
76
|
+
the API key or forward it to a Storefront or Admin client.
|
|
77
|
+
|
|
78
|
+
The backend configuration owns these server-only options directly; Payload
|
|
79
|
+
Sync exports no consumer-facing option or HTTP contract.
|
|
80
|
+
|
|
81
|
+
### Options and exact defaults
|
|
82
|
+
|
|
83
|
+
| Option | Required | Implemented default | Purpose |
|
|
84
|
+
| ----------------------- | -------- | ------------------------- | ---------------------------------------------------------------------------------------------- |
|
|
85
|
+
| `payloadUrl` | yes | none | Payload origin using `http` or `https`; one trailing slash is removed. The client adds `/api`. |
|
|
86
|
+
| `payloadApiKey` | yes | none | API key of the dedicated Payload service user. |
|
|
87
|
+
| `payloadAuthCollection` | no | `users` | Payload auth collection slug used in the API-key Authorization header. |
|
|
88
|
+
| `collections.product` | no | `medusa-products` | Product mirror collection slug. |
|
|
89
|
+
| `collections.variant` | no | `medusa-product-variants` | Variant mirror collection slug. |
|
|
90
|
+
| `requestTimeoutMs` | no | `10000` | Timeout for one Payload REST request; accepted range `100`–`120000`. |
|
|
91
|
+
| `backfillBatchSize` | no | `50` | Medusa/Payload page size for reconciliation; accepted range `1`–`500`. |
|
|
92
|
+
| `backfillBatchDelayMs` | no | `250` | Delay between non-final Medusa product pages; accepted range `0`–`60000`. |
|
|
93
|
+
|
|
94
|
+
Collection and auth slugs must be lowercase Payload-style slugs.
|
|
95
|
+
`apps/backend/medusa-config.ts` currently forwards only the resolved URL,
|
|
96
|
+
API key, and auth collection; changes to non-secret shop defaults must be
|
|
97
|
+
modeled directly in the plugin's options there.
|
|
98
|
+
|
|
99
|
+
The REST client rejects any successful JSON response larger than 8 MiB with
|
|
100
|
+
`PAYLOAD_RESPONSE_TOO_LARGE` and cancels the response stream. This fixed bound
|
|
101
|
+
keeps a malformed or unexpectedly expanded Payload response from consuming
|
|
102
|
+
unbounded memory while leaving ample room for `/api/access` and the maximum
|
|
103
|
+
configured page of 500 deliberately minimal mirror documents. Keep editorial
|
|
104
|
+
and other large fields out of the mirror collections rather than relying on the
|
|
105
|
+
limit as storage capacity.
|
|
106
|
+
|
|
107
|
+
## Configure Payload authentication
|
|
108
|
+
|
|
109
|
+
Create a dedicated user in one Payload auth collection and enable Payload's
|
|
110
|
+
standard API-key strategy on that collection. For the default auth slug:
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
import type { CollectionConfig } from "payload";
|
|
114
|
+
|
|
115
|
+
export const Users: CollectionConfig = {
|
|
116
|
+
slug: "users",
|
|
117
|
+
auth: {
|
|
118
|
+
useAPIKey: true,
|
|
119
|
+
},
|
|
120
|
+
fields: [
|
|
121
|
+
// project-specific user fields
|
|
122
|
+
],
|
|
123
|
+
};
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
After restarting Payload with `useAPIKey: true`:
|
|
127
|
+
|
|
128
|
+
1. Create one account dedicated to this integration in the configured auth
|
|
129
|
+
collection through Payload's normal Admin or Local API.
|
|
130
|
+
2. Enable API-key authentication for that account and copy the generated key
|
|
131
|
+
once into the Medusa server's secret store.
|
|
132
|
+
3. Copy the account's Payload document ID into the Payload server environment.
|
|
133
|
+
|
|
134
|
+
Keep the resulting values server-side:
|
|
135
|
+
|
|
136
|
+
- its document ID as `PAYLOAD_SYNC_SERVICE_USER_ID` in the Payload process;
|
|
137
|
+
- its API key as `PAYLOAD_SERVICE_API_KEY` in the Medusa process.
|
|
138
|
+
|
|
139
|
+
Set `payloadAuthCollection` to the auth collection's actual slug. The plugin
|
|
140
|
+
sends the standard header
|
|
141
|
+
`Authorization: <payloadAuthCollection> API-Key <payloadApiKey>` on every
|
|
142
|
+
Payload request. It never logs this header or the key. Do not put either secret
|
|
143
|
+
in a public environment variable, client bundle, URL, or checked-in `.env`
|
|
144
|
+
file.
|
|
145
|
+
|
|
146
|
+
No custom Payload endpoint, hook, field factory, or companion package is
|
|
147
|
+
required.
|
|
148
|
+
|
|
149
|
+
## Payload mirror collection contract
|
|
150
|
+
|
|
151
|
+
Both mirror collections enable Payload drafts, stay hidden from the main Admin
|
|
152
|
+
navigation, deny every delete, and give only the dedicated service user create
|
|
153
|
+
and update access. Any authenticated Payload user may read them. Anonymous
|
|
154
|
+
reads receive only published documents through an explicit `_status` filter.
|
|
155
|
+
|
|
156
|
+
Place the shared helpers in a server-only Payload source file:
|
|
157
|
+
|
|
158
|
+
```ts
|
|
159
|
+
// payload-sync-access.ts
|
|
160
|
+
import type { Access, FieldAccess } from "payload";
|
|
161
|
+
|
|
162
|
+
export const isPayloadSyncService: Access = ({ req }) =>
|
|
163
|
+
Boolean(
|
|
164
|
+
req.user?.id &&
|
|
165
|
+
String(req.user.id) === process.env.PAYLOAD_SYNC_SERVICE_USER_ID,
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
export const syncFieldUpdate: FieldAccess = ({ req }) =>
|
|
169
|
+
Boolean(
|
|
170
|
+
req.user?.id &&
|
|
171
|
+
String(req.user.id) === process.env.PAYLOAD_SYNC_SERVICE_USER_ID,
|
|
172
|
+
);
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
`PAYLOAD_SYNC_SERVICE_USER_ID` must be set before Payload starts. A missing or
|
|
176
|
+
incorrect ID denies mirror writes.
|
|
177
|
+
|
|
178
|
+
### Product mirror
|
|
179
|
+
|
|
180
|
+
```ts
|
|
181
|
+
// collections/MedusaProducts.ts
|
|
182
|
+
import type { CollectionConfig } from "payload";
|
|
183
|
+
|
|
184
|
+
import { isPayloadSyncService, syncFieldUpdate } from "../payload-sync-access";
|
|
185
|
+
|
|
186
|
+
export const MedusaProducts: CollectionConfig = {
|
|
187
|
+
slug: "medusa-products",
|
|
188
|
+
admin: { hidden: true, useAsTitle: "title" },
|
|
189
|
+
versions: { drafts: true },
|
|
190
|
+
access: {
|
|
191
|
+
read: ({ req }) => (req.user ? true : { _status: { equals: "published" } }),
|
|
192
|
+
create: isPayloadSyncService,
|
|
193
|
+
update: isPayloadSyncService,
|
|
194
|
+
delete: () => false,
|
|
195
|
+
},
|
|
196
|
+
fields: [
|
|
197
|
+
{
|
|
198
|
+
name: "medusaId",
|
|
199
|
+
type: "text",
|
|
200
|
+
required: true,
|
|
201
|
+
unique: true,
|
|
202
|
+
index: true,
|
|
203
|
+
admin: { readOnly: true },
|
|
204
|
+
access: { update: syncFieldUpdate },
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
name: "title",
|
|
208
|
+
type: "text",
|
|
209
|
+
required: true,
|
|
210
|
+
admin: { readOnly: true },
|
|
211
|
+
access: { update: syncFieldUpdate },
|
|
212
|
+
},
|
|
213
|
+
{
|
|
214
|
+
name: "handle",
|
|
215
|
+
type: "text",
|
|
216
|
+
required: true,
|
|
217
|
+
index: true,
|
|
218
|
+
admin: { readOnly: true },
|
|
219
|
+
access: { update: syncFieldUpdate },
|
|
220
|
+
},
|
|
221
|
+
],
|
|
222
|
+
};
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
`medusaId` is the only identity key and must stay unique. `handle` is
|
|
226
|
+
intentionally **not unique**. If a deleted Medusa product is later replaced by
|
|
227
|
+
a new product using the old handle, its new `medusaId` identifies a new mirror
|
|
228
|
+
document. A title or handle change with the same `medusaId` updates the existing
|
|
229
|
+
document and Payload records the revision.
|
|
230
|
+
|
|
231
|
+
Field-level access does not replace collection access: the service user still
|
|
232
|
+
needs collection-level `update` permission for field updates to succeed.
|
|
233
|
+
|
|
234
|
+
### Variant mirror
|
|
235
|
+
|
|
236
|
+
```ts
|
|
237
|
+
// collections/MedusaProductVariants.ts
|
|
238
|
+
import type { CollectionConfig } from "payload";
|
|
239
|
+
|
|
240
|
+
import { isPayloadSyncService, syncFieldUpdate } from "../payload-sync-access";
|
|
241
|
+
|
|
242
|
+
export const MedusaProductVariants: CollectionConfig = {
|
|
243
|
+
slug: "medusa-product-variants",
|
|
244
|
+
admin: { hidden: true, useAsTitle: "title" },
|
|
245
|
+
versions: { drafts: true },
|
|
246
|
+
access: {
|
|
247
|
+
read: ({ req }) => (req.user ? true : { _status: { equals: "published" } }),
|
|
248
|
+
create: isPayloadSyncService,
|
|
249
|
+
update: isPayloadSyncService,
|
|
250
|
+
delete: () => false,
|
|
251
|
+
},
|
|
252
|
+
fields: [
|
|
253
|
+
{
|
|
254
|
+
name: "medusaId",
|
|
255
|
+
type: "text",
|
|
256
|
+
required: true,
|
|
257
|
+
unique: true,
|
|
258
|
+
index: true,
|
|
259
|
+
admin: { readOnly: true },
|
|
260
|
+
access: { update: syncFieldUpdate },
|
|
261
|
+
},
|
|
262
|
+
{
|
|
263
|
+
name: "product",
|
|
264
|
+
type: "relationship",
|
|
265
|
+
relationTo: "medusa-products",
|
|
266
|
+
required: true,
|
|
267
|
+
index: true,
|
|
268
|
+
admin: { readOnly: true },
|
|
269
|
+
access: { update: syncFieldUpdate },
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
name: "title",
|
|
273
|
+
type: "text",
|
|
274
|
+
required: true,
|
|
275
|
+
admin: { readOnly: true },
|
|
276
|
+
access: { update: syncFieldUpdate },
|
|
277
|
+
},
|
|
278
|
+
],
|
|
279
|
+
};
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
When `collections.product` or `collections.variant` uses a custom slug, change
|
|
283
|
+
the corresponding Payload `slug` values and change the variant `relationTo` to
|
|
284
|
+
the configured product mirror slug as well.
|
|
285
|
+
|
|
286
|
+
Register `Users`, `MedusaProducts`, and `MedusaProductVariants` in the project's
|
|
287
|
+
normal `payload.config.ts`. The plugin does not create or migrate these
|
|
288
|
+
collections.
|
|
289
|
+
|
|
290
|
+
## Editorial isolation
|
|
291
|
+
|
|
292
|
+
Mirror documents contain only the plugin-owned facts shown above. Do not add
|
|
293
|
+
editorial Rich Text, SEO, media, compliance, or other mutable content fields to
|
|
294
|
+
them. Create any editorial collections your project needs and relate them to
|
|
295
|
+
the product or variant mirror using normal Payload relationships.
|
|
296
|
+
|
|
297
|
+
The plugin neither requires nor inspects those editorial collection names,
|
|
298
|
+
fields, access rules, relationships, drafts, or publishing workflow. It never
|
|
299
|
+
reads or writes editorial documents. This is the key editorial safety property:
|
|
300
|
+
Medusa immediately updates and publishes only hidden mirror facts, so it cannot
|
|
301
|
+
publish or overwrite an in-progress editorial draft.
|
|
302
|
+
|
|
303
|
+
## State and publishing invariant
|
|
304
|
+
|
|
305
|
+
Every mirror write sends `_status: "draft"` or `_status: "published"`
|
|
306
|
+
explicitly. The plugin does not use `draft=true` as a status transition and
|
|
307
|
+
never hard-deletes a Payload document.
|
|
308
|
+
|
|
309
|
+
| Medusa state/action | Product mirror | Current variant mirrors | Historical/deleted variant mirrors | Editorial documents |
|
|
310
|
+
| ----------------------- | --------------------- | ----------------------------- | ---------------------------------- | ------------------- |
|
|
311
|
+
| Published create/update | published immediately | published immediately | draft | untouched |
|
|
312
|
+
| draft/proposed/rejected | draft immediately | draft immediately | draft | untouched |
|
|
313
|
+
| Product delete | draft | draft | draft | untouched |
|
|
314
|
+
| Product republish | published immediately | published immediately | draft | untouched |
|
|
315
|
+
| Variant delete | unchanged | deleted variant becomes draft | draft | untouched |
|
|
316
|
+
|
|
317
|
+
On every product sync, the plugin queries the current Medusa variants. A
|
|
318
|
+
published product publishes exactly those current variants and drafts mirror
|
|
319
|
+
variants no longer present in Medusa. Republish therefore restores all current
|
|
320
|
+
variants but never resurrects historical or deleted variants. A draft,
|
|
321
|
+
proposed, rejected, or deleted product drafts its product mirror and all related
|
|
322
|
+
variant mirrors immediately.
|
|
323
|
+
|
|
324
|
+
A standalone variant sync first ensures the parent product mirror exists and
|
|
325
|
+
uses its Payload document ID for the required relationship. The variant follows
|
|
326
|
+
the current Medusa parent product status.
|
|
327
|
+
|
|
328
|
+
Repeated events are idempotent by `medusaId`. Payload's required unique index is
|
|
329
|
+
the concurrency guard for parallel creates. Payload 3.87 reports that race as a
|
|
330
|
+
structured HTTP 400 `ValidationError`; the client recognizes only the exact
|
|
331
|
+
configured-collection `medusaId` shape, reads the winner once, and updates it
|
|
332
|
+
with the requesting snapshot. Legacy HTTP 409 conflicts remain compatible.
|
|
333
|
+
Other validation errors remain ordinary permanent 4xx failures. Safe repeat
|
|
334
|
+
processing may create additional Payload versions, but not additional mirror
|
|
335
|
+
identities.
|
|
336
|
+
|
|
337
|
+
### Ordering and locking contract
|
|
338
|
+
|
|
339
|
+
Product sync, variant sync, product deletion, variant deletion, and both orphan
|
|
340
|
+
reconciliation page types share one coarse `payload-sync:catalog` lock. After
|
|
341
|
+
acquiring it, the workflow reads the authoritative Medusa entity again and
|
|
342
|
+
then performs the required Payload writes before releasing the lock. A delayed
|
|
343
|
+
old event therefore converges to the current Medusa state instead of winning
|
|
344
|
+
merely because its Payload request completed last.
|
|
345
|
+
|
|
346
|
+
The plugin uses Medusa 2.19.0's public owner-aware `acquire` and `release`
|
|
347
|
+
operations rather than `locking.execute`. Every attempt has a random owner ID,
|
|
348
|
+
a 15-second acquisition deadline, and a 30-second expiring lease. While the
|
|
349
|
+
authoritative callback is active, the same owner renews the lease every 10
|
|
350
|
+
seconds. Lease continuity is tracked conservatively from the start of each
|
|
351
|
+
successful provider call. A heartbeat that starts at or after the last
|
|
352
|
+
confirmed deadline is not sent, and a renewal first observed at or after that
|
|
353
|
+
deadline is not accepted as continuity even if the provider reports success.
|
|
354
|
+
|
|
355
|
+
Individual acquire, renew, and release provider calls are locally observed for
|
|
356
|
+
at most their configured deadline. A timed-out provider promise cannot be
|
|
357
|
+
cancelled; if it later acquires or renews a lease, cleanup uses only that
|
|
358
|
+
attempt's original owner ID and is itself locally observed for a finite period.
|
|
359
|
+
An unresolved cleanup can therefore remain in flight, but it cannot delete a
|
|
360
|
+
successor owner's lease and the provider-side lease remains finite. The
|
|
361
|
+
heartbeat timer is cancelled before the normal owner-specific release,
|
|
362
|
+
including error paths. A stopped process therefore leaves a finite lease rather
|
|
363
|
+
than an ownerless permanent lock.
|
|
364
|
+
|
|
365
|
+
If renewal or release ownership is lost while a callback is already active,
|
|
366
|
+
the callback is allowed to settle, its stale owner cannot release a successor's
|
|
367
|
+
lease, and the plugin acquires a fresh owner for one more complete authoritative
|
|
368
|
+
read-and-write pass. The original retryable lifecycle error is still propagated
|
|
369
|
+
to the workflow retry boundary even when that recovery pass encounters a
|
|
370
|
+
permanent Payload error. This means an effect that ran after lease loss is not
|
|
371
|
+
accepted as the final completer; operators can still observe a transient state
|
|
372
|
+
until the recovery/retry pass finishes. Lock lifecycle failures use redacted
|
|
373
|
+
structured error codes and are never treated as permanent Payload contract
|
|
374
|
+
failures.
|
|
375
|
+
|
|
376
|
+
Medusa's default in-memory locking provider coordinates only one backend
|
|
377
|
+
process. A deployment with multiple workers or backend replicas must configure
|
|
378
|
+
a shared provider that implements Medusa's owner-ID checks, same-owner lease
|
|
379
|
+
renewal, expiry takeover, and owner-specific release. Medusa 2.19.0's Redis
|
|
380
|
+
locking provider has those semantics when every process uses the same Redis
|
|
381
|
+
instance. The installed Medusa 2.19.0 PostgreSQL locking provider does not let a
|
|
382
|
+
different owner take over its expired owner row through `acquire`, so it does
|
|
383
|
+
not satisfy this plugin's process-death recovery contract. Reconciliation must
|
|
384
|
+
use the same compatible provider and lock domain as event workers. Otherwise
|
|
385
|
+
the ordering guarantee is only process-local or does not apply.
|
|
386
|
+
|
|
387
|
+
## Reduced startup capability check
|
|
388
|
+
|
|
389
|
+
At normal startup, the plugin calls Payload's standard authenticated
|
|
390
|
+
`/api/access` endpoint. It validates:
|
|
391
|
+
|
|
392
|
+
- reachable authentication;
|
|
393
|
+
- both configured collection slugs;
|
|
394
|
+
- collection-level `read`, `create`, and `update` capabilities;
|
|
395
|
+
- required field capabilities only when Payload exposes those field shapes.
|
|
396
|
+
|
|
397
|
+
This is intentionally a reduced preflight. With no custom Payload code, the
|
|
398
|
+
standard endpoint cannot prove every field type, `unique`/index rule,
|
|
399
|
+
relationship `relationTo`, drafts/version setting, delete ACL, or omitted field
|
|
400
|
+
shape. The copyable schema above is the authoritative configuration contract;
|
|
401
|
+
runtime writes and the real Payload contract test are the authoritative
|
|
402
|
+
protocol gate.
|
|
403
|
+
|
|
404
|
+
Recognizable auth, schema, permission, and other non-retryable contract errors
|
|
405
|
+
fail startup with the affected collection or capability. Temporary network
|
|
406
|
+
failures, timeouts, HTTP 408, 429, and 5xx responses produce a structured,
|
|
407
|
+
redacted warning and allow startup in degraded mode; later workflows retry.
|
|
408
|
+
Remote preflight I/O is skipped in Medusa's data-loader-only/migration context.
|
|
409
|
+
|
|
410
|
+
## Initial and repeat reconciliation
|
|
411
|
+
|
|
412
|
+
Reconciliation is the initial backfill and the repair tool after missed or
|
|
413
|
+
permanently failed events. Run the package's built script; do not invoke a raw
|
|
414
|
+
TypeScript source path when the same plugin is enabled in the backend, because
|
|
415
|
+
that can register the workflows twice.
|
|
416
|
+
|
|
417
|
+
From the shop's root, run:
|
|
418
|
+
|
|
419
|
+
```sh
|
|
420
|
+
pnpm exec medusa exec \
|
|
421
|
+
node_modules/@hartl-services/medusa-payload-sync/.medusa/server/src/scripts/reconcile-payload-catalog.js
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
The command uses the shop's configured database and server-only Payload
|
|
425
|
+
credentials.
|
|
426
|
+
|
|
427
|
+
Every run:
|
|
428
|
+
|
|
429
|
+
1. scans Medusa products in ascending, bounded pages;
|
|
430
|
+
2. invokes the normal product workflow sequentially, including current
|
|
431
|
+
variants;
|
|
432
|
+
3. waits `backfillBatchDelayMs` only between non-final product pages;
|
|
433
|
+
4. scans draft-inclusive product and variant mirrors in stable `medusaId`
|
|
434
|
+
order;
|
|
435
|
+
5. drafts published mirror orphans whose `medusaId` no longer exists in
|
|
436
|
+
Medusa;
|
|
437
|
+
6. logs one machine-readable summary.
|
|
438
|
+
|
|
439
|
+
The summary fields are `scannedProducts`, `syncedProducts`, `syncedVariants`,
|
|
440
|
+
`draftedOrphanProducts`, `draftedOrphanVariants`, and `failedProductIds`.
|
|
441
|
+
Individual product failures do not stop later products or orphan scans. After
|
|
442
|
+
all work is attempted, any non-empty `failedProductIds` makes the command exit
|
|
443
|
+
with an error. Automation must require an empty list.
|
|
444
|
+
|
|
445
|
+
If a multi-ID orphan page partially succeeds and then retries, that run's
|
|
446
|
+
drafted-orphan count can undercount mirrors that already converged on the first
|
|
447
|
+
attempt. Mirror state and repeat-run convergence are authoritative; this is a
|
|
448
|
+
metrics-only limitation.
|
|
449
|
+
|
|
450
|
+
The runner is restartable and idempotent: safe repeat runs converge on the
|
|
451
|
+
current Medusa state without duplicate mirror documents. Pagination is not a
|
|
452
|
+
database snapshot, so rerun after concurrent catalog changes. V1 deliberately
|
|
453
|
+
has no automatic schedule; operators decide when to rerun it.
|
|
454
|
+
|
|
455
|
+
## Retries, logs, and incident recovery
|
|
456
|
+
|
|
457
|
+
External workflow steps use Medusa retries (`maxRetries: 3`,
|
|
458
|
+
`retryInterval: 2`) and idempotent writes. Transient network failures, timeouts,
|
|
459
|
+
HTTP 408, 429, and 5xx responses are retryable. Auth, validation, missing
|
|
460
|
+
capability, duplicate-identity, invalid-response, and other ordinary 4xx
|
|
461
|
+
contract errors are permanent, except for the handled create unique conflict.
|
|
462
|
+
Failed event processing is propagated rather than silently acknowledged.
|
|
463
|
+
|
|
464
|
+
Structured JSON logs include the operation and classified `code`, plus the
|
|
465
|
+
entity type, `medusaId`, event name, workflow/run identifiers, collection, HTTP
|
|
466
|
+
status, retryability, and attempt where available. Authorization values,
|
|
467
|
+
API-key values, and response headers are redacted. Do not add secrets to log
|
|
468
|
+
context or paste unredacted request data into incident records.
|
|
469
|
+
|
|
470
|
+
V1 has no plugin-owned dead-letter table. Recover an incident in this order:
|
|
471
|
+
|
|
472
|
+
1. Inspect structured logs by `medusaId` and error `code`.
|
|
473
|
+
2. Repair the authentication, collection schema/ACL, or network cause.
|
|
474
|
+
3. Rerun the full reconciliation command.
|
|
475
|
+
4. Require `failedProductIds: []` and confirm the expected draft/published
|
|
476
|
+
product and variant states.
|
|
477
|
+
|
|
478
|
+
Because reconciliation also drafts orphans, it repairs both missing updates and
|
|
479
|
+
missed delete events without deleting Payload history.
|
|
480
|
+
|
|
481
|
+
## V1 out of scope
|
|
482
|
+
|
|
483
|
+
- Payload editorial/content collections, Rich Text, media, SEO, or compliance
|
|
484
|
+
schemas
|
|
485
|
+
- a Payload package, hook, custom endpoint, field factory, or automatic schema
|
|
486
|
+
creation
|
|
487
|
+
- Storefront integration or merging Medusa and Payload responses
|
|
488
|
+
- Medusa categories, collections, prices, inventory, Sales Channels, or other
|
|
489
|
+
commerce facts
|
|
490
|
+
- Medusa Module Links or `defineLink`
|
|
491
|
+
- Medusa Admin UI, an Admin reconciliation route, or an automatic schedule
|
|
492
|
+
- Payload hard delete or a configurable delete mode
|
|
493
|
+
- a plugin-owned dead-letter database/table
|
|
494
|
+
|
|
495
|
+
Projects are free to add any separate Payload collections and relationships
|
|
496
|
+
needed for their editorial model; those remain outside this plugin's contract.
|
|
497
|
+
|
|
498
|
+
## Plugin development contract
|
|
499
|
+
|
|
500
|
+
Payload Sync is server-to-server integration code and intentionally exposes no
|
|
501
|
+
Store or Admin HTTP routes. It has no public DTO, schema, path, Storefront
|
|
502
|
+
client, or runtime Payload-credential contract.
|
|
503
|
+
|
|
504
|
+
If this component gains a Medusa HTTP route, define its request/query/response
|
|
505
|
+
DTOs with `@medusajs/framework/zod`, derive public types from those schemas,
|
|
506
|
+
add them and a small path object to `@hartl-services/medusa-food-supplements-contracts`, and
|
|
507
|
+
let consumers use their own Medusa SDK with `sdk.client.fetch<T>`.
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@hartl-services/medusa-payload-sync",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Mirrors Medusa product and variant facts into Payload CMS.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"files": [
|
|
7
|
+
".medusa/server",
|
|
8
|
+
"README.md",
|
|
9
|
+
"LICENSE"
|
|
10
|
+
],
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://gitlab.com/hartl-services-gmbh/services/food-supplements-e-commerce.git",
|
|
14
|
+
"directory": "plugins/medusa-payload-sync"
|
|
15
|
+
},
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"access": "public",
|
|
18
|
+
"registry": "https://registry.npmjs.org/"
|
|
19
|
+
},
|
|
20
|
+
"sideEffects": [
|
|
21
|
+
"./.medusa/**"
|
|
22
|
+
],
|
|
23
|
+
"exports": {
|
|
24
|
+
"./package.json": "./package.json",
|
|
25
|
+
"./.medusa/server/src/modules/*": "./.medusa/server/src/modules/*/index.js",
|
|
26
|
+
"./.medusa/server/src/*": "./.medusa/server/src/*"
|
|
27
|
+
},
|
|
28
|
+
"peerDependencies": {
|
|
29
|
+
"@medusajs/framework": "^2.19.0",
|
|
30
|
+
"@medusajs/medusa": "^2.19.0"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@medusajs/cli": "2.19.0",
|
|
34
|
+
"@medusajs/framework": "2.19.0",
|
|
35
|
+
"@medusajs/medusa": "2.19.0",
|
|
36
|
+
"@swc/core": "^1.7.28",
|
|
37
|
+
"@swc/jest": "^0.2.39",
|
|
38
|
+
"@types/jest": "^29.5.14",
|
|
39
|
+
"@types/node": "^20.12.11",
|
|
40
|
+
"jest": "^29.7.0",
|
|
41
|
+
"typescript": "^5.6.2"
|
|
42
|
+
},
|
|
43
|
+
"engines": {
|
|
44
|
+
"node": ">=20"
|
|
45
|
+
},
|
|
46
|
+
"scripts": {
|
|
47
|
+
"build": "medusa plugin:build",
|
|
48
|
+
"typecheck": "tsc --noEmit",
|
|
49
|
+
"test:unit": "jest --config jest.config.js",
|
|
50
|
+
"test:contract": "jest --config jest.contract.config.js --runInBand"
|
|
51
|
+
}
|
|
52
|
+
}
|