Skip to content

Commit c7485f5

Browse files
Merge pull request #78 from focusreactive/refactor/translator-schemamap-fieldlike
refactor(translator): typed FieldLike projection for schemaMap (replaces JSON round-trip)
2 parents e1ee5f1 + 981adc6 commit c7485f5

8 files changed

Lines changed: 417 additions & 34 deletions

File tree

packages/payload-plugin-translator/docs/plans/2026-07-15-schemamap-fieldlike-projection.md

Lines changed: 40 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
# Task — Replace the `schemaMap` JSON round-trip with a typed `FieldLike` projection
22

3-
**Date:** 2026-07-15
4-
**Status:** backlog (internal). Deferred out of the `TranslateCollectionPlugin` reshape
5-
(`2026-07-14-translate-collection-plugin-reshape.md`) as a separate, test-backed task.
6-
**Scope:** internal only — no public API change.
3+
**Date:** 2026-07-15 · **implemented:** 2026-07-17 (after the core/ layering redesign).
4+
**Status:** implemented. Preceded by a read-only code investigation that de-risked the property
5+
contract, the Payload-sanitize premise, and placement (findings folded in below).
6+
**Scope:** internal only — no public API change. Ships as `refactor:` (patch).
77

88
## Problem
99

@@ -41,9 +41,32 @@ properties the pipeline uses (`name`, `type`, `localized`, `fields`, `blocks`, `
4141
every nesting level, and build `schemaMap` from it instead of the JSON round-trip. This makes the
4242
schema contract explicit and typed.
4343

44-
`FieldLike` already exists in `core/field-traversal` (see `fieldLike.types.test.ts`) and the pipeline
44+
`FieldLike` already exists in `core/kernel/field-traversal` (post-refactor location) and the pipeline
4545
already operates on it — the gap is only the `schemaMap` construction in `plugin.ts`.
4646

47+
## Findings from the pre-implementation investigation (evidence-based)
48+
49+
- **Contract is complete but `blocks`/`tabs` are not scalars.** Every `schemaMap` consumer (pipeline,
50+
provenance fingerprint, auto-translate drift gate, field-path resolver) reads only
51+
`type` · `name` · `localized` · `custom` (just `custom.translateKit.exclude`) · `fields` · `blocks` ·
52+
`tabs`. Nothing else is read (`editor`/`admin`/`relationTo`/`validate` — none; `relationTo` appears only
53+
in dead code). BUT the projector must recurse into sub-shapes: **`block.slug` is load-bearing**
54+
(block-type dispatch) and `tab.name`/`tab.fields` (+`tab.localized`) are required — copying `blocks`/`tabs`
55+
opaquely would silently break translation. This corrected the doc's original flat property list.
56+
- **Premise confirmed in Payload source.** `sanitizeFields` does `delete field.localized` **in place** on
57+
any field under a localized ancestor (propagated via `parentIsLocalized`). The pipeline reads per-field
58+
`localized` (no inherited computation), so a **deep** pre-sanitize copy is mandatory — a shared reference
59+
re-introduces the silent no-translate bug.
60+
- **richText needs no schema data.** The lexical config lives in `field.editor` (async functions), but the
61+
pipeline reads the lexical tree from the **document value** at runtime, never from the field schema — so
62+
dropping `editor` (and all functions) is safe. This is exactly why `structuredClone` was impossible and
63+
the projection is sound.
64+
- **Placement without breaking payload-free core.** The projector is a pure `FieldLike[] → FieldLike[]`
65+
deep copy living in `core/kernel/field-traversal/projectFieldLike.ts` — it never imports Payload. The
66+
`Field[] → FieldLike[]` assignment happens at the call site in `plugin.ts` (where Payload types are
67+
legal), since Payload's `Field` is structurally assignable to `FieldLike`. `custom` is copied by
68+
reference (passthrough bag Payload never mutates; deep-copying it would hit the same function problem).
69+
4770
## Risk / why it's a separate task
4871

4972
This is content-projection work with a real data-correctness trap, not cosmetics:
@@ -56,15 +79,18 @@ This is content-projection work with a real data-correctness trap, not cosmetics
5679

5780
## Acceptance criteria
5881

59-
- [ ] A typed `Field[] → FieldLike[]` projector replaces the JSON round-trip in `plugin.ts`.
60-
- [ ] `localized: true` is preserved on deeply-nested fields across group / array / blocks / named &
61-
unnamed tabs / row / collapsible.
62-
- [ ] The projected schema is an independent deep copy — unaffected by later mutation of the source
63-
collection objects (regression test simulating Payload's `localized` stripping).
64-
- [ ] `schemaMap` is typed as `FieldLike[]` (no `any` at the boundary).
65-
- [ ] Type-check + lint clean; existing translation/staleness tests unchanged and green.
82+
- [x] A typed `FieldLike[] → FieldLike[]` projector (`projectFieldsToFieldLike`) replaces the JSON
83+
round-trip in `plugin.ts` (Payload's `Field[]` assigns structurally at the call site).
84+
- [x] `localized: true` is preserved on deeply-nested fields across group / array / blocks / named &
85+
unnamed tabs / row / collapsible (copied by value at projection time).
86+
- [x] The projected schema is an independent deep copy — regression test simulates Payload's in-place
87+
`delete field.localized` on the source and asserts the projection is unaffected.
88+
- [x] `schemaMap` (`CollectionSchemaMap`) is typed as `FieldLike[]` (no `any` at the boundary); the two
89+
internal consumers that annotated `Field[]` (`resolveFieldSubtree`, `Provenance.service`) retyped to `FieldLike[]`.
90+
- [x] Type-check + lint clean (repo baseline, no new warnings); full suite green.
6691

6792
## References
6893

69-
- `src/plugin.ts` — the `TODO` next to `schemaMap` marks this.
70-
- `src/core/field-traversal/` — existing `FieldLike` + traversal walkers to reuse.
94+
- `src/plugin.ts` — the JSON round-trip (now `projectFieldsToFieldLike`).
95+
- `src/core/kernel/field-traversal/projectFieldLike.ts` (+ `.test.ts`) — the projector + regression test.
96+
- `src/core/kernel/field-traversal/` — existing `FieldLike` types the projection produces.

packages/payload-plugin-translator/src/core/kernel/field-traversal/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ export { findFieldByPath } from "./findFieldByPath";
22
export type { FieldPathResult } from "./findFieldByPath";
33
export { hasFields, isBlockItem, isTabsField } from "./guards";
44
export { classifyField, matchElementById, resolveBlockFields, tabScopes } from "./kernel";
5+
export { projectFieldsToFieldLike } from "./projectFieldLike";
56
export {
67
fieldAffectsData,
78
fieldIsArrayType,
Lines changed: 285 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
1+
import { describe, it, expect } from "vitest";
2+
3+
import type { FieldLike } from "./types";
4+
import { projectFieldsToFieldLike } from "./projectFieldLike";
5+
6+
// Tests are derived from the projection CONTRACT (design doc + Payload field model), not from the
7+
// implementation: the projector must deep-copy exactly { type, name, localized, custom, fields,
8+
// blocks(+slug), tabs(name/localized/fields) }, drop everything else, keep every field 1:1, and be
9+
// independent of later in-place mutation of the source (Payload's `delete field.localized`).
10+
11+
const one = (fields: FieldLike[]): FieldLike => projectFieldsToFieldLike(fields)[0];
12+
13+
describe("projectFieldsToFieldLike — property whitelist (leaf level)", () => {
14+
it("keeps type, name, localized, and custom", () => {
15+
const leaf = one([
16+
{
17+
type: "text",
18+
name: "title",
19+
localized: true,
20+
custom: { translateKit: { exclude: false } },
21+
},
22+
]);
23+
expect(leaf).toEqual({
24+
type: "text",
25+
name: "title",
26+
localized: true,
27+
custom: { translateKit: { exclude: false } },
28+
});
29+
});
30+
31+
it("drops every non-whitelisted property (admin, required, validate, editor, hooks, defaultValue, access)", () => {
32+
const leaf = one([
33+
{
34+
type: "richText",
35+
name: "body",
36+
localized: true,
37+
required: true,
38+
admin: { position: "sidebar" },
39+
defaultValue: "x",
40+
editor: { config: async () => ({}) },
41+
validate: () => true,
42+
hooks: { beforeChange: [() => undefined] },
43+
access: { read: () => true },
44+
},
45+
] as unknown as FieldLike[]);
46+
expect(leaf).toEqual({ type: "richText", name: "body", localized: true });
47+
for (const dropped of [
48+
"required",
49+
"admin",
50+
"defaultValue",
51+
"editor",
52+
"validate",
53+
"hooks",
54+
"access",
55+
]) {
56+
expect(leaf).not.toHaveProperty(dropped);
57+
}
58+
});
59+
60+
it("preserves `localized: false` verbatim (not dropped, not coerced)", () => {
61+
const leaf = one([{ type: "text", name: "sku", localized: false }]);
62+
expect(leaf.localized).toBe(false);
63+
expect(leaf).toEqual({ type: "text", name: "sku", localized: false });
64+
});
65+
66+
it("omits name/localized/custom keys when absent on the source", () => {
67+
const leaf = one([{ type: "text" }]);
68+
expect(leaf).toEqual({ type: "text" });
69+
expect(leaf).not.toHaveProperty("name");
70+
expect(leaf).not.toHaveProperty("localized");
71+
expect(leaf).not.toHaveProperty("custom");
72+
});
73+
74+
it("preserves arbitrary and empty custom contents", () => {
75+
expect(one([{ type: "text", name: "a", custom: { k: 1, nested: { x: [2] } } }]).custom).toEqual(
76+
{
77+
k: 1,
78+
nested: { x: [2] },
79+
}
80+
);
81+
expect(one([{ type: "text", name: "b", custom: {} }]).custom).toEqual({});
82+
});
83+
});
84+
85+
describe("projectFieldsToFieldLike — containers (kept 1:1, recursed)", () => {
86+
it("named group: keeps name, recurses fields", () => {
87+
expect(
88+
one([{ type: "group", name: "seo", fields: [{ type: "text", name: "title" }] }])
89+
).toEqual({
90+
type: "group",
91+
name: "seo",
92+
fields: [{ type: "text", name: "title" }],
93+
});
94+
});
95+
96+
it("unnamed group: recurses fields, no name", () => {
97+
const g = one([{ type: "group", fields: [{ type: "text", name: "loose" }] }]);
98+
expect(g).toEqual({ type: "group", fields: [{ type: "text", name: "loose" }] });
99+
expect(g).not.toHaveProperty("name");
100+
});
101+
102+
it("array: keeps name, recurses element fields", () => {
103+
expect(
104+
one([{ type: "array", name: "items", fields: [{ type: "text", name: "label" }] }])
105+
).toEqual({
106+
type: "array",
107+
name: "items",
108+
fields: [{ type: "text", name: "label" }],
109+
});
110+
});
111+
112+
it("blocks: keeps name and each block's slug + fields, across multiple blocks", () => {
113+
expect(
114+
one([
115+
{
116+
type: "blocks",
117+
name: "sections",
118+
blocks: [
119+
{ slug: "hero", fields: [{ type: "text", name: "heading", localized: true }] },
120+
{ slug: "cta", fields: [{ type: "text", name: "label" }] },
121+
],
122+
},
123+
])
124+
).toEqual({
125+
type: "blocks",
126+
name: "sections",
127+
blocks: [
128+
{ slug: "hero", fields: [{ type: "text", name: "heading", localized: true }] },
129+
{ slug: "cta", fields: [{ type: "text", name: "label" }] },
130+
],
131+
});
132+
});
133+
134+
it("tabs: keeps named (name/localized/fields) and unnamed (fields) tabs", () => {
135+
expect(
136+
one([
137+
{
138+
type: "tabs",
139+
tabs: [
140+
{ name: "meta", localized: true, fields: [{ type: "text", name: "slug" }] },
141+
{ fields: [{ type: "text", name: "loose", localized: true }] },
142+
],
143+
},
144+
])
145+
).toEqual({
146+
type: "tabs",
147+
tabs: [
148+
{ name: "meta", localized: true, fields: [{ type: "text", name: "slug" }] },
149+
{ fields: [{ type: "text", name: "loose", localized: true }] },
150+
],
151+
});
152+
});
153+
154+
it("presentational row/collapsible are transparent (fields recursed, no name)", () => {
155+
const [row, collapsible] = projectFieldsToFieldLike([
156+
{ type: "row", fields: [{ type: "text", name: "a" }] },
157+
{ type: "collapsible", fields: [{ type: "text", name: "b" }] },
158+
]);
159+
expect(row).toEqual({ type: "row", fields: [{ type: "text", name: "a" }] });
160+
expect(collapsible).toEqual({ type: "collapsible", fields: [{ type: "text", name: "b" }] });
161+
});
162+
163+
it("ui field is kept 1:1 with no fields", () => {
164+
expect(one([{ type: "ui", name: "spacer" } as FieldLike])).toEqual({
165+
type: "ui",
166+
name: "spacer",
167+
});
168+
});
169+
170+
it("keeps an empty fields array as [] (does not drop the container)", () => {
171+
expect(one([{ type: "group", name: "empty", fields: [] }])).toEqual({
172+
type: "group",
173+
name: "empty",
174+
fields: [],
175+
});
176+
});
177+
178+
it("preserves sibling order and count at the root", () => {
179+
const projected = projectFieldsToFieldLike([
180+
{ type: "text", name: "a" },
181+
{ type: "group", name: "b", fields: [] },
182+
{ type: "ui", name: "c" } as FieldLike,
183+
{ type: "array", name: "d", fields: [] },
184+
]);
185+
expect(projected.map((f) => `${f.type}:${f.name ?? ""}`)).toEqual([
186+
"text:a",
187+
"group:b",
188+
"ui:c",
189+
"array:d",
190+
]);
191+
});
192+
});
193+
194+
describe("projectFieldsToFieldLike — deep independence (the localized-stripping trap)", () => {
195+
it("nested `localized` survives Payload's in-place `delete` under a group", () => {
196+
const leaf = { type: "text", name: "body", localized: true } as FieldLike;
197+
const source: FieldLike[] = [{ type: "group", name: "g", localized: true, fields: [leaf] }];
198+
const projected = projectFieldsToFieldLike(source);
199+
200+
delete (source[0] as { localized?: boolean }).localized;
201+
delete (leaf as { localized?: boolean }).localized;
202+
203+
expect(projected[0].localized).toBe(true);
204+
expect(projected[0].fields?.[0]).toEqual({ type: "text", name: "body", localized: true });
205+
});
206+
207+
it("nested `localized` survives under an array element", () => {
208+
const leaf = { type: "text", name: "label", localized: true } as FieldLike;
209+
const source: FieldLike[] = [{ type: "array", name: "items", fields: [leaf] }];
210+
const projected = projectFieldsToFieldLike(source);
211+
delete (leaf as { localized?: boolean }).localized;
212+
expect(projected[0].fields?.[0]?.localized).toBe(true);
213+
});
214+
215+
it("nested `localized` survives inside a block and under a named tab", () => {
216+
const blockLeaf = { type: "text", name: "heading", localized: true } as FieldLike;
217+
const tabLeaf = { type: "text", name: "slug", localized: true } as FieldLike;
218+
const source: FieldLike[] = [
219+
{ type: "blocks", name: "s", blocks: [{ slug: "hero", fields: [blockLeaf] }] },
220+
{ type: "tabs", tabs: [{ name: "meta", localized: true, fields: [tabLeaf] }] },
221+
];
222+
const projected = projectFieldsToFieldLike(source);
223+
delete (blockLeaf as { localized?: boolean }).localized;
224+
delete (tabLeaf as { localized?: boolean }).localized;
225+
expect(projected[0].blocks?.[0]?.fields?.[0]?.localized).toBe(true);
226+
expect(projected[1].tabs?.[0]?.fields?.[0]?.localized).toBe(true);
227+
});
228+
229+
it("deep mixed nesting (group > array > blocks > named tab > leaf) preserves the bottom `localized`", () => {
230+
const deepLeaf = { type: "text", name: "text", localized: true } as FieldLike;
231+
const source: FieldLike[] = [
232+
{
233+
type: "group",
234+
name: "g",
235+
fields: [
236+
{
237+
type: "array",
238+
name: "rows",
239+
fields: [
240+
{
241+
type: "blocks",
242+
name: "blk",
243+
blocks: [
244+
{
245+
slug: "b",
246+
fields: [{ type: "tabs", tabs: [{ name: "t", fields: [deepLeaf] }] }],
247+
},
248+
],
249+
},
250+
],
251+
},
252+
],
253+
},
254+
];
255+
const projected = projectFieldsToFieldLike(source);
256+
delete (deepLeaf as { localized?: boolean }).localized;
257+
258+
const bottom =
259+
projected[0].fields?.[0]?.fields?.[0]?.blocks?.[0]?.fields?.[0]?.tabs?.[0]?.fields?.[0];
260+
expect(bottom).toEqual({ type: "text", name: "text", localized: true });
261+
});
262+
263+
it("adding a sibling to the source field list does not affect the projection", () => {
264+
const source: FieldLike[] = [
265+
{ type: "group", name: "g", fields: [{ type: "text", name: "a" }] },
266+
];
267+
const projected = projectFieldsToFieldLike(source);
268+
source[0].fields?.push({ type: "text", name: "injected" });
269+
expect(projected[0].fields).toHaveLength(1);
270+
});
271+
272+
it("produces distinct object identities at every level (deep copy, not shared references)", () => {
273+
const source: FieldLike[] = [
274+
{
275+
type: "blocks",
276+
name: "s",
277+
blocks: [{ slug: "hero", fields: [{ type: "text", name: "h" }] }],
278+
},
279+
];
280+
const projected = projectFieldsToFieldLike(source);
281+
expect(projected[0]).not.toBe(source[0]);
282+
expect(projected[0].blocks?.[0]).not.toBe(source[0].blocks?.[0]);
283+
expect(projected[0].blocks?.[0]?.fields?.[0]).not.toBe(source[0].blocks?.[0]?.fields?.[0]);
284+
});
285+
});

0 commit comments

Comments
 (0)