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). */