@sequoialabs/payload-plugin-reversia 0.1.7 → 0.1.8

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.
@@ -124,6 +124,61 @@ function applyTranslations({ allowedFields, data, sourceDoc, previousDoc, }) {
124
124
  }
125
125
  return { updateData, diff, acceptedFields };
126
126
  }
127
+ /**
128
+ * Payload's unique validator rejects an update when the new value already
129
+ * exists on another doc in the same locale. Reversia legitimately produces
130
+ * colliding translations (proper nouns, short phrases, numeric-suffix titles
131
+ * that don't change across locales), so without a pre-check the whole item
132
+ * fails with `ValidationError: <field>.<locale>: Value must be unique`.
133
+ *
134
+ * For every scalar field in `updateData` that the schema marks `unique: true`,
135
+ * query the target collection/locale for any OTHER doc with that value. When
136
+ * one exists, drop the field from the write so the rest of the update can go
137
+ * through. The source of truth stays on the first doc that claimed the value;
138
+ * the second doc's target-locale slot remains empty and can be edited
139
+ * manually later.
140
+ */
141
+ async function dropUniqueCollisions(params) {
142
+ const { payload, collection, id, locale, fields, updateData, acceptedFields, diff } = params;
143
+ for (const field of fields) {
144
+ if (field.isContainer || !field.unique) {
145
+ continue;
146
+ }
147
+ if (!(field.name in updateData)) {
148
+ continue;
149
+ }
150
+ const value = updateData[field.name];
151
+ if (value === null || value === undefined || value === '') {
152
+ continue;
153
+ }
154
+ const existing = await payload.find({
155
+ collection: collection,
156
+ locale: locale,
157
+ depth: 0,
158
+ limit: 1,
159
+ pagination: false,
160
+ where: {
161
+ and: [{ [field.name]: { equals: value } }, { id: { not_equals: id } }],
162
+ },
163
+ });
164
+ if (existing.docs.length === 0) {
165
+ continue;
166
+ }
167
+ payload.logger.warn({
168
+ collection,
169
+ id,
170
+ targetLocale: locale,
171
+ field: field.name,
172
+ collidingDocId: existing.docs[0]?.id,
173
+ }, '[reversia] skipping field to avoid unique collision');
174
+ delete updateData[field.name];
175
+ const acceptedIdx = acceptedFields.indexOf(field.name);
176
+ if (acceptedIdx !== -1) {
177
+ acceptedFields.splice(acceptedIdx, 1);
178
+ }
179
+ delete diff[field.name];
180
+ }
181
+ }
127
182
  export function createResourcesInsertEndpoint(pluginConfig, collectionsMap, globalsMap) {
128
183
  return {
129
184
  path: '/reversia/resources-insert',
@@ -238,6 +293,20 @@ export function createResourcesInsertEndpoint(pluginConfig, collectionsMap, glob
238
293
  response.errors.push(`Item ${index}: no recognised fields in data (keys: ${Object.keys(item.data).join(', ')})`);
239
294
  continue;
240
295
  }
296
+ await dropUniqueCollisions({
297
+ payload: req.payload,
298
+ collection: slug,
299
+ id: itemId,
300
+ locale: item.targetLocale,
301
+ fields: allowedFields,
302
+ updateData,
303
+ acceptedFields,
304
+ diff,
305
+ });
306
+ if (acceptedFields.length === 0 || Object.keys(updateData).length === 0) {
307
+ response.errors.push(`Item ${index}: all translatable fields skipped due to unique-constraint collisions in ${item.targetLocale}`);
308
+ continue;
309
+ }
241
310
  await withRetry(() => req.payload.update({
242
311
  collection: slug,
243
312
  id: itemId,
@@ -1,3 +1,3 @@
1
1
  import type { CollectionConfig, Endpoint, GlobalConfig } from 'payload';
2
2
  import type { ReversiaPluginConfig } from '../types.js';
3
- export declare function createResourcesEndpoint(pluginConfig: ReversiaPluginConfig, collectionsMap: Map<string, CollectionConfig>, _globalsMap: Map<string, GlobalConfig>): Endpoint;
3
+ export declare function createResourcesEndpoint(pluginConfig: ReversiaPluginConfig, collectionsMap: Map<string, CollectionConfig>, globalsMap: Map<string, GlobalConfig>): Endpoint;
@@ -25,7 +25,7 @@ function getLabelValue(doc, fields) {
25
25
  const value = doc?.[labelField.name];
26
26
  return typeof value === 'string' ? value : undefined;
27
27
  }
28
- export function createResourcesEndpoint(pluginConfig, collectionsMap, _globalsMap) {
28
+ export function createResourcesEndpoint(pluginConfig, collectionsMap, globalsMap) {
29
29
  return {
30
30
  path: '/reversia/resources',
31
31
  method: 'get',
@@ -98,6 +98,47 @@ export function createResourcesEndpoint(pluginConfig, collectionsMap, _globalsMa
98
98
  response.content.push({ type: resourceType, data: items });
99
99
  }
100
100
  }
101
+ // Globals are singletons (one doc per slug, id = slug), emitted after
102
+ // collections so pagination via `cursor` resumes at the right slot.
103
+ // Each global counts as 1 toward `limit`. Cursor semantics mirror the
104
+ // collection loop: when we find the type the cursor points to, that
105
+ // slot has already been returned — advance past it and resume from
106
+ // the next iteration.
107
+ for (const [slug, global] of globalsMap) {
108
+ const resourceType = `payloadcms:global:${slug}`;
109
+ if (requestedTypes && !requestedTypes.includes(resourceType)) {
110
+ continue;
111
+ }
112
+ if (!startFromCursor) {
113
+ if (cursor && cursor.type === resourceType) {
114
+ startFromCursor = true;
115
+ continue;
116
+ }
117
+ continue;
118
+ }
119
+ if (totalFetched >= limit) {
120
+ break;
121
+ }
122
+ const localizedFields = findLocalizedFields(global.fields);
123
+ if (localizedFields.length === 0) {
124
+ continue;
125
+ }
126
+ const doc = await req.payload.findGlobal({ slug, locale: defaultLocale });
127
+ const { content, contentTypes } = extractContent(doc, localizedFields);
128
+ if (Object.keys(content).length === 0) {
129
+ continue;
130
+ }
131
+ const item = {
132
+ id: slug,
133
+ label: getLabelValue(doc, localizedFields),
134
+ content,
135
+ contentTypes: Object.keys(contentTypes).length > 0 ? contentTypes : undefined,
136
+ };
137
+ response.content.push({ type: resourceType, data: [item] });
138
+ lastType = resourceType;
139
+ lastId = slug;
140
+ totalFetched++;
141
+ }
101
142
  if (lastType && lastId && totalFetched >= limit) {
102
143
  response.cursor = encodeCursor(lastType, lastId);
103
144
  }
package/dist/types.d.ts CHANGED
@@ -183,6 +183,15 @@ export interface LocalizedFieldInfo {
183
183
  * the update.
184
184
  */
185
185
  hasRequiredLeaf?: boolean;
186
+ /**
187
+ * True when a top-level localized scalar is `unique: true`. Used during
188
+ * insertion to pre-check for cross-document collisions in the target locale
189
+ * before writing — Reversia may translate two docs' source values to the
190
+ * same target string, and Payload's unique validator would reject the
191
+ * second write. Only meaningful for scalars; Payload doesn't support
192
+ * `unique` on containers.
193
+ */
194
+ unique?: boolean;
186
195
  }
187
196
  export interface ResourceItem {
188
197
  id: string;
@@ -134,6 +134,7 @@ function describeTopLevelField(field) {
134
134
  const maxLength = getNumericProp(field, 'maxLength');
135
135
  const minLength = getNumericProp(field, 'minLength');
136
136
  const required = getBoolProp(field, 'required');
137
+ const unique = getBoolProp(field, 'unique');
137
138
  return {
138
139
  name,
139
140
  label,
@@ -151,6 +152,7 @@ function describeTopLevelField(field) {
151
152
  ...(maxLength !== undefined && { maxLength }),
152
153
  ...(minLength !== undefined && { minLength }),
153
154
  ...(required && { hasRequiredLeaf: true }),
155
+ ...(unique && { unique: true }),
154
156
  };
155
157
  }
156
158
  // Top-level richText / json (localized or not localized but containing
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sequoialabs/payload-plugin-reversia",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "author": {
5
5
  "name": "Jean Walrave",
6
6
  "email": "contact@reversia.tech",