Skip to content

Commit fce8ff4

Browse files
authored
Named import mappings: wire defineMapping into the import path (#2611) (#2629)
1 parent 3962023 commit fce8ff4

19 files changed

Lines changed: 581 additions & 14 deletions
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@objectstack/rest': minor
3+
'@objectstack/spec': minor
4+
---
5+
6+
feat(rest,spec): named import mappings (#2611) — `POST /data/:object/import` accepts `mappingName`, resolving a registered `defineMapping` artifact (stack `mappings:`) and applying its fieldMapping pipeline (rename + constant/map/split/join; lookup delegates to the built-in reference resolution) as a strict projection before coercion. The artifact's `mode`/`upsertKey` serve as writeMode/matchFields defaults; explicit request values win. Errors are loud and specific: `MAPPING_NOT_FOUND`, `MAPPING_TARGET_MISMATCH`, `MAPPING_FORMAT_MISMATCH`, `CONFLICTING_MAPPING` (mutually exclusive with the inline rename), and `UNSUPPORTED_TRANSFORM` for `javascript` (no server-side sandbox — never silently skipped). `defineStack` cross-reference validation now rejects mappings targeting undefined objects and `javascript` transforms at build time.

content/docs/references/api/metadata.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -314,7 +314,7 @@ Metadata query with filtering, sorting, and pagination
314314

315315
| Property | Type | Required | Description |
316316
| :--- | :--- | :--- | :--- |
317-
| **types** | `Enum<'object' \| 'field' \| 'validation' \| 'hook' \| 'seed' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| 'datasource' \| 'external_catalog' \| 'translation' \| 'email_template' \| 'doc' \| 'book' \| 'permission' \| 'profile' \| 'role' \| 'agent' \| 'tool' \| 'skill'>[]` | optional | Filter by metadata types |
317+
| **types** | `Enum<'object' \| 'field' \| 'validation' \| 'hook' \| 'seed' \| 'mapping' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| 'datasource' \| 'external_catalog' \| 'translation' \| 'email_template' \| 'doc' \| 'book' \| 'permission' \| 'profile' \| 'role' \| 'agent' \| 'tool' \| 'skill'>[]` | optional | Filter by metadata types |
318318
| **namespaces** | `string[]` | optional | Filter by namespaces |
319319
| **packageId** | `string` | optional | Filter by owning package |
320320
| **search** | `string` | optional | Full-text search query |
@@ -349,7 +349,7 @@ Metadata query with filtering, sorting, and pagination
349349

350350
| Property | Type | Required | Description |
351351
| :--- | :--- | :--- | :--- |
352-
| **type** | `Enum<'object' \| 'field' \| 'validation' \| 'hook' \| 'seed' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| 'datasource' \| 'external_catalog' \| 'translation' \| 'email_template' \| 'doc' \| 'book' \| 'permission' \| 'profile' \| 'role' \| 'agent' \| 'tool' \| 'skill'>` || Metadata type |
352+
| **type** | `Enum<'object' \| 'field' \| 'validation' \| 'hook' \| 'seed' \| 'mapping' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| 'datasource' \| 'external_catalog' \| 'translation' \| 'email_template' \| 'doc' \| 'book' \| 'permission' \| 'profile' \| 'role' \| 'agent' \| 'tool' \| 'skill'>` || Metadata type |
353353
| **name** | `string` || Item name (snake_case) |
354354
| **data** | `Record<string, any>` || Metadata payload |
355355
| **namespace** | `string` | optional | Optional namespace |

content/docs/references/kernel/metadata-plugin.mdx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ const result = MetadataBulkRegisterRequest.parse(data);
130130
| Property | Type | Required | Description |
131131
| :--- | :--- | :--- | :--- |
132132
| **event** | `Enum<'metadata.registered' \| 'metadata.updated' \| 'metadata.unregistered' \| 'metadata.validated' \| 'metadata.deployed' \| 'metadata.overlay.applied' \| 'metadata.overlay.removed' \| 'metadata.imported' \| 'metadata.exported'>` || Event type |
133-
| **metadataType** | `Enum<'object' \| 'field' \| 'validation' \| 'hook' \| 'seed' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| 'datasource' \| 'external_catalog' \| 'translation' \| 'email_template' \| 'doc' \| 'book' \| 'permission' \| 'profile' \| 'role' \| 'agent' \| 'tool' \| 'skill'>` || Metadata type |
133+
| **metadataType** | `Enum<'object' \| 'field' \| 'validation' \| 'hook' \| 'seed' \| 'mapping' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| 'datasource' \| 'external_catalog' \| 'translation' \| 'email_template' \| 'doc' \| 'book' \| 'permission' \| 'profile' \| 'role' \| 'agent' \| 'tool' \| 'skill'>` || Metadata type |
134134
| **name** | `string` || Metadata item name |
135135
| **namespace** | `string` | optional | Namespace |
136136
| **packageId** | `string` | optional | Owning package ID |
@@ -147,7 +147,7 @@ const result = MetadataBulkRegisterRequest.parse(data);
147147

148148
| Property | Type | Required | Description |
149149
| :--- | :--- | :--- | :--- |
150-
| **types** | `Enum<'object' \| 'field' \| 'validation' \| 'hook' \| 'seed' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| 'datasource' \| 'external_catalog' \| 'translation' \| 'email_template' \| 'doc' \| 'book' \| 'permission' \| 'profile' \| 'role' \| 'agent' \| 'tool' \| 'skill'>[]` | optional | Filter by metadata types |
150+
| **types** | `Enum<'object' \| 'field' \| 'validation' \| 'hook' \| 'seed' \| 'mapping' \| 'view' \| 'page' \| 'dashboard' \| 'app' \| 'action' \| 'report' \| 'dataset' \| 'flow' \| 'job' \| 'datasource' \| 'external_catalog' \| 'translation' \| 'email_template' \| 'doc' \| 'book' \| 'permission' \| 'profile' \| 'role' \| 'agent' \| 'tool' \| 'skill'>[]` | optional | Filter by metadata types |
151151
| **namespaces** | `string[]` | optional | Filter by namespaces |
152152
| **packageId** | `string` | optional | Filter by owning package |
153153
| **search** | `string` | optional | Full-text search query |
@@ -185,6 +185,7 @@ const result = MetadataBulkRegisterRequest.parse(data);
185185
* `validation`
186186
* `hook`
187187
* `seed`
188+
* `mapping`
188189
* `view`
189190
* `page`
190191
* `dashboard`

examples/app-showcase/objectstack.config.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import { allPortals } from './src/ui/portals/index.js';
4141
import { ShowcaseSeedData } from './src/data/seed/index.js';
4242
import { allCubes } from './src/data/analytics/showcase.cube.js';
4343
import { allObjectExtensions } from './src/data/extensions/account.extension.js';
44+
import { allMappings } from './src/data/mappings/index.js';
4445

4546
// Ambient `process` for the env-var overrides below — the showcase tsconfig
4647
// doesn't pull in `@types/node`, but the CLI provides the real `process` at
@@ -164,6 +165,9 @@ export default defineStack({
164165
// (`/api/v1/analytics/*`) — no `requires` token needed; the CLI always
165166
// loads it and registers these cubes (see src/data/analytics/).
166167
analyticsCubes: allCubes,
168+
// Named import mappings (#2611) — resolved by the import endpoint via
169+
// `mappingName` (see src/data/mappings/).
170+
mappings: allMappings,
167171

168172
// UI
169173
apps: [ShowcaseApp],

examples/app-showcase/src/coverage.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,12 @@ export const KIND_COVERAGE: Record<MetadataType, KindCoverage> = {
7474
},
7575
hook: { status: 'demonstrated', files: ['src/data/hooks/index.ts'] },
7676
seed: { status: 'demonstrated', files: ['src/data/seed/index.ts'] },
77+
mapping: {
78+
status: 'demonstrated',
79+
files: ['src/data/mappings/index.ts'],
80+
notes:
81+
'Named import mapping resolved via mappingName at POST /data/:object/import (#2611); promoted to a registry kind per the ADR-0088 admission test.',
82+
},
7783

7884
// ── ui ──
7985
view: { status: 'demonstrated', files: ['src/ui/views/task.view.ts', 'src/ui/views/project.view.ts'] },
@@ -165,12 +171,6 @@ export const STACK_COLLECTION_COVERAGE: Record<string, KindCoverage> = {
165171
notes:
166172
'Declarative ApiEndpoint metadata (object_operation + flow targets), executed by the runtime dispatcher (handleApiEndpoint). Complements the code-mounted endpoint in src/system/server/ (router kind stays waived: code-only).',
167173
},
168-
mappings: {
169-
status: 'waived',
170-
reason:
171-
'defineMapping artifacts are registered but never consumed — the REST import path only accepts an inline per-request mapping, so a stack-level mapping is inert.',
172-
issue: 'https://github.com/objectstack-ai/framework/issues/2611',
173-
},
174174
connectors: {
175175
status: 'waived',
176176
reason:
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { defineMapping } from '@objectstack/spec';
4+
5+
/**
6+
* Named import mapping (#2611) — the reusable, governed counterpart of the
7+
* import wizard's one-off inline rename. This one models the classic
8+
* enterprise case: a marketing tool exports leads weekly with ITS column
9+
* names and ITS channel codes; the mapping is defined once, ships with the
10+
* package, and every import (human or scheduled job) references it by name:
11+
*
12+
* POST /api/v1/data/showcase_inquiry/import
13+
* { "format": "csv", "csv": "...", "mappingName": "showcase_inquiry_feed" }
14+
*
15+
* mode/upsertKey make re-importing the same file idempotent (dedupe on
16+
* email) — no writeMode/matchFields needed on the request. The `map`
17+
* transform translates the source system's channel codes; the import
18+
* pipeline's own coercion + reference resolution still run afterwards.
19+
*/
20+
export const InquiryFeedMapping = defineMapping({
21+
name: 'showcase_inquiry_feed',
22+
label: 'Inquiry feed (marketing CSV)',
23+
sourceFormat: 'csv',
24+
targetObject: 'showcase_inquiry',
25+
fieldMapping: [
26+
{ source: 'Full Name', target: 'name' },
27+
{ source: 'E-mail', target: 'email' },
28+
{ source: 'Company', target: 'company' },
29+
{ source: 'Message', target: 'message' },
30+
{
31+
source: 'Channel',
32+
target: 'source',
33+
transform: 'map',
34+
params: { valueMap: { Webform: 'website', 'Partner Referral': 'referral' } },
35+
},
36+
],
37+
mode: 'upsert',
38+
upsertKey: ['email'],
39+
});
40+
41+
export const allMappings = [InquiryFeedMapping];

examples/app-showcase/src/docs/showcase_tour_data.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,11 +45,17 @@ object: showcase_project
4545
name: project_status_flow
4646
```
4747

48-
## Hooks & seed data
48+
## Hooks, seed data & import mappings
4949

5050
- `src/data/hooks/` — data-layer lifecycle hooks (before/after CRUD).
5151
- `src/data/seed/` — the seed dataset that makes every view render something
5252
real on first boot.
53+
- `src/data/mappings/` — a **named import mapping**: the marketing tool's
54+
CSV columns (`Full Name`, `Channel`, …) map onto `showcase_inquiry` once,
55+
as governed metadata; every import then just passes
56+
`mappingName: "showcase_inquiry_feed"` to
57+
`POST /api/v1/data/showcase_inquiry/import` — re-importing the same file
58+
is idempotent (artifact-declared upsert on email).
5359

5460
## Extensions & analytics
5561

examples/app-showcase/test/gap-fill.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,33 @@ describe('showcase gap fill — analytics cube', () => {
3333
});
3434
});
3535

36+
describe('showcase gap fill — named import mapping (#2611)', () => {
37+
it('is wired into the stack definition', () => {
38+
const mappings = (stack as { mappings?: Array<{ name: string }> }).mappings ?? [];
39+
expect(mappings.map((m) => m.name)).toContain('showcase_inquiry_feed');
40+
});
41+
42+
it('targets an existing object and its upsertKey fields exist on that object', () => {
43+
const mappings = (stack as { mappings?: Array<{ targetObject: string; upsertKey?: string[]; fieldMapping: Array<{ target: string | string[] }> }> }).mappings ?? [];
44+
const objects = ((stack as { objects?: Array<{ name: string; fields?: Record<string, unknown> }> }).objects ?? []);
45+
for (const m of mappings) {
46+
const target = objects.find((o) => o.name === m.targetObject);
47+
expect(target, `mapping targets missing object '${m.targetObject}'`).toBeDefined();
48+
const fieldNames = new Set(Object.keys(target?.fields ?? {}));
49+
for (const key of m.upsertKey ?? []) {
50+
expect(fieldNames.has(key), `upsertKey '${key}' not a field of '${m.targetObject}'`).toBe(true);
51+
}
52+
// Every mapped target lands on a real field — a typo'd target would
53+
// silently import into nowhere.
54+
for (const entry of m.fieldMapping) {
55+
for (const t of Array.isArray(entry.target) ? entry.target : [entry.target]) {
56+
expect(fieldNames.has(t), `mapped target '${t}' not a field of '${m.targetObject}'`).toBe(true);
57+
}
58+
}
59+
}
60+
});
61+
});
62+
3663
describe('showcase gap fill — declarative api endpoints', () => {
3764
it('is wired into the stack definition', () => {
3865
const apis = (stack as { apis?: Array<{ name: string }> }).apis ?? [];

packages/metadata-core/src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export const MetadataTypeSchema = z.enum([
2121
'field',
2222
'validation',
2323
'hook',
24+
'mapping',
2425
'view',
2526
'page',
2627
'dashboard',

packages/rest/src/import-integration.test.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,3 +309,110 @@ describe('import route — real engine + protocol integration', () => {
309309
expect(String(res._json.error)).toMatch(/xlsx/i);
310310
});
311311
});
312+
313+
// ---------------------------------------------------------------------------
314+
// Named mapping artifacts (#2611) — `mappingName` resolves a registered
315+
// `mapping` item and applies its fieldMapping pipeline before coercion.
316+
// ---------------------------------------------------------------------------
317+
describe('import route — named mapping artifact (#2611)', () => {
318+
let route: any;
319+
let engine: any;
320+
321+
const TASK_CSV_MAPPING = {
322+
name: 'task_feed_import',
323+
label: 'Task feed import',
324+
sourceFormat: 'csv',
325+
targetObject: 'task',
326+
fieldMapping: [
327+
{ source: 'ID', target: 'id', transform: 'none' },
328+
{ source: 'Task Title', target: 'title', transform: 'none' },
329+
// Source system codes → select LABELS; the built-in metaMap coercion
330+
// then turns the label (高/低) into the storage code (high/low) —
331+
// the artifact transform and the coercion pipeline COMPOSE.
332+
{ source: 'Prio', target: 'priority', transform: 'map', params: { valueMap: { P1: '高', P3: '低' } } },
333+
{ source: 'Assignee', target: 'owner', transform: 'lookup' },
334+
{ source: 'ignored_by_projection', target: 'score', transform: 'constant', params: { value: 5 } },
335+
],
336+
mode: 'upsert',
337+
upsertKey: ['id'],
338+
};
339+
340+
beforeEach(async () => {
341+
({ route, engine } = await boot());
342+
engine.registry.registerItem('mapping', TASK_CSV_MAPPING as any, 'name');
343+
engine.registry.registerItem(
344+
'mapping',
345+
{ ...TASK_CSV_MAPPING, name: 'user_only_mapping', targetObject: 'user' } as any,
346+
'name',
347+
);
348+
engine.registry.registerItem(
349+
'mapping',
350+
{
351+
name: 'task_js_mapping', targetObject: 'task', sourceFormat: 'csv',
352+
fieldMapping: [{ source: 'x', target: 'title', transform: 'javascript' }],
353+
} as any,
354+
'name',
355+
);
356+
engine.registry.registerItem(
357+
'mapping',
358+
{ ...TASK_CSV_MAPPING, name: 'task_json_mapping', sourceFormat: 'json' } as any,
359+
'name',
360+
);
361+
});
362+
363+
it('applies rename + map + constant + lookup, strict projection, artifact upsert defaults', async () => {
364+
const csv = [
365+
'ID,Task Title,Prio,Assignee,Junk Column',
366+
't1,迁移旧数据,P1,张三,DROP-ME',
367+
't2,巡检,P3,李四,DROP-ME-TOO',
368+
].join('\n');
369+
// No writeMode/matchFields in the request — the artifact's
370+
// mode:'upsert' + upsertKey:['id'] apply as defaults.
371+
const res = await call(route, { format: 'csv', csv, mappingName: 'task_feed_import' });
372+
expect(res._json).toMatchObject({ total: 2, ok: 2 });
373+
374+
const one = await engine.findOne('task', { where: { id: 't1' } });
375+
// map: P1→高, then coercion 高→high; lookup: 张三→u1 via metaMap;
376+
// constant: score=5; strict projection: Junk Column never lands.
377+
expect(one).toMatchObject({ title: '迁移旧数据', priority: 'high', owner: 'u1', score: 5 });
378+
expect(one['Junk Column']).toBeUndefined();
379+
380+
// Re-import the same file → artifact upsert semantics update, not dupe.
381+
const res2 = await call(route, { format: 'csv', csv, mappingName: 'task_feed_import' });
382+
expect(res2._json.ok).toBe(2);
383+
const all = await engine.find('task', { where: {} });
384+
expect(all.filter((r: any) => r.id === 't1')).toHaveLength(1);
385+
});
386+
387+
it('404s on an unknown mappingName', async () => {
388+
const res = await call(route, { format: 'csv', csv: 'ID\nx', mappingName: 'nope' });
389+
expect(res._status).toBe(404);
390+
expect(res._json.code).toBe('MAPPING_NOT_FOUND');
391+
});
392+
393+
it('400s when the mapping targets a different object', async () => {
394+
const res = await call(route, { format: 'csv', csv: 'ID\nx', mappingName: 'user_only_mapping' });
395+
expect(res._status).toBe(400);
396+
expect(res._json.code).toBe('MAPPING_TARGET_MISMATCH');
397+
});
398+
399+
it('400s when mappingName and an inline mapping are both provided', async () => {
400+
const res = await call(route, {
401+
format: 'csv', csv: 'ID\nx', mappingName: 'task_feed_import', mapping: { ID: 'id' },
402+
});
403+
expect(res._status).toBe(400);
404+
expect(res._json.code).toBe('CONFLICTING_MAPPING');
405+
});
406+
407+
it('400s on a javascript transform instead of silently skipping it', async () => {
408+
const res = await call(route, { format: 'csv', csv: 'x\n1', mappingName: 'task_js_mapping' });
409+
expect(res._status).toBe(400);
410+
expect(res._json.code).toBe('UNSUPPORTED_TRANSFORM');
411+
});
412+
413+
it('400s when the payload format contradicts the artifact sourceFormat', async () => {
414+
const res = await call(route, { format: 'csv', csv: 'ID\nx', mappingName: 'task_json_mapping' });
415+
expect(res._status).toBe(400);
416+
expect(res._json.code).toBe('MAPPING_FORMAT_MISMATCH');
417+
});
418+
});

0 commit comments

Comments
 (0)