@sequoialabs/payload-plugin-reversia 0.1.7 → 0.2.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.
package/README.md CHANGED
@@ -71,6 +71,7 @@ Then mark whichever fields are translatable. **You don't need to annotate contai
71
71
  - **[Field annotations](./docs/field-annotations.md)** — the `custom.reversia` metadata reference.
72
72
  - **[Rich text & JSON fields](./docs/rich-text.md)** — `translatableKeys`, path patterns, and the `extract`/`apply` escape hatch.
73
73
  - **[API reference](./docs/api-reference.md)** — HTTP endpoints consumed by the Reversia SaaS.
74
+ - **[Triggering crawls](./docs/trigger-crawl.md)** — `triggerCrawl()` for server code and `useTriggerCrawl()` for admin UI.
74
75
  - **[Contributing](./CONTRIBUTING.md)** — local dev, linting, testing.
75
76
 
76
77
  ## Requirements
@@ -0,0 +1 @@
1
+ export { type TriggerCrawlArgs, type TriggerCrawlOutcome, type TriggerCrawlStatus, type UseTriggerCrawlOptions, type UseTriggerCrawlResult, useTriggerCrawl, } from './use-trigger-crawl';
@@ -0,0 +1 @@
1
+ export { useTriggerCrawl, } from './use-trigger-crawl';
@@ -0,0 +1,30 @@
1
+ import type { CollectionSlug, GlobalSlug } from 'payload';
2
+ export type TriggerCrawlStatus = 'idle' | 'pending' | 'success' | 'error';
3
+ export interface UseTriggerCrawlOptions {
4
+ /**
5
+ * Override the server endpoint path. Defaults to
6
+ * `/api/reversia/dashboard/trigger-crawl`.
7
+ */
8
+ endpoint?: string;
9
+ }
10
+ export interface TriggerCrawlArgs {
11
+ /**
12
+ * Collection and/or global slugs to crawl. Slugs are prefixed with
13
+ * `payloadcms:` server-side before reaching Reversia. Omit/empty to crawl
14
+ * every enabled resource for the project.
15
+ */
16
+ types?: Array<CollectionSlug | GlobalSlug>;
17
+ /** Bypass cached crawl content. Defaults to `false`. */
18
+ noCache?: boolean;
19
+ }
20
+ export interface TriggerCrawlOutcome {
21
+ success: boolean;
22
+ error?: string;
23
+ }
24
+ export interface UseTriggerCrawlResult {
25
+ trigger: (args?: TriggerCrawlArgs) => Promise<TriggerCrawlOutcome>;
26
+ status: TriggerCrawlStatus;
27
+ error: string | null;
28
+ reset: () => void;
29
+ }
30
+ export declare function useTriggerCrawl(options?: UseTriggerCrawlOptions): UseTriggerCrawlResult;
@@ -0,0 +1,43 @@
1
+ 'use client';
2
+ import { useCallback, useState } from 'react';
3
+ const DEFAULT_ENDPOINT = '/api/reversia/dashboard/trigger-crawl';
4
+ export function useTriggerCrawl(options = {}) {
5
+ const endpoint = options.endpoint ?? DEFAULT_ENDPOINT;
6
+ const [status, setStatus] = useState('idle');
7
+ const [error, setError] = useState(null);
8
+ const trigger = useCallback(async (args = {}) => {
9
+ setStatus('pending');
10
+ setError(null);
11
+ try {
12
+ const response = await fetch(endpoint, {
13
+ method: 'POST',
14
+ headers: { 'Content-Type': 'application/json' },
15
+ credentials: 'same-origin',
16
+ body: JSON.stringify({
17
+ ...(args.types && args.types.length > 0 ? { types: args.types } : {}),
18
+ ...(args.noCache ? { noCache: true } : {}),
19
+ }),
20
+ });
21
+ const body = (await response.json().catch(() => ({})));
22
+ if (!response.ok || body.success === false) {
23
+ const message = body.error ?? `Request failed (${response.status})`;
24
+ setStatus('error');
25
+ setError(message);
26
+ return { success: false, error: message };
27
+ }
28
+ setStatus('success');
29
+ return { success: true };
30
+ }
31
+ catch (err) {
32
+ const message = err instanceof Error ? err.message : String(err);
33
+ setStatus('error');
34
+ setError(message);
35
+ return { success: false, error: message };
36
+ }
37
+ }, [endpoint]);
38
+ const reset = useCallback(() => {
39
+ setStatus('idle');
40
+ setError(null);
41
+ }, []);
42
+ return { trigger, status, error, reset };
43
+ }
@@ -1,3 +1,3 @@
1
1
  import type { Endpoint } from 'payload';
2
- import type { ReversiaPluginConfig } from '../types.js';
2
+ import type { ReversiaPluginConfig } from '../types';
3
3
  export declare function createConfirmResourcesSyncEndpoint(pluginConfig: ReversiaPluginConfig): Endpoint;
@@ -1,5 +1,5 @@
1
- import { unauthorizedResponse, validateApiKey } from '../utils/auth.js';
2
- import { decodeCursor } from '../utils/cursor.js';
1
+ import { unauthorizedResponse, validateApiKey } from '../utils/auth';
2
+ import { decodeCursor } from '../utils/cursor';
3
3
  export function createConfirmResourcesSyncEndpoint(pluginConfig) {
4
4
  return {
5
5
  path: '/reversia/confirm-resources-sync',
@@ -1,3 +1,3 @@
1
1
  import type { CollectionConfig, Endpoint, GlobalConfig } from 'payload';
2
- import type { ReversiaPluginConfig } from '../types.js';
2
+ import type { ReversiaPluginConfig } from '../types';
3
3
  export declare function createResourceEndpoint(pluginConfig: ReversiaPluginConfig, collectionsMap: Map<string, CollectionConfig>, globalsMap: Map<string, GlobalConfig>): Endpoint;
@@ -1,6 +1,6 @@
1
- import { unauthorizedResponse, validateApiKey } from '../utils/auth.js';
2
- import { findLocalizedFields, serializeField } from '../utils/fields.js';
3
- import { resolveDefaultLocale } from '../utils/payload-helpers.js';
1
+ import { unauthorizedResponse, validateApiKey } from '../utils/auth';
2
+ import { findLocalizedFields, serializeField } from '../utils/fields';
3
+ import { resolveDefaultLocale } from '../utils/payload-helpers';
4
4
  function extract(doc, fields) {
5
5
  const content = {};
6
6
  const contentTypes = {};
@@ -1,3 +1,3 @@
1
1
  import type { CollectionConfig, Endpoint, GlobalConfig } from 'payload';
2
- import type { ReversiaPluginConfig } from '../types.js';
2
+ import type { ReversiaPluginConfig } from '../types';
3
3
  export declare function createResourcesDefinitionEndpoint(pluginConfig: ReversiaPluginConfig, collectionsMap: Map<string, CollectionConfig>, globalsMap: Map<string, GlobalConfig>): Endpoint;
@@ -1,6 +1,6 @@
1
- import { unauthorizedResponse, validateApiKey } from '../utils/auth.js';
2
- import { buildTranslatableConfiguration, findLocalizedFields } from '../utils/fields.js';
3
- import { resolveStaticLabel } from '../utils/labels.js';
1
+ import { unauthorizedResponse, validateApiKey } from '../utils/auth';
2
+ import { buildTranslatableConfiguration, findLocalizedFields } from '../utils/fields';
3
+ import { resolveStaticLabel } from '../utils/labels';
4
4
  export function createResourcesDefinitionEndpoint(pluginConfig, collectionsMap, globalsMap) {
5
5
  return {
6
6
  path: '/reversia/resources-definition',
@@ -1,3 +1,3 @@
1
1
  import type { CollectionConfig, Endpoint, GlobalConfig } from 'payload';
2
- import type { ReversiaPluginConfig } from '../types.js';
2
+ import type { ReversiaPluginConfig } from '../types';
3
3
  export declare function createResourcesInsertEndpoint(pluginConfig: ReversiaPluginConfig, collectionsMap: Map<string, CollectionConfig>, globalsMap: Map<string, GlobalConfig>): Endpoint;
@@ -1,5 +1,5 @@
1
- import { unauthorizedResponse, validateApiKey } from '../utils/auth.js';
2
- import { deflatePopulatedRelationships, deserializeFieldValue, findLocalizedFields, } from '../utils/fields.js';
1
+ import { unauthorizedResponse, validateApiKey } from '../utils/auth';
2
+ import { deflatePopulatedRelationships, deserializeFieldValue, findLocalizedFields, } from '../utils/fields';
3
3
  const WRITE_CONFLICT_MAX_RETRIES = 3;
4
4
  const WRITE_CONFLICT_BASE_DELAY_MS = 50;
5
5
  function isWriteConflict(error) {
@@ -109,7 +109,24 @@ function applyTranslations({ allowedFields, data, sourceDoc, previousDoc, }) {
109
109
  continue;
110
110
  }
111
111
  const sourceValue = source ? source[fieldName] : undefined;
112
- const finalValue = deserializeFieldValue(field, sourceValue, translatedValue);
112
+ let finalValue;
113
+ try {
114
+ finalValue = deserializeFieldValue(field, sourceValue, translatedValue);
115
+ }
116
+ catch (error) {
117
+ // Re-throw with the offending field name attached so the caller's
118
+ // logger can pinpoint which container's source-locale shape blew up
119
+ // (e.g. a richText with a missing `children` array, an array item
120
+ // without an `id`). Without this, the surface error is something like
121
+ // "Cannot set properties of undefined (setting '0')" with no clue
122
+ // which field caused it across a multi-field document.
123
+ const message = error instanceof Error ? error.message : String(error);
124
+ const wrapped = new Error(`field "${fieldName}" failed to deserialize: ${message}`);
125
+ if (error instanceof Error && error.stack) {
126
+ wrapped.stack = error.stack;
127
+ }
128
+ throw wrapped;
129
+ }
113
130
  updateData[fieldName] = finalValue;
114
131
  acceptedFields.push(fieldName);
115
132
  const prevValue = previous ? previous[fieldName] : undefined;
@@ -124,6 +141,65 @@ function applyTranslations({ allowedFields, data, sourceDoc, previousDoc, }) {
124
141
  }
125
142
  return { updateData, diff, acceptedFields };
126
143
  }
144
+ /**
145
+ * Payload's unique validator rejects an update when the new value already
146
+ * exists on another doc in the same locale. Reversia legitimately produces
147
+ * colliding translations (proper nouns, short phrases, numeric-suffix titles
148
+ * that don't change across locales), so without a pre-check the whole item
149
+ * fails with `ValidationError: <field>.<locale>: Value must be unique`.
150
+ *
151
+ * For every scalar field in `updateData` that the schema marks `unique: true`,
152
+ * query the target collection/locale for any OTHER doc with that value. When
153
+ * one exists, drop the field from the write so the rest of the update can go
154
+ * through. The source of truth stays on the first doc that claimed the value;
155
+ * the second doc's target-locale slot remains empty and can be edited
156
+ * manually later.
157
+ */
158
+ async function dropUniqueCollisions(params) {
159
+ const { payload, collection, id, locale, fields, updateData, acceptedFields, diff } = params;
160
+ const dropped = [];
161
+ for (const field of fields) {
162
+ if (field.isContainer || !field.unique) {
163
+ continue;
164
+ }
165
+ if (!(field.name in updateData)) {
166
+ continue;
167
+ }
168
+ const value = updateData[field.name];
169
+ if (value === null || value === undefined || value === '') {
170
+ continue;
171
+ }
172
+ const existing = await payload.find({
173
+ collection: collection,
174
+ locale: locale,
175
+ depth: 0,
176
+ limit: 1,
177
+ pagination: false,
178
+ where: {
179
+ and: [{ [field.name]: { equals: value } }, { id: { not_equals: id } }],
180
+ },
181
+ });
182
+ if (existing.docs.length === 0) {
183
+ continue;
184
+ }
185
+ payload.logger.warn({
186
+ collection,
187
+ id,
188
+ targetLocale: locale,
189
+ field: field.name,
190
+ value,
191
+ collidingDocId: existing.docs[0]?.id,
192
+ }, '[reversia] skipping field to avoid unique collision');
193
+ delete updateData[field.name];
194
+ const acceptedIdx = acceptedFields.indexOf(field.name);
195
+ if (acceptedIdx !== -1) {
196
+ acceptedFields.splice(acceptedIdx, 1);
197
+ }
198
+ delete diff[field.name];
199
+ dropped.push(field.name);
200
+ }
201
+ return dropped;
202
+ }
127
203
  export function createResourcesInsertEndpoint(pluginConfig, collectionsMap, globalsMap) {
128
204
  return {
129
205
  path: '/reversia/resources-insert',
@@ -238,6 +314,20 @@ export function createResourcesInsertEndpoint(pluginConfig, collectionsMap, glob
238
314
  response.errors.push(`Item ${index}: no recognised fields in data (keys: ${Object.keys(item.data).join(', ')})`);
239
315
  continue;
240
316
  }
317
+ const droppedForUnique = await dropUniqueCollisions({
318
+ payload: req.payload,
319
+ collection: slug,
320
+ id: itemId,
321
+ locale: item.targetLocale,
322
+ fields: allowedFields,
323
+ updateData,
324
+ acceptedFields,
325
+ diff,
326
+ });
327
+ if (acceptedFields.length === 0 || Object.keys(updateData).length === 0) {
328
+ response.errors.push(`Item ${index} (${item.type} ${itemId} → ${item.targetLocale}): all translatable fields skipped due to unique-constraint collisions [${droppedForUnique.join(', ')}]`);
329
+ continue;
330
+ }
241
331
  await withRetry(() => req.payload.update({
242
332
  collection: slug,
243
333
  id: itemId,
@@ -1,3 +1,3 @@
1
1
  import type { CollectionConfig, Endpoint } from 'payload';
2
- import type { ReversiaPluginConfig } from '../types.js';
2
+ import type { ReversiaPluginConfig } from '../types';
3
3
  export declare function createResourcesSyncEndpoint(pluginConfig: ReversiaPluginConfig, _collectionsMap: Map<string, CollectionConfig>): Endpoint;
@@ -1,6 +1,6 @@
1
- import { unauthorizedResponse, validateApiKey } from '../utils/auth.js';
2
- import { decodeCursor, encodeCursor } from '../utils/cursor.js';
3
- import { parseLimit } from '../utils/payload-helpers.js';
1
+ import { unauthorizedResponse, validateApiKey } from '../utils/auth';
2
+ import { decodeCursor, encodeCursor } from '../utils/cursor';
3
+ import { parseLimit } from '../utils/payload-helpers';
4
4
  export function createResourcesSyncEndpoint(pluginConfig, _collectionsMap) {
5
5
  return {
6
6
  path: '/reversia/resources-sync',
@@ -1,3 +1,3 @@
1
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;
2
+ import type { ReversiaPluginConfig } from '../types';
3
+ export declare function createResourcesEndpoint(pluginConfig: ReversiaPluginConfig, collectionsMap: Map<string, CollectionConfig>, globalsMap: Map<string, GlobalConfig>): Endpoint;
@@ -1,7 +1,7 @@
1
- import { unauthorizedResponse, validateApiKey } from '../utils/auth.js';
2
- import { decodeCursor, encodeCursor } from '../utils/cursor.js';
3
- import { findLocalizedFields, serializeField } from '../utils/fields.js';
4
- import { parseLimit, resolveDefaultLocale } from '../utils/payload-helpers.js';
1
+ import { unauthorizedResponse, validateApiKey } from '../utils/auth';
2
+ import { decodeCursor, encodeCursor } from '../utils/cursor';
3
+ import { findLocalizedFields, serializeField } from '../utils/fields';
4
+ import { parseLimit, resolveDefaultLocale } from '../utils/payload-helpers';
5
5
  function extractContent(doc, fields) {
6
6
  const content = {};
7
7
  const contentTypes = {};
@@ -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
  }
@@ -1,3 +1,3 @@
1
1
  import type { Endpoint } from 'payload';
2
- import type { ReversiaPluginConfig } from '../types.js';
2
+ import type { ReversiaPluginConfig } from '../types';
3
3
  export declare function createSettingsEndpoint(pluginConfig: ReversiaPluginConfig): Endpoint;
@@ -1,4 +1,4 @@
1
- import { unauthorizedResponse, validateApiKey } from '../utils/auth.js';
1
+ import { unauthorizedResponse, validateApiKey } from '../utils/auth';
2
2
  export function createSettingsEndpoint(pluginConfig) {
3
3
  return {
4
4
  path: '/reversia/settings',
@@ -0,0 +1,3 @@
1
+ import type { Endpoint } from 'payload';
2
+ import type { ReversiaPluginConfig } from '../types';
3
+ export declare function createTriggerCrawlDashboardEndpoint(pluginConfig: ReversiaPluginConfig): Endpoint;
@@ -0,0 +1,34 @@
1
+ import { triggerCrawl } from '../trigger-crawl';
2
+ export function createTriggerCrawlDashboardEndpoint(pluginConfig) {
3
+ return {
4
+ path: '/reversia/dashboard/trigger-crawl',
5
+ method: 'post',
6
+ handler: async (req) => {
7
+ if (!req.user) {
8
+ return Response.json({ error: 'Unauthorized' }, { status: 401 });
9
+ }
10
+ const body = req.json ? await req.json() : undefined;
11
+ const types = Array.isArray(body?.types)
12
+ ? body.types.filter((t) => typeof t === 'string')
13
+ : undefined;
14
+ const noCache = Boolean(body?.noCache);
15
+ try {
16
+ const result = await triggerCrawl({
17
+ apiKey: pluginConfig.apiKey,
18
+ baseUrl: pluginConfig.baseUrl,
19
+ ...(types && types.length > 0 ? { types } : {}),
20
+ ...(noCache ? { noCache: true } : {}),
21
+ });
22
+ return Response.json(result);
23
+ }
24
+ catch (error) {
25
+ const message = error instanceof Error ? error.message : String(error);
26
+ req.payload.logger.error({
27
+ msg: '[reversia] dashboard trigger-crawl failed',
28
+ err: message,
29
+ });
30
+ return Response.json({ error: message }, { status: 502 });
31
+ }
32
+ },
33
+ };
34
+ }
package/dist/index.d.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import type { Config } from 'payload';
2
- import type { ReversiaPluginConfig } from './types.js';
3
- export type { ConfirmResourcesSyncResponse, InsertionRequest, InsertionResponse, ResourceDefinition, ResourceItem, ResourceResponse, ReversiaErrorResponse, ReversiaFieldCustom, ReversiaPluginConfig, SettingsResponse, StreamResponse, TranslatableFieldConfig, } from './types.js';
4
- export { ReversiaFieldBehavior, ReversiaFieldType } from './types.js';
2
+ import type { ReversiaPluginConfig } from './types';
3
+ export type { TriggerCrawlOptions, TriggerCrawlResult } from './trigger-crawl';
4
+ export { triggerCrawl } from './trigger-crawl';
5
+ export type { ConfirmResourcesSyncResponse, InsertionRequest, InsertionResponse, ResourceDefinition, ResourceItem, ResourceResponse, ReversiaErrorResponse, ReversiaFieldCustom, ReversiaPluginConfig, SettingsResponse, StreamResponse, TranslatableFieldConfig, } from './types';
6
+ export { ReversiaFieldBehavior, ReversiaFieldType } from './types';
5
7
  export declare const reversiaPlugin: (pluginConfig: ReversiaPluginConfig) => (config: Config) => Config;
6
8
  export default reversiaPlugin;
package/dist/index.js CHANGED
@@ -1,14 +1,16 @@
1
- import { reversiaSyncPendingCollection } from './collections/sync-pending.js';
2
- import { createConfirmResourcesSyncEndpoint } from './endpoints/confirm-resources-sync.js';
3
- import { createResourceEndpoint } from './endpoints/resource.js';
4
- import { createResourcesEndpoint } from './endpoints/resources.js';
5
- import { createResourcesDefinitionEndpoint } from './endpoints/resources-definition.js';
6
- import { createResourcesInsertEndpoint } from './endpoints/resources-insert.js';
7
- import { createResourcesSyncEndpoint } from './endpoints/resources-sync.js';
8
- import { createSettingsEndpoint } from './endpoints/settings.js';
9
- import { createAfterChangeHook } from './hooks/after-change.js';
10
- import { findLocalizedFields } from './utils/fields.js';
11
- export { ReversiaFieldBehavior, ReversiaFieldType } from './types.js';
1
+ import { reversiaSyncPendingCollection } from './collections/sync-pending';
2
+ import { createConfirmResourcesSyncEndpoint } from './endpoints/confirm-resources-sync';
3
+ import { createResourceEndpoint } from './endpoints/resource';
4
+ import { createResourcesEndpoint } from './endpoints/resources';
5
+ import { createResourcesDefinitionEndpoint } from './endpoints/resources-definition';
6
+ import { createResourcesInsertEndpoint } from './endpoints/resources-insert';
7
+ import { createResourcesSyncEndpoint } from './endpoints/resources-sync';
8
+ import { createSettingsEndpoint } from './endpoints/settings';
9
+ import { createTriggerCrawlDashboardEndpoint } from './endpoints/trigger-crawl-dashboard';
10
+ import { createAfterChangeHook } from './hooks/after-change';
11
+ import { findLocalizedFields } from './utils/fields';
12
+ export { triggerCrawl } from './trigger-crawl';
13
+ export { ReversiaFieldBehavior, ReversiaFieldType } from './types';
12
14
  const APPLIED_MARKER = Symbol.for('payload-plugin-reversia.applied');
13
15
  export const reversiaPlugin = (pluginConfig) => (config) => {
14
16
  if (pluginConfig.disabled) {
@@ -76,6 +78,7 @@ export const reversiaPlugin = (pluginConfig) => (config) => {
76
78
  createResourcesInsertEndpoint(pluginConfig, collectionsMap, globalsMap),
77
79
  createConfirmResourcesSyncEndpoint(pluginConfig),
78
80
  createSettingsEndpoint(pluginConfig),
81
+ createTriggerCrawlDashboardEndpoint(pluginConfig),
79
82
  ];
80
83
  return config;
81
84
  };
@@ -0,0 +1,24 @@
1
+ import type { CollectionSlug, GlobalSlug } from 'payload';
2
+ export interface TriggerCrawlOptions {
3
+ /** Reversia project API key (the same value passed to `reversiaPlugin({ apiKey })`). */
4
+ apiKey: string;
5
+ /**
6
+ * Collection and/or global slugs to crawl. Each slug is prefixed with
7
+ * `payloadcms:` before being sent to Reversia. Omit/empty → crawl all
8
+ * enabled resources for the project.
9
+ */
10
+ types?: Array<CollectionSlug | GlobalSlug>;
11
+ /** Bypass cached crawl content. Defaults to `false`. */
12
+ noCache?: boolean;
13
+ /**
14
+ * Override the Reversia API base URL. Defaults to `process.env.REVERSIA_API_URL`
15
+ * then a built-in production/staging URL.
16
+ */
17
+ baseUrl?: string;
18
+ /** Optional custom fetch (for tests / proxies). Defaults to the global `fetch`. */
19
+ fetch?: typeof fetch;
20
+ }
21
+ export interface TriggerCrawlResult {
22
+ success: boolean;
23
+ }
24
+ export declare function triggerCrawl(options: TriggerCrawlOptions): Promise<TriggerCrawlResult>;
@@ -0,0 +1,47 @@
1
+ const DEFAULT_BASE_URL = 'https://staging.api.reversia.tech';
2
+ const TYPE_NAMESPACE = 'payloadcms:';
3
+ const TRIGGER_CRAWL_PATH = '/projects/trigger-crawl';
4
+ export async function triggerCrawl(options) {
5
+ if (typeof options.apiKey !== 'string' || options.apiKey.length === 0) {
6
+ throw new Error('[reversia] apiKey is required. Pass `TriggerCrawlOptions.apiKey` as a non-empty string.');
7
+ }
8
+ const baseUrl = (options.baseUrl ?? process.env.REVERSIA_API_URL ?? DEFAULT_BASE_URL).replace(/\/+$/, '');
9
+ const normalizedTypes = (options.types ?? [])
10
+ .filter((t) => typeof t === 'string' && t.length > 0)
11
+ .map((t) => `${TYPE_NAMESPACE}${t}`);
12
+ const body = {};
13
+ if (normalizedTypes.length > 0) {
14
+ body.types = normalizedTypes;
15
+ }
16
+ if (options.noCache) {
17
+ body.noCache = true;
18
+ }
19
+ const fetchImpl = options.fetch ?? fetch;
20
+ const response = await fetchImpl(`${baseUrl}${TRIGGER_CRAWL_PATH}`, {
21
+ method: 'POST',
22
+ headers: {
23
+ Authorization: `Bearer ${options.apiKey}`,
24
+ 'Content-Type': 'application/json',
25
+ Accept: 'application/json',
26
+ },
27
+ body: JSON.stringify(body),
28
+ });
29
+ if (!response.ok) {
30
+ let excerpt = '';
31
+ try {
32
+ excerpt = (await response.text()).slice(0, 200);
33
+ }
34
+ catch {
35
+ // ignore body read errors — keep the status info
36
+ }
37
+ throw new Error(`[reversia] trigger-crawl failed: ${response.status} ${response.statusText}${excerpt ? ` — ${excerpt}` : ''}`);
38
+ }
39
+ let parsed = null;
40
+ try {
41
+ parsed = (await response.json());
42
+ }
43
+ catch {
44
+ parsed = null;
45
+ }
46
+ return { success: parsed?.success !== false };
47
+ }
package/dist/types.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { CollectionSlug } from 'payload';
2
- import type { LeafSegment } from './utils/path-resolver.js';
2
+ import type { LeafSegment } from './utils/path-resolver';
3
3
  export interface ReversiaPluginConfig {
4
4
  /**
5
5
  * API key used by Reversia SaaS to authenticate requests.
@@ -20,6 +20,12 @@ export interface ReversiaPluginConfig {
20
20
  * Whether the plugin is disabled. Defaults to false.
21
21
  */
22
22
  disabled?: boolean;
23
+ /**
24
+ * Override the Reversia API base URL used by `triggerCrawl` and the
25
+ * dashboard button. Defaults to `process.env.REVERSIA_API_URL` then the
26
+ * built-in production URL.
27
+ */
28
+ baseUrl?: string;
23
29
  }
24
30
  export declare enum ReversiaFieldType {
25
31
  TEXT = "TEXT",
@@ -183,6 +189,15 @@ export interface LocalizedFieldInfo {
183
189
  * the update.
184
190
  */
185
191
  hasRequiredLeaf?: boolean;
192
+ /**
193
+ * True when a top-level localized scalar is `unique: true`. Used during
194
+ * insertion to pre-check for cross-document collisions in the target locale
195
+ * before writing — Reversia may translate two docs' source values to the
196
+ * same target string, and Payload's unique validator would reject the
197
+ * second write. Only meaningful for scalars; Payload doesn't support
198
+ * `unique` on containers.
199
+ */
200
+ unique?: boolean;
186
201
  }
187
202
  export interface ResourceItem {
188
203
  id: string;
@@ -1,4 +1,4 @@
1
- import type { Cursor } from '../types.js';
1
+ import type { Cursor } from '../types';
2
2
  /**
3
3
  * Opaque cursor encoding. Uses JSON in base64url so the delimiter concern
4
4
  * (types or ids containing `|`) is eliminated.
@@ -1,6 +1,6 @@
1
1
  import type { Field } from 'payload';
2
- import type { LocalizedFieldInfo, TranslatableFieldConfig } from '../types.js';
3
- import { ReversiaFieldType } from '../types.js';
2
+ import type { LocalizedFieldInfo, TranslatableFieldConfig } from '../types';
3
+ import { ReversiaFieldType } from '../types';
4
4
  /**
5
5
  * Collects one `LocalizedFieldInfo` per top-level field that is itself
6
6
  * localized OR that contains at least one localized descendant.
@@ -1,7 +1,7 @@
1
- import { ReversiaFieldType } from '../types.js';
2
- import { applyByKeys, compileKeyMatcher, DEFAULT_RICHTEXT_KEYS, extractByKeys, } from './json-extract.js';
3
- import { resolveStaticLabel } from './labels.js';
4
- import { applyTranslationsToContainer, joinPointers, resolveLeafLocations, } from './path-resolver.js';
1
+ import { ReversiaFieldType } from '../types';
2
+ import { applyByKeys, compileKeyMatcher, DEFAULT_RICHTEXT_KEYS, extractByKeys, } from './json-extract';
3
+ import { resolveStaticLabel } from './labels';
4
+ import { applyTranslationsToContainer, joinPointers, resolveLeafLocations, } from './path-resolver';
5
5
  function getFieldLabel(field) {
6
6
  if (!('name' in field)) {
7
7
  return '';
@@ -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.2.0",
4
4
  "author": {
5
5
  "name": "Jean Walrave",
6
6
  "email": "contact@reversia.tech",
@@ -27,6 +27,10 @@
27
27
  ".": {
28
28
  "types": "./dist/index.d.ts",
29
29
  "import": "./dist/index.js"
30
+ },
31
+ "./client": {
32
+ "types": "./dist/client/index.d.ts",
33
+ "import": "./dist/client/index.js"
30
34
  }
31
35
  },
32
36
  "main": "dist/index.js",
@@ -44,14 +48,17 @@
44
48
  "prepublishOnly": "bun run test && bun run build"
45
49
  },
46
50
  "peerDependencies": {
47
- "payload": "^3.0.0"
51
+ "payload": "^3.0.0",
52
+ "react": "^18.0.0 || ^19.0.0"
48
53
  },
49
54
  "devDependencies": {
50
55
  "@biomejs/biome": "2.4.12",
51
56
  "@payloadcms/db-sqlite": "^3.0.0",
52
57
  "@payloadcms/richtext-lexical": "^3.79.1",
53
58
  "@types/bun": "^1.3.10",
59
+ "@types/react": "^19.0.0",
54
60
  "payload": "^3.0.0",
61
+ "react": "^19.0.0",
55
62
  "typescript": "^5.0.0"
56
63
  }
57
- }
64
+ }