@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.
- package/dist/endpoints/resources-insert.js +75 -13
- package/dist/utils/fields.d.ts +14 -0
- package/dist/utils/fields.js +129 -6
- package/package.json +2 -2
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { unauthorizedResponse, validateApiKey } from '../utils/auth.js';
|
|
2
|
-
import {
|
|
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
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
|
|
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 =
|
|
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
|
-
//
|
|
95
|
-
// and
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/utils/fields.d.ts
CHANGED
|
@@ -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;
|
package/dist/utils/fields.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
522
|
-
|
|
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 (
|
|
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] =
|
|
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