Skip to content

Commit 50af6fa

Browse files
fix(rest)!: apply an inline import mapping entry's transform, defaultValue and required (#25540)
<!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4UuDWJsBGQpadCH8JR2xZQ5re)_ Fixes #25491 Clause-②: no (narrowing) — the import doors refuse two request shapes they used to run: an inline mapping entry declaring `transform: 'date_format'` (or a transform name outside the declared set), and an entry marked `required: true` whose column a row leaves blank. Nothing is widened. Metadata-protocol: none (a REST import request's handling; nothing a metadata author writes changes) ## What changes `ImportRequestSchema.mapping` accepts a `FieldMappingEntry[]` whose entries carry `transform`, `defaultValue` and `required`. `prepareImportRequest` (`packages/rest/src/import-prepare.ts`) kept only each entry's column and field and dropped the rest, so the import ran with no transform. Each entry's rules now run on its column after the rows are built and before cell coercion, for every format (csv, json rows, xlsx) and for both doors that parse through this function (the sync import and the async import job): - `trim` / `uppercase` / `lowercase` rewrite a string cell; `none` and `lookup` copy it (a lookup cell is still resolved to an id by the runner's reference resolution, as the named mapping's `lookup` is). - `defaultValue` fills a blank cell (absent, empty, whitespace or one of `nullValues`, judged by core's `isBlank`, the predicate cell coercion uses). - `required: true` with a cell still blank refuses the request `400 MISSING_REQUIRED_FIELD` before any row, on the dry run and the commit alike, naming the column and the rows. - `date_format`, and any name outside the declared set, refuses the request `400 UNSUPPORTED_TRANSFORM` naming the transform. A plain `{ sourceField, targetField }` entry produces no rule and the row is built exactly as before. ## Measured before choosing the route The dispatch asked to reuse what the named-mapping path does. Measured on `origin/main` `dc3e5b9d7b`: the named path (`applyMappingToRows`, `packages/core/src/utils/import-mapping.ts`) implements a different vocabulary, the `mapping` artifact's `none / constant / map / split / join / lookup` (and refuses `javascript`). `trim`, `uppercase`, `lowercase` and `date_format` are implemented nowhere in the tree (`git grep` over `packages`, tests excluded: the only hit is the spec enum), and no path implements an entry's `defaultValue` or `required`. So there was no shared implementation of these to hand the entries to, and this PR adds no second one: the three string transforms live in one table in `import-prepare.ts`; `none` and `lookup` keep the named path's semantics (copy, resolve downstream); the blank test reuses core's `isBlank`. No `packages/spec` change and no file outside `packages/rest`. `date_format` is refused rather than applied: `FieldMappingEntry` carries no format for it, so it has no defined input. A date or datetime target is already parsed as a date by cell coercion, so the remedy is to drop the transform. ## Tests `packages/rest/src/import-inline-mapping-entry.test.ts` drives the real `POST /api/v1/data/:object/import` route (real ObjectQL engine, protocol, sqlite `:memory:`): - the card's reproduction: `uppercase` on the credit-code column passes a `^[0-9A-Z]{18}$` format rule and is stored upper-cased, under `writeMode: upsert` / `matchFields: ['credit_code']`; - `lowercase` on an email column: `ZHOU@X.EXAMPLE.COM` then `zhou@x.example.com` under `matchFields: ['supplier', 'email']` leaves one contact (created 1, then updated 1); - `trim` with `trimWhitespace: false`; `defaultValue` for a blank cell and a missing column; `required` refusal on the dry run and the commit with nothing written; `required` satisfied by a `defaultValue`; `date_format` refused `400 UNSUPPORTED_TRANSFORM`; - control: a plain `{ sourceField, targetField }` mapping (and `transform: 'none'`) stores the cells untouched. Ablation (one-shot, through `scripts/ablation-replace.mjs`, source-resolved suite so no dist leg): flipping the guard `if (entryRules.length > 0) {` to a less-than-zero comparison (so the rules never run) turned 5 of 8 red (uppercase, lowercase, trim, defaultValue, required); the 3 that stay green are the parse-time `date_format` refusal, required-with-default and the control, as expected. Restored: blob equals HEAD, `git diff HEAD` empty. ## Local verification (head `b5a3141e7f`, base `dc3e5b9d7b`) - `pnpm --filter @objectstack/rest exec vitest run --maxWorkers=2 src/import-inline-mapping-entry.test.ts`: 8/8 passed (lock VERDICT command-exit 0). - `pnpm --filter @objectstack/rest exec vitest run --maxWorkers=2` (whole package): Test Files 322 passed, Tests 6616 passed / 344 skipped (VERDICT command-exit 0). Source unchanged since; the later commits touch only the changeset. - `pnpm --filter @objectstack/rest build && pnpm --filter @objectstack/rest typecheck` (tsc + `check:test-typecheck`, which compiles the new test file): VERDICT command-exit 0. - Full dogfood battery (owed by the `!` title and the `(narrowing)` arm), on `b5a3141e7f`: `pnpm --filter '@objectstack/dogfood^...' build && pnpm --filter @objectstack/dogfood test` under the verify lock: VERDICT command-exit 0, Test Files 290 passed / 3 skipped, Tests 2494 passed / 18 skipped. - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran`: 67 derived, 65 run (all exit 0), 2 NOT MEASURED: `pnpm check:dual-build-cjs-loads` (exit 3, PREREQUISITE NOT MET: needs a whole-workspace build) and `pnpm check:type-check-debt` (exit 3: its closure build waits on the verify lock, which a sibling's dogfood run held past the wait budget). Both run in CI. - `pnpm lint`: NOT MEASURED repo-wide (the verify lock was held by a sibling's 28-minute run past three wait budgets); narrowed and declared: `pnpm exec eslint --no-inline-config` over the two changed source files, exit 0. CI's Lint & Repo Gates runs the repo-wide sweep. - `origin/main` moved 11 commits since the base; none touches `packages/rest`, `packages/core/src/utils` or the spec's import schema, and `git merge-tree` against it is clean, so no merge was taken. ## Acceptance notes - A `required` miss refuses the whole request rather than failing only the offending rows: the runner (`packages/core`) has no seam for a pre-failed row, and giving it one is outside this card's file surface. The refusal names every row, so the operator fixes the file once. - The `import-mapping.ts` docblock still describes the inline mapping as a plain rename; it is accurate for the compact record form and for plain entries. --- 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01CxBKycaiUpA1yGx1XyoGqX --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 60d8fd2 commit 50af6fa

5 files changed

Lines changed: 373 additions & 5 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
'@objectstack/rest': patch
3+
---
4+
5+
fix(rest)!: an inline import mapping entry's `transform`, `defaultValue` and `required` are applied, not dropped
6+
7+
`POST /api/v1/data/:object/import`, the async import job and plugin-auth's admin user import (`POST /api/v1/auth/admin/import-users`) all parse their body through the same request parser, which accepts `mapping` as a `FieldMappingEntry[]`, whose entries carry `transform`, `defaultValue` and `required`. The request parser kept only the column each entry names with `sourceField` and the field it names with `targetField` and dropped the rest, so a request asking for `transform: 'uppercase'` validated, ran, and stored the cell unchanged. Each entry's rules now run on its column before the cells are coerced:
8+
9+
- `trim`, `uppercase` and `lowercase` rewrite a string cell. `none` and `lookup` copy it; a `lookup` cell is resolved to a record id by the import's reference resolution, as before.
10+
- `defaultValue` fills a cell that is blank (absent, empty, whitespace or one of the request's `nullValues`).
11+
- `required: true` refuses the request with `400 MISSING_REQUIRED_FIELD` when a row still leaves that column blank, naming the column and the rows. The refusal comes before any row is written, on the dry run and the commit alike.
12+
- `transform: 'date_format'` refuses the request with `400 UNSUPPORTED_TRANSFORM`: an entry carries no date format for it to apply. A cell mapped to a date or datetime field is already parsed as a date, so drop the transform. A transform name outside the declared set is refused the same way.
13+
14+
A plain `{ sourceField, targetField }` entry, and the compact `{ column: field }` record, map columns exactly as before.
15+
16+
Clause-②: no (narrowing) — the import doors (the sync import, the async import job and plugin-auth's admin user import) refuse two request shapes they used to run: an inline mapping entry declaring `transform: 'date_format'` (or a transform name outside the declared set), and an entry marked `required: true` whose column a row leaves blank. Nothing is widened.
17+
18+
Metadata-protocol: none (a REST import request's handling; nothing a metadata author writes changes)
19+
20+
<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition, stored row or stored shape is added, removed, renamed or re-shaped, and no exported declaration is removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. What changes is how the REST import doors handle a request body's inline mapping entries: their declared per-cell rules now run, and two shapes those rules make unanswerable are refused. The remedy is a request edit (drop `date_format`, fill the required cells or give the entry a `defaultValue`), never a rewrite of a package source or a stored row. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). -->
21+
22+
**BREAKING** for a caller that relied on either request running.
23+
24+
- **Before.** An inline entry's `transform`, `defaultValue` and `required` were ignored; the import ran with the rename alone.
25+
- **Now.** They are applied; `date_format` and a blank `required` cell refuse the request with `400`.
26+
- **Fix.** Drop `transform: 'date_format'` from the entry (the date field's own parse still applies), and fill a required column or give its entry a `defaultValue`.

‎content/docs/data-modeling/import-mappings.mdx‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,7 @@ sending both is `400 CONFLICTING_MAPPING`.
198198
| | inline `mapping` | `mappingName` |
199199
|:---|:---|:---|
200200
| Shape | A flat `{ "<source column>": "<field>" }` rename (or a `sourceField`/`targetField` array) | A registered `mapping` artifact |
201-
| Transforms | None — rename only | The `fieldMapping` pipeline |
201+
| Transforms | Per entry of the array form: `transform` (`trim`, `uppercase`, `lowercase`; `none` and `lookup` copy the cell), `defaultValue` for a blank cell, `required`. The flat record is a rename only | The `fieldMapping` pipeline |
202202
| Unmapped columns | **Pass through** to the write path under their own header | **Dropped.** The artifact is a strict projection: only mapped targets survive |
203203
| Lives | In the one request | In a package or in metadata, reusable |
204204

@@ -218,7 +218,8 @@ These are whole-request failures — nothing is written, and there is no per-row
218218
| 400 | `MAPPING_TARGET_MISMATCH` | The artifact's `targetObject` is not the object in the URL. |
219219
| 400 | `MAPPING_FORMAT_UNSUPPORTED` | The artifact declares `sourceFormat: 'xml'` or `'sql'`; the endpoint accepts csv, json and xlsx payloads only. |
220220
| 400 | `MAPPING_FORMAT_MISMATCH` | The declared `sourceFormat` contradicts the payload actually sent. A `csv` mapping does apply to an `xlsx` payload — those rows are tabular in the same way — but a `json` mapping does not. |
221-
| 400 | `UNSUPPORTED_TRANSFORM` | Some entry declares `javascript`, or a transform name the pipeline does not implement. |
221+
| 400 | `UNSUPPORTED_TRANSFORM` | Some artifact entry declares `javascript`, or a transform name the pipeline does not implement; or an inline entry declares `date_format` (it carries no format to apply) or a name outside `none`, `lookup`, `trim`, `uppercase`, `lowercase`. |
222+
| 400 | `MISSING_REQUIRED_FIELD` | An inline entry is marked `required: true` and some row leaves its column blank after its `defaultValue`. The error names the column and the rows; it applies to the dry run and the commit alike. |
222223
| 400 | `CONFLICTING_MAPPING` | Both `mappingName` and an inline `mapping` were supplied. |
223224
| 400 | `INVALID_REQUEST` | No recognizable payload, or `writeMode` resolved to update/upsert with no `matchFields` from either the request or the artifact. |
224225
| 413 | `PAYLOAD_TOO_LARGE` | More than 5,000 rows on the synchronous route. |
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#25491] An inline `FieldMappingEntry[]` mapping's per-cell rules
5+
* (`transform`, `defaultValue`, `required`) are applied by the REAL
6+
* `POST /data/:object/import` route before the cells are coerced: they used to
7+
* be accepted and dropped, leaving only the rename. Driven through a real
8+
* ObjectQL engine + protocol on sqlite `:memory:`, as
9+
* `import-integration.test.ts` is.
10+
*/
11+
12+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
13+
import { ObjectQL } from '@objectstack/objectql';
14+
import { SqlDriver } from '@objectstack/driver-sql';
15+
import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
16+
import { SysMetadataAuditObject, SysMetadataCommitObject, SysMetadataHistoryObject, SysMetadataObject } from '@objectstack/metadata-core';
17+
import { RestServer } from './rest-server';
18+
import { installDefaultOrganization } from './default-organization.testkit.js';
19+
20+
const SUPPLIER = {
21+
name: 'supplier', label: 'Supplier', systemFields: false,
22+
fields: {
23+
id: { name: 'id', type: 'text' as const, primaryKey: true },
24+
name: { name: 'name', type: 'text' as const, label: 'Name' },
25+
country: { name: 'country', type: 'text' as const, label: 'Country' },
26+
credit_code: { name: 'credit_code', type: 'text' as const, label: 'Credit code' },
27+
},
28+
validations: [{
29+
type: 'format' as const, name: 'credit_code_format', field: 'credit_code',
30+
regex: '^[0-9A-Z]{18}$', message: 'Credit code must be 18 digits or capital letters',
31+
}],
32+
};
33+
34+
const CONTACT = {
35+
name: 'contact', label: 'Contact', systemFields: false,
36+
fields: {
37+
id: { name: 'id', type: 'text' as const, primaryKey: true },
38+
supplier: { name: 'supplier', type: 'text' as const, label: 'Supplier' },
39+
email: { name: 'email', type: 'email' as const, label: 'Email' },
40+
name: { name: 'name', type: 'text' as const, label: 'Name' },
41+
},
42+
};
43+
44+
const liveEngines: ObjectQL[] = [];
45+
afterEach(async () => {
46+
while (liveEngines.length) {
47+
try { await liveEngines.pop()?.destroy(); } catch { /* noop */ }
48+
}
49+
});
50+
51+
function makeRes() {
52+
const res: any = {
53+
write: () => true, end: () => {},
54+
header: () => res,
55+
status: (code: number) => { res._status = code; return res; },
56+
json: (body: any) => { res._json = body; return res; },
57+
};
58+
return res;
59+
}
60+
61+
async function boot() {
62+
const engine = new ObjectQL();
63+
liveEngines.push(engine);
64+
engine.registerDriver(new SqlDriver({
65+
client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true,
66+
}), true);
67+
await engine.init();
68+
engine.registry.registerObject(SUPPLIER as any);
69+
engine.registry.registerObject(CONTACT as any);
70+
await engine.syncSchemas();
71+
await installDefaultOrganization(engine);
72+
for (const o of [SysMetadataObject, SysMetadataHistoryObject, SysMetadataAuditObject, SysMetadataCommitObject]) {
73+
if (!engine.registry.getObject(o.name)) engine.registry.registerObject(o as any);
74+
}
75+
const protocol = new ObjectStackProtocolImplementation(engine as any);
76+
const noop = () => {};
77+
const server = { get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, listen: async () => {}, close: async () => {} };
78+
const rest = new RestServer(server as any, protocol as any, { api: { requireAuth: false } } as any);
79+
(rest as any).resolveExecCtx = async () => ({ userId: 'test-user' });
80+
rest.registerRoutes();
81+
const route = rest.getRoutes().find((r: any) => r.method === 'POST' && r.path === '/api/v1/data/:object/import');
82+
expect(route).toBeDefined();
83+
return { engine, route };
84+
}
85+
86+
describe('import route — inline FieldMappingEntry rules (#25491)', () => {
87+
let engine: any;
88+
let route: any;
89+
const call = async (object: string, body: any) => {
90+
const res = makeRes();
91+
await route.handler({ params: { object }, body } as any, res);
92+
return res;
93+
};
94+
95+
beforeEach(async () => {
96+
({ engine, route } = await boot());
97+
});
98+
99+
it('uppercase: a lowercase credit code passes the ^[0-9A-Z]{18}$ rule and is stored upper-cased (the card\'s reproduction)', async () => {
100+
const res = await call('supplier', {
101+
format: 'csv',
102+
csv: '公司全称,国家 / 地区,统一社会信用代码\n示例公司,中国,91000000ma0trial02\n',
103+
mapping: [
104+
{ sourceField: '公司全称', targetField: 'name' },
105+
{ sourceField: '国家 / 地区', targetField: 'country' },
106+
{ sourceField: '统一社会信用代码', targetField: 'credit_code', transform: 'uppercase' },
107+
],
108+
writeMode: 'upsert', matchFields: ['credit_code'],
109+
});
110+
expect(res._json).toMatchObject({ total: 1, ok: 1, errors: 0, created: 1 });
111+
const stored = await engine.find('supplier', {});
112+
expect(stored).toHaveLength(1);
113+
expect(stored[0]).toMatchObject({ name: '示例公司', country: '中国', credit_code: '91000000MA0TRIAL02' });
114+
});
115+
116+
it('lowercase: two spellings of one email merge into one contact under matchFields [supplier, email]', async () => {
117+
const mapping = [
118+
{ sourceField: 'Supplier', targetField: 'supplier' },
119+
{ sourceField: 'Email', targetField: 'email', transform: 'lowercase' },
120+
{ sourceField: 'Name', targetField: 'name' },
121+
];
122+
const first = await call('contact', {
123+
format: 'csv', csv: 'Supplier,Email,Name\ns1,ZHOU@X.EXAMPLE.COM,Zhou\n',
124+
mapping, writeMode: 'upsert', matchFields: ['supplier', 'email'],
125+
});
126+
expect(first._json).toMatchObject({ ok: 1, created: 1 });
127+
const second = await call('contact', {
128+
format: 'csv', csv: 'Supplier,Email,Name\ns1,zhou@x.example.com,Zhou Wei\n',
129+
mapping, writeMode: 'upsert', matchFields: ['supplier', 'email'],
130+
});
131+
expect(second._json).toMatchObject({ ok: 1, created: 0, updated: 1 });
132+
const stored = await engine.find('contact', {});
133+
expect(stored).toHaveLength(1);
134+
expect(stored[0]).toMatchObject({ supplier: 's1', email: 'zhou@x.example.com', name: 'Zhou Wei' });
135+
});
136+
137+
it('trim: strips the cell even when the request turns trimWhitespace off', async () => {
138+
const res = await call('supplier', {
139+
format: 'json', trimWhitespace: false,
140+
rows: [{ N: ' Acme ', C: ' CN ' }],
141+
mapping: [
142+
{ sourceField: 'N', targetField: 'name', transform: 'trim' },
143+
{ sourceField: 'C', targetField: 'country' },
144+
],
145+
});
146+
expect(res._json).toMatchObject({ ok: 1, created: 1 });
147+
const [stored] = await engine.find('supplier', {});
148+
expect(stored).toMatchObject({ name: 'Acme', country: ' CN ' });
149+
});
150+
151+
it('defaultValue: fills a blank cell and a missing column, leaves a filled cell alone', async () => {
152+
const res = await call('supplier', {
153+
format: 'csv', csv: 'Name,Country\nA,\nB,JP\n',
154+
mapping: [
155+
{ sourceField: 'Name', targetField: 'name' },
156+
{ sourceField: 'Country', targetField: 'country', defaultValue: 'CN' },
157+
{ sourceField: 'Code', targetField: 'credit_code', defaultValue: '000000000000000000' },
158+
],
159+
});
160+
expect(res._json).toMatchObject({ ok: 2, created: 2 });
161+
const stored = await engine.find('supplier', { orderBy: [{ field: 'name', order: 'asc' }] });
162+
expect(stored.map((r: any) => [r.name, r.country, r.credit_code])).toEqual([
163+
['A', 'CN', '000000000000000000'],
164+
['B', 'JP', '000000000000000000'],
165+
]);
166+
});
167+
168+
it('required: a blank required cell refuses the request before any row, on the dry run and the commit alike', async () => {
169+
const body = {
170+
format: 'csv', csv: 'Name,Country\nA,CN\nB,\n',
171+
mapping: [
172+
{ sourceField: 'Name', targetField: 'name' },
173+
{ sourceField: 'Country', targetField: 'country', required: true },
174+
],
175+
};
176+
const dry = await call('supplier', { ...body, dryRun: true });
177+
const commit = await call('supplier', body);
178+
for (const res of [dry, commit]) {
179+
expect(res._status).toBe(400);
180+
expect(res._json?.error?.code ?? res._json?.code).toBe('MISSING_REQUIRED_FIELD');
181+
expect(JSON.stringify(res._json)).toContain('row 2');
182+
}
183+
expect(await engine.find('supplier', {})).toHaveLength(0);
184+
});
185+
186+
it('required with a defaultValue is satisfied by the default', async () => {
187+
const res = await call('supplier', {
188+
format: 'csv', csv: 'Name,Country\nB,\n',
189+
mapping: [
190+
{ sourceField: 'Name', targetField: 'name' },
191+
{ sourceField: 'Country', targetField: 'country', required: true, defaultValue: 'CN' },
192+
],
193+
});
194+
expect(res._json).toMatchObject({ ok: 1, created: 1 });
195+
});
196+
197+
it('date_format is refused with 400 UNSUPPORTED_TRANSFORM naming it, and nothing is written', async () => {
198+
const res = await call('supplier', {
199+
format: 'csv', csv: 'Name\nA\n',
200+
mapping: [{ sourceField: 'Name', targetField: 'name', transform: 'date_format' }],
201+
});
202+
expect(res._status).toBe(400);
203+
expect(res._json?.error?.code ?? res._json?.code).toBe('UNSUPPORTED_TRANSFORM');
204+
expect(JSON.stringify(res._json)).toContain('date_format');
205+
expect(await engine.find('supplier', {})).toHaveLength(0);
206+
});
207+
208+
it('control: a plain { sourceField, targetField } array mapping is the rename it always was', async () => {
209+
const res = await call('supplier', {
210+
format: 'csv', csv: 'Name,Country\n Acme ,cn\n', trimWhitespace: false,
211+
mapping: [
212+
{ sourceField: 'Name', targetField: 'name' },
213+
{ sourceField: 'Country', targetField: 'country', transform: 'none' },
214+
],
215+
});
216+
expect(res._json).toMatchObject({ ok: 1, created: 1 });
217+
const [stored] = await engine.find('supplier', {});
218+
expect(stored).toMatchObject({ name: ' Acme ', country: 'cn' });
219+
});
220+
});

‎packages/rest/src/import-mapping.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,11 @@
66
* pipeline to parsed rows.
77
*
88
* Seam with the inline request `mapping`:
9-
* • inline = a plain `{ sourceColumn: targetField }` RENAME for one-off,
10-
* wizard-driven imports — unmapped columns pass through untouched.
9+
* • inline = a `{ sourceColumn: targetField }` rename (or a
10+
* `FieldMappingEntry[]` whose entries may add `transform`
11+
* trim/uppercase/lowercase, `defaultValue` and `required`, applied in
12+
* `import-prepare.ts`) for one-off, wizard-driven imports — unmapped
13+
* columns pass through untouched.
1114
* • artifact = a reusable, governed ETL projection for recurring /
1215
* programmatic imports — the output row contains ONLY the mapped
1316
* targets (a strict projection; source files from external systems

0 commit comments

Comments
 (0)