@sequoialabs/payload-plugin-reversia 0.1.2 → 0.1.4

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,32 @@
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 +
22
+ Math.random() * WRITE_CONFLICT_BASE_DELAY_MS;
23
+ await new Promise((resolve) => setTimeout(resolve, delay));
24
+ continue;
25
+ }
26
+ throw error;
27
+ }
28
+ }
29
+ }
3
30
  function stringifyDiffValue(value) {
4
31
  if (typeof value === 'string') {
5
32
  return value;
@@ -29,15 +56,53 @@ function applyTranslations({ allowedFields, data, sourceDoc, previousDoc, }) {
29
56
  // populated `{ id, filename, url }` objects that Payload's update validator
30
57
  // rejects.
31
58
  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.
59
+ // Build updateData from fields Reversia sent, plus any required fields that
60
+ // are empty in the target locale. Payload validates ALL required fields on
61
+ // every update (not just the ones in `data`), so an empty required field in
62
+ // the target locale would block the entire write. We seed those from source.
36
63
  const updateData = {};
37
64
  const diff = {};
38
65
  const acceptedFields = [];
39
66
  const previous = previousDoc ?? null;
40
67
  const source = cleanSource ?? null;
68
+ const dataKeys = new Set(Object.keys(data));
69
+ // Seed required fields not being translated so Payload's whole-document
70
+ // validation doesn't reject the update.
71
+ //
72
+ // Scalars: only seed when the target locale has no value at all (null /
73
+ // undefined / empty string). If the target already has something, leave it.
74
+ //
75
+ // Containers: always seed from source when not in `data`. The target might
76
+ // have a "structurally present but content-empty" value (e.g. Payload
77
+ // auto-creates 9 default block items from `defaultValue` but every block's
78
+ // required inner richText is null). A shallow null-check misses this; a
79
+ // deep check is impractical. Seeding writes source-locale content as a
80
+ // placeholder — once Reversia translates the field and sends it in `data`,
81
+ // the seed is overwritten with the real translation.
82
+ for (const field of allowedFields) {
83
+ if (!field.hasRequiredLeaf) {
84
+ continue;
85
+ }
86
+ if (dataKeys.has(field.name)) {
87
+ continue; // Reversia is sending it — will be handled below
88
+ }
89
+ const sourceValue = source ? source[field.name] : undefined;
90
+ if (sourceValue === undefined || sourceValue === null) {
91
+ continue; // Source is also empty — nothing we can do
92
+ }
93
+ if (field.isContainer) {
94
+ // Always seed containers from source — inner required subfields may be
95
+ // empty even when the container itself is non-null in the target.
96
+ updateData[field.name] = deflatePopulatedRelationships(structuredClone(sourceValue));
97
+ }
98
+ else {
99
+ // Scalars: only seed when truly empty in target.
100
+ const targetValue = previous ? previous[field.name] : undefined;
101
+ if (targetValue === undefined || targetValue === null || targetValue === '') {
102
+ updateData[field.name] = sourceValue;
103
+ }
104
+ }
105
+ }
41
106
  for (const [fieldName, translatedValue] of Object.entries(data)) {
42
107
  const field = fieldByName.get(fieldName);
43
108
  if (!field) {
@@ -118,12 +183,12 @@ export function createResourcesInsertEndpoint(pluginConfig, collectionsMap, glob
118
183
  response.errors.push(`Item ${index}: no recognised fields in data (keys: ${Object.keys(item.data).join(', ')})`);
119
184
  continue;
120
185
  }
121
- await req.payload.updateGlobal({
186
+ await withRetry(() => req.payload.updateGlobal({
122
187
  slug: globalSlug,
123
188
  locale: item.targetLocale,
124
189
  data: updateData,
125
190
  context: { reversiaInsertion: true },
126
- });
191
+ }));
127
192
  response[index] = { index, type: item.type, id: globalSlug, diff };
128
193
  continue;
129
194
  }
@@ -141,6 +206,7 @@ export function createResourcesInsertEndpoint(pluginConfig, collectionsMap, glob
141
206
  response.errors.push(`Item ${index}: id is required for collection resources`);
142
207
  continue;
143
208
  }
209
+ const itemId = item.id;
144
210
  const allowedFields = findLocalizedFields(collection.fields);
145
211
  const [sourceDoc, previousDoc] = await Promise.all([
146
212
  req.payload.findByID({
@@ -166,14 +232,14 @@ export function createResourcesInsertEndpoint(pluginConfig, collectionsMap, glob
166
232
  response.errors.push(`Item ${index}: no recognised fields in data (keys: ${Object.keys(item.data).join(', ')})`);
167
233
  continue;
168
234
  }
169
- await req.payload.update({
235
+ await withRetry(() => req.payload.update({
170
236
  collection: slug,
171
- id: item.id,
237
+ id: itemId,
172
238
  locale: item.targetLocale,
173
239
  data: updateData,
174
240
  context: { reversiaInsertion: true },
175
- });
176
- response[index] = { index, type: item.type, id: item.id, diff };
241
+ }));
242
+ response[index] = { index, type: item.type, id: itemId, diff };
177
243
  }
178
244
  catch (error) {
179
245
  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;
@@ -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
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sequoialabs/payload-plugin-reversia",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "author": {
5
5
  "name": "Jean Walrave",
6
6
  "email": "contact@reversia.tech",