@workbench-kit/field-remap 0.0.1-prototype.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,115 @@
1
+ # `@workbench-kit/field-remap`
2
+
3
+ Field remap **runtime**: reshape structure A into structure B with mapping edges and `convertToShape`.
4
+
5
+ This package does **not** ship a mapping UI. Hosts adapt a tree or table UI into `MappingEdge[]`
6
+ and call `convertToShape`. The workbench sample (**Field Remap → A → B**) demonstrates a nested
7
+ tree mapper with list context; flat OSS adapters (for example `react-table-mapping`) remain useful
8
+ for leaf-only hosts.
9
+
10
+ ## Install
11
+
12
+ ```powershell
13
+ pnpm add @workbench-kit/field-remap@prototype
14
+ ```
15
+
16
+ ## Capabilities
17
+
18
+ | Pattern | Support |
19
+ | ----------------------------------------- | -------------------------------------------------------------------- |
20
+ | Leaf → leaf rename | Yes |
21
+ | Nested object paths | Yes (`path` + `writeObjectPath`) |
22
+ | Array whole copy | Yes (`identity`) |
23
+ | Array item projection | Yes (`itemSourcePath`) |
24
+ | Array → scalar reduce | Yes (`array:first`, `array:join`) |
25
+ | String format chain | Yes (`string:trim` / `upper` / `lower` / `prefix` / `suffix`, max 3) |
26
+ | Array<object> → Array<object> | Yes (`itemEdges` list context) |
27
+ | Index / wildcard paths | No (P2) |
28
+
29
+ Middle “graph nodes” in the sample UI are just `MappingEdge.transformIds` steps
30
+ (plus optional `transformOptionSteps`), not a separate document type. The workbench
31
+ sample renders them with `@xyflow/react` (source out → transform → target in).
32
+
33
+ ## Quick start
34
+
35
+ ```ts
36
+ import {
37
+ convertToShape,
38
+ createBuiltinValueTransformRegistry,
39
+ defineConversion,
40
+ defineDataShape,
41
+ sourceFieldsFromPlainObject,
42
+ targetSlotsFromPlainObject,
43
+ } from '@workbench-kit/field-remap';
44
+
45
+ const structureA = {
46
+ user_name: 'Ada',
47
+ tags: [{ name: 'math' }, { name: 'computing' }],
48
+ };
49
+
50
+ const shapes = [
51
+ defineDataShape({
52
+ id: 'a',
53
+ label: 'A',
54
+ role: 'source',
55
+ fields: sourceFieldsFromPlainObject(structureA, { idPrefix: 'a' }),
56
+ }),
57
+ defineDataShape({
58
+ id: 'b',
59
+ label: 'B',
60
+ role: 'target',
61
+ fields: targetSlotsFromPlainObject({ name: '', labels: [{ title: '' }] }, { idPrefix: 'b' }),
62
+ }),
63
+ ];
64
+
65
+ const conversion = defineConversion({
66
+ id: 'a→b',
67
+ sourceShapeIds: ['a'],
68
+ targetShapeId: 'b',
69
+ edges: [
70
+ {
71
+ id: 'e-name',
72
+ sourceFieldId: 'a.user_name',
73
+ targetSlotId: 'b.name',
74
+ },
75
+ {
76
+ id: 'e-tags',
77
+ sourceFieldId: 'a.tags',
78
+ targetSlotId: 'b.labels',
79
+ itemEdges: [
80
+ {
81
+ id: 'e-title',
82
+ sourceFieldId: 'a.tags.item.name',
83
+ targetSlotId: 'b.labels.item.title',
84
+ },
85
+ ],
86
+ },
87
+ ],
88
+ });
89
+
90
+ const { output } = convertToShape({
91
+ conversion,
92
+ shapes,
93
+ inputs: { a: structureA },
94
+ transforms: createBuiltinValueTransformRegistry(),
95
+ });
96
+ // { name: 'Ada', labels: [{ title: 'math' }, { title: 'computing' }] }
97
+ ```
98
+
99
+ Hosts may `registry.register()` additional transforms (the sample registers `expr:jsonata` via
100
+ [jsonata](https://jsonata.org/)).
101
+
102
+ ## Layout
103
+
104
+ ```text
105
+ src/
106
+ domain/document/ edges + FieldRemapDocument
107
+ domain/shapes/ DataShape, ConversionDefinition, convertToShape
108
+ domain/ingest/ plain object → fields / slots
109
+ domain/mapping/ path helpers, list context, conflicts
110
+ registry/ ValueTransform registry (identity, array:first, array:join)
111
+ ```
112
+
113
+ ## Stability
114
+
115
+ Published on the npm `@prototype` tag. Prefer the root export; deep paths are unsupported.
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@workbench-kit/field-remap",
3
+ "version": "0.0.1-prototype.0",
4
+ "private": false,
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./src/index.ts"
8
+ },
9
+ "files": [
10
+ "src",
11
+ "!src/**/*.test.ts",
12
+ "!src/**/*.test.tsx",
13
+ "!src/**/*.stories.ts",
14
+ "!src/**/*.stories.tsx"
15
+ ],
16
+ "description": "Field remap runtime: convert structure A into structure B via edges and convertToShape.",
17
+ "publishConfig": {
18
+ "access": "public",
19
+ "tag": "prototype",
20
+ "provenance": true
21
+ },
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/NewChoBo/workbench-kit.git",
25
+ "directory": "packages/field-remap"
26
+ },
27
+ "scripts": {
28
+ "test": "pnpm exec vitest run --config ../../vitest.config.ts src",
29
+ "typecheck": "tsc -p tsconfig.json --noEmit"
30
+ }
31
+ }
@@ -0,0 +1,14 @@
1
+ /** Maximum ordered transforms applied on a single edge. */
2
+ export const MAX_TRANSFORM_CHAIN = 3;
3
+
4
+ /** Built-in pass-through transform id (kept here to avoid import cycles). */
5
+ export const IDENTITY_TRANSFORM_ID = 'identity';
6
+
7
+ /** Legacy id aliases → canonical ids (empty until hosts need migration). */
8
+ export const TRANSFORM_ID_ALIASES: Readonly<Record<string, string>> = {};
9
+
10
+ /** Map a legacy or canonical transform id to its canonical form. */
11
+ export function canonicalizeTransformId(id: string): string {
12
+ const trimmed = id.trim();
13
+ return TRANSFORM_ID_ALIASES[trimmed] ?? trimmed;
14
+ }
@@ -0,0 +1,129 @@
1
+ import { normalizeMappingEdges } from './mappingEdge.js';
2
+ import type { MappingEdge, FieldRemapDocument } from '../types.js';
3
+
4
+ export const FIELD_REMAP_DOCUMENT_VERSION = 1 as const;
5
+
6
+ /** Thrown when parse/deserialize sees a document `version` other than the supported constant. */
7
+ export class UnsupportedFieldRemapDocumentVersionError extends Error {
8
+ readonly version: unknown;
9
+ readonly expectedVersion: typeof FIELD_REMAP_DOCUMENT_VERSION;
10
+
11
+ constructor(version: unknown) {
12
+ super(
13
+ `Unsupported field remap document version ${String(version)}; expected ${FIELD_REMAP_DOCUMENT_VERSION}.`,
14
+ );
15
+ this.name = 'UnsupportedFieldRemapDocumentVersionError';
16
+ this.version = version;
17
+ this.expectedVersion = FIELD_REMAP_DOCUMENT_VERSION;
18
+ }
19
+ }
20
+
21
+ /** Thrown when parse/deserialize receives a value that is not a mapping document. */
22
+ export class InvalidFieldRemapDocumentError extends Error {
23
+ constructor(message: string) {
24
+ super(message);
25
+ this.name = 'InvalidFieldRemapDocumentError';
26
+ }
27
+ }
28
+
29
+ /** Build a versioned, normalized mapping document for host persistence. */
30
+ export function createFieldRemapDocument(edges: readonly MappingEdge[]): FieldRemapDocument {
31
+ return {
32
+ version: FIELD_REMAP_DOCUMENT_VERSION,
33
+ edges: normalizeMappingEdges(edges),
34
+ };
35
+ }
36
+
37
+ /** Normalize edges (including legacy transform id aliases) on a persisted document. */
38
+ export function normalizeFieldRemapDocument(document: FieldRemapDocument): FieldRemapDocument {
39
+ if (document.version !== FIELD_REMAP_DOCUMENT_VERSION) {
40
+ throw new UnsupportedFieldRemapDocumentVersionError(document.version);
41
+ }
42
+ return {
43
+ version: FIELD_REMAP_DOCUMENT_VERSION,
44
+ edges: normalizeMappingEdges(document.edges),
45
+ };
46
+ }
47
+
48
+ /**
49
+ * Stable JSON serialization for persistence / clipboard.
50
+ * Always emits the current document version with normalized edges.
51
+ */
52
+ export function serializeFieldRemapDocument(
53
+ document: FieldRemapDocument | readonly MappingEdge[],
54
+ ): string {
55
+ const doc = Array.isArray(document)
56
+ ? createFieldRemapDocument(document)
57
+ : createFieldRemapDocument((document as FieldRemapDocument).edges);
58
+ return JSON.stringify(doc);
59
+ }
60
+
61
+ /**
62
+ * Parse an unknown JSON value into a normalized `FieldRemapDocument`.
63
+ * Rejects unsupported versions and malformed shapes with typed errors.
64
+ */
65
+ export function parseFieldRemapDocument(input: unknown): FieldRemapDocument {
66
+ if (!input || typeof input !== 'object' || Array.isArray(input)) {
67
+ throw new InvalidFieldRemapDocumentError(
68
+ 'Expected a field remap document object with version and edges.',
69
+ );
70
+ }
71
+
72
+ const record = input as Record<string, unknown>;
73
+ if (!('version' in record)) {
74
+ throw new InvalidFieldRemapDocumentError('Field remap document is missing version.');
75
+ }
76
+ if (record.version !== FIELD_REMAP_DOCUMENT_VERSION) {
77
+ throw new UnsupportedFieldRemapDocumentVersionError(record.version);
78
+ }
79
+ if (!Array.isArray(record.edges)) {
80
+ throw new InvalidFieldRemapDocumentError('Field remap document edges must be an array.');
81
+ }
82
+
83
+ return normalizeFieldRemapDocument({
84
+ version: FIELD_REMAP_DOCUMENT_VERSION,
85
+ edges: record.edges as MappingEdge[],
86
+ });
87
+ }
88
+
89
+ /** JSON.parse + {@link parseFieldRemapDocument}. */
90
+ export function deserializeFieldRemapDocument(json: string): FieldRemapDocument {
91
+ let parsed: unknown;
92
+ try {
93
+ parsed = JSON.parse(json) as unknown;
94
+ } catch (error) {
95
+ const detail = error instanceof Error ? error.message : String(error);
96
+ throw new InvalidFieldRemapDocumentError(`Field remap document JSON is invalid: ${detail}`);
97
+ }
98
+ return parseFieldRemapDocument(parsed);
99
+ }
100
+
101
+ /**
102
+ * Migrate an unknown persisted value to the current {@link FieldRemapDocument}.
103
+ *
104
+ * Hosts should call this (or `parseFieldRemapDocument`) at load time so future
105
+ * document versions can be rewritten here without changing call sites.
106
+ *
107
+ * **v1:** passthrough normalize (legacy transform id aliases rewritten).
108
+ * Future versions: add `case` branches that rewrite into v1 shape, then normalize.
109
+ */
110
+ export function migrateFieldRemapDocument(input: unknown): FieldRemapDocument {
111
+ if (!input || typeof input !== 'object' || Array.isArray(input)) {
112
+ throw new InvalidFieldRemapDocumentError(
113
+ 'Expected a field remap document object with version and edges.',
114
+ );
115
+ }
116
+
117
+ const record = input as Record<string, unknown>;
118
+ if (!('version' in record)) {
119
+ throw new InvalidFieldRemapDocumentError('Field remap document is missing version.');
120
+ }
121
+
122
+ switch (record.version) {
123
+ case FIELD_REMAP_DOCUMENT_VERSION:
124
+ return parseFieldRemapDocument(input);
125
+ // Future: case 2: return parseFieldRemapDocument(migrateV2ToV1(record));
126
+ default:
127
+ throw new UnsupportedFieldRemapDocumentVersionError(record.version);
128
+ }
129
+ }
@@ -0,0 +1,136 @@
1
+ import {
2
+ canonicalizeTransformId,
3
+ IDENTITY_TRANSFORM_ID,
4
+ MAX_TRANSFORM_CHAIN,
5
+ } from '../constants.js';
6
+ import {
7
+ sanitizeOptionRecord,
8
+ sanitizeOptionSteps,
9
+ sharedOptionsFromSteps,
10
+ } from '../mapping/transformOptions.js';
11
+ import type { MappingEdge } from '../types.js';
12
+
13
+ export { MAX_TRANSFORM_CHAIN } from '../constants.js';
14
+
15
+ function sanitizeTransformIds(ids: readonly string[] | undefined): string[] {
16
+ const cleaned = ids
17
+ ?.map((id) => canonicalizeTransformId(id))
18
+ .filter((id) => id.length > 0 && id !== IDENTITY_TRANSFORM_ID);
19
+ if (!cleaned || cleaned.length === 0) {
20
+ return [];
21
+ }
22
+ return cleaned.slice(0, MAX_TRANSFORM_CHAIN);
23
+ }
24
+
25
+ /**
26
+ * Resolve the effective transform chain for an edge.
27
+ * Prefers `transformIds`; falls back to legacy `transformId`.
28
+ * Identity / empty / null → `[]` (pass-through).
29
+ */
30
+ export function edgeTransformIds(edge: MappingEdge): string[] {
31
+ const fromList = sanitizeTransformIds(edge.transformIds);
32
+ if (fromList.length > 0) {
33
+ return fromList;
34
+ }
35
+
36
+ const legacy = edge.transformId ? canonicalizeTransformId(edge.transformId) : '';
37
+ if (legacy && legacy !== IDENTITY_TRANSFORM_ID) {
38
+ return [legacy];
39
+ }
40
+
41
+ return [];
42
+ }
43
+
44
+ /** Per-item transform chain applied after optional `itemSourcePath` projection. */
45
+ export function edgeItemTransformIds(edge: MappingEdge): string[] {
46
+ return sanitizeTransformIds(edge.itemTransformIds);
47
+ }
48
+
49
+ /**
50
+ * Normalize an edge to the `transformIds` model while keeping `transformId`
51
+ * as the first chain step (or `null`) for older hosts.
52
+ *
53
+ * Option bags:
54
+ * - Prefer `transformOptionSteps` when present (aligned to chain length).
55
+ * - Keep `transformOptions` as the first non-empty step (or the legacy shared bag).
56
+ * - Same rules for `itemTransformOptionSteps` / `itemTransformOptions`.
57
+ */
58
+ export function normalizeMappingEdge(edge: MappingEdge): MappingEdge {
59
+ const ids = edgeTransformIds(edge);
60
+ const itemIds = edgeItemTransformIds(edge);
61
+ const itemSourcePath = edge.itemSourcePath?.trim() || undefined;
62
+ const itemEdges =
63
+ edge.itemEdges && edge.itemEdges.length > 0
64
+ ? edge.itemEdges.map((child) => {
65
+ // One collection level — drop nested list contexts on children.
66
+ const { itemEdges: _nested, ...rest } = child;
67
+ return normalizeMappingEdge(rest);
68
+ })
69
+ : undefined;
70
+
71
+ const transformOptionSteps = sanitizeOptionSteps(edge.transformOptionSteps, ids.length);
72
+ const transformOptions =
73
+ sharedOptionsFromSteps(transformOptionSteps) ?? sanitizeOptionRecord(edge.transformOptions);
74
+
75
+ const itemTransformOptionSteps = sanitizeOptionSteps(
76
+ edge.itemTransformOptionSteps,
77
+ itemIds.length,
78
+ );
79
+ const itemTransformOptions =
80
+ sharedOptionsFromSteps(itemTransformOptionSteps) ??
81
+ sanitizeOptionRecord(edge.itemTransformOptions);
82
+
83
+ return {
84
+ id: edge.id,
85
+ sourceFieldId: edge.sourceFieldId,
86
+ targetSlotId: edge.targetSlotId,
87
+ transformIds: ids.length > 0 ? ids : undefined,
88
+ transformId: ids[0] ?? null,
89
+ ...(transformOptionSteps ? { transformOptionSteps } : {}),
90
+ ...(transformOptions ? { transformOptions } : {}),
91
+ ...(itemSourcePath && !itemEdges ? { itemSourcePath } : {}),
92
+ ...(itemIds.length > 0 && !itemEdges ? { itemTransformIds: itemIds } : {}),
93
+ ...(itemTransformOptionSteps && !itemEdges ? { itemTransformOptionSteps } : {}),
94
+ ...(itemTransformOptions && !itemEdges ? { itemTransformOptions } : {}),
95
+ ...(itemEdges ? { itemEdges } : {}),
96
+ };
97
+ }
98
+
99
+ export function normalizeMappingEdges(edges: readonly MappingEdge[]): MappingEdge[] {
100
+ return edges.map(normalizeMappingEdge);
101
+ }
102
+
103
+ /** Build a normalized edge from wire / assign actions. */
104
+ export function createMappingEdge(input: {
105
+ readonly id: string;
106
+ readonly sourceFieldId: string;
107
+ readonly targetSlotId: string;
108
+ readonly transformIds?: readonly string[];
109
+ /** Legacy single id; ignored when `transformIds` is provided. */
110
+ readonly transformId?: string | null;
111
+ readonly transformOptionSteps?: readonly (Readonly<Record<string, unknown>> | undefined)[];
112
+ readonly transformOptions?: Readonly<Record<string, unknown>>;
113
+ /** Optional per-item projection path for array mappings. */
114
+ readonly itemSourcePath?: string;
115
+ /** Optional per-item transform chain after projection. */
116
+ readonly itemTransformIds?: readonly string[];
117
+ readonly itemTransformOptionSteps?: readonly (Readonly<Record<string, unknown>> | undefined)[];
118
+ readonly itemTransformOptions?: Readonly<Record<string, unknown>>;
119
+ /** List-context child edges for array-of-object → array-of-object. */
120
+ readonly itemEdges?: readonly MappingEdge[];
121
+ }): MappingEdge {
122
+ return normalizeMappingEdge({
123
+ id: input.id,
124
+ sourceFieldId: input.sourceFieldId,
125
+ targetSlotId: input.targetSlotId,
126
+ transformIds: input.transformIds,
127
+ transformId: input.transformId,
128
+ transformOptionSteps: input.transformOptionSteps,
129
+ transformOptions: input.transformOptions,
130
+ itemSourcePath: input.itemSourcePath,
131
+ itemTransformIds: input.itemTransformIds,
132
+ itemTransformOptionSteps: input.itemTransformOptionSteps,
133
+ itemTransformOptions: input.itemTransformOptions,
134
+ itemEdges: input.itemEdges,
135
+ });
136
+ }
@@ -0,0 +1,103 @@
1
+ import { isPlainObject } from '../mapping/pathUtils.js';
2
+ import type { FieldDataType, SourceField } from '../types.js';
3
+
4
+ export interface SourceFieldsFromPlainObjectOptions {
5
+ /** Prefix for generated field ids (default `src`). */
6
+ readonly idPrefix?: string;
7
+ /** Max nesting depth for object children (default 4). */
8
+ readonly maxDepth?: number;
9
+ }
10
+
11
+ function inferDataType(value: unknown): FieldDataType {
12
+ if (value === null || value === undefined) {
13
+ return 'unknown';
14
+ }
15
+ if (typeof value === 'string') {
16
+ return 'string';
17
+ }
18
+ if (typeof value === 'number' && Number.isFinite(value)) {
19
+ return 'number';
20
+ }
21
+ if (typeof value === 'boolean') {
22
+ return 'boolean';
23
+ }
24
+ if (value instanceof Date && !Number.isNaN(value.getTime())) {
25
+ return 'datetime';
26
+ }
27
+ if (Array.isArray(value)) {
28
+ return 'array';
29
+ }
30
+ if (isPlainObject(value)) {
31
+ return 'object';
32
+ }
33
+ return 'unknown';
34
+ }
35
+
36
+ function fieldFromEntry(
37
+ key: string,
38
+ value: unknown,
39
+ path: string,
40
+ idPrefix: string,
41
+ depth: number,
42
+ maxDepth: number,
43
+ ): SourceField {
44
+ const id = path ? `${idPrefix}.${path}` : `${idPrefix}.${key}`;
45
+ const dataType = inferDataType(value);
46
+ const base: SourceField = {
47
+ id,
48
+ label: key,
49
+ path,
50
+ dataType,
51
+ sampleValue: value,
52
+ };
53
+
54
+ if (depth >= maxDepth) {
55
+ return base;
56
+ }
57
+
58
+ if (isPlainObject(value)) {
59
+ const children = Object.entries(value).map(([childKey, childValue]) =>
60
+ fieldFromEntry(
61
+ childKey,
62
+ childValue,
63
+ path ? `${path}.${childKey}` : childKey,
64
+ idPrefix,
65
+ depth + 1,
66
+ maxDepth,
67
+ ),
68
+ );
69
+ return children.length > 0 ? { ...base, children } : base;
70
+ }
71
+
72
+ if (Array.isArray(value) && value.length > 0 && isPlainObject(value[0])) {
73
+ const sampleItem = value[0] as Record<string, unknown>;
74
+ const children = Object.entries(sampleItem).map(([childKey, childValue]) =>
75
+ fieldFromEntry(childKey, childValue, childKey, `${id}.item`, depth + 1, maxDepth),
76
+ );
77
+ return children.length > 0 ? { ...base, children } : base;
78
+ }
79
+
80
+ return base;
81
+ }
82
+
83
+ /**
84
+ * Infer a shallow/nested `SourceField` tree from a plain sample object.
85
+ * Useful for demos and hosts that have a sample payload but no formal schema.
86
+ *
87
+ * - Top-level keys become root fields.
88
+ * - Nested plain objects become `children` (up to `maxDepth`).
89
+ * - Arrays of objects expose item-key children for projection pickers.
90
+ */
91
+ export function sourceFieldsFromPlainObject(
92
+ sample: unknown,
93
+ options: SourceFieldsFromPlainObjectOptions = {},
94
+ ): SourceField[] {
95
+ if (!isPlainObject(sample)) {
96
+ return [];
97
+ }
98
+ const idPrefix = options.idPrefix?.trim() || 'src';
99
+ const maxDepth = Math.max(0, options.maxDepth ?? 4);
100
+ return Object.entries(sample).map(([key, value]) =>
101
+ fieldFromEntry(key, value, key, idPrefix, 0, maxDepth),
102
+ );
103
+ }
@@ -0,0 +1,41 @@
1
+ import { sourceFieldsFromPlainObject } from './sourceFieldsFromPlainObject.js';
2
+ import type { SourceField, TargetSlot } from '../types.js';
3
+
4
+ export interface TargetSlotsFromPlainObjectOptions {
5
+ /** Prefix for generated slot ids (default `tgt`). */
6
+ readonly idPrefix?: string;
7
+ /** Max nesting depth for object children (default 4). */
8
+ readonly maxDepth?: number;
9
+ }
10
+
11
+ function sourceFieldToTargetSlot(field: SourceField): TargetSlot {
12
+ const slot: TargetSlot = {
13
+ id: field.id,
14
+ label: field.label,
15
+ ...(field.path ? { path: field.path } : {}),
16
+ ...(field.dataType ? { dataType: field.dataType } : {}),
17
+ };
18
+ if (field.children && field.children.length > 0) {
19
+ return {
20
+ ...slot,
21
+ children: field.children.map(sourceFieldToTargetSlot),
22
+ };
23
+ }
24
+ return slot;
25
+ }
26
+
27
+ /**
28
+ * Infer a nested `TargetSlot` tree from a plain sample object (or target shape).
29
+ * Mirrors {@link sourceFieldsFromPlainObject}: same nesting / array-of-object rules,
30
+ * keeping `path` for `convertToShape` output assembly (no `sampleValue`).
31
+ */
32
+ export function targetSlotsFromPlainObject(
33
+ sample: unknown,
34
+ options: TargetSlotsFromPlainObjectOptions = {},
35
+ ): TargetSlot[] {
36
+ const idPrefix = options.idPrefix?.trim() || 'tgt';
37
+ return sourceFieldsFromPlainObject(sample, {
38
+ idPrefix,
39
+ maxDepth: options.maxDepth,
40
+ }).map(sourceFieldToTargetSlot);
41
+ }
@@ -0,0 +1,93 @@
1
+ import { edgeTransformIds } from '../document/mappingEdge.js';
2
+ import { applyTransformChain } from '../../registry/createValueTransformRegistry.js';
3
+ import { BUILTIN_TRANSFORM_IDS } from '../../registry/builtinTransforms.js';
4
+ import { resolveOptionSteps } from './transformOptions.js';
5
+ import { isPlainObject, readObjectPath, writeObjectPath } from './pathUtils.js';
6
+ import { findTargetSlot, flattenSourceFields, flattenTargetSlots } from './treeUtils.js';
7
+ import type {
8
+ MappingEdge,
9
+ SourceField,
10
+ TargetSlot,
11
+ TransformContext,
12
+ ValueTransformRegistry,
13
+ } from '../types.js';
14
+
15
+ function findSourceField(fields: readonly SourceField[], fieldId: string): SourceField | undefined {
16
+ return flattenSourceFields(fields).find((field) => field.id === fieldId);
17
+ }
18
+
19
+ function itemRelativePath(
20
+ node: { readonly path?: string; readonly label: string; readonly id: string } | undefined,
21
+ ): string {
22
+ if (!node) {
23
+ return '';
24
+ }
25
+ const path = node.path?.trim();
26
+ if (path) {
27
+ return path;
28
+ }
29
+ return node.label.trim() || node.id.split('.').pop() || '';
30
+ }
31
+
32
+ /**
33
+ * Convert each object in a source array through list-context `itemEdges`.
34
+ * Child field paths are treated as item-relative (ingest array children).
35
+ */
36
+ export function convertArrayWithItemEdges(input: {
37
+ readonly items: unknown;
38
+ readonly itemEdges: readonly MappingEdge[];
39
+ readonly sources: readonly SourceField[];
40
+ readonly targets: readonly TargetSlot[];
41
+ readonly transforms: ValueTransformRegistry;
42
+ readonly context?: TransformContext;
43
+ }): unknown[] {
44
+ if (!Array.isArray(input.items)) {
45
+ return [];
46
+ }
47
+
48
+ const targetLeaves = flattenTargetSlots(input.targets);
49
+
50
+ return input.items.map((rawItem) => {
51
+ const item = isPlainObject(rawItem) ? rawItem : {};
52
+ let outItem: Record<string, unknown> = {};
53
+
54
+ for (const edge of input.itemEdges) {
55
+ const sourceField = findSourceField(input.sources, edge.sourceFieldId);
56
+ const targetSlot =
57
+ findTargetSlot(input.targets, edge.targetSlotId) ??
58
+ targetLeaves.find((slot) => slot.id === edge.targetSlotId);
59
+ if (!sourceField || !targetSlot) {
60
+ continue;
61
+ }
62
+
63
+ const sourcePath = itemRelativePath(sourceField);
64
+ const sourceValue = sourcePath ? readObjectPath(item, sourcePath) : undefined;
65
+
66
+ const chain = edgeTransformIds(edge);
67
+ let value: unknown;
68
+ if (chain.length === 0) {
69
+ const identity = input.transforms.get(BUILTIN_TRANSFORM_IDS.identity);
70
+ value = identity
71
+ ? identity.apply(sourceValue, { ...input.context, sampleValue: sourceValue })
72
+ : sourceValue;
73
+ } else {
74
+ const steps = resolveOptionSteps(chain, edge.transformOptionSteps, edge.transformOptions);
75
+ value = applyTransformChain(
76
+ input.transforms,
77
+ chain,
78
+ sourceValue,
79
+ { ...input.context, sampleValue: sourceValue },
80
+ steps,
81
+ );
82
+ }
83
+
84
+ const targetPath = itemRelativePath(targetSlot);
85
+ if (!targetPath) {
86
+ continue;
87
+ }
88
+ outItem = writeObjectPath(outItem, targetPath, value);
89
+ }
90
+
91
+ return outItem;
92
+ });
93
+ }