diff --git a/.changeset/field-mapping-tri-source-c12.md b/.changeset/field-mapping-tri-source-c12.md new file mode 100644 index 0000000000..9dc2315d19 --- /dev/null +++ b/.changeset/field-mapping-tri-source-c12.md @@ -0,0 +1,110 @@ +--- +"@objectstack/spec": major +--- + +BREAKING(spec): `FieldMapping` named three declarations — the two domain-specific +sides are renamed to `ConnectorFieldMapping` and `ImportFieldMapping` (#4703, #4535 C12) + +`FieldMapping` / `FieldMappingSchema` were exported by **three** entry points for +**three different declarations**, so which type you got depended only on the import +path — the #4411 trap, one entry worse than the usual pair: + +| entry | declaration | keys | shape | +|:--|:--|:--|:--| +| `@objectstack/spec/shared` (**unchanged**) | `shared/mapping.zod.ts` | 4 | the base — plain `z.object` | +| `@objectstack/spec/integration` (**renamed**) | `integration/connector.zod.ts` | 7 | `Base.extend({ dataType, required, syncMode })` | +| `@objectstack/spec/data` (**renamed**) | `data/mapping.zod.ts` | 4 | an independent `strictObject` | + +The first two are base-and-superset. The third is **not the same concept at all**: it +is the column mapping of a CSV/table import (`mapping.fieldMapping[]`), not a +connector's remote-field mapping. Three ways the two are mutually unparseable: + +1. **`transform` is the same key name with incompatible value types.** `shared` / + `integration` take the discriminated union `FieldMappingTransformSchema` + (`{ type: 'cast', targetType: 'string' }`); `data` takes a flat `TransformType` + enum defaulting to `'none'`, steering a separate `params` bag. +2. **Different cardinality.** `data` accepts `string | string[]` for `source` and + `target` — one target field may be composed from several columns (`split` / + `join`). The other two accept a single `string`. +3. **Opposite failure modes for an unknown key.** `data` is a `strictObject` + (#4001): it **throws**, naming the canonical spelling. The other two are plain + `z.object`: they **strip silently**. Under one shared name, the same typo is a + hard error in one domain and a no-op in the other. + +Per **ADR-0112 D9(a)** the domain-specific sides take a domain prefix and the base +keeps the bare name — the same ruling that produced `ConnectorRateLimitConfig` +(#4684), `ConnectorErrorCategory` and `ConnectorRetryStrategy`. This is not a new +convention: `data/ExternalFieldMappingSchema` already extends the same base and, +purely because it carries a prefix, never entered the dual-source baseline at all. + +The dual-source baseline shrinks **16 → 14**. + +## FROM → TO + +```ts +// before — @objectstack/spec/integration +import { FieldMappingSchema, type FieldMapping } from '@objectstack/spec/integration'; +// after +import { + ConnectorFieldMappingSchema, + type ConnectorFieldMapping, +} from '@objectstack/spec/integration'; + +// before — @objectstack/spec/data +import { FieldMappingSchema, type FieldMapping } from '@objectstack/spec/data'; +// after +import { + ImportFieldMappingSchema, + type ImportFieldMapping, +} from '@objectstack/spec/data'; +``` + +**Importing from `@objectstack/spec/shared`? Nothing changes** — that `FieldMapping` +is the base, keeps its name, its four keys and its plain-`z.object` behaviour. + +No deprecated aliases are kept on either renamed entry: re-exporting the old name +would be a third declaration of it and would re-open the trap this change closes. + +⚠️ **Do not "fix" the compile error by re-pointing the import at +`@objectstack/spec/shared`.** That name resolves, and it is the wrong schema. On the +connector side it silently costs you `dataType` / `required` / `syncMode` — the base +is not `.strict()`, so those keys are **stripped at parse time** and the mapping runs +without them. On the import side the base rejects arrays and the enum form of +`transform` outright. Take the prefixed name for the domain you are in. + +## Authored metadata needs no migration + +This renames TypeScript exports and two internal JSON Schema `$def`s — **not a single +authorable key**. All eleven keys carry over unchanged, verified by the +`authorable-surface.json` ratchet rather than by inspection: + +- `connectors[].fieldMappings[]` — `source`, `target`, `transform`, `defaultValue`, + `dataType`, `required`, `syncMode` (7) +- `mapping.fieldMapping[]` — `source`, `target`, `transform`, `params` (4) + +Same names, same types, same defaults, same strictness. Existing stack metadata, +stored `sys_metadata` rows and published apps are byte-for-byte unaffected, which is +why this ships with **no ADR-0087 conversion and no tombstone**: nothing was retired. +The `major` is for the two renamed TypeScript exports alone — the only edit an upgrade +needs is the import above. + +The published JSON Schema `$id`s move with the defs: +`…/integration/FieldMapping.json` → `…/integration/ConnectorFieldMapping.json`, and +`…/data/FieldMapping.json` → `…/data/ImportFieldMapping.json`. + +## Gate change riding along + +`scripts/lib/renamed-defs.ts` (the #4684 carry-over table) gets its first entries +beyond the original one, and with them the first rules that only bind when the table +holds **more than one**: + +- **two sources onto one target is rejected.** That is a merge, not two renames, and + it defeats the table's purpose: `build-schemas.ts` carries the snapshot into a map + keyed by the *new* key, so two defs' entries for one property name collapse — and + the surviving `[RETIRED]` state is whichever was carried last. A key live under one + def and tombstoned under the other would then read as already-retired, and the + "every live → retired transition needs a registered conversion" check would never + fire for it. +- **a chained rename (A → B → C) is rejected by name.** It was already red as + "B is not emitted", which is true but misdiagnoses it as a typo; the carry is a + single pass, so chains are unsupported outright. diff --git a/content/docs/getting-started/quick-reference.mdx b/content/docs/getting-started/quick-reference.mdx index a11646456a..3de1aa9626 100644 --- a/content/docs/getting-started/quick-reference.mdx +++ b/content/docs/getting-started/quick-reference.mdx @@ -25,7 +25,7 @@ Core business logic and data modeling schemas. | **[Validation](/docs/references/data/validation)** | `validation.zod.ts` | ValidationRule | Business validation rules | | **[Datasource](/docs/references/data/datasource)** | `datasource.zod.ts` | Datasource, DriverDefinition | Database connection configs | | **[Analytics](/docs/references/data/analytics)** | `analytics.zod.ts` | Analytics | Data analytics and aggregation | -| **[Mapping](/docs/references/data/mapping)** | `mapping.zod.ts` | FieldMapping | Field transformation mappings | +| **[Mapping](/docs/references/data/mapping)** | `mapping.zod.ts` | ImportFieldMapping | Field transformation mappings | | **[Hook](/docs/references/data/hook)** | `hook.zod.ts` | Hook, HookEvent | Lifecycle event hooks | | **[Data Engine](/docs/references/data/data-engine)** | `data-engine.zod.ts` | DataEngine | Data engine configuration | | **[Driver](/docs/references/data/driver)** | `driver.zod.ts` | Driver, DriverCapabilities | Database driver interface | diff --git a/content/docs/references/data/mapping.mdx b/content/docs/references/data/mapping.mdx index 0cd86d8712..451b1188ef 100644 --- a/content/docs/references/data/mapping.mdx +++ b/content/docs/references/data/mapping.mdx @@ -24,16 +24,16 @@ green run. ## TypeScript Usage ```typescript -import { FieldMappingSchema, MappingSchema, TransformType } from '@objectstack/spec/data'; -import type { FieldMapping, Mapping } from '@objectstack/spec/data'; +import { ImportFieldMappingSchema, MappingSchema, TransformType } from '@objectstack/spec/data'; +import type { ImportFieldMapping, Mapping } from '@objectstack/spec/data'; // Validate data -const result = FieldMappingSchema.parse(data); +const result = ImportFieldMappingSchema.parse(data); ``` --- -## FieldMapping +## ImportFieldMapping ### Properties diff --git a/content/docs/references/integration/connector.mdx b/content/docs/references/integration/connector.mdx index 9ec6c4e787..7e6167993d 100644 --- a/content/docs/references/integration/connector.mdx +++ b/content/docs/references/integration/connector.mdx @@ -132,8 +132,8 @@ with simple `auth` — or by `[automation/sync.zod.ts](/docs/references/automati ## TypeScript Usage ```typescript -import { CircuitBreakerConfigSchema, ConnectorSchema, ConnectorActionSchema, ConnectorErrorCategorySchema, ConnectorHealthSchema, ConnectorRateLimitConfigSchema, ConnectorRetryStrategySchema, ConnectorStatusSchema, ConnectorTriggerSchema, ConnectorTypeSchema, DataSyncConfigSchema, DeclarativeConnectorEntrySchema, ErrorMappingConfigSchema, ErrorMappingRuleSchema, HealthCheckConfigSchema, RateLimitStrategySchema, RetryConfigSchema, SyncStrategySchema, WebhookConfigSchema, WebhookEventSchema, WebhookSignatureAlgorithmSchema } from '@objectstack/spec/integration'; -import type { CircuitBreakerConfig, Connector, ConnectorErrorCategory, ConnectorHealth, ConnectorRateLimitConfig, ConnectorRetryStrategy, ConnectorStatus, ConnectorType, DataSyncConfig, DeclarativeConnectorEntry, ErrorMappingConfig, ErrorMappingRule, HealthCheckConfig, RateLimitStrategy, RetryConfig, SyncStrategy, WebhookConfig, WebhookEvent, WebhookSignatureAlgorithm } from '@objectstack/spec/integration'; +import { CircuitBreakerConfigSchema, ConnectorSchema, ConnectorActionSchema, ConnectorErrorCategorySchema, ConnectorFieldMappingSchema, ConnectorHealthSchema, ConnectorRateLimitConfigSchema, ConnectorRetryStrategySchema, ConnectorStatusSchema, ConnectorTriggerSchema, ConnectorTypeSchema, DataSyncConfigSchema, DeclarativeConnectorEntrySchema, ErrorMappingConfigSchema, ErrorMappingRuleSchema, HealthCheckConfigSchema, RateLimitStrategySchema, RetryConfigSchema, SyncStrategySchema, WebhookConfigSchema, WebhookEventSchema, WebhookSignatureAlgorithmSchema } from '@objectstack/spec/integration'; +import type { CircuitBreakerConfig, Connector, ConnectorErrorCategory, ConnectorFieldMapping, ConnectorHealth, ConnectorRateLimitConfig, ConnectorRetryStrategy, ConnectorStatus, ConnectorType, DataSyncConfig, DeclarativeConnectorEntry, ErrorMappingConfig, ErrorMappingRule, HealthCheckConfig, RateLimitStrategy, RetryConfig, SyncStrategy, WebhookConfig, WebhookEvent, WebhookSignatureAlgorithm } from '@objectstack/spec/integration'; // Validate data const result = CircuitBreakerConfigSchema.parse(data); @@ -223,6 +223,23 @@ Standard error category * `integration_error` +--- + +## ConnectorFieldMapping + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **source** | `string` | ✅ | Source field name | +| **target** | `string` | ✅ | Target field name | +| **transform** | `{ type: 'constant'; value: any } \| { type: 'cast'; targetType: Enum<'string' \| 'number' \| 'boolean' \| 'date'> } \| { type: 'lookup'; table: string; keyField: string; valueField: string } \| { type: 'javascript'; expression: string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object } } \| { type: 'map'; mappings: Record }` | optional | Transformation to apply | +| **defaultValue** | `any` | optional | Default if source is null/undefined | +| **dataType** | `Enum<'string' \| 'number' \| 'boolean' \| 'date' \| 'datetime' \| 'json' \| 'array'>` | optional | Target data type | +| **required** | `boolean` | optional | Field is required | +| **syncMode** | `Enum<'read_only' \| 'write_only' \| 'bidirectional'>` | optional | Sync mode | + + --- ## ConnectorHealth diff --git a/content/docs/references/integration/mapping.mdx b/content/docs/references/integration/mapping.mdx deleted file mode 100644 index afd60050a4..0000000000 --- a/content/docs/references/integration/mapping.mdx +++ /dev/null @@ -1,36 +0,0 @@ ---- -title: Mapping -description: Mapping protocol schemas ---- - -{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} - -## TypeScript Usage - -```typescript -import { FieldMappingSchema } from '@objectstack/spec/integration'; -import type { FieldMapping } from '@objectstack/spec/integration'; - -// Validate data -const result = FieldMappingSchema.parse(data); -``` - ---- - -## FieldMapping - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **source** | `string` | ✅ | Source field name | -| **target** | `string` | ✅ | Target field name | -| **transform** | `{ type: 'constant'; value: any } \| { type: 'cast'; targetType: Enum<'string' \| 'number' \| 'boolean' \| 'date'> } \| { type: 'lookup'; table: string; keyField: string; valueField: string } \| { type: 'javascript'; expression: string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object } } \| { type: 'map'; mappings: Record }` | optional | Transformation to apply | -| **defaultValue** | `any` | optional | Default if source is null/undefined | -| **dataType** | `Enum<'string' \| 'number' \| 'boolean' \| 'date' \| 'datetime' \| 'json' \| 'array'>` | optional | Target data type | -| **required** | `boolean` | optional | Field is required | -| **syncMode** | `Enum<'read_only' \| 'write_only' \| 'bidirectional'>` | optional | Sync mode | - - ---- - diff --git a/content/docs/references/integration/meta.json b/content/docs/references/integration/meta.json index d49912f689..d443f3db08 100644 --- a/content/docs/references/integration/meta.json +++ b/content/docs/references/integration/meta.json @@ -4,7 +4,6 @@ "---Connectors---", "connector", "connector-auth", - "mapping", "---Transport & Storage---", "offline" ] diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index 3b7aea0304..f11c637c79 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -370,8 +370,6 @@ "FieldGroupCollapse (type)", "FieldGroupSection (interface)", "FieldInput (type)", - "FieldMapping (type)", - "FieldMappingSchema (const)", "FieldNode (type)", "FieldNodeSchema (const)", "FieldOperators (type)", @@ -411,6 +409,8 @@ "IMPORT_BOOLEAN_TRUE_TOKENS (const)", "IMPORT_REFERENCE_TYPES (const)", "INSTANT_TYPES (const)", + "ImportFieldMapping (type)", + "ImportFieldMappingSchema (const)", "IndexSchema (const)", "InstantValueSchema (const)", "JSONValidation (type)", @@ -3924,6 +3924,8 @@ "ConnectorDescriptor (interface)", "ConnectorErrorCategory (type)", "ConnectorErrorCategorySchema (const)", + "ConnectorFieldMapping (type)", + "ConnectorFieldMappingSchema (const)", "ConnectorHealth (type)", "ConnectorHealthSchema (const)", "ConnectorInput (type)", @@ -3958,8 +3960,6 @@ "ErrorMappingConfigSchema (const)", "ErrorMappingRule (type)", "ErrorMappingRuleSchema (const)", - "FieldMapping (type)", - "FieldMappingSchema (const)", "HealthCheckConfig (type)", "HealthCheckConfigSchema (const)", "RateLimitStrategy (type)", diff --git a/packages/spec/authorable-surface.json b/packages/spec/authorable-surface.json index e1eeaa303b..0e7621d767 100644 --- a/packages/spec/authorable-surface.json +++ b/packages/spec/authorable-surface.json @@ -3483,10 +3483,6 @@ "data/Field:unique", "data/Field:visibleWhen", "data/Field:widget", - "data/FieldMapping:params", - "data/FieldMapping:source", - "data/FieldMapping:target", - "data/FieldMapping:transform", "data/FieldReference:$field", "data/FilePersistenceConfig:autoSaveInterval", "data/FilePersistenceConfig:path", @@ -3537,6 +3533,10 @@ "data/HookContext:session", "data/HookContext:transaction", "data/HookContext:user", + "data/ImportFieldMapping:params", + "data/ImportFieldMapping:source", + "data/ImportFieldMapping:target", + "data/ImportFieldMapping:transform", "data/Index:fields", "data/Index:name", "data/Index:partial", @@ -4141,6 +4141,13 @@ "integration/ConnectorAction:key", "integration/ConnectorAction:label", "integration/ConnectorAction:outputSchema", + "integration/ConnectorFieldMapping:dataType", + "integration/ConnectorFieldMapping:defaultValue", + "integration/ConnectorFieldMapping:required", + "integration/ConnectorFieldMapping:source", + "integration/ConnectorFieldMapping:syncMode", + "integration/ConnectorFieldMapping:target", + "integration/ConnectorFieldMapping:transform", "integration/ConnectorHealth:circuitBreaker", "integration/ConnectorHealth:healthCheck", "integration/ConnectorInstanceAPIKeyAuth:credentialRef", @@ -4207,13 +4214,6 @@ "integration/ErrorMappingRule:targetCategory", "integration/ErrorMappingRule:targetCode", "integration/ErrorMappingRule:userMessage", - "integration/FieldMapping:dataType", - "integration/FieldMapping:defaultValue", - "integration/FieldMapping:required", - "integration/FieldMapping:source", - "integration/FieldMapping:syncMode", - "integration/FieldMapping:target", - "integration/FieldMapping:transform", "integration/HealthCheckConfig:enabled", "integration/HealthCheckConfig:endpoint", "integration/HealthCheckConfig:expectedStatus", diff --git a/packages/spec/dual-source-exports.baseline.json b/packages/spec/dual-source-exports.baseline.json index 0b5c76ffe5..7fc7647580 100644 --- a/packages/spec/dual-source-exports.baseline.json +++ b/packages/spec/dual-source-exports.baseline.json @@ -10,8 +10,6 @@ "EnvironmentArtifactInput — [./cloud (type)] ≠ [./system (type)]", "EnvironmentArtifactSchema — [./cloud (const)] ≠ [./system (const)]", "EventSchema — [./automation (const)] ≠ [./kernel (const)]", - "FieldMapping — [./data (type)] ≠ [./integration (type)] ≠ [./shared (type)]", - "FieldMappingSchema — [./data (const)] ≠ [./integration (const)] ≠ [./shared (const)]", "HttpMethod — [./api, ./shared (type)] ≠ [./ui (type)]", "PackageDependency — [./cloud (type)] ≠ [./kernel (type)]", "PackageDependencySchema — [./cloud (const)] ≠ [./kernel (const)]", diff --git a/packages/spec/json-schema.manifest.json b/packages/spec/json-schema.manifest.json index 57a9b4696d..17704dcfb2 100644 --- a/packages/spec/json-schema.manifest.json +++ b/packages/spec/json-schema.manifest.json @@ -753,7 +753,6 @@ "data/FeedFilterMode", "data/FeedItemType", "data/Field", - "data/FieldMapping", "data/FieldNode", "data/FieldReference", "data/FieldType", @@ -769,6 +768,7 @@ "data/HookBodyCapability", "data/HookContext", "data/HookEvent", + "data/ImportFieldMapping", "data/Index", "data/InstantValue", "data/JSONValidation", @@ -872,6 +872,7 @@ "integration/Connector", "integration/ConnectorAction", "integration/ConnectorErrorCategory", + "integration/ConnectorFieldMapping", "integration/ConnectorHealth", "integration/ConnectorInstanceAPIKeyAuth", "integration/ConnectorInstanceAuth", @@ -887,7 +888,6 @@ "integration/DeclarativeConnectorEntry", "integration/ErrorMappingConfig", "integration/ErrorMappingRule", - "integration/FieldMapping", "integration/HealthCheckConfig", "integration/RateLimitStrategy", "integration/RetryConfig", diff --git a/packages/spec/scripts/lib/renamed-defs.ts b/packages/spec/scripts/lib/renamed-defs.ts index b90752390c..911e706350 100644 --- a/packages/spec/scripts/lib/renamed-defs.ts +++ b/packages/spec/scripts/lib/renamed-defs.ts @@ -57,6 +57,17 @@ export const RENAMED_DEFS: Readonly> = { // #4684 / ADR-0112 D9a — the connector-side (outbound throttling) config no // longer shares a name with `shared/RateLimitConfig` (inbound API limiting). 'integration/RateLimitConfig': 'integration/ConnectorRateLimitConfig', + + // #4703 / ADR-0112 D9a — `FieldMapping` was published by THREE defs at once. + // The two domain-specific sides take a domain prefix; `shared/FieldMapping` + // is the BASE that this rename's target (and `data/ExternalFieldMapping`) + // extend, so it keeps the bare name and is deliberately absent from this + // table. Note what an `extend` means for the invariants below: the target + // def's key set is a superset of the base's, so every carried key is found, + // while the base def is emitted unchanged and is neither a source nor a + // target here. + 'integration/FieldMapping': 'integration/ConnectorFieldMapping', // 7 keys carried + 'data/FieldMapping': 'data/ImportFieldMapping', // 4 keys carried }; /** @@ -80,12 +91,16 @@ export function carryAuthorableKey( * * Returns one human-readable problem line per broken entry; an empty array * means the table is honest about this build. + * + * The last two rules are about entries *interacting*, and only bind once the + * table holds more than one entry — which #4703 is the first change to do. */ export function checkRenameTable( emittedDefs: ReadonlySet, renames: Readonly> = RENAMED_DEFS, ): string[] { const problems: string[] = []; + const claimedBy = new Map(); // target def -> first source claiming it for (const [from, to] of Object.entries(renames)) { if (from === to) { problems.push(`${from} → ${to}: source and target are the same def.`); @@ -106,6 +121,43 @@ export function checkRenameTable( `and need the tombstone route, not this table.`, ); } + // Two sources onto one target is a MERGE, not two renames. Left unchecked + // it defeats the table's own reason to exist: `build-schemas.ts` carries + // the snapshot into a `prev` map keyed by the NEW key, so the two defs' + // entries for the same property name collapse — last one wins — and with + // them the property's recorded retired state. A key that was live under + // one def and tombstoned under the other would then read as "already + // retired", and check (b) — every live → retired transition needs a + // registered ADR-0087 conversion — would never fire for it. That is a key + // leaving the contract with the table's blessing, the one thing it must + // not be able to explain (#4684). Converging two defs is a real change: + // make it one rename plus an explicit retirement. + const first = claimedBy.get(to); + if (first !== undefined) { + problems.push( + `${from} → ${to}: the TARGET def is already claimed by ${first}. ` + + `Two sources onto one target is a merge, not a rename: the carried key ` + + `sets (and their retired states) would silently collapse into one.`, + ); + } else { + claimedBy.set(to, from); + } + } + // A chain (A → B → C) is rejected above as "B is not emitted", which is true + // but misdiagnoses it as a typo. Say what it is: a def that is renamed away + // cannot also be a rename target, or the carry is order-dependent — `prev` + // is built in one pass and never re-visits a key it has already rewritten. + for (const [from, to] of Object.entries(renames)) { + // A self-rename is `to in renames` by construction; rule 1 already named it, + // and reporting it twice would just bury the real diagnosis. + if (from === to) continue; + if (Object.hasOwn(renames, to)) { + problems.push( + `${from} → ${to}: the TARGET def is itself renamed away (${to} → ${renames[to]}). ` + + `Chained renames are not supported — the carry is a single pass. Point ` + + `${from} straight at the final name.`, + ); + } } return problems; } diff --git a/packages/spec/scripts/renamed-defs.test.ts b/packages/spec/scripts/renamed-defs.test.ts index c25df24d25..205ce301c7 100644 --- a/packages/spec/scripts/renamed-defs.test.ts +++ b/packages/spec/scripts/renamed-defs.test.ts @@ -83,6 +83,58 @@ describe('checkRenameTable', () => { expect(problems).toHaveLength(1); expect(problems[0]).toContain('source and target are the same def'); }); + + // ─── Rules that only bind with MORE THAN ONE entry (#4703) ────────────── + // The table shipped with a single entry, so nothing exercised how two + // entries interact. #4703 is the first change to add two at once. + + it('accepts several independent renames in one build', () => { + expect( + checkRenameTable(new Set(['integration/NewA', 'data/NewB', 'shared/Untouched']), { + 'integration/OldA': 'integration/NewA', + 'data/OldB': 'data/NewB', + }), + ).toEqual([]); + }); + + it('rejects two sources claiming ONE target — that is a merge, not two renames', () => { + // Why this must be fatal: `build-schemas.ts` carries the snapshot into a + // map keyed by the NEW key, so `A:foo` and `B:foo` collapse onto `X:foo` + // and the surviving entry's RETIRED state is whichever was carried last. + // A key that was live under one def and tombstoned under the other reads + // as already-retired, and check (b) — live → retired needs a registered + // ADR-0087 conversion — never fires. The table would then be explaining a + // key leaving the contract, the one thing it may not do. + const problems = checkRenameTable(new Set(['integration/X']), { + 'integration/A': 'integration/X', + 'integration/B': 'integration/X', + }); + expect(problems).toHaveLength(1); + expect(problems[0]).toContain('already claimed by integration/A'); + }); + + it('rejects a chained rename, and says it is a chain rather than a typo', () => { + // A → B → C. The pre-existing "target not emitted" rule already made this + // red (B is gone), but named it a misspelling — which invites the wrong + // repair. The carry is a single pass, so a chain is unsupported outright. + const problems = checkRenameTable(new Set(['integration/C']), { + 'integration/A': 'integration/B', + 'integration/B': 'integration/C', + }); + expect(problems.some((p) => p.includes('is itself renamed away'))).toBe(true); + }); + + it('does not mistake the BASE of an extend for a rename source', () => { + // #4703's shape: `integration/ConnectorFieldMapping` is + // `shared/FieldMapping.extend(…)`. The base is still emitted under its own + // name — it is neither source nor target — and the target's key set is a + // superset of the base's, so no carried key can go missing. + expect( + checkRenameTable(new Set(['shared/FieldMapping', 'integration/ConnectorFieldMapping']), { + 'integration/FieldMapping': 'integration/ConnectorFieldMapping', + }), + ).toEqual([]); + }); }); describe('the committed RENAMED_DEFS table', () => { @@ -98,9 +150,34 @@ describe('the committed RENAMED_DEFS table', () => { expect(RENAMED_DEFS['shared/RateLimitConfig']).toBeUndefined(); }); + it('records the #4703 tri-source FieldMapping renames — both of them', () => { + expect(RENAMED_DEFS['integration/FieldMapping']).toBe( + 'integration/ConnectorFieldMapping', + ); + expect(RENAMED_DEFS['data/FieldMapping']).toBe('data/ImportFieldMapping'); + }); + + it('leaves the shared BASE alone — `shared/FieldMapping` keeps the bare name', () => { + // `integration/ConnectorFieldMapping` and `data/ExternalFieldMapping` both + // `.extend()` it. Renaming the base would move keys under two other defs + // and change nothing about the collision, which was between the two + // domain-specific sides and the base's own name. + expect(RENAMED_DEFS['shared/FieldMapping']).toBeUndefined(); + }); + it('is well-formed: no self-renames, no two defs claiming one target', () => { const targets = Object.values(RENAMED_DEFS); for (const [from, to] of Object.entries(RENAMED_DEFS)) expect(from).not.toBe(to); expect(new Set(targets).size).toBe(targets.length); }); + + it('is internally consistent by its own build-time rules', () => { + // The three checks above are hand-written assertions about the committed + // table; this one runs the REAL validator over it with a set of emitted + // defs synthesised from the table itself. It is what catches a future + // entry that is well-formed in isolation but interacts badly — a chain, or + // a second source pointed at an existing target. + const emitted = new Set(Object.values(RENAMED_DEFS)); + expect(checkRenameTable(emitted, RENAMED_DEFS)).toEqual([]); + }); }); diff --git a/packages/spec/src/data/mapping.test.ts b/packages/spec/src/data/mapping.test.ts index 208e360899..4499bf62b1 100644 --- a/packages/spec/src/data/mapping.test.ts +++ b/packages/spec/src/data/mapping.test.ts @@ -1,10 +1,10 @@ import { describe, it, expect } from 'vitest'; import { MappingSchema, - FieldMappingSchema, + ImportFieldMappingSchema, TransformType, type Mapping, - type FieldMapping + type ImportFieldMapping } from './mapping.zod'; describe('TransformType', () => { @@ -23,18 +23,18 @@ describe('TransformType', () => { }); }); -describe('FieldMappingSchema', () => { +describe('ImportFieldMappingSchema', () => { it('should accept valid minimal field mapping', () => { - const validMapping: FieldMapping = { + const validMapping: ImportFieldMapping = { source: 'first_name', target: 'firstName' }; - expect(() => FieldMappingSchema.parse(validMapping)).not.toThrow(); + expect(() => ImportFieldMappingSchema.parse(validMapping)).not.toThrow(); }); it('should accept field mapping with single source and target', () => { - const mapping = FieldMappingSchema.parse({ + const mapping = ImportFieldMappingSchema.parse({ source: 'email', target: 'email_address', transform: 'none' @@ -45,7 +45,7 @@ describe('FieldMappingSchema', () => { }); it('should accept field mapping with array sources', () => { - const mapping = FieldMappingSchema.parse({ + const mapping = ImportFieldMappingSchema.parse({ source: ['first_name', 'last_name'], target: 'full_name', transform: 'join', @@ -56,7 +56,7 @@ describe('FieldMappingSchema', () => { }); it('should accept field mapping with array targets', () => { - const mapping = FieldMappingSchema.parse({ + const mapping = ImportFieldMappingSchema.parse({ source: 'full_name', target: ['first_name', 'last_name'], transform: 'split', @@ -67,7 +67,7 @@ describe('FieldMappingSchema', () => { }); it('should apply default transform type', () => { - const mapping = FieldMappingSchema.parse({ + const mapping = ImportFieldMappingSchema.parse({ source: 'field1', target: 'field2' }); @@ -76,7 +76,7 @@ describe('FieldMappingSchema', () => { }); it('should accept constant transform', () => { - const mapping = FieldMappingSchema.parse({ + const mapping = ImportFieldMappingSchema.parse({ source: 'unused', target: 'status', transform: 'constant', @@ -88,7 +88,7 @@ describe('FieldMappingSchema', () => { }); it('should accept lookup transform', () => { - const mapping = FieldMappingSchema.parse({ + const mapping = ImportFieldMappingSchema.parse({ source: 'account_name', target: 'account_id', transform: 'lookup', @@ -107,7 +107,7 @@ describe('FieldMappingSchema', () => { }); it('should accept map transform', () => { - const mapping = FieldMappingSchema.parse({ + const mapping = ImportFieldMappingSchema.parse({ source: 'status', target: 'status_code', transform: 'map', @@ -125,7 +125,7 @@ describe('FieldMappingSchema', () => { }); it('should accept split transform', () => { - const mapping = FieldMappingSchema.parse({ + const mapping = ImportFieldMappingSchema.parse({ source: 'full_name', target: ['first_name', 'last_name'], transform: 'split', @@ -137,7 +137,7 @@ describe('FieldMappingSchema', () => { }); it('should accept join transform', () => { - const mapping = FieldMappingSchema.parse({ + const mapping = ImportFieldMappingSchema.parse({ source: ['street', 'city', 'zip'], target: 'full_address', transform: 'join', @@ -149,7 +149,7 @@ describe('FieldMappingSchema', () => { }); it('should accept javascript transform', () => { - const mapping = FieldMappingSchema.parse({ + const mapping = ImportFieldMappingSchema.parse({ source: 'raw_data', target: 'processed_data', transform: 'javascript', diff --git a/packages/spec/src/data/mapping.zod.ts b/packages/spec/src/data/mapping.zod.ts index 3f37e612bc..42efa990d8 100644 --- a/packages/spec/src/data/mapping.zod.ts +++ b/packages/spec/src/data/mapping.zod.ts @@ -92,9 +92,31 @@ export const TransformType = z.enum([ ]); /** - * Field Mapping Item + * Import Field Mapping Item — one column of an import mapping. + * + * Renamed from `FieldMappingSchema` / `FieldMapping` (#4703, ADR-0112 D9a). + * Three entry points published that name for three declarations, so which type + * an importer got depended only on the import path (the #4411 trap). This one + * was never a spelling variant of the other two: it maps **source columns of a + * file onto object fields** for `MappingSchema`'s import pipeline, not fields + * of a connector's remote object. Three ways the shapes are incompatible, each + * pinned in `src/integration/connector.test.ts` (the cross-entry block, next to + * the #4684 one) so a future "let's just unify these" has to argue with a red + * test: + * + * 1. `transform` is a plain {@link TransformType} enum defaulting to `'none'`, + * steering a flat `params` bag. `shared`/`integration` use the discriminated + * union `FieldMappingTransformSchema` (`{ type: 'cast', targetType }` …). + * Same key name, mutually unparseable values. + * 2. `source` / `target` accept `string | string[]` here — one target field may + * be composed from several columns (`split` / `join`). The other two take a + * single `string`. + * 3. This schema is a {@link strictObject} (#4001): an unknown key THROWS with + * an alias/typo prescription. The other two are plain `z.object` and strip + * silently. Opposite failure modes under one name is exactly how a snippet + * copied across domains "works" and quietly does nothing. */ -export const FieldMappingSchema = lazySchema(() => strictObject({ +export const ImportFieldMappingSchema = lazySchema(() => strictObject({ surface: 'this field mapping', history: MAPPING_HISTORY, aliases: { @@ -195,7 +217,7 @@ export const MappingSchema = lazySchema(() => strictObject({ targetObject: z.string().describe('Target Object Name'), /** Column Mappings */ - fieldMapping: z.array(FieldMappingSchema), + fieldMapping: z.array(ImportFieldMappingSchema), /** Upsert Logic */ mode: z.enum(['insert', 'update', 'upsert']).default('insert'), @@ -227,4 +249,4 @@ export type MappingInput = z.input; export function defineMapping(config: z.input): Mapping { return MappingSchema.parse(config); } -export type FieldMapping = z.infer; +export type ImportFieldMapping = z.infer; diff --git a/packages/spec/src/integration/connector.test.ts b/packages/spec/src/integration/connector.test.ts index bf7bb4852f..2f7c48d12f 100644 --- a/packages/spec/src/integration/connector.test.ts +++ b/packages/spec/src/integration/connector.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest'; import { z } from 'zod'; import { // Field Mapping - FieldMappingSchema, + ConnectorFieldMappingSchema, // Data Sync DataSyncConfigSchema, @@ -34,7 +34,7 @@ import { // Types type Connector, - type FieldMapping, + type ConnectorFieldMapping, type DataSyncConfig, type WebhookConfig, } from './connector.zod'; @@ -159,16 +159,16 @@ describe('ConnectorAuthConfigSchema (Authentication)', () => { // Field Mapping Tests // ============================================================================ -describe('FieldMappingSchema', () => { +describe('ConnectorFieldMappingSchema', () => { it('should accept valid field mapping', () => { - const mapping: FieldMapping = { + const mapping: ConnectorFieldMapping = { source: 'firstName', target: 'first_name', dataType: 'string', syncMode: 'bidirectional', }; - expect(() => FieldMappingSchema.parse(mapping)).not.toThrow(); + expect(() => ConnectorFieldMappingSchema.parse(mapping)).not.toThrow(); }); it('should accept field with transformation', () => { @@ -181,7 +181,7 @@ describe('FieldMappingSchema', () => { }, }; - const parsed = FieldMappingSchema.parse(mapping); + const parsed = ConnectorFieldMappingSchema.parse(mapping); expect(parsed.transform?.type).toBe('javascript'); }); @@ -191,7 +191,7 @@ describe('FieldMappingSchema', () => { target: 'field_1', }; - const parsed = FieldMappingSchema.parse(mapping); + const parsed = ConnectorFieldMappingSchema.parse(mapping); expect(parsed.required).toBe(false); expect(parsed.syncMode).toBe('bidirectional'); }); @@ -783,14 +783,297 @@ describe('[#4684] RateLimitConfig no longer names two declarations', () => { expect(shared.get('RateLimitConfigSchema')).toMatch(/^src\/shared\/http\.zod\.ts:\d+$/); // And the general invariant for this pair of entries: any name they BOTH - // export must resolve to one and the same declaration. `FieldMapping` / - // `FieldMappingSchema` are the remaining known offenders (#4535 C12) — they - // stay listed here so this pin fails the moment a NEW one appears, instead - // of being written as a blanket "no shared names" that never held. - const KNOWN_STILL_DUAL_SOURCE = ['FieldMapping', 'FieldMappingSchema']; + // export must resolve to one and the same declaration. The list stayed + // explicit rather than being written as a blanket "no shared names" that + // never held — so that a NEW offender fails here instead of hiding inside a + // permanently-red assertion. #4535 C12 (#4703) cleared its last two + // entries (`FieldMapping` / `FieldMappingSchema`), so it is now empty and + // the invariant has finally graduated to the blanket form it always wanted + // to be. **Do not "fix" a failure here by adding a name back to this list** + // — that is the dual-source trap re-opening. + const KNOWN_STILL_DUAL_SOURCE: string[] = []; const conflicts = [...shared.keys()] .filter((name) => integration.has(name) && integration.get(name) !== shared.get(name)) .sort(); expect(conflicts).toEqual(KNOWN_STILL_DUAL_SOURCE); }); }); + +// ───────────────────────────────────────────────────────────────────────────── +// #4535 C12 / #4703 — `FieldMapping` named THREE declarations +// ───────────────────────────────────────────────────────────────────────────── +// +// Same defect class as #4684 above, one entry worse: `FieldMapping` / +// `FieldMappingSchema` resolved to a different declaration on EACH of +// `./shared`, `./integration` and `./data`. +// +// ./shared — the base. plain `z.object`, 4 keys. +// ./integration — `Base.extend({ dataType, required, syncMode })`, 7 keys. +// A superset, and a connector's remote-field mapping. +// ./data — an INDEPENDENT `strictObject`, 4 keys, and a different +// CONCEPT: the column mapping of a CSV/table import. +// +// The first two are base-and-superset, so "converge them" is a tempting read. +// It is wrong in both directions: widening the base to 7 keys pushes connector +// sync semantics onto `automation/sync.zod.ts` and `data/external-lookup.zod.ts` +// which also extend it, and narrowing the connector side to 4 is a retirement of +// three live keys, not a naming fix. ADR-0112 D9a's prefix remedy applies, and +// the file next door already demonstrates it: `data/ExternalFieldMappingSchema` +// extends the same base and, purely because it carries a prefix, never entered +// the dual-source baseline at all. +// +// The `./data` side is not a spelling variant of anything. The tests below pin +// the three incompatibilities, because they are the ARGUMENT for the rename and +// the thing a future "let's just unify these" has to defeat. +describe('[#4703] FieldMapping no longer names three declarations', () => { + it('each entry exposes exactly one field-mapping name, and not the others’', async () => { + const integrationEntry = await import('./index'); + const dataEntry = await import('../data/index'); + const sharedEntry = await import('../shared/index'); + + expect(integrationEntry.ConnectorFieldMappingSchema).toBeDefined(); + expect(dataEntry.ImportFieldMappingSchema).toBeDefined(); + // The base keeps the bare name — it is the incumbent, and two other defs + // extend it. + expect(sharedEntry.FieldMappingSchema).toBeDefined(); + + // No compatibility alias on either renamed side. Re-exporting the old name + // would be a third declaration of it and would re-open the trap. + expect('FieldMappingSchema' in integrationEntry).toBe(false); + expect('FieldMappingSchema' in dataEntry).toBe(false); + expect('ConnectorFieldMappingSchema' in dataEntry).toBe(false); + expect('ImportFieldMappingSchema' in integrationEntry).toBe(false); + }); + + it('./shared keeps the base declaration byte-for-byte', async () => { + const sharedEntry = await import('../shared/index'); + const integrationEntry = await import('./index'); + + // Four keys, `transform` a discriminated union, `source`/`target` required. + expect( + sharedEntry.FieldMappingSchema.parse({ + source: 'FirstName', + target: 'first_name', + transform: { type: 'cast', targetType: 'string' }, + defaultValue: '', + }), + ).toEqual({ + source: 'FirstName', + target: 'first_name', + transform: { type: 'cast', targetType: 'string' }, + defaultValue: '', + }); + + // And it is a DIFFERENT object from the connector superset that extends it + // — `.extend()` builds a new schema, which is why both were in the baseline. + expect(integrationEntry.ConnectorFieldMappingSchema).not.toBe( + sharedEntry.FieldMappingSchema, + ); + }); + + // ── Difference 1: `transform` is the same key name with mutually + // unparseable value types. The hardest evidence that these are two + // concepts rather than three spellings of one. + it('`transform` means a discriminated union on two sides and a flat enum on the third', async () => { + const dataEntry = await import('../data/index'); + const sharedEntry = await import('../shared/index'); + const integrationEntry = await import('./index'); + + const unionForm = { type: 'cast' as const, targetType: 'string' as const }; + + // shared / integration: the object form parses… + expect( + sharedEntry.FieldMappingSchema.parse({ source: 'a', target: 'b', transform: unionForm }) + .transform, + ).toEqual(unionForm); + expect( + integrationEntry.ConnectorFieldMappingSchema.parse({ + source: 'a', + target: 'b', + transform: unionForm, + }).transform, + ).toEqual(unionForm); + // …and the enum form does NOT. + expect( + sharedEntry.FieldMappingSchema.safeParse({ source: 'a', target: 'b', transform: 'join' }) + .success, + ).toBe(false); + + // data: exactly the other way round — a bare enum steering a flat `params` + // bag, defaulting to 'none'. + expect( + dataEntry.ImportFieldMappingSchema.parse({ source: 'a', target: 'b' }).transform, + ).toBe('none'); + expect( + dataEntry.ImportFieldMappingSchema.parse({ + source: 'a', + target: 'b', + transform: 'join', + params: { separator: ' ' }, + }).params?.separator, + ).toBe(' '); + expect( + dataEntry.ImportFieldMappingSchema.safeParse({ + source: 'a', + target: 'b', + transform: unionForm, + }).success, + ).toBe(false); + }); + + // ── Difference 2: cardinality. An import may compose one target field from + // several source columns; a connector mapping is 1:1. + it('./data accepts arrays for source/target where the other two take a single string', async () => { + const dataEntry = await import('../data/index'); + const sharedEntry = await import('../shared/index'); + const integrationEntry = await import('./index'); + + const composed = { source: ['first_name', 'last_name'], target: 'full_name' }; + + expect(dataEntry.ImportFieldMappingSchema.parse({ ...composed, transform: 'join' }).source) + .toEqual(['first_name', 'last_name']); + expect( + dataEntry.ImportFieldMappingSchema.parse({ + source: 'full_name', + target: ['first_name', 'last_name'], + transform: 'split', + }).target, + ).toEqual(['first_name', 'last_name']); + + expect(sharedEntry.FieldMappingSchema.safeParse(composed).success).toBe(false); + expect(integrationEntry.ConnectorFieldMappingSchema.safeParse(composed).success).toBe(false); + }); + + // ── Difference 3: OPPOSITE failure modes for an unknown key. This is what + // made one shared name actively dangerous: the same typo is a hard error + // on one side and a silent no-op on the other (ADR-0104's silent-strip + // class), so a snippet moved between domains "works" and does nothing. + it('an unknown key THROWS on ./data and is silently stripped by the other two', async () => { + const dataEntry = await import('../data/index'); + const sharedEntry = await import('../shared/index'); + const integrationEntry = await import('./index'); + + // `strictObject` (#4001) — rejects, and prescribes the canonical spelling. + const rejected = dataEntry.ImportFieldMappingSchema.safeParse({ + source: 'a', + target: 'b', + sourceField: 'a', // a real alias, deliberately: the message must name it + }); + expect(rejected.success).toBe(false); + expect(JSON.stringify(rejected.error?.issues)).toContain('source'); + + // Plain `z.object` on the other two — the foreign key vanishes and the + // parse reports success. Pinned, not fixed: it is correct behaviour for a + // non-strict schema. The defect was the shared NAME. + expect( + sharedEntry.FieldMappingSchema.parse({ source: 'a', target: 'b', syncMode: 'read_only' }), + ).toEqual({ source: 'a', target: 'b' }); + expect( + integrationEntry.ConnectorFieldMappingSchema.parse({ + source: 'a', + target: 'b', + params: { separator: ' ' }, // a `./data` key, meaningless here + }), + ).toEqual({ source: 'a', target: 'b', required: false, syncMode: 'bidirectional' }); + }); + + // The load-bearing one, and the reason this block exists at all: `FieldMapping` + // is a TYPE. Every runtime assertion above stays green if any entry re-adds + // `export type FieldMapping = z.infer<…>`, which IS the defect. #4642 proved a + // compile-time `Assert< Equal< … > >` is dead text in this package + // (`tsconfig.json` excludes `**/*.test.ts`, vitest never enables `typecheck`), + // so this resolves each entry's exports through their alias chains with the + // TypeScript compiler API — the same symbol-identity measurement + // `check:dual-source-exports` makes against `dist`, run over `src/` so it is + // part of `pnpm test`. Three entries means THREE pairs, all checked. + it('no name resolves to two declarations across ./shared, ./integration and ./data (types included)', async () => { + const ts = (await import('typescript')).default; + const { resolve, relative, dirname } = await import('node:path'); + const { fileURLToPath } = await import('node:url'); + + const specDir = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); + const entries = { + './shared': resolve(specDir, 'src/shared/index.ts'), + './integration': resolve(specDir, 'src/integration/index.ts'), + './data': resolve(specDir, 'src/data/index.ts'), + }; + const program = ts.createProgram(Object.values(entries), { + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + skipLibCheck: true, + noEmit: true, + }); + const checker = program.getTypeChecker(); + const unalias = (s: import('typescript').Symbol) => + s.getFlags() & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(s) : s; + + /** entry → exported name → `file:line` of the ORIGINAL declaration. */ + const originsByEntry = new Map>(); + for (const [sub, file] of Object.entries(entries)) { + const sf = program.getSourceFile(file); + const moduleSym = sf && checker.getSymbolAtLocation(sf); + // Without this, a resolution failure would make every assertion below + // pass vacuously — the exact way a gate goes dormant (#4642). + expect(moduleSym, `${sub} module symbol must resolve`).toBeTruthy(); + + const origins = new Map(); + for (const exported of checker.getExportsOfModule(moduleSym!)) { + const decl = unalias(exported).declarations?.[0]; + if (!decl) continue; + const declFile = decl.getSourceFile(); + origins.set( + exported.getName(), + `${relative(specDir, declFile.fileName)}:${ + declFile.getLineAndCharacterOfPosition(decl.getStart()).line + 1 + }`, + ); + } + // Guard #2: an entry that resolved to nothing would also pass vacuously. + expect(origins.size, `${sub} must export something`).toBeGreaterThan(20); + originsByEntry.set(sub, origins); + } + + const shared = originsByEntry.get('./shared')!; + const integration = originsByEntry.get('./integration')!; + const data = originsByEntry.get('./data')!; + + // Each renamed name resolves into its own domain's file… + for (const name of ['ConnectorFieldMapping', 'ConnectorFieldMappingSchema']) { + expect(integration.get(name), name).toMatch(/^src\/integration\/connector\.zod\.ts:\d+$/); + } + for (const name of ['ImportFieldMapping', 'ImportFieldMappingSchema']) { + expect(data.get(name), name).toMatch(/^src\/data\/mapping\.zod\.ts:\d+$/); + } + // …the base keeps the bare name on `./shared` only… + for (const name of ['FieldMapping', 'FieldMappingSchema']) { + expect(shared.get(name), name).toMatch(/^src\/shared\/mapping\.zod\.ts:\d+$/); + expect(integration.get(name), `${name} must be gone from ./integration`).toBeUndefined(); + expect(data.get(name), `${name} must be gone from ./data`).toBeUndefined(); + } + // …and neither renamed name leaks onto the other domain's entry. + expect(data.get('ConnectorFieldMappingSchema')).toBeUndefined(); + expect(integration.get('ImportFieldMappingSchema')).toBeUndefined(); + + // The general invariant, now over all THREE pairs. `./data` re-exports a + // handful of `./shared` declarations (identical origin — that is fine and + // is what this measures), so only a name resolving to two DIFFERENT files + // counts. + const pairs: Array<[string, Map, string, Map]> = [ + ['./shared', shared, './integration', integration], + ['./shared', shared, './data', data], + ['./integration', integration, './data', data], + ]; + const conflicts: string[] = []; + for (const [leftName, left, rightName, right] of pairs) { + for (const [name, origin] of left) { + const other = right.get(name); + if (other !== undefined && other !== origin) { + conflicts.push(`${name} — ${leftName} ${origin} ≠ ${rightName} ${other}`); + } + } + } + // Empty, and it must stay empty. A failure here is a NEW dual-source name; + // the fix is to converge or prefix it, never to allow-list it back in. + expect(conflicts.sort()).toEqual([]); + }); +}); diff --git a/packages/spec/src/integration/connector.zod.ts b/packages/spec/src/integration/connector.zod.ts index 0c2587ecb8..c2090f486a 100644 --- a/packages/spec/src/integration/connector.zod.ts +++ b/packages/spec/src/integration/connector.zod.ts @@ -97,12 +97,25 @@ import { FieldMappingSchema as BaseFieldMappingSchema } from '../shared/mapping. /** * Connector Field Mapping Configuration - * - * Extends the base field mapping with connector-specific features - * like bidirectional sync modes and data type mapping. + * + * Extends the base field mapping ({@link BaseFieldMappingSchema}, declared in + * `shared/mapping.zod.ts`) with connector-specific features like bidirectional + * sync modes and data type mapping. + * + * Renamed from `FieldMappingSchema` / `FieldMapping` (#4703, ADR-0112 D9a): + * THREE entry points published that name for three declarations — `./shared` + * (this schema's base, 4 keys), `./integration` (this one, 7 keys) and + * `./data` (`ImportFieldMappingSchema`, a CSV/table column mapping that is not + * a connector mapping at all). Which type an importer got depended only on the + * import path — the #4411 trap. Prefixing the domain-specific sides keeps the + * base name for the base, matching `ConnectorRateLimitConfig` (#4684), + * `ConnectorErrorCategory` and `ConnectorRetryStrategy` in this same file, and + * `ExternalFieldMappingSchema` in `data/external-lookup.zod.ts` — which extends + * the same base and, precisely because it carries a domain prefix, never + * entered the dual-source baseline. */ import { lazySchema } from '../shared/lazy-schema'; -export const FieldMappingSchema = lazySchema(() => BaseFieldMappingSchema.extend({ +export const ConnectorFieldMappingSchema = lazySchema(() => BaseFieldMappingSchema.extend({ /** * Data type mapping (connector-specific) */ @@ -131,7 +144,7 @@ export const FieldMappingSchema = lazySchema(() => BaseFieldMappingSchema.extend ]).default('bidirectional').describe('Sync mode'), })); -export type FieldMapping = z.infer; +export type ConnectorFieldMapping = z.infer; // ============================================================================ // Data Synchronization Configuration @@ -673,7 +686,7 @@ export const ConnectorSchema = lazySchema(() => z.object({ /** * Field mappings */ - fieldMappings: z.array(FieldMappingSchema).optional().describe('Field mapping rules'), + fieldMappings: z.array(ConnectorFieldMappingSchema).optional().describe('Field mapping rules'), /** * Webhook configuration