@sequoialabs/payload-plugin-reversia 0.1.0 → 0.1.1

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.
@@ -1,5 +1,5 @@
1
1
  import { unauthorizedResponse, validateApiKey } from '../utils/auth.js';
2
- import { cloneLocalizedContainersFromSource, deserializeFieldValue, findLocalizedFields, } from '../utils/fields.js';
2
+ import { deflatePopulatedRelationships, deserializeFieldValue, findLocalizedFields, } from '../utils/fields.js';
3
3
  function stringifyDiffValue(value) {
4
4
  if (typeof value === 'string') {
5
5
  return value;
@@ -21,14 +21,23 @@ function indexFieldsByName(fields) {
21
21
  }
22
22
  function applyTranslations({ allowedFields, data, sourceDoc, previousDoc, }) {
23
23
  const fieldByName = indexFieldsByName(allowedFields);
24
- // Source clone is the base required nested siblings are preserved even
25
- // when Reversia only sends a subset of leaves (mirrors the PrestaShop
26
- // module's "clone source, replace with what we got" approach).
27
- const updateData = cloneLocalizedContainersFromSource(sourceDoc, allowedFields);
24
+ // Deflate the source doc ONCE before anything reads from it. Payload's
25
+ // Lexical `afterRead` populates relationship/upload fields inside block
26
+ // nodes even at `depth: 0`, and the internal `payloadDataLoader` cache can
27
+ // serve a populated version from a prior higher-depth fetch. Deflating here
28
+ // guarantees `deserializeFieldValue` sees plain string IDs everywhere — not
29
+ // populated `{ id, filename, url }` objects that Payload's update validator
30
+ // rejects.
31
+ const cleanSource = sourceDoc ? deflatePopulatedRelationships(sourceDoc) : null;
32
+ // Build updateData ONLY from fields Reversia actually sent translations for.
33
+ // Never pre-clone containers that aren't being translated — writing them
34
+ // back would overwrite other locales' existing values with source-language
35
+ // content, because Payload's update replaces the entire localized slot.
36
+ const updateData = {};
28
37
  const diff = {};
29
38
  const acceptedFields = [];
30
39
  const previous = previousDoc ?? null;
31
- const source = sourceDoc ?? null;
40
+ const source = cleanSource ?? null;
32
41
  for (const [fieldName, translatedValue] of Object.entries(data)) {
33
42
  const field = fieldByName.get(fieldName);
34
43
  if (!field) {
@@ -91,11 +100,13 @@ export function createResourcesInsertEndpoint(pluginConfig, collectionsMap, glob
91
100
  continue;
92
101
  }
93
102
  const allowedFields = findLocalizedFields(globalConfig.fields);
94
- // Always fetch sourcerequired to populate non-localized siblings
95
- // and to deserialize JSON-pointer maps. Target fetch is for diff.
103
+ // depth: 0 is critical without it, Payload populates relationship
104
+ // and upload fields as full objects instead of raw IDs. Our clone
105
+ // would then write those objects back, which Payload's validator
106
+ // rejects with "invalid relationships: [object Object]".
96
107
  const [sourceDoc, previousDoc] = await Promise.all([
97
- req.payload.findGlobal({ slug: globalSlug, locale: item.sourceLocale }),
98
- req.payload.findGlobal({ slug: globalSlug, locale: item.targetLocale }),
108
+ req.payload.findGlobal({ slug: globalSlug, locale: item.sourceLocale, depth: 0 }),
109
+ req.payload.findGlobal({ slug: globalSlug, locale: item.targetLocale, depth: 0 }),
99
110
  ]);
100
111
  const { updateData, diff, acceptedFields } = applyTranslations({
101
112
  allowedFields,
@@ -136,11 +147,13 @@ export function createResourcesInsertEndpoint(pluginConfig, collectionsMap, glob
136
147
  collection: slug,
137
148
  id: item.id,
138
149
  locale: item.sourceLocale,
150
+ depth: 0,
139
151
  }),
140
152
  req.payload.findByID({
141
153
  collection: slug,
142
154
  id: item.id,
143
155
  locale: item.targetLocale,
156
+ depth: 0,
144
157
  }),
145
158
  ]);
146
159
  const { updateData, diff, acceptedFields } = applyTranslations({
@@ -163,12 +176,61 @@ export function createResourcesInsertEndpoint(pluginConfig, collectionsMap, glob
163
176
  response[index] = { index, type: item.type, id: item.id, diff };
164
177
  }
165
178
  catch (error) {
166
- const message = error instanceof Error ? error.message : String(error);
167
- req.payload.logger.error({ err: error, item: { type: item.type, id: item.id, targetLocale: item.targetLocale } }, '[reversia] insertion failed');
168
- response.errors.push(`Item ${index} (${item.type}${item.id ? ` ${item.id}` : ''} → ${item.targetLocale}): ${message}`);
179
+ recordInsertionFailure(req, response, error, index, item);
169
180
  }
170
181
  }
171
182
  return Response.json(response);
172
183
  },
173
184
  };
174
185
  }
186
+ /**
187
+ * Payload's ValidationError stashes structured field-level diagnostics on
188
+ * `error.data.errors` (`[{ field, message }]`). Pull them out so the Reversia
189
+ * warn log carries enough context to pinpoint which field blew up on which
190
+ * item without needing to tail the Payload server logs.
191
+ */
192
+ function extractPayloadFieldErrors(error) {
193
+ if (!error || typeof error !== 'object') {
194
+ return undefined;
195
+ }
196
+ const data = error.data;
197
+ if (!data || typeof data !== 'object') {
198
+ return undefined;
199
+ }
200
+ const errors = data.errors;
201
+ if (!Array.isArray(errors)) {
202
+ return undefined;
203
+ }
204
+ const out = [];
205
+ for (const entry of errors) {
206
+ if (!entry || typeof entry !== 'object') {
207
+ continue;
208
+ }
209
+ const field = entry.field;
210
+ const path = entry.path;
211
+ const message = entry.message;
212
+ out.push({
213
+ field: typeof field === 'string' ? field : typeof path === 'string' ? path : '(unknown)',
214
+ message: typeof message === 'string' ? message : '(unknown)',
215
+ });
216
+ }
217
+ return out.length > 0 ? out : undefined;
218
+ }
219
+ function recordInsertionFailure(req, response, error, index, item) {
220
+ const message = error instanceof Error ? error.message : String(error);
221
+ const fieldErrors = extractPayloadFieldErrors(error);
222
+ req.payload.logger.error({
223
+ err: error,
224
+ item: {
225
+ type: item.type,
226
+ id: item.id,
227
+ targetLocale: item.targetLocale,
228
+ dataKeys: Object.keys(item.data ?? {}),
229
+ },
230
+ fieldErrors,
231
+ }, '[reversia] insertion failed');
232
+ const fieldSummary = fieldErrors
233
+ ? ` [${fieldErrors.map((e) => `${e.field}: ${e.message}`).join('; ')}]`
234
+ : '';
235
+ response.errors.push(`Item ${index} (${item.type}${item.id ? ` ${item.id}` : ''} → ${item.targetLocale}): ${message}${fieldSummary}`);
236
+ }
@@ -56,3 +56,17 @@ export declare function deserializeFieldValue(field: LocalizedFieldInfo, sourceV
56
56
  * a subset of leaves.
57
57
  */
58
58
  export declare function cloneLocalizedContainersFromSource(sourceDoc: unknown, fields: readonly LocalizedFieldInfo[]): Record<string, unknown>;
59
+ /**
60
+ * Payload's Lexical `afterRead` populates relationship / upload fields inside
61
+ * block nodes even when the top-level query uses `depth: 0`. This means a
62
+ * source-locale clone can contain
63
+ * `media: { id: '...', filename: '...', url: '...' }`
64
+ * where Payload's `update()` expects a plain string ID. On write, Payload's
65
+ * relationship validator sees `[object Object]` and rejects.
66
+ *
67
+ * This utility walks ANY value tree and replaces populated-looking objects
68
+ * (plain objects with a string `id` property at positions where a raw ID is
69
+ * expected — i.e. inside Lexical block node `fields`) with just their `id`.
70
+ * Top-level and non-block objects are left untouched.
71
+ */
72
+ export declare function deflatePopulatedRelationships(value: unknown): unknown;
@@ -502,8 +502,7 @@ export function deserializeFieldValue(field, sourceValue, translatedRaw) {
502
502
  }
503
503
  function bucketTranslationsByLeaf(field, translations) {
504
504
  // Order leaves by descending segment length so deeper / more specific leaves
505
- // claim pointers before broader catch-alls. (Stable for the common
506
- // shallow-only schema.)
505
+ // claim pointers before broader catch-alls.
507
506
  const ordered = [...field.leaves].sort((a, b) => b.segments.length - a.segments.length);
508
507
  const remaining = new Map(Object.entries(translations));
509
508
  const buckets = [];
@@ -518,10 +517,17 @@ function bucketTranslationsByLeaf(field, translations) {
518
517
  }
519
518
  continue;
520
519
  }
521
- if (leafPath === null) {
522
- continue;
520
+ // json leaf: match by static prefix when possible (key-only segments),
521
+ // or by structural pattern when the leaf is under an iterate/block
522
+ // segment (leafPath is null because we can't build a static prefix —
523
+ // the pointer contains a numeric array index that varies per instance).
524
+ if (leafPath !== null) {
525
+ if (leafPath === '' || pointerStartsWith(pointer, leafPath)) {
526
+ entries[pointer] = value;
527
+ remaining.delete(pointer);
528
+ }
523
529
  }
524
- if (leafPath === '' || pointerStartsWith(pointer, leafPath)) {
530
+ else if (matchesJsonLeafPattern(pointer, leaf)) {
525
531
  entries[pointer] = value;
526
532
  remaining.delete(pointer);
527
533
  }
@@ -532,6 +538,40 @@ function bucketTranslationsByLeaf(field, translations) {
532
538
  }
533
539
  return buckets;
534
540
  }
541
+ /**
542
+ * For json leaves under iterate/iterateBlock segments, check if a pointer
543
+ * structurally matches the leaf's pattern: key segments match literally,
544
+ * iterate/iterateBlock segments match any numeric index, and the pointer
545
+ * may extend deeper (sub-pointers inside the json value).
546
+ */
547
+ function matchesJsonLeafPattern(pointer, leaf) {
548
+ if (pointer === '') {
549
+ return leaf.segments.length === 0;
550
+ }
551
+ const parts = pointer.slice(1).split('/');
552
+ let p = 0;
553
+ for (const seg of leaf.segments) {
554
+ if (p >= parts.length) {
555
+ return false;
556
+ }
557
+ if (seg.kind === 'key') {
558
+ if (parts[p] !== seg.name) {
559
+ return false;
560
+ }
561
+ p++;
562
+ }
563
+ else {
564
+ // iterate / iterateBlock: expect a numeric index
565
+ if (!/^\d+$/.test(parts[p])) {
566
+ return false;
567
+ }
568
+ p++;
569
+ }
570
+ }
571
+ // The pointer matched all leaf segments and may extend deeper into the
572
+ // json value (sub-pointers like /root/children/0/text). That's expected.
573
+ return true;
574
+ }
535
575
  /**
536
576
  * For a scalar leaf, the incoming pointer must address exactly the leaf's
537
577
  * structural shape: every `key` segment becomes a literal pointer segment;
@@ -702,6 +742,18 @@ export function cloneLocalizedContainersFromSource(sourceDoc, fields) {
702
742
  }
703
743
  const src = sourceDoc;
704
744
  for (const field of fields) {
745
+ // Only containers need a source-clone base — they carry required nested
746
+ // siblings (array/block ids, blockType, non-localized subfields) that
747
+ // Payload will re-validate on update. Top-level scalars are independent
748
+ // per-locale values: if Reversia hasn't translated one yet, leaving it out
749
+ // of `updateData` preserves whatever the target locale already has.
750
+ // Writing the source-locale scalar into the target locale is actively
751
+ // harmful — any `validate: (value, { locale }) => …` on the field (common
752
+ // on `title`, `slug`, etc.) would see the wrong-language value and reject
753
+ // the update.
754
+ if (!field.isContainer) {
755
+ continue;
756
+ }
705
757
  if (!(field.name in src)) {
706
758
  continue;
707
759
  }
@@ -709,7 +761,78 @@ export function cloneLocalizedContainersFromSource(sourceDoc, fields) {
709
761
  if (value === undefined || value === null) {
710
762
  continue;
711
763
  }
712
- out[field.name] = field.isContainer ? structuredClone(value) : value;
764
+ out[field.name] = deflatePopulatedRelationships(structuredClone(value));
713
765
  }
714
766
  return out;
715
767
  }
768
+ /**
769
+ * Payload's Lexical `afterRead` populates relationship / upload fields inside
770
+ * block nodes even when the top-level query uses `depth: 0`. This means a
771
+ * source-locale clone can contain
772
+ * `media: { id: '...', filename: '...', url: '...' }`
773
+ * where Payload's `update()` expects a plain string ID. On write, Payload's
774
+ * relationship validator sees `[object Object]` and rejects.
775
+ *
776
+ * This utility walks ANY value tree and replaces populated-looking objects
777
+ * (plain objects with a string `id` property at positions where a raw ID is
778
+ * expected — i.e. inside Lexical block node `fields`) with just their `id`.
779
+ * Top-level and non-block objects are left untouched.
780
+ */
781
+ export function deflatePopulatedRelationships(value) {
782
+ if (value === null || value === undefined || typeof value !== 'object') {
783
+ return value;
784
+ }
785
+ if (Array.isArray(value)) {
786
+ return value.map(deflatePopulatedRelationships);
787
+ }
788
+ const obj = value;
789
+ // Lexical block node — deflate populated fields
790
+ if (obj.type === 'block' && obj.fields && typeof obj.fields === 'object') {
791
+ const fields = obj.fields;
792
+ const deflated = {};
793
+ for (const [key, val] of Object.entries(fields)) {
794
+ if (key === 'id' || key === 'blockType' || key === 'blockName') {
795
+ deflated[key] = val;
796
+ }
797
+ else if (Array.isArray(val)) {
798
+ deflated[key] = val.map((item) => isPopulatedDoc(item) ? item.id : item);
799
+ }
800
+ else if (isPopulatedDoc(val)) {
801
+ deflated[key] = val.id;
802
+ }
803
+ else {
804
+ deflated[key] = val;
805
+ }
806
+ }
807
+ return {
808
+ ...obj,
809
+ fields: deflated,
810
+ ...(Array.isArray(obj.children)
811
+ ? { children: obj.children.map(deflatePopulatedRelationships) }
812
+ : {}),
813
+ };
814
+ }
815
+ // Non-block node — recurse into children only (don't touch field values at
816
+ // other levels, they come from the DB via depth: 0 which handles them).
817
+ if (Array.isArray(obj.children)) {
818
+ return {
819
+ ...obj,
820
+ children: obj.children.map(deflatePopulatedRelationships),
821
+ };
822
+ }
823
+ // Plain object (group value, etc.) — recurse all keys in case there are
824
+ // nested Lexical trees or arrays.
825
+ const out = {};
826
+ for (const [k, v] of Object.entries(obj)) {
827
+ out[k] = deflatePopulatedRelationships(v);
828
+ }
829
+ return out;
830
+ }
831
+ function isPopulatedDoc(val) {
832
+ if (!val || typeof val !== 'object' || Array.isArray(val)) {
833
+ return false;
834
+ }
835
+ const obj = val;
836
+ return (typeof obj.id === 'string' &&
837
+ ('createdAt' in obj || 'updatedAt' in obj || 'filename' in obj || 'mimeType' in obj));
838
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sequoialabs/payload-plugin-reversia",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "author": {
5
5
  "name": "Jean Walrave",
6
6
  "email": "contact@reversia.tech",
@@ -54,4 +54,4 @@
54
54
  "payload": "^3.0.0",
55
55
  "typescript": "^5.0.0"
56
56
  }
57
- }
57
+ }