@sequoialabs/payload-plugin-reversia 0.1.2 → 0.1.3
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 +59 -10
- package/dist/types.d.ts +15 -0
- package/dist/utils/fields.js +45 -6
- package/package.json +1 -1
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
import { unauthorizedResponse, validateApiKey } from '../utils/auth.js';
|
|
2
2
|
import { deflatePopulatedRelationships, deserializeFieldValue, findLocalizedFields, } from '../utils/fields.js';
|
|
3
|
+
const WRITE_CONFLICT_MAX_RETRIES = 3;
|
|
4
|
+
const WRITE_CONFLICT_BASE_DELAY_MS = 50;
|
|
5
|
+
function isWriteConflict(error) {
|
|
6
|
+
if (!error || typeof error !== 'object') {
|
|
7
|
+
return false;
|
|
8
|
+
}
|
|
9
|
+
const message = 'message' in error && typeof error.message === 'string'
|
|
10
|
+
? error.message
|
|
11
|
+
: '';
|
|
12
|
+
return message.includes('Write conflict') || message.includes('write conflict');
|
|
13
|
+
}
|
|
14
|
+
async function withRetry(fn) {
|
|
15
|
+
for (let attempt = 0;; attempt++) {
|
|
16
|
+
try {
|
|
17
|
+
return await fn();
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
if (attempt < WRITE_CONFLICT_MAX_RETRIES && isWriteConflict(error)) {
|
|
21
|
+
const delay = WRITE_CONFLICT_BASE_DELAY_MS * 2 ** attempt + Math.random() * WRITE_CONFLICT_BASE_DELAY_MS;
|
|
22
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
throw error;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
3
29
|
function stringifyDiffValue(value) {
|
|
4
30
|
if (typeof value === 'string') {
|
|
5
31
|
return value;
|
|
@@ -29,15 +55,37 @@ function applyTranslations({ allowedFields, data, sourceDoc, previousDoc, }) {
|
|
|
29
55
|
// populated `{ id, filename, url }` objects that Payload's update validator
|
|
30
56
|
// rejects.
|
|
31
57
|
const cleanSource = sourceDoc ? deflatePopulatedRelationships(sourceDoc) : null;
|
|
32
|
-
// Build updateData
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
58
|
+
// Build updateData from fields Reversia sent, plus any required fields that
|
|
59
|
+
// are empty in the target locale. Payload validates ALL required fields on
|
|
60
|
+
// every update (not just the ones in `data`), so an empty required field in
|
|
61
|
+
// the target locale would block the entire write. We seed those from source.
|
|
36
62
|
const updateData = {};
|
|
37
63
|
const diff = {};
|
|
38
64
|
const acceptedFields = [];
|
|
39
65
|
const previous = previousDoc ?? null;
|
|
40
66
|
const source = cleanSource ?? null;
|
|
67
|
+
const dataKeys = new Set(Object.keys(data));
|
|
68
|
+
// Seed required fields that are empty in target and not being translated.
|
|
69
|
+
for (const field of allowedFields) {
|
|
70
|
+
if (!field.hasRequiredLeaf) {
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (dataKeys.has(field.name)) {
|
|
74
|
+
continue; // Reversia is sending it — will be handled below
|
|
75
|
+
}
|
|
76
|
+
const targetValue = previous ? previous[field.name] : undefined;
|
|
77
|
+
if (targetValue !== undefined && targetValue !== null) {
|
|
78
|
+
continue; // Target already has a value — don't overwrite
|
|
79
|
+
}
|
|
80
|
+
const sourceValue = source ? source[field.name] : undefined;
|
|
81
|
+
if (sourceValue === undefined || sourceValue === null) {
|
|
82
|
+
continue; // Source is also empty — nothing we can do
|
|
83
|
+
}
|
|
84
|
+
// Seed from source so Payload's required validation doesn't reject.
|
|
85
|
+
updateData[field.name] = field.isContainer
|
|
86
|
+
? deflatePopulatedRelationships(structuredClone(sourceValue))
|
|
87
|
+
: sourceValue;
|
|
88
|
+
}
|
|
41
89
|
for (const [fieldName, translatedValue] of Object.entries(data)) {
|
|
42
90
|
const field = fieldByName.get(fieldName);
|
|
43
91
|
if (!field) {
|
|
@@ -118,12 +166,12 @@ export function createResourcesInsertEndpoint(pluginConfig, collectionsMap, glob
|
|
|
118
166
|
response.errors.push(`Item ${index}: no recognised fields in data (keys: ${Object.keys(item.data).join(', ')})`);
|
|
119
167
|
continue;
|
|
120
168
|
}
|
|
121
|
-
await req.payload.updateGlobal({
|
|
169
|
+
await withRetry(() => req.payload.updateGlobal({
|
|
122
170
|
slug: globalSlug,
|
|
123
171
|
locale: item.targetLocale,
|
|
124
172
|
data: updateData,
|
|
125
173
|
context: { reversiaInsertion: true },
|
|
126
|
-
});
|
|
174
|
+
}));
|
|
127
175
|
response[index] = { index, type: item.type, id: globalSlug, diff };
|
|
128
176
|
continue;
|
|
129
177
|
}
|
|
@@ -141,6 +189,7 @@ export function createResourcesInsertEndpoint(pluginConfig, collectionsMap, glob
|
|
|
141
189
|
response.errors.push(`Item ${index}: id is required for collection resources`);
|
|
142
190
|
continue;
|
|
143
191
|
}
|
|
192
|
+
const itemId = item.id;
|
|
144
193
|
const allowedFields = findLocalizedFields(collection.fields);
|
|
145
194
|
const [sourceDoc, previousDoc] = await Promise.all([
|
|
146
195
|
req.payload.findByID({
|
|
@@ -166,14 +215,14 @@ export function createResourcesInsertEndpoint(pluginConfig, collectionsMap, glob
|
|
|
166
215
|
response.errors.push(`Item ${index}: no recognised fields in data (keys: ${Object.keys(item.data).join(', ')})`);
|
|
167
216
|
continue;
|
|
168
217
|
}
|
|
169
|
-
await req.payload.update({
|
|
218
|
+
await withRetry(() => req.payload.update({
|
|
170
219
|
collection: slug,
|
|
171
|
-
id:
|
|
220
|
+
id: itemId,
|
|
172
221
|
locale: item.targetLocale,
|
|
173
222
|
data: updateData,
|
|
174
223
|
context: { reversiaInsertion: true },
|
|
175
|
-
});
|
|
176
|
-
response[index] = { index, type: item.type, id:
|
|
224
|
+
}));
|
|
225
|
+
response[index] = { index, type: item.type, id: itemId, diff };
|
|
177
226
|
}
|
|
178
227
|
catch (error) {
|
|
179
228
|
recordInsertionFailure(req, response, error, index, item);
|
package/dist/types.d.ts
CHANGED
|
@@ -110,6 +110,10 @@ export interface TranslatableFieldConfig {
|
|
|
110
110
|
behavior?: ReversiaFieldBehavior;
|
|
111
111
|
type?: ReversiaFieldType;
|
|
112
112
|
selected?: boolean;
|
|
113
|
+
rules?: {
|
|
114
|
+
maxLength?: number;
|
|
115
|
+
minLength?: number;
|
|
116
|
+
};
|
|
113
117
|
}
|
|
114
118
|
export interface ResourceDefinition {
|
|
115
119
|
type: string;
|
|
@@ -168,6 +172,17 @@ export interface LocalizedFieldInfo {
|
|
|
168
172
|
reversia?: ReversiaFieldCustom;
|
|
169
173
|
/** One descriptor per localized leaf inside the container. */
|
|
170
174
|
leaves: LocalizedLeaf[];
|
|
175
|
+
/** Payload `maxLength` on the field (text / textarea / code). */
|
|
176
|
+
maxLength?: number;
|
|
177
|
+
/** Payload `minLength` on the field (text / textarea / code). */
|
|
178
|
+
minLength?: number;
|
|
179
|
+
/**
|
|
180
|
+
* True when Payload marks this field (or any required inner leaf) as
|
|
181
|
+
* `required`. Used during insertion to seed empty target-locale slots from
|
|
182
|
+
* the source locale so Payload's whole-document validation doesn't reject
|
|
183
|
+
* the update.
|
|
184
|
+
*/
|
|
185
|
+
hasRequiredLeaf?: boolean;
|
|
171
186
|
}
|
|
172
187
|
export interface ResourceItem {
|
|
173
188
|
id: string;
|
package/dist/utils/fields.js
CHANGED
|
@@ -2,9 +2,6 @@ import { ReversiaFieldType } from '../types.js';
|
|
|
2
2
|
import { applyByKeys, compileKeyMatcher, DEFAULT_RICHTEXT_KEYS, extractByKeys, } from './json-extract.js';
|
|
3
3
|
import { resolveStaticLabel } from './labels.js';
|
|
4
4
|
import { applyTranslationsToContainer, joinPointers, resolveLeafLocations, } from './path-resolver.js';
|
|
5
|
-
/* -------------------------------------------------------------------------- */
|
|
6
|
-
/* Schema introspection */
|
|
7
|
-
/* -------------------------------------------------------------------------- */
|
|
8
5
|
function getFieldLabel(field) {
|
|
9
6
|
if (!('name' in field)) {
|
|
10
7
|
return '';
|
|
@@ -59,6 +56,14 @@ const SCALAR_PAYLOAD_TYPES = new Set([
|
|
|
59
56
|
function isScalarPayloadType(type) {
|
|
60
57
|
return SCALAR_PAYLOAD_TYPES.has(type);
|
|
61
58
|
}
|
|
59
|
+
function getNumericProp(field, prop) {
|
|
60
|
+
const val = field[prop];
|
|
61
|
+
return typeof val === 'number' && Number.isFinite(val) ? val : undefined;
|
|
62
|
+
}
|
|
63
|
+
function getBoolProp(field, prop) {
|
|
64
|
+
const val = field[prop];
|
|
65
|
+
return typeof val === 'boolean' ? val : undefined;
|
|
66
|
+
}
|
|
62
67
|
/* -------------------------------------------------------------------------- */
|
|
63
68
|
/* Discovery: top-level localized fields */
|
|
64
69
|
/* -------------------------------------------------------------------------- */
|
|
@@ -126,6 +131,9 @@ function describeTopLevelField(field) {
|
|
|
126
131
|
// Top-level scalar that is itself localized → non-container, value shipped
|
|
127
132
|
// as a primitive.
|
|
128
133
|
if (isLocalized(field) && isScalarPayloadType(payloadFieldType)) {
|
|
134
|
+
const maxLength = getNumericProp(field, 'maxLength');
|
|
135
|
+
const minLength = getNumericProp(field, 'minLength');
|
|
136
|
+
const required = getBoolProp(field, 'required');
|
|
129
137
|
return {
|
|
130
138
|
name,
|
|
131
139
|
label,
|
|
@@ -140,6 +148,9 @@ function describeTopLevelField(field) {
|
|
|
140
148
|
reversia,
|
|
141
149
|
},
|
|
142
150
|
],
|
|
151
|
+
...(maxLength !== undefined && { maxLength }),
|
|
152
|
+
...(minLength !== undefined && { minLength }),
|
|
153
|
+
...(required && { hasRequiredLeaf: true }),
|
|
143
154
|
};
|
|
144
155
|
}
|
|
145
156
|
// Top-level richText / json (localized or not localized but containing
|
|
@@ -163,6 +174,7 @@ function describeTopLevelField(field) {
|
|
|
163
174
|
reversia,
|
|
164
175
|
},
|
|
165
176
|
],
|
|
177
|
+
...(getBoolProp(field, 'required') && { hasRequiredLeaf: true }),
|
|
166
178
|
};
|
|
167
179
|
}
|
|
168
180
|
// Structured wrapper: group / array / blocks. Walk inside to collect
|
|
@@ -192,6 +204,11 @@ function describeTopLevelField(field) {
|
|
|
192
204
|
isContainer: true,
|
|
193
205
|
reversia,
|
|
194
206
|
leaves: innerLeaves,
|
|
207
|
+
// Containers (arrays, blocks, groups) almost always have required inner
|
|
208
|
+
// subfields we can't cheaply enumerate. Marking hasRequiredLeaf ensures we
|
|
209
|
+
// always seed empty target-locale containers from source so Payload's
|
|
210
|
+
// whole-document required-field validation doesn't reject the update.
|
|
211
|
+
hasRequiredLeaf: true,
|
|
195
212
|
};
|
|
196
213
|
}
|
|
197
214
|
function collectLeaves(fields, parentSegments, out) {
|
|
@@ -272,6 +289,27 @@ function resolveAsLabel(field, hasLabelAlready) {
|
|
|
272
289
|
}
|
|
273
290
|
return false;
|
|
274
291
|
}
|
|
292
|
+
function resolveRules(field) {
|
|
293
|
+
// Rules only make sense for non-container scalars — containers ship as JSON
|
|
294
|
+
// pointer maps where the concept of "max length" doesn't apply at the
|
|
295
|
+
// top-level entry.
|
|
296
|
+
if (field.isContainer) {
|
|
297
|
+
return undefined;
|
|
298
|
+
}
|
|
299
|
+
const maxLength = field.maxLength;
|
|
300
|
+
const minLength = field.minLength;
|
|
301
|
+
if (maxLength === undefined && minLength === undefined) {
|
|
302
|
+
return undefined;
|
|
303
|
+
}
|
|
304
|
+
const rules = {};
|
|
305
|
+
if (maxLength !== undefined) {
|
|
306
|
+
rules.maxLength = maxLength;
|
|
307
|
+
}
|
|
308
|
+
if (minLength !== undefined) {
|
|
309
|
+
rules.minLength = minLength;
|
|
310
|
+
}
|
|
311
|
+
return rules;
|
|
312
|
+
}
|
|
275
313
|
function humanize(segment) {
|
|
276
314
|
const spaced = segment
|
|
277
315
|
.replace(/[_-]+/g, ' ')
|
|
@@ -309,6 +347,10 @@ export function buildTranslatableConfiguration(localizedFields) {
|
|
|
309
347
|
if (field.reversia?.selected === false) {
|
|
310
348
|
fieldConfig.selected = false;
|
|
311
349
|
}
|
|
350
|
+
const rules = resolveRules(field);
|
|
351
|
+
if (rules) {
|
|
352
|
+
fieldConfig.rules = rules;
|
|
353
|
+
}
|
|
312
354
|
config[field.name] = fieldConfig;
|
|
313
355
|
}
|
|
314
356
|
return config;
|
|
@@ -417,9 +459,6 @@ function resolveTranslatableKeys(leaf) {
|
|
|
417
459
|
}
|
|
418
460
|
return null;
|
|
419
461
|
}
|
|
420
|
-
/* -------------------------------------------------------------------------- */
|
|
421
|
-
/* Deserialization */
|
|
422
|
-
/* -------------------------------------------------------------------------- */
|
|
423
462
|
/**
|
|
424
463
|
* Reverses `serializeField` for one top-level field.
|
|
425
464
|
*
|