Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/named-import-mappings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@objectstack/rest': minor
'@objectstack/spec': minor
---

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.
4 changes: 2 additions & 2 deletions content/docs/references/api/metadata.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -314,7 +314,7 @@ Metadata query with filtering, sorting, and pagination

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **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 |
| **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 |
| **namespaces** | `string[]` | optional | Filter by namespaces |
| **packageId** | `string` | optional | Filter by owning package |
| **search** | `string` | optional | Full-text search query |
Expand Down Expand Up @@ -349,7 +349,7 @@ Metadata query with filtering, sorting, and pagination

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **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 |
| **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 |
| **name** | `string` | ✅ | Item name (snake_case) |
| **data** | `Record<string, any>` | ✅ | Metadata payload |
| **namespace** | `string` | optional | Optional namespace |
Expand Down
5 changes: 3 additions & 2 deletions content/docs/references/kernel/metadata-plugin.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ const result = MetadataBulkRegisterRequest.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **event** | `Enum<'metadata.registered' \| 'metadata.updated' \| 'metadata.unregistered' \| 'metadata.validated' \| 'metadata.deployed' \| 'metadata.overlay.applied' \| 'metadata.overlay.removed' \| 'metadata.imported' \| 'metadata.exported'>` | ✅ | Event type |
| **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 |
| **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 |
| **name** | `string` | ✅ | Metadata item name |
| **namespace** | `string` | optional | Namespace |
| **packageId** | `string` | optional | Owning package ID |
Expand All @@ -147,7 +147,7 @@ const result = MetadataBulkRegisterRequest.parse(data);

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **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 |
| **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 |
| **namespaces** | `string[]` | optional | Filter by namespaces |
| **packageId** | `string` | optional | Filter by owning package |
| **search** | `string` | optional | Full-text search query |
Expand Down Expand Up @@ -185,6 +185,7 @@ const result = MetadataBulkRegisterRequest.parse(data);
* `validation`
* `hook`
* `seed`
* `mapping`
* `view`
* `page`
* `dashboard`
Expand Down
4 changes: 4 additions & 0 deletions examples/app-showcase/objectstack.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import { allPortals } from './src/ui/portals/index.js';
import { ShowcaseSeedData } from './src/data/seed/index.js';
import { allCubes } from './src/data/analytics/showcase.cube.js';
import { allObjectExtensions } from './src/data/extensions/account.extension.js';
import { allMappings } from './src/data/mappings/index.js';

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

// UI
apps: [ShowcaseApp],
Expand Down
12 changes: 6 additions & 6 deletions examples/app-showcase/src/coverage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,12 @@ export const KIND_COVERAGE: Record<MetadataType, KindCoverage> = {
},
hook: { status: 'demonstrated', files: ['src/data/hooks/index.ts'] },
seed: { status: 'demonstrated', files: ['src/data/seed/index.ts'] },
mapping: {
status: 'demonstrated',
files: ['src/data/mappings/index.ts'],
notes:
'Named import mapping resolved via mappingName at POST /data/:object/import (#2611); promoted to a registry kind per the ADR-0088 admission test.',
},

// ── ui ──
view: { status: 'demonstrated', files: ['src/ui/views/task.view.ts', 'src/ui/views/project.view.ts'] },
Expand Down Expand Up @@ -165,12 +171,6 @@ export const STACK_COLLECTION_COVERAGE: Record<string, KindCoverage> = {
notes:
'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).',
},
mappings: {
status: 'waived',
reason:
'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.',
issue: 'https://github.com/objectstack-ai/framework/issues/2611',
},
connectors: {
status: 'waived',
reason:
Expand Down
41 changes: 41 additions & 0 deletions examples/app-showcase/src/data/mappings/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { defineMapping } from '@objectstack/spec';

/**
* Named import mapping (#2611) — the reusable, governed counterpart of the
* import wizard's one-off inline rename. This one models the classic
* enterprise case: a marketing tool exports leads weekly with ITS column
* names and ITS channel codes; the mapping is defined once, ships with the
* package, and every import (human or scheduled job) references it by name:
*
* POST /api/v1/data/showcase_inquiry/import
* { "format": "csv", "csv": "...", "mappingName": "showcase_inquiry_feed" }
*
* mode/upsertKey make re-importing the same file idempotent (dedupe on
* email) — no writeMode/matchFields needed on the request. The `map`
* transform translates the source system's channel codes; the import
* pipeline's own coercion + reference resolution still run afterwards.
*/
export const InquiryFeedMapping = defineMapping({
name: 'showcase_inquiry_feed',
label: 'Inquiry feed (marketing CSV)',
sourceFormat: 'csv',
targetObject: 'showcase_inquiry',
fieldMapping: [
{ source: 'Full Name', target: 'name' },
{ source: 'E-mail', target: 'email' },
{ source: 'Company', target: 'company' },
{ source: 'Message', target: 'message' },
{
source: 'Channel',
target: 'source',
transform: 'map',
params: { valueMap: { Webform: 'website', 'Partner Referral': 'referral' } },
},
],
mode: 'upsert',
upsertKey: ['email'],
});

export const allMappings = [InquiryFeedMapping];
8 changes: 7 additions & 1 deletion examples/app-showcase/src/docs/showcase_tour_data.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,17 @@ object: showcase_project
name: project_status_flow
```

## Hooks & seed data
## Hooks, seed data & import mappings

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

## Extensions & analytics

Expand Down
27 changes: 27 additions & 0 deletions examples/app-showcase/test/gap-fill.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,33 @@ describe('showcase gap fill — analytics cube', () => {
});
});

describe('showcase gap fill — named import mapping (#2611)', () => {
it('is wired into the stack definition', () => {
const mappings = (stack as { mappings?: Array<{ name: string }> }).mappings ?? [];
expect(mappings.map((m) => m.name)).toContain('showcase_inquiry_feed');
});

it('targets an existing object and its upsertKey fields exist on that object', () => {
const mappings = (stack as { mappings?: Array<{ targetObject: string; upsertKey?: string[]; fieldMapping: Array<{ target: string | string[] }> }> }).mappings ?? [];
const objects = ((stack as { objects?: Array<{ name: string; fields?: Record<string, unknown> }> }).objects ?? []);
for (const m of mappings) {
const target = objects.find((o) => o.name === m.targetObject);
expect(target, `mapping targets missing object '${m.targetObject}'`).toBeDefined();
const fieldNames = new Set(Object.keys(target?.fields ?? {}));
for (const key of m.upsertKey ?? []) {
expect(fieldNames.has(key), `upsertKey '${key}' not a field of '${m.targetObject}'`).toBe(true);
}
// Every mapped target lands on a real field — a typo'd target would
// silently import into nowhere.
for (const entry of m.fieldMapping) {
for (const t of Array.isArray(entry.target) ? entry.target : [entry.target]) {
expect(fieldNames.has(t), `mapped target '${t}' not a field of '${m.targetObject}'`).toBe(true);
}
}
}
});
});

describe('showcase gap fill — declarative api endpoints', () => {
it('is wired into the stack definition', () => {
const apis = (stack as { apis?: Array<{ name: string }> }).apis ?? [];
Expand Down
1 change: 1 addition & 0 deletions packages/metadata-core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export const MetadataTypeSchema = z.enum([
'field',
'validation',
'hook',
'mapping',
'view',
'page',
'dashboard',
Expand Down
107 changes: 107 additions & 0 deletions packages/rest/src/import-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -309,3 +309,110 @@ describe('import route — real engine + protocol integration', () => {
expect(String(res._json.error)).toMatch(/xlsx/i);
});
});

// ---------------------------------------------------------------------------
// Named mapping artifacts (#2611) — `mappingName` resolves a registered
// `mapping` item and applies its fieldMapping pipeline before coercion.
// ---------------------------------------------------------------------------
describe('import route — named mapping artifact (#2611)', () => {
let route: any;
let engine: any;

const TASK_CSV_MAPPING = {
name: 'task_feed_import',
label: 'Task feed import',
sourceFormat: 'csv',
targetObject: 'task',
fieldMapping: [
{ source: 'ID', target: 'id', transform: 'none' },
{ source: 'Task Title', target: 'title', transform: 'none' },
// Source system codes → select LABELS; the built-in metaMap coercion
// then turns the label (高/低) into the storage code (high/low) —
// the artifact transform and the coercion pipeline COMPOSE.
{ source: 'Prio', target: 'priority', transform: 'map', params: { valueMap: { P1: '高', P3: '低' } } },
{ source: 'Assignee', target: 'owner', transform: 'lookup' },
{ source: 'ignored_by_projection', target: 'score', transform: 'constant', params: { value: 5 } },
],
mode: 'upsert',
upsertKey: ['id'],
};

beforeEach(async () => {
({ route, engine } = await boot());
engine.registry.registerItem('mapping', TASK_CSV_MAPPING as any, 'name');
engine.registry.registerItem(
'mapping',
{ ...TASK_CSV_MAPPING, name: 'user_only_mapping', targetObject: 'user' } as any,
'name',
);
engine.registry.registerItem(
'mapping',
{
name: 'task_js_mapping', targetObject: 'task', sourceFormat: 'csv',
fieldMapping: [{ source: 'x', target: 'title', transform: 'javascript' }],
} as any,
'name',
);
engine.registry.registerItem(
'mapping',
{ ...TASK_CSV_MAPPING, name: 'task_json_mapping', sourceFormat: 'json' } as any,
'name',
);
});

it('applies rename + map + constant + lookup, strict projection, artifact upsert defaults', async () => {
const csv = [
'ID,Task Title,Prio,Assignee,Junk Column',
't1,迁移旧数据,P1,张三,DROP-ME',
't2,巡检,P3,李四,DROP-ME-TOO',
].join('\n');
// No writeMode/matchFields in the request — the artifact's
// mode:'upsert' + upsertKey:['id'] apply as defaults.
const res = await call(route, { format: 'csv', csv, mappingName: 'task_feed_import' });
expect(res._json).toMatchObject({ total: 2, ok: 2 });

const one = await engine.findOne('task', { where: { id: 't1' } });
// map: P1→高, then coercion 高→high; lookup: 张三→u1 via metaMap;
// constant: score=5; strict projection: Junk Column never lands.
expect(one).toMatchObject({ title: '迁移旧数据', priority: 'high', owner: 'u1', score: 5 });
expect(one['Junk Column']).toBeUndefined();

// Re-import the same file → artifact upsert semantics update, not dupe.
const res2 = await call(route, { format: 'csv', csv, mappingName: 'task_feed_import' });
expect(res2._json.ok).toBe(2);
const all = await engine.find('task', { where: {} });
expect(all.filter((r: any) => r.id === 't1')).toHaveLength(1);
});

it('404s on an unknown mappingName', async () => {
const res = await call(route, { format: 'csv', csv: 'ID\nx', mappingName: 'nope' });
expect(res._status).toBe(404);
expect(res._json.code).toBe('MAPPING_NOT_FOUND');
});

it('400s when the mapping targets a different object', async () => {
const res = await call(route, { format: 'csv', csv: 'ID\nx', mappingName: 'user_only_mapping' });
expect(res._status).toBe(400);
expect(res._json.code).toBe('MAPPING_TARGET_MISMATCH');
});

it('400s when mappingName and an inline mapping are both provided', async () => {
const res = await call(route, {
format: 'csv', csv: 'ID\nx', mappingName: 'task_feed_import', mapping: { ID: 'id' },
});
expect(res._status).toBe(400);
expect(res._json.code).toBe('CONFLICTING_MAPPING');
});

it('400s on a javascript transform instead of silently skipping it', async () => {
const res = await call(route, { format: 'csv', csv: 'x\n1', mappingName: 'task_js_mapping' });
expect(res._status).toBe(400);
expect(res._json.code).toBe('UNSUPPORTED_TRANSFORM');
});

it('400s when the payload format contradicts the artifact sourceFormat', async () => {
const res = await call(route, { format: 'csv', csv: 'ID\nx', mappingName: 'task_json_mapping' });
expect(res._status).toBe(400);
expect(res._json.code).toBe('MAPPING_FORMAT_MISMATCH');
});
});
66 changes: 66 additions & 0 deletions packages/rest/src/import-mapping.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect } from 'vitest';
import { applyMappingToRows, type MappingArtifactLike } from './import-mapping';

const artifact = (fieldMapping: MappingArtifactLike['fieldMapping']): MappingArtifactLike => ({
name: 'm', targetObject: 'o', fieldMapping,
});

describe('applyMappingToRows — transform semantics', () => {
it('none renames; output is a strict projection (unmapped columns drop)', () => {
const r = applyMappingToRows(
[{ A: '1', Junk: 'x' }],
artifact([{ source: 'A', target: 'a', transform: 'none' }]),
);
expect(r).toEqual({ ok: true, rows: [{ a: '1' }] });
});

it('constant writes params.value regardless of the source cell', () => {
const r = applyMappingToRows(
[{ A: 'whatever' }],
artifact([{ source: 'A', target: 'tier', transform: 'constant', params: { value: 'gold' } }]),
);
expect(r).toEqual({ ok: true, rows: [{ tier: 'gold' }] });
});

it('map translates known values and passes unknown ones through', () => {
const r = applyMappingToRows(
[{ S: 'Open' }, { S: 'Weird' }],
artifact([{ source: 'S', target: 's', transform: 'map', params: { valueMap: { Open: 'draft' } } }]),
);
expect(r).toEqual({ ok: true, rows: [{ s: 'draft' }, { s: 'Weird' }] });
});

it('split fans one column into positional targets (missing parts → undefined)', () => {
const r = applyMappingToRows(
[{ Name: 'John Doe' }, { Name: 'Cher' }],
artifact([{ source: 'Name', target: ['first', 'last'], transform: 'split', params: { separator: ' ' } }]),
);
expect(r).toEqual({ ok: true, rows: [{ first: 'John', last: 'Doe' }, { first: 'Cher', last: undefined }] });
});

it('join concatenates source columns, skipping empties', () => {
const r = applyMappingToRows(
[{ City: 'Berlin', Street: 'Unter den Linden' }, { City: 'Rome', Street: '' }],
artifact([{ source: ['City', 'Street'], target: 'address', transform: 'join', params: { separator: ', ' } }]),
);
expect(r).toEqual({ ok: true, rows: [{ address: 'Berlin, Unter den Linden' }, { address: 'Rome' }] });
});

it('lookup copies the raw value through (metaMap resolves it downstream)', () => {
const r = applyMappingToRows(
[{ Owner: '张三' }],
artifact([{ source: 'Owner', target: 'owner', transform: 'lookup' }]),
);
expect(r).toEqual({ ok: true, rows: [{ owner: '张三' }] });
});

it('rejects an unknown transform loudly', () => {
const r = applyMappingToRows(
[{ A: '1' }],
artifact([{ source: 'A', target: 'a', transform: 'zip' as never }]),
);
expect(r).toMatchObject({ ok: false, status: 400, code: 'UNSUPPORTED_TRANSFORM' });
});
});
Loading