diff --git a/.changeset/import-mapping-name-declared.md b/.changeset/import-mapping-name-declared.md new file mode 100644 index 0000000000..7f307f2e32 --- /dev/null +++ b/.changeset/import-mapping-name-declared.md @@ -0,0 +1,14 @@ +--- +'@objectstack/spec': patch +--- + +Declare `mappingName` on `ImportRequestSchema` (and therefore on the aliased +`CreateImportJobRequestSchema`). Both import routes already accepted it on the +wire — `prepareImportRequest` resolves the named `mapping` artifact and refuses +`mappingName` plus an inline `mapping` with `400 CONFLICTING_MAPPING` — but the +published contract could not express it, so the typed SDK call +`client.data.import(object, { mappingName: '…' })` was a TS2353 compile error +(#10330). The key is now declared with the same mutual exclusion as a schema +`.refine()`, so a conflicting pair is rejected at authoring time as well as by +the route. Additive only: the schema is a plain `z.object` that strips unknown +keys, so no existing caller changes behavior. diff --git a/content/docs/data-modeling/import-mappings.mdx b/content/docs/data-modeling/import-mappings.mdx index 004e525512..84047ecb57 100644 --- a/content/docs/data-modeling/import-mappings.mdx +++ b/content/docs/data-modeling/import-mappings.mdx @@ -177,13 +177,6 @@ with `xlsxBase64` — and on the asynchronous Add `"dryRun": true` to get the same report with nothing persisted; the verdict comes from the engine's own write-path validation. - - `mappingName` is accepted on the wire but is **not** declared on the SDK's - `ImportRequest` type, so `client.data.import(object, { mappingName: '…' })` does not - type-check today. Issue the request directly until that is closed — - [#10330](https://github.com/objectstack-ai/objectstack/issues/10330). - - ### What the artifact contributes to the request | Request key | When omitted | When present | diff --git a/content/docs/references/api/export.mdx b/content/docs/references/api/export.mdx index fc622f44e3..907b605fb1 100644 --- a/content/docs/references/api/export.mdx +++ b/content/docs/references/api/export.mdx @@ -76,6 +76,7 @@ const result = CreateExportJobRequestSchema.parse(data); | **xlsxBase64** | `string` | optional | Base64-encoded .xlsx workbook bytes (when format = xlsx); parsed server-side | | **sheet** | `string \| integer` | optional | Worksheet name or 1-based index to read (xlsx; defaults to the first sheet) | | **mapping** | `Record \| { sourceField: string; targetField: string; targetLabel?: string; transform: Enum<'none' \| 'uppercase' \| 'lowercase' \| 'trim' \| 'date_format' \| 'lookup'>; … }[]` | optional | Source column → target field mapping | +| **mappingName** | `string` | optional | Name of a registered `mapping` metadata artifact to apply; the server resolves it (org-scoped rows first, then env-wide) and projects columns through it. Mutually exclusive with an inline `mapping` — supplying both is refused (400 CONFLICTING_MAPPING). | | **dryRun** | `boolean` | optional (default: `false`) | Validate + coerce every row without persisting. The verdict is the engine's own write-path validation, with one boundary an author should know: a preview runs NO automations. Hooks never fire in a dry run (#6037) — a preview that executed user-authored side effects (mail, outbound calls, writes to other objects) would be the retired `validateOnly` defect in a new spelling. So a dry run with `runAutomations: true` can report `required` for a field a `beforeInsert` hook would populate during the real import; for hook-derived fields the real write is authoritative. | | **writeMode** | `Enum<'insert' \| 'update' \| 'upsert'>` | optional (default: `"insert"`) | insert / update / upsert semantics | | **matchFields** | `string[]` | optional | Fields that identify an existing record (required for update/upsert) | @@ -365,6 +366,7 @@ Type: `{ sourceField: string; targetField: string; targetLabel?: string; transfo | **xlsxBase64** | `string` | optional | Base64-encoded .xlsx workbook bytes (when format = xlsx); parsed server-side | | **sheet** | `string \| integer` | optional | Worksheet name or 1-based index to read (xlsx; defaults to the first sheet) | | **mapping** | `Record \| { sourceField: string; targetField: string; targetLabel?: string; transform: Enum<'none' \| 'uppercase' \| 'lowercase' \| 'trim' \| 'date_format' \| 'lookup'>; … }[]` | optional | Source column → target field mapping | +| **mappingName** | `string` | optional | Name of a registered `mapping` metadata artifact to apply; the server resolves it (org-scoped rows first, then env-wide) and projects columns through it. Mutually exclusive with an inline `mapping` — supplying both is refused (400 CONFLICTING_MAPPING). | | **dryRun** | `boolean` | optional (default: `false`) | Validate + coerce every row without persisting. The verdict is the engine's own write-path validation, with one boundary an author should know: a preview runs NO automations. Hooks never fire in a dry run (#6037) — a preview that executed user-authored side effects (mail, outbound calls, writes to other objects) would be the retired `validateOnly` defect in a new spelling. So a dry run with `runAutomations: true` can report `required` for a field a `beforeInsert` hook would populate during the real import; for hook-derived fields the real write is authoritative. | | **writeMode** | `Enum<'insert' \| 'update' \| 'upsert'>` | optional (default: `"insert"`) | insert / update / upsert semantics | | **matchFields** | `string[]` | optional | Fields that identify an existing record (required for update/upsert) | diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json index feafaf7eb4..c53c12d87e 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -373,6 +373,7 @@ "api/CreateImportJobRequest:dryRun", "api/CreateImportJobRequest:format", "api/CreateImportJobRequest:mapping", + "api/CreateImportJobRequest:mappingName", "api/CreateImportJobRequest:matchFields", "api/CreateImportJobRequest:nullValues", "api/CreateImportJobRequest:rows", @@ -878,6 +879,7 @@ "api/ImportRequest:dryRun", "api/ImportRequest:format", "api/ImportRequest:mapping", + "api/ImportRequest:mappingName", "api/ImportRequest:matchFields", "api/ImportRequest:nullValues", "api/ImportRequest:rows", diff --git a/packages/spec/src/api/export.test.ts b/packages/spec/src/api/export.test.ts index 4926e0b47d..56531fe920 100644 --- a/packages/spec/src/api/export.test.ts +++ b/packages/spec/src/api/export.test.ts @@ -1,4 +1,5 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, expectTypeOf } from 'vitest'; +import type { ImportRequest } from './export.zod'; import { ExportFormat, ExportJobStatus, @@ -774,3 +775,89 @@ describe('ImportRequestSchema — runAutomations declared default (#6704)', () = expect(described).toMatch(/ON by default/); }); }); + +/** + * `mappingName` declared on the contract (#10330). + * + * The wire accepted it long before the schema declared it: both import routes + * read `body.mappingName` off the raw body in `prepareImportRequest` + * (`packages/rest/src/import-prepare.ts`), while `ImportRequestSchema` + * declared fifteen other keys — so the typed SDK could not express the one + * request parameter the `mapping` metadata kind exists for (its ADR-0088 + * admission consumer, #2611). Enforced-but-undeclared, the mirror of the + * declared-but-unenforced shape. + * + * The mutual exclusion with an inline `mapping` is asserted at BOTH layers on + * purpose: the schema `.refine()` here rejects the pair for anyone building + * the body through the published schema, and the route-level + * `400 CONFLICTING_MAPPING` (pinned in + * `packages/rest/src/import-integration.test.ts`) keeps refusing it on the + * wire, because the route parses the raw body itself and never depends on + * callers having used this schema. + */ +describe('ImportRequestSchema — mappingName declared (#10330)', () => { + const base = { format: 'csv' as const, csv: 'Full Name,E-mail\nAda,ada@example.com\n' }; + + it('parses a body naming a registered mapping, and the value survives', () => { + const parsed = ImportRequestSchema.parse({ ...base, mappingName: 'showcase_inquiry_feed' }); + expect(parsed.mappingName).toBe('showcase_inquiry_feed'); + }); + + it('parses the same body through the async twin — it is the same schema object', () => { + // `CreateImportJobRequestSchema === ImportRequestSchema`, but both defs + // are PUBLISHED separately, so the async route's declaration is asserted + // by name rather than left to the reader to infer from the aliasing. + const parsed = CreateImportJobRequestSchema + .parse({ ...base, mappingName: 'showcase_inquiry_feed' }); + expect(parsed.mappingName).toBe('showcase_inquiry_feed'); + }); + + it('the typed SDK request can express it — the #10330 TS2353 repro, inverted', () => { + // Before the declaration this exact literal was a compile error + // (TS2353: 'mappingName' does not exist in type …). The literal itself is + // the pin: this file is type-checked, so the key regressing out of the + // schema turns this line back into that error. + const req: ImportRequest = { + format: 'csv', + csv: 'Full Name,E-mail\nAda,ada@example.com\n', + mappingName: 'showcase_inquiry_feed', + }; + expect(req.mappingName).toBe('showcase_inquiry_feed'); + expectTypeOf().toEqualTypeOf(); + }); + + it('refuses mappingName plus an inline mapping at parse, naming the conflict', () => { + const result = ImportRequestSchema.safeParse({ + ...base, + mappingName: 'showcase_inquiry_feed', + mapping: { 'Full Name': 'name' }, + }); + expect(result.success).toBe(false); + const issue = result.success ? undefined : result.error.issues[0]; + expect(issue?.message).toBe('Provide either mappingName or an inline mapping, not both'); + expect(issue?.path).toEqual(['mappingName']); + }); + + it('refuses the pair on the async twin too', () => { + const result = CreateImportJobRequestSchema.safeParse({ + ...base, + mappingName: 'showcase_inquiry_feed', + mapping: { 'Full Name': 'name' }, + }); + expect(result.success).toBe(false); + }); + + it('keeps accepting each side alone — the refine only bites the pair', () => { + expect(ImportRequestSchema.safeParse({ ...base, mappingName: 'x' }).success).toBe(true); + expect( + ImportRequestSchema.safeParse({ ...base, mapping: { 'Full Name': 'name' } }).success, + ).toBe(true); + }); + + it('describes the exclusion — the prose ships in the reference tables', () => { + const described = (ImportRequestSchema.shape.mappingName as { description?: string }) + .description ?? ''; + expect(described).toMatch(/[Mm]utually\s+exclusive/); + expect(described).toMatch(/CONFLICTING_MAPPING/); + }); +}); diff --git a/packages/spec/src/api/export.zod.ts b/packages/spec/src/api/export.zod.ts index 2ec32e830c..40fa36149b 100644 --- a/packages/spec/src/api/export.zod.ts +++ b/packages/spec/src/api/export.zod.ts @@ -327,6 +327,12 @@ export const ImportRequestSchema = lazySchema(() => z.object({ .describe('Worksheet name or 1-based index to read (xlsx; defaults to the first sheet)'), mapping: ImportMappingSchema.optional() .describe('Source column → target field mapping'), + mappingName: z.string().optional() + .describe( + 'Name of a registered `mapping` metadata artifact to apply; the server resolves it ' + + '(org-scoped rows first, then env-wide) and projects columns through it. Mutually ' + + 'exclusive with an inline `mapping` — supplying both is refused (400 CONFLICTING_MAPPING).', + ), dryRun: z.boolean().default(false) .describe( 'Validate + coerce every row without persisting. The verdict is the engine\'s own write-path ' + @@ -359,6 +365,14 @@ export const ImportRequestSchema = lazySchema(() => z.object({ .describe('Keep unmatched select values instead of failing the row'), skipBlankMatchKey: z.boolean().default(false) .describe('Skip rows whose matchFields are blank (default: upsert creates them, update skips them)'), +}).refine((body) => !(body.mappingName && body.mapping), { + // Same exclusion the route enforces (400 CONFLICTING_MAPPING in + // `packages/rest/src/import-prepare.ts`) — surfaced at authoring time for + // anyone building the body through the schema. The route check stays: it + // parses the raw body itself, so the wire-level refusal never depends on + // callers having used this schema. + message: 'Provide either mappingName or an inline mapping, not both', + path: ['mappingName'], })); export type ImportRequest = z.input; /** Post-parse shape of {@link ImportRequest} — defaults applied, transforms run (ADR-0122). */