Skip to content

Commit 0acac27

Browse files
Merge pull request #74 from focusreactive/refactor/translate-collection-plugin
refactor(translator): reshape TranslateCollectionPlugin orchestration (#73)
2 parents a3f61fe + f259c24 commit 0acac27

34 files changed

Lines changed: 1006 additions & 397 deletions

packages/payload-plugin-translator/CLAUDE.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,34 @@ Scope:
3232
Non-trivial work is designed in a committed doc under [docs/plans/](docs/plans)
3333
before implementation. Deprecations are tracked in
3434
[docs/DEPRECATIONS.md](docs/DEPRECATIONS.md) (keyed by date + PR, removal = next major).
35+
36+
## File naming — role tags
37+
38+
Files carry a **role tag** as a dotted suffix: `<Domain>.<role>.ts` with a PascalCase
39+
domain base (e.g. `Provenance.service.ts`). The tag names *what kind of thing* the file is,
40+
so a directory listing reads as an architecture map. Extend this vocabulary rather than
41+
inventing a parallel scheme.
42+
43+
| Role | Tag | Example |
44+
| ---- | --- | ------- |
45+
| Framework-agnostic port / abstract contract (in `core/`) | `.interface.ts` | `ProvenanceStore.interface.ts` |
46+
| Domain / policy service | `.service.ts` | `Provenance.service.ts` |
47+
| Payload adapter implementing a port | `.store.ts` (or a role word for the port kind) | `Provenance.store.ts` |
48+
| Payload collection factory | `.collection.ts` | `Provenance.collection.ts` |
49+
| Payload hook | `.hook.ts` | `ProvenanceCleanup.hook.ts` |
50+
| Config-time wiring (`configure(ctx) → ConfigModifier`) | `.wiring.ts` | `Provenance.wiring.ts` |
51+
| Narrow structural shapes (Payload-type slices — see below) | `.shapes.ts` | `Provenance.shapes.ts` |
52+
| Translation-pipeline stage | `.stage.ts` | `DataReconciler.stage.ts` |
53+
| Pluggable strategy / provider / factory | `.strategy.ts` · `.provider.ts` · `.factory.ts` | `Overwrite.strategy.ts` |
54+
| Client RSC / admin boundary | `.export.tsx` · `.server.tsx` · `.client.ts` | `TranslateDocument.server.tsx` |
55+
56+
Not everything is tagged: `index.ts` barrels, and small **pure helpers** named by topic
57+
(`slugGuard.ts`, `staleness.ts`) stay untagged. The tag is for role-bearing units, not every file.
58+
59+
**Structural Payload-type slices (`.shapes.ts`).** Functions/classes must not depend on
60+
Payload's god types (`Config`, `CollectionConfig`, …). Define a narrow structural interface with
61+
only the fields you touch — the real Payload type is assignable to it (structural typing), so it
62+
"plugs in" with no adapter and tests pass a tiny literal. Full Payload types stay near the surface:
63+
`plugin.ts`, `PluginConfigBuilder.applyTo`, the `ConfigModifier` contract (`types/ConfigModifier.ts`),
64+
and HTTP route boundaries (`PayloadRequest`) — not in leaf helpers. See
65+
`server/modules/provenance/Provenance.shapes.ts`.

packages/payload-plugin-translator/docs/plans/2026-07-14-translate-collection-plugin-reshape.md

Lines changed: 252 additions & 0 deletions
Large diffs are not rendered by default.

packages/payload-plugin-translator/src/composition/levels/useDocTranslationApi.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ import type { LevelContext } from "../../server/modules/translation-levels";
66
* Contribute the runner-agnostic document-translation API — the shared route
77
* bundle, bound to the level context's runner. Both `documentLevel` and
88
* `collectionLevel` call this; the plugin deduplicates the endpoints by method +
9-
* path, so the bundle registers exactly once. The schema map and provenance
10-
* store factory ride along for the staleness endpoints.
9+
* path, so the bundle registers exactly once. The provenance service factory
10+
* rides along for the staleness endpoints (its schema lives inside the service).
1111
*/
1212
export function useDocTranslationApi(ctx: LevelContext): void {
1313
ctx.addEndpoints(
@@ -18,8 +18,7 @@ export function useDocTranslationApi(ctx: LevelContext): void {
1818
},
1919
access: ctx.access,
2020
basePath: ctx.basePath,
21-
schemaMap: ctx.schemaMap,
22-
provenanceStoreFactory: ctx.provenanceStoreFactory,
21+
provenanceServiceFactory: ctx.provenanceServiceFactory,
2322
})
2423
);
2524
}

packages/payload-plugin-translator/src/plugin.ts

Lines changed: 21 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,17 @@
1-
import type { CollectionConfig, Config, Payload } from "payload";
1+
import type { CollectionConfig, Config } from "payload";
22

33
import { CacheProviderExport } from "./client/app/cache/CacheProvider.export";
4-
import {
5-
DEFAULT_PROVENANCE_SLUG,
6-
PayloadProvenanceStore,
7-
assertProvenanceSlugFree,
8-
injectProvenanceCleanup,
9-
isProvenanceCollection,
10-
makeProvenanceCollection,
11-
} from "./server/modules/provenance";
4+
import { configureProvenance } from "./server/modules/provenance";
125
import type { AccessGuard } from "./types/AccessGuard";
136
import type { TranslationProvider } from "./core/translation-providers";
14-
import type {
15-
TaskRunnerProvider,
16-
TaskRunnerContext,
17-
TaskRunnerFactory,
18-
} from "./server/modules/task-runner";
19-
import { TranslateDocumentHandler } from "./server/features/translate-document";
20-
import {
21-
LifecycleNotifier,
22-
taskFromHandlerInput,
23-
withQueuedNotification,
24-
} from "./server/modules/lifecycle";
7+
import type { TaskRunnerProvider } from "./server/modules/task-runner";
8+
import { wireTranslateRunner } from "./server/features/translate-document";
259
import type { TranslationLifecycleCallbacks } from "./server/modules/lifecycle";
2610
import { documentLevel, collectionLevel } from "./composition/levels";
2711
import type { TranslationLevel } from "./server/modules/translation-levels";
2812
import { PluginConfigBuilder } from "./server/modules/translation-levels/PluginConfigBuilder";
2913
import { normalizePath } from "./server/shared";
3014

31-
/**
32-
* Resolve the opt-in `provenance` config to a sidecar slug, or `null` when disabled.
33-
* `false`/omitted → off; `true` or `{}` → on with the default slug; `{ slug }` → on with that slug.
34-
*/
35-
function resolveProvenanceSlug(provenance: TranslatorPluginConfig["provenance"]): string | null {
36-
if (!provenance) return null;
37-
if (provenance === true) return DEFAULT_PROVENANCE_SLUG;
38-
// `||` (not `??`) so an empty/blank slug falls back to the default instead of silently disabling.
39-
return provenance.slug || DEFAULT_PROVENANCE_SLUG;
40-
}
41-
4215
export type TranslatorPluginConfig = {
4316
/**
4417
* Original collection configs (same objects passed to buildConfig).
@@ -132,75 +105,30 @@ export class TranslateCollectionPlugin {
132105
basePath: rawBasePath = "/translate",
133106
} = this.pluginConfig;
134107

135-
const lifecycleCallbacks: TranslationLifecycleCallbacks = lifecycle ?? {};
136-
137-
// Build schema map from deep-cloned collections
138-
// Deep clone is required because Payload mutates the original collection objects,
139-
// removing `localized: true` from nested fields during sanitization.
140-
// We use JSON round-trip instead of structuredClone because Lexical editor
141-
// configs contain async functions that structuredClone cannot handle.
142-
// TODO: Consider introducing a FieldLike interface with only the properties
143-
// used by the pipeline (name, type, localized, fields, blocks, tabs, custom)
144-
// to make the contract explicit and avoid reliance on JSON round-trip.
108+
// Build schema map from deep-cloned collections.
109+
// Deep clone is required because Payload mutates the original collection objects, removing
110+
// `localized: true` from nested fields during sanitization. JSON round-trip (not
111+
// structuredClone) because Lexical editor configs contain async functions structuredClone
112+
// cannot handle.
113+
// TODO: Consider introducing a FieldLike interface with only the properties used by the
114+
// pipeline (name, type, localized, fields, blocks, tabs, custom) to make the contract explicit
115+
// and avoid reliance on JSON round-trip.
145116
const schemaMap = new Map(
146117
collections.map((col) => [col.slug, JSON.parse(JSON.stringify(col.fields))])
147118
);
148119
const collectionSlugs = new Set(schemaMap.keys());
149120
const basePath = normalizePath(rawBasePath);
150-
const provenanceSlug = resolveProvenanceSlug(provenance);
151-
if (provenanceSlug) {
152-
const existing = (config.collections ?? []).filter(
153-
(collection) => !isProvenanceCollection(collection)
154-
);
155-
assertProvenanceSlugFree(provenanceSlug, existing);
156-
}
157-
const provenanceStoreFactory = provenanceSlug
158-
? (p: Payload) => new PayloadProvenanceStore(p, provenanceSlug)
159-
: undefined;
160121

161-
const translateHandler = new TranslateDocumentHandler(
122+
// Each concern owns its own config-time wiring and exposes it uniformly; init() just composes.
123+
const provenanceModule = configureProvenance(provenance, schemaMap);
124+
const { taskRunnerFactory, configModifier: runnerConfigModifier } = wireTranslateRunner({
162125
translationProvider,
163126
schemaMap,
164-
provenanceStoreFactory
165-
);
166-
167-
const runnerContext: TaskRunnerContext = {
168-
handler: async (payload, input) => {
169-
const notifier = new LifecycleNotifier(lifecycleCallbacks, payload.logger);
170-
const task = taskFromHandlerInput(input);
171-
try {
172-
await translateHandler.handle(payload, {
173-
collection: input.collection,
174-
collectionId: input.collectionId,
175-
sourceLng: input.sourceLng,
176-
targetLng: input.targetLng,
177-
strategy: input.strategy,
178-
publishOnTranslation: input.publishOnTranslation,
179-
});
180-
} catch (error) {
181-
await notifier.failed(task, error);
182-
throw error; // rethrow so the runner marks the job failed
183-
}
184-
await notifier.completed(task);
185-
},
127+
provenanceServiceFactory: provenanceModule.serviceFactory,
128+
runner,
129+
lifecycle: lifecycle ?? {},
186130
collections: Array.from(collectionSlugs),
187-
};
188-
const runnerConfigModifier = runner.configure(runnerContext);
189-
190-
// Bind the context once so routes receive a self-sufficient factory: the
191-
// runner needs no mutable per-instance handler state and create() has no
192-
// "configure() must run first" ordering coupling (translator plan, 0c).
193-
// When an `onQueued` callback is set, decorate the runner so `enqueue` fires it.
194-
const taskRunnerFactory: TaskRunnerFactory = {
195-
create: (payload) => {
196-
const taskRunner = runner.create(payload, runnerContext.handler);
197-
if (!lifecycleCallbacks.onQueued) return taskRunner;
198-
return withQueuedNotification(
199-
taskRunner,
200-
new LifecycleNotifier(lifecycleCallbacks, payload.logger)
201-
);
202-
},
203-
};
131+
});
204132

205133
const activeLevels = levels ?? [documentLevel(), collectionLevel()];
206134
const builder = new PluginConfigBuilder({
@@ -210,26 +138,12 @@ export class TranslateCollectionPlugin {
210138
taskRunnerFactory,
211139
schemaMap,
212140
translationProvider,
213-
provenanceStoreFactory,
141+
provenanceServiceFactory: provenanceModule.serviceFactory,
214142
});
215143
for (const level of activeLevels) level.extend(builder);
216144

217145
builder.addConfigModifier(runnerConfigModifier);
218-
if (provenanceSlug && provenanceStoreFactory) {
219-
builder.addConfigModifier((cfg) => {
220-
const alreadyAdded = cfg.collections?.some(
221-
(collection) => collection.slug === provenanceSlug && isProvenanceCollection(collection)
222-
);
223-
if (!alreadyAdded) {
224-
cfg.collections = [
225-
...(cfg.collections ?? []),
226-
makeProvenanceCollection(provenanceSlug),
227-
];
228-
}
229-
injectProvenanceCleanup(cfg, collectionSlugs, provenanceStoreFactory, provenanceSlug);
230-
return cfg;
231-
});
232-
}
146+
builder.addConfigModifier(provenanceModule.configure(collectionSlugs));
233147
builder.addAdminProvider(new CacheProviderExport(basePath));
234148

235149
// The single place the Payload config is mutated.

packages/payload-plugin-translator/src/server/features/_lib/sourceDocument.ts

Lines changed: 0 additions & 17 deletions
This file was deleted.

packages/payload-plugin-translator/src/server/features/createTranslationRoutes.test.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ const deps = (basePath?: string): TranslationRoutesDeps => ({
1111
taskRunnerFactory: { create: vi.fn() },
1212
collectionConfig: { availableCollections: new Set() },
1313
basePath,
14-
schemaMap: new Map(),
1514
});
1615

1716
describe("createTranslationRoutes (bundle contract)", () => {

packages/payload-plugin-translator/src/server/features/createTranslationRoutes.ts

Lines changed: 8 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,6 @@
11
import type { CollectionSlug, Endpoint } from "payload";
22

3-
import type { AccessGuard } from "../shared";
4-
import type { TaskRunnerFactory } from "../modules/task-runner";
5-
import type { CollectionSchemaMap } from "../../types/CollectionSchemaMap";
6-
import type { ProvenanceStoreFactory } from "../modules/provenance";
3+
import type { TranslationContext } from "../modules/translation-levels";
74

85
import { createEnqueueRoute } from "./enqueue-translation";
96
import { createRunRoute } from "./run-translation";
@@ -13,16 +10,13 @@ import { createGetDocumentStatusRoute } from "./get-document-status";
1310
import { createGetCollectionStatusRoute } from "./get-collection-status";
1411
import { createGetDocumentStalenessRoute, createDismissStalenessRoute } from "./staleness";
1512

16-
export type TranslationRoutesDeps = {
17-
taskRunnerFactory: TaskRunnerFactory;
13+
export type TranslationRoutesDeps = Pick<
14+
TranslationContext,
15+
"taskRunnerFactory" | "access" | "provenanceServiceFactory"
16+
> & {
1817
/** Collections the plugin manages — gates which slugs the routes accept. */
1918
collectionConfig: { availableCollections: Set<CollectionSlug> };
20-
access?: AccessGuard;
2119
basePath?: string;
22-
/** Original per-collection field schema — needed to recompute source fingerprints for staleness. */
23-
schemaMap: CollectionSchemaMap;
24-
/** Builds a provenance store; absent when provenance is disabled (staleness then reports empty). */
25-
provenanceStoreFactory?: ProvenanceStoreFactory;
2620
};
2721

2822
/**
@@ -35,20 +29,18 @@ export type TranslationRoutesDeps = {
3529
* document and collection levels each contribute this bundle via the level
3630
* context; the plugin deduplicates by method + path, so it registers exactly
3731
* once. The staleness endpoints always register; when provenance is disabled
38-
* (`provenanceStoreFactory` absent) they simply report no staleness.
32+
* (`provenanceServiceFactory` absent) they simply report no staleness.
3933
*/
4034
export function createTranslationRoutes({
4135
taskRunnerFactory,
4236
collectionConfig,
4337
access,
4438
basePath,
45-
schemaMap,
46-
provenanceStoreFactory,
39+
provenanceServiceFactory,
4740
}: TranslationRoutesDeps): Endpoint[] {
4841
const stalenessConfig = {
4942
availableCollections: collectionConfig.availableCollections,
50-
schemaMap,
51-
provenanceStoreFactory,
43+
provenanceServiceFactory,
5244
};
5345
return [
5446
createEnqueueRoute(taskRunnerFactory, collectionConfig, access, basePath),

packages/payload-plugin-translator/src/server/features/staleness/dismissStaleness.handler.ts

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import { isCollectionAvailable } from "../_lib/collection-utils";
55

66
import { DismissStalenessInputSchema } from "./model";
77
import type { StalenessConfig } from "./model";
8-
import { dismissLocaleStaleness } from "./service";
98

109
/** Dismisses (acknowledges) staleness of one target locale for a document. */
1110
export class DismissStalenessHandler {
@@ -23,13 +22,12 @@ export class DismissStalenessHandler {
2322
return ServerResponse.badRequest("Collection not available for translation");
2423
}
2524

26-
await dismissLocaleStaleness(
27-
req.payload,
28-
this.config,
29-
collectionSlug,
30-
collection_id,
31-
target_lng
32-
);
25+
const service = this.config.provenanceServiceFactory?.(req.payload);
26+
await service?.dismiss({
27+
collectionSlug: collectionSlug,
28+
documentId: collection_id,
29+
targetLocale: target_lng,
30+
});
3331
return ServerResponse.success({ success: true });
3432
}
3533
}

packages/payload-plugin-translator/src/server/features/staleness/getDocumentStaleness.handler.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import { isCollectionAvailable } from "../_lib/collection-utils";
55

66
import { GetDocumentStalenessInputSchema } from "./model";
77
import type { StalenessConfig } from "./model";
8-
import { computeDocumentStaleness } from "./service";
98

109
/**
1110
* Reads per-locale staleness for a single document. Best-effort: a recompute failure (e.g. the
@@ -28,12 +27,8 @@ export class GetDocumentStalenessHandler {
2827
}
2928

3029
try {
31-
const locales = await computeDocumentStaleness(
32-
req.payload,
33-
this.config,
34-
collectionSlug,
35-
collection_id
36-
);
30+
const service = this.config.provenanceServiceFactory?.(req.payload);
31+
const locales = service ? await service.getStaleness(collectionSlug, collection_id) : [];
3732
return ServerResponse.success({ locales });
3833
} catch (error) {
3934
req.payload.logger.error({
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
export { createGetDocumentStalenessRoute, createDismissStalenessRoute } from "./route";
2-
export type { StalenessConfig, StalenessLocaleOutput } from "./model";
2+
export type { StalenessConfig } from "./model";

0 commit comments

Comments
 (0)