@sequoialabs/payload-plugin-reversia 0.1.1 → 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.
@@ -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 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.
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: item.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: item.id, diff };
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;
@@ -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
  *
@@ -786,7 +825,7 @@ export function deflatePopulatedRelationships(value) {
786
825
  return value.map(deflatePopulatedRelationships);
787
826
  }
788
827
  const obj = value;
789
- // Lexical block node — deflate populated fields
828
+ // Lexical block node — deflate populated fields inside `fields`.
790
829
  if (obj.type === 'block' && obj.fields && typeof obj.fields === 'object') {
791
830
  const fields = obj.fields;
792
831
  const deflated = {};
@@ -794,14 +833,8 @@ export function deflatePopulatedRelationships(value) {
794
833
  if (key === 'id' || key === 'blockType' || key === 'blockName') {
795
834
  deflated[key] = val;
796
835
  }
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
836
  else {
804
- deflated[key] = val;
837
+ deflated[key] = deflateValue(val);
805
838
  }
806
839
  }
807
840
  return {
@@ -812,22 +845,46 @@ export function deflatePopulatedRelationships(value) {
812
845
  : {}),
813
846
  };
814
847
  }
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.
848
+ // Any other object — recurse all keys, deflating populated docs wherever
849
+ // they appear. This catches relationship / upload fields inside Payload
850
+ // array items, group subfields, etc. — not just Lexical blocks.
825
851
  const out = {};
826
852
  for (const [k, v] of Object.entries(obj)) {
827
- out[k] = deflatePopulatedRelationships(v);
853
+ out[k] = deflateValue(v);
828
854
  }
829
855
  return out;
830
856
  }
857
+ /**
858
+ * Deflate a single value: if it's a populated doc, return its id. If it's a
859
+ * polymorphic relationship `{ relationTo, value: <populated> }`, deflate the
860
+ * inner value. Otherwise recurse.
861
+ */
862
+ function deflateValue(val) {
863
+ if (val === null || val === undefined || typeof val !== 'object') {
864
+ return val;
865
+ }
866
+ if (Array.isArray(val)) {
867
+ return val.map(deflateValue);
868
+ }
869
+ // Direct populated doc → raw id (e.g. upload field: { id, filename, ... })
870
+ if (isPopulatedDoc(val)) {
871
+ return val.id;
872
+ }
873
+ // Polymorphic relationship: { relationTo: 'x', value: <populated> }
874
+ const obj = val;
875
+ if (typeof obj.relationTo === 'string' && 'value' in obj && isPopulatedDoc(obj.value)) {
876
+ return { relationTo: obj.relationTo, value: obj.value.id };
877
+ }
878
+ // hasMany polymorphic: { relationTo: 'x', value: [<populated>, ...] }
879
+ if (typeof obj.relationTo === 'string' && Array.isArray(obj.value)) {
880
+ return {
881
+ relationTo: obj.relationTo,
882
+ value: obj.value.map((item) => isPopulatedDoc(item) ? item.id : item),
883
+ };
884
+ }
885
+ // Recurse into nested structure
886
+ return deflatePopulatedRelationships(val);
887
+ }
831
888
  function isPopulatedDoc(val) {
832
889
  if (!val || typeof val !== 'object' || Array.isArray(val)) {
833
890
  return false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sequoialabs/payload-plugin-reversia",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "author": {
5
5
  "name": "Jean Walrave",
6
6
  "email": "contact@reversia.tech",