@sequoialabs/payload-plugin-reversia 0.1.0

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.
Files changed (39) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +84 -0
  3. package/dist/collections/sync-pending.d.ts +2 -0
  4. package/dist/collections/sync-pending.js +27 -0
  5. package/dist/endpoints/confirm-resources-sync.d.ts +3 -0
  6. package/dist/endpoints/confirm-resources-sync.js +31 -0
  7. package/dist/endpoints/resource.d.ts +3 -0
  8. package/dist/endpoints/resource.js +82 -0
  9. package/dist/endpoints/resources-definition.d.ts +3 -0
  10. package/dist/endpoints/resources-definition.js +54 -0
  11. package/dist/endpoints/resources-insert.d.ts +3 -0
  12. package/dist/endpoints/resources-insert.js +174 -0
  13. package/dist/endpoints/resources-sync.d.ts +3 -0
  14. package/dist/endpoints/resources-sync.js +59 -0
  15. package/dist/endpoints/resources.d.ts +3 -0
  16. package/dist/endpoints/resources.js +107 -0
  17. package/dist/endpoints/settings.d.ts +3 -0
  18. package/dist/endpoints/settings.js +34 -0
  19. package/dist/hooks/after-change.d.ts +11 -0
  20. package/dist/hooks/after-change.js +40 -0
  21. package/dist/index.d.ts +6 -0
  22. package/dist/index.js +82 -0
  23. package/dist/types.d.ts +238 -0
  24. package/dist/types.js +12 -0
  25. package/dist/utils/auth.d.ts +3 -0
  26. package/dist/utils/auth.js +27 -0
  27. package/dist/utils/cursor.d.ts +7 -0
  28. package/dist/utils/cursor.js +30 -0
  29. package/dist/utils/fields.d.ts +58 -0
  30. package/dist/utils/fields.js +715 -0
  31. package/dist/utils/json-extract.d.ts +37 -0
  32. package/dist/utils/json-extract.js +186 -0
  33. package/dist/utils/labels.d.ts +12 -0
  34. package/dist/utils/labels.js +27 -0
  35. package/dist/utils/path-resolver.d.ts +53 -0
  36. package/dist/utils/path-resolver.js +157 -0
  37. package/dist/utils/payload-helpers.d.ts +16 -0
  38. package/dist/utils/payload-helpers.js +45 -0
  39. package/package.json +57 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sequoia Labs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,84 @@
1
+ # @sequoialabs/payload-plugin-reversia
2
+
3
+ A [PayloadCMS](https://payloadcms.com/) plugin that integrates with [Reversia](https://reversia.tech), a translation SaaS platform, enabling seamless content localization and synchronization across multiple languages.
4
+
5
+ ## How it works
6
+
7
+ 1. The plugin scans your PayloadCMS config and detects every top-level field that contains at least one `localized: true` leaf — scalars, groups, arrays, blocks, richText, json all qualify.
8
+ 2. When content changes, an `afterChange` hook records the resource in a hidden sync queue.
9
+ 3. Reversia polls the plugin's HTTP endpoints to fetch translatable content.
10
+ 4. Each resource entry is keyed by top-level field name. Scalars ship as plain values. **Containers ship one JSON-pointer map per field** containing only the localized atoms — non-localized siblings, structural keys, and unrelated data never reach Reversia.
11
+ 5. Once translated, Reversia pushes translations back. The plugin **clones the source-locale document** as the base of the update, then overlays each translated leaf at its pointer — required nested siblings (block ids, `blockType`, non-localized subfields) are guaranteed to be present so Payload validation passes.
12
+ 6. A confirmation endpoint clears processed items from the sync queue (`updatedAt`-based, so re-edits during translation aren't lost).
13
+
14
+ ## Installation
15
+
16
+ ```bash
17
+ npm install @sequoialabs/payload-plugin-reversia
18
+ # or
19
+ bun add @sequoialabs/payload-plugin-reversia
20
+ ```
21
+
22
+ ## Quick start
23
+
24
+ ```ts
25
+ import { buildConfig } from 'payload';
26
+ import { reversiaPlugin } from '@sequoialabs/payload-plugin-reversia';
27
+
28
+ export default buildConfig({
29
+ plugins: [
30
+ reversiaPlugin({
31
+ apiKey: process.env.REVERSIA_API_KEY!,
32
+ enabledCollections: ['posts', 'pages'],
33
+ enabledGlobals: ['site-settings'],
34
+ }),
35
+ ],
36
+ });
37
+ ```
38
+
39
+ Then mark whichever fields are translatable. **You don't need to annotate containers** — declaring `localized: true` on any inner subfield is enough; the plugin auto-detects the top-level container and emits a single JSON-pointer entry for it.
40
+
41
+ ```ts
42
+ // Top-level localized scalar — shipped as a primitive.
43
+ { name: 'title', type: 'text', localized: true }
44
+
45
+ // Top-level richText — shipped as a JSON-pointer map of `text`/`url`/`alt`
46
+ // leaves by default.
47
+ { name: 'body', type: 'richText', localized: true }
48
+
49
+ // Slug field — ship as text, mark behaviour for Reversia.
50
+ {
51
+ name: 'slug',
52
+ type: 'text',
53
+ localized: true,
54
+ custom: { reversia: { behavior: 'slug' } },
55
+ }
56
+
57
+ // Blocks container — auto-detected. One entry per page keyed by `body`,
58
+ // value: `{"/0/heading":"…","/2/heading":"…"}`.
59
+ {
60
+ name: 'body',
61
+ type: 'blocks',
62
+ blocks: [
63
+ { slug: 'hero', fields: [{ name: 'heading', type: 'text', localized: true }] },
64
+ ],
65
+ }
66
+ ```
67
+
68
+ ## Documentation
69
+
70
+ - **[Configuration](./docs/configuration.md)** — plugin options, auth, what gets exposed.
71
+ - **[Field annotations](./docs/field-annotations.md)** — the `custom.reversia` metadata reference.
72
+ - **[Rich text & JSON fields](./docs/rich-text.md)** — `translatableKeys`, path patterns, and the `extract`/`apply` escape hatch.
73
+ - **[API reference](./docs/api-reference.md)** — HTTP endpoints consumed by the Reversia SaaS.
74
+ - **[Contributing](./CONTRIBUTING.md)** — local dev, linting, testing.
75
+
76
+ ## Requirements
77
+
78
+ - PayloadCMS v3.0.0+
79
+ - Node.js 20+ or Bun
80
+ - At least one `localized: true` field on a collection or global
81
+
82
+ ## License
83
+
84
+ MIT
@@ -0,0 +1,2 @@
1
+ import type { CollectionConfig } from 'payload';
2
+ export declare const reversiaSyncPendingCollection: CollectionConfig;
@@ -0,0 +1,27 @@
1
+ export const reversiaSyncPendingCollection = {
2
+ slug: 'reversia-sync-pending',
3
+ admin: {
4
+ hidden: true,
5
+ },
6
+ access: {
7
+ // Hidden, internal queue — no public read/write access by default.
8
+ read: () => false,
9
+ create: () => false,
10
+ update: () => false,
11
+ delete: () => false,
12
+ },
13
+ fields: [
14
+ {
15
+ name: 'resourceType',
16
+ type: 'text',
17
+ required: true,
18
+ index: true,
19
+ },
20
+ {
21
+ name: 'resourceId',
22
+ type: 'text',
23
+ required: true,
24
+ index: true,
25
+ },
26
+ ],
27
+ };
@@ -0,0 +1,3 @@
1
+ import type { Endpoint } from 'payload';
2
+ import type { ReversiaPluginConfig } from '../types.js';
3
+ export declare function createConfirmResourcesSyncEndpoint(pluginConfig: ReversiaPluginConfig): Endpoint;
@@ -0,0 +1,31 @@
1
+ import { unauthorizedResponse, validateApiKey } from '../utils/auth.js';
2
+ import { decodeCursor } from '../utils/cursor.js';
3
+ export function createConfirmResourcesSyncEndpoint(pluginConfig) {
4
+ return {
5
+ path: '/reversia/confirm-resources-sync',
6
+ method: 'post',
7
+ handler: async (req) => {
8
+ if (!validateApiKey(req, pluginConfig.apiKey)) {
9
+ return unauthorizedResponse();
10
+ }
11
+ const body = req.data ??
12
+ (req.json ? await req.json() : undefined);
13
+ if (!body?.cursor) {
14
+ return Response.json({ error: 'cursor is required' }, { status: 400 });
15
+ }
16
+ const cursor = decodeCursor(body.cursor);
17
+ if (!cursor) {
18
+ return Response.json({ error: 'Invalid cursor' }, { status: 400 });
19
+ }
20
+ // Rows edited after the cursor have a later `updatedAt` and must survive.
21
+ // Using timestamp comparison is portable across Mongo/Postgres/SQLite —
22
+ // id reuse on SQLite would silently corrupt the queue.
23
+ const result = await req.payload.delete({
24
+ collection: 'reversia-sync-pending',
25
+ where: { updatedAt: { less_than_equal: cursor.id } },
26
+ });
27
+ const deleted = Array.isArray(result.docs) ? result.docs.length : 0;
28
+ return Response.json({ success: true, deleted });
29
+ },
30
+ };
31
+ }
@@ -0,0 +1,3 @@
1
+ import type { CollectionConfig, Endpoint, GlobalConfig } from 'payload';
2
+ import type { ReversiaPluginConfig } from '../types.js';
3
+ export declare function createResourceEndpoint(pluginConfig: ReversiaPluginConfig, collectionsMap: Map<string, CollectionConfig>, globalsMap: Map<string, GlobalConfig>): Endpoint;
@@ -0,0 +1,82 @@
1
+ import { unauthorizedResponse, validateApiKey } from '../utils/auth.js';
2
+ import { findLocalizedFields, serializeField } from '../utils/fields.js';
3
+ import { resolveDefaultLocale } from '../utils/payload-helpers.js';
4
+ function extract(doc, fields) {
5
+ const content = {};
6
+ const contentTypes = {};
7
+ for (const field of fields) {
8
+ const entry = serializeField(field, doc);
9
+ if (!entry) {
10
+ continue;
11
+ }
12
+ content[entry.name] = entry.value;
13
+ if (entry.contentType) {
14
+ contentTypes[entry.name] = entry.contentType;
15
+ }
16
+ }
17
+ return { content, contentTypes };
18
+ }
19
+ function getLabelValue(doc, fields) {
20
+ const labelField = fields.find((f) => !f.isContainer && (f.name === 'title' || f.name === 'name'));
21
+ if (!labelField) {
22
+ return undefined;
23
+ }
24
+ const value = doc?.[labelField.name];
25
+ return typeof value === 'string' ? value : undefined;
26
+ }
27
+ export function createResourceEndpoint(pluginConfig, collectionsMap, globalsMap) {
28
+ return {
29
+ path: '/reversia/resource',
30
+ method: 'get',
31
+ handler: async (req) => {
32
+ if (!validateApiKey(req, pluginConfig.apiKey)) {
33
+ return unauthorizedResponse();
34
+ }
35
+ const resourceType = req.searchParams.get('resourceType');
36
+ const resourceId = req.searchParams.get('resourceId');
37
+ if (!resourceType || resourceType.length === 0) {
38
+ return Response.json({ error: 'resourceType is required' }, { status: 400 });
39
+ }
40
+ const defaultLocale = resolveDefaultLocale(req);
41
+ if (resourceType.startsWith('payloadcms:global:')) {
42
+ const globalSlug = resourceType.slice('payloadcms:global:'.length);
43
+ const globalConfig = globalsMap.get(globalSlug);
44
+ if (!globalConfig) {
45
+ return Response.json({ error: `Global ${globalSlug} not found` }, { status: 404 });
46
+ }
47
+ const localizedFields = findLocalizedFields(globalConfig.fields);
48
+ const doc = await req.payload.findGlobal({ slug: globalSlug, locale: defaultLocale });
49
+ const { content, contentTypes } = extract(doc, localizedFields);
50
+ return Response.json({
51
+ id: globalSlug,
52
+ content,
53
+ contentTypes: Object.keys(contentTypes).length > 0 ? contentTypes : undefined,
54
+ });
55
+ }
56
+ if (!resourceType.startsWith('payloadcms:')) {
57
+ return Response.json({ error: 'Unknown resourceType' }, { status: 400 });
58
+ }
59
+ const slug = resourceType.slice('payloadcms:'.length);
60
+ const collection = collectionsMap.get(slug);
61
+ if (!collection) {
62
+ return Response.json({ error: `Collection ${slug} not found` }, { status: 404 });
63
+ }
64
+ if (!resourceId || resourceId.length === 0) {
65
+ return Response.json({ error: 'resourceId is required for collection resources' }, { status: 400 });
66
+ }
67
+ const localizedFields = findLocalizedFields(collection.fields);
68
+ const doc = await req.payload.findByID({
69
+ collection: slug,
70
+ id: resourceId,
71
+ locale: defaultLocale,
72
+ });
73
+ const { content, contentTypes } = extract(doc, localizedFields);
74
+ return Response.json({
75
+ id: String(doc.id),
76
+ label: getLabelValue(doc, localizedFields),
77
+ content,
78
+ contentTypes: Object.keys(contentTypes).length > 0 ? contentTypes : undefined,
79
+ });
80
+ },
81
+ };
82
+ }
@@ -0,0 +1,3 @@
1
+ import type { CollectionConfig, Endpoint, GlobalConfig } from 'payload';
2
+ import type { ReversiaPluginConfig } from '../types.js';
3
+ export declare function createResourcesDefinitionEndpoint(pluginConfig: ReversiaPluginConfig, collectionsMap: Map<string, CollectionConfig>, globalsMap: Map<string, GlobalConfig>): Endpoint;
@@ -0,0 +1,54 @@
1
+ import { unauthorizedResponse, validateApiKey } from '../utils/auth.js';
2
+ import { buildTranslatableConfiguration, findLocalizedFields } from '../utils/fields.js';
3
+ import { resolveStaticLabel } from '../utils/labels.js';
4
+ export function createResourcesDefinitionEndpoint(pluginConfig, collectionsMap, globalsMap) {
5
+ return {
6
+ path: '/reversia/resources-definition',
7
+ method: 'get',
8
+ handler: async (req) => {
9
+ if (!validateApiKey(req, pluginConfig.apiKey)) {
10
+ return unauthorizedResponse();
11
+ }
12
+ const definitions = [];
13
+ for (const [slug, collection] of collectionsMap) {
14
+ const localizedFields = findLocalizedFields(collection.fields);
15
+ if (localizedFields.length === 0) {
16
+ continue;
17
+ }
18
+ const count = await req.payload.count({ collection: slug });
19
+ definitions.push({
20
+ type: `payloadcms:${slug}`,
21
+ label: {
22
+ singular: resolveStaticLabel(collection.labels?.singular, slug),
23
+ plural: resolveStaticLabel(collection.labels?.plural, slug),
24
+ },
25
+ group: 'payloadcms',
26
+ version: '1.0.0',
27
+ configuration: buildTranslatableConfiguration(localizedFields),
28
+ configurationType: 'MULTIPLE',
29
+ count: count.totalDocs,
30
+ synchronizable: true,
31
+ });
32
+ }
33
+ for (const [slug, global] of globalsMap) {
34
+ const localizedFields = findLocalizedFields(global.fields);
35
+ if (localizedFields.length === 0) {
36
+ continue;
37
+ }
38
+ definitions.push({
39
+ type: `payloadcms:global:${slug}`,
40
+ label: {
41
+ singular: resolveStaticLabel(global.label, slug),
42
+ plural: resolveStaticLabel(global.label, slug),
43
+ },
44
+ group: 'payloadcms',
45
+ version: '1.0.0',
46
+ configuration: buildTranslatableConfiguration(localizedFields),
47
+ configurationType: 'ENTITY',
48
+ synchronizable: true,
49
+ });
50
+ }
51
+ return Response.json(definitions);
52
+ },
53
+ };
54
+ }
@@ -0,0 +1,3 @@
1
+ import type { CollectionConfig, Endpoint, GlobalConfig } from 'payload';
2
+ import type { ReversiaPluginConfig } from '../types.js';
3
+ export declare function createResourcesInsertEndpoint(pluginConfig: ReversiaPluginConfig, collectionsMap: Map<string, CollectionConfig>, globalsMap: Map<string, GlobalConfig>): Endpoint;
@@ -0,0 +1,174 @@
1
+ import { unauthorizedResponse, validateApiKey } from '../utils/auth.js';
2
+ import { cloneLocalizedContainersFromSource, deserializeFieldValue, findLocalizedFields, } from '../utils/fields.js';
3
+ function stringifyDiffValue(value) {
4
+ if (typeof value === 'string') {
5
+ return value;
6
+ }
7
+ if (value === null || value === undefined) {
8
+ return '';
9
+ }
10
+ if (typeof value === 'object') {
11
+ return JSON.stringify(value);
12
+ }
13
+ return String(value);
14
+ }
15
+ function indexFieldsByName(fields) {
16
+ const map = new Map();
17
+ for (const f of fields) {
18
+ map.set(f.name, f);
19
+ }
20
+ return map;
21
+ }
22
+ function applyTranslations({ allowedFields, data, sourceDoc, previousDoc, }) {
23
+ const fieldByName = indexFieldsByName(allowedFields);
24
+ // Source clone is the base — required nested siblings are preserved even
25
+ // when Reversia only sends a subset of leaves (mirrors the PrestaShop
26
+ // module's "clone source, replace with what we got" approach).
27
+ const updateData = cloneLocalizedContainersFromSource(sourceDoc, allowedFields);
28
+ const diff = {};
29
+ const acceptedFields = [];
30
+ const previous = previousDoc ?? null;
31
+ const source = sourceDoc ?? null;
32
+ for (const [fieldName, translatedValue] of Object.entries(data)) {
33
+ const field = fieldByName.get(fieldName);
34
+ if (!field) {
35
+ continue;
36
+ }
37
+ const sourceValue = source ? source[fieldName] : undefined;
38
+ const finalValue = deserializeFieldValue(field, sourceValue, translatedValue);
39
+ updateData[fieldName] = finalValue;
40
+ acceptedFields.push(fieldName);
41
+ const prevValue = previous ? previous[fieldName] : undefined;
42
+ if (prevValue === undefined || prevValue === null) {
43
+ continue;
44
+ }
45
+ const prevString = stringifyDiffValue(prevValue);
46
+ const newString = stringifyDiffValue(finalValue);
47
+ if (prevString !== newString) {
48
+ diff[fieldName] = prevString;
49
+ }
50
+ }
51
+ return { updateData, diff, acceptedFields };
52
+ }
53
+ export function createResourcesInsertEndpoint(pluginConfig, collectionsMap, globalsMap) {
54
+ return {
55
+ path: '/reversia/resources-insert',
56
+ method: 'put',
57
+ handler: async (req) => {
58
+ if (!validateApiKey(req, pluginConfig.apiKey)) {
59
+ return unauthorizedResponse();
60
+ }
61
+ const body = req.data ??
62
+ (req.json ? await req.json() : undefined);
63
+ if (!Array.isArray(body)) {
64
+ return Response.json({ error: 'Request body must be an array' }, { status: 400 });
65
+ }
66
+ const response = { errors: [] };
67
+ for (let index = 0; index < body.length; index++) {
68
+ const item = body[index];
69
+ if (!item || typeof item !== 'object') {
70
+ response.errors.push(`Item ${index}: must be an object`);
71
+ continue;
72
+ }
73
+ if (!item.type) {
74
+ response.errors.push(`Item ${index}: type is required`);
75
+ continue;
76
+ }
77
+ if (!item.targetLocale) {
78
+ response.errors.push(`Item ${index}: targetLocale is required`);
79
+ continue;
80
+ }
81
+ if (!item.data || typeof item.data !== 'object') {
82
+ response.errors.push(`Item ${index}: data is required and must be an object`);
83
+ continue;
84
+ }
85
+ try {
86
+ if (item.type.startsWith('payloadcms:global:')) {
87
+ const globalSlug = item.type.slice('payloadcms:global:'.length);
88
+ const globalConfig = globalsMap.get(globalSlug);
89
+ if (!globalConfig) {
90
+ response.errors.push(`Item ${index}: global ${globalSlug} not found`);
91
+ continue;
92
+ }
93
+ const allowedFields = findLocalizedFields(globalConfig.fields);
94
+ // Always fetch source — required to populate non-localized siblings
95
+ // and to deserialize JSON-pointer maps. Target fetch is for diff.
96
+ const [sourceDoc, previousDoc] = await Promise.all([
97
+ req.payload.findGlobal({ slug: globalSlug, locale: item.sourceLocale }),
98
+ req.payload.findGlobal({ slug: globalSlug, locale: item.targetLocale }),
99
+ ]);
100
+ const { updateData, diff, acceptedFields } = applyTranslations({
101
+ allowedFields,
102
+ data: item.data,
103
+ sourceDoc,
104
+ previousDoc,
105
+ });
106
+ if (acceptedFields.length === 0) {
107
+ response.errors.push(`Item ${index}: no recognised fields in data (keys: ${Object.keys(item.data).join(', ')})`);
108
+ continue;
109
+ }
110
+ await req.payload.updateGlobal({
111
+ slug: globalSlug,
112
+ locale: item.targetLocale,
113
+ data: updateData,
114
+ context: { reversiaInsertion: true },
115
+ });
116
+ response[index] = { index, type: item.type, id: globalSlug, diff };
117
+ continue;
118
+ }
119
+ if (!item.type.startsWith('payloadcms:')) {
120
+ response.errors.push(`Item ${index}: unknown resourceType "${item.type}"`);
121
+ continue;
122
+ }
123
+ const slug = item.type.slice('payloadcms:'.length);
124
+ const collection = collectionsMap.get(slug);
125
+ if (!collection) {
126
+ response.errors.push(`Item ${index}: collection ${slug} not found`);
127
+ continue;
128
+ }
129
+ if (!item.id) {
130
+ response.errors.push(`Item ${index}: id is required for collection resources`);
131
+ continue;
132
+ }
133
+ const allowedFields = findLocalizedFields(collection.fields);
134
+ const [sourceDoc, previousDoc] = await Promise.all([
135
+ req.payload.findByID({
136
+ collection: slug,
137
+ id: item.id,
138
+ locale: item.sourceLocale,
139
+ }),
140
+ req.payload.findByID({
141
+ collection: slug,
142
+ id: item.id,
143
+ locale: item.targetLocale,
144
+ }),
145
+ ]);
146
+ const { updateData, diff, acceptedFields } = applyTranslations({
147
+ allowedFields,
148
+ data: item.data,
149
+ sourceDoc,
150
+ previousDoc,
151
+ });
152
+ if (acceptedFields.length === 0) {
153
+ response.errors.push(`Item ${index}: no recognised fields in data (keys: ${Object.keys(item.data).join(', ')})`);
154
+ continue;
155
+ }
156
+ await req.payload.update({
157
+ collection: slug,
158
+ id: item.id,
159
+ locale: item.targetLocale,
160
+ data: updateData,
161
+ context: { reversiaInsertion: true },
162
+ });
163
+ response[index] = { index, type: item.type, id: item.id, diff };
164
+ }
165
+ catch (error) {
166
+ const message = error instanceof Error ? error.message : String(error);
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}`);
169
+ }
170
+ }
171
+ return Response.json(response);
172
+ },
173
+ };
174
+ }
@@ -0,0 +1,3 @@
1
+ import type { CollectionConfig, Endpoint } from 'payload';
2
+ import type { ReversiaPluginConfig } from '../types.js';
3
+ export declare function createResourcesSyncEndpoint(pluginConfig: ReversiaPluginConfig, _collectionsMap: Map<string, CollectionConfig>): Endpoint;
@@ -0,0 +1,59 @@
1
+ import { unauthorizedResponse, validateApiKey } from '../utils/auth.js';
2
+ import { decodeCursor, encodeCursor } from '../utils/cursor.js';
3
+ import { parseLimit } from '../utils/payload-helpers.js';
4
+ export function createResourcesSyncEndpoint(pluginConfig, _collectionsMap) {
5
+ return {
6
+ path: '/reversia/resources-sync',
7
+ method: 'get',
8
+ handler: async (req) => {
9
+ if (!validateApiKey(req, pluginConfig.apiKey)) {
10
+ return unauthorizedResponse();
11
+ }
12
+ const cursorParam = req.searchParams.get('cursor');
13
+ const limit = parseLimit(req.searchParams.get('limit'));
14
+ const cursor = decodeCursor(cursorParam);
15
+ const where = {};
16
+ if (cursor) {
17
+ // Cursor.id carries the `updatedAt` checkpoint as an ISO string.
18
+ where.updatedAt = { greater_than: cursor.id };
19
+ }
20
+ const pendingDocs = await req.payload.find({
21
+ collection: 'reversia-sync-pending',
22
+ where,
23
+ sort: 'updatedAt',
24
+ limit,
25
+ });
26
+ if (pendingDocs.docs.length === 0) {
27
+ return Response.json({ content: [], cursor: null });
28
+ }
29
+ const grouped = new Map();
30
+ for (const doc of pendingDocs.docs) {
31
+ const pending = doc;
32
+ const existing = grouped.get(pending.resourceType) ?? [];
33
+ existing.push(pending.resourceId);
34
+ grouped.set(pending.resourceType, existing);
35
+ }
36
+ const content = [];
37
+ for (const [resourceType, identifiers] of grouped) {
38
+ content.push({
39
+ type: resourceType,
40
+ data: [
41
+ {
42
+ id: resourceType,
43
+ content: { identifiers },
44
+ },
45
+ ],
46
+ });
47
+ }
48
+ const lastDoc = pendingDocs.docs[pendingDocs.docs.length - 1];
49
+ const lastUpdatedAt = typeof lastDoc.updatedAt === 'string'
50
+ ? lastDoc.updatedAt
51
+ : new Date(lastDoc.updatedAt).toISOString();
52
+ const lastCursor = encodeCursor('reversia-sync-pending', lastUpdatedAt);
53
+ return Response.json({
54
+ content,
55
+ cursor: lastCursor,
56
+ });
57
+ },
58
+ };
59
+ }
@@ -0,0 +1,3 @@
1
+ import type { CollectionConfig, Endpoint, GlobalConfig } from 'payload';
2
+ import type { ReversiaPluginConfig } from '../types.js';
3
+ export declare function createResourcesEndpoint(pluginConfig: ReversiaPluginConfig, collectionsMap: Map<string, CollectionConfig>, _globalsMap: Map<string, GlobalConfig>): Endpoint;