@workbench-kit/field-remap 0.0.1-prototype.0 → 0.0.2-prototype.0.2.11
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 +288 -115
- package/package.json +1 -1
- package/src/domain/abort.ts +29 -0
- package/src/domain/constants.ts +14 -14
- package/src/domain/document/fieldRemapDocument.ts +163 -129
- package/src/domain/document/mappingEdge.ts +136 -136
- package/src/domain/ingest/sourceFieldsFromPlainObject.ts +103 -103
- package/src/domain/ingest/targetSlotsFromPlainObject.ts +41 -41
- package/src/domain/mapping/convertItemEdges.ts +98 -93
- package/src/domain/mapping/dateFormat.ts +102 -102
- package/src/domain/mapping/mappingConflicts.ts +90 -90
- package/src/domain/mapping/mappingOperators.ts +297 -0
- package/src/domain/mapping/objectPathSafety.ts +141 -0
- package/src/domain/mapping/pathUtils.ts +302 -156
- package/src/domain/mapping/resolveMappedValue.ts +98 -96
- package/src/domain/mapping/transformOptions.ts +184 -184
- package/src/domain/mapping/treeUtils.ts +32 -32
- package/src/domain/shapes/conversionDefinition.ts +95 -95
- package/src/domain/shapes/convertMappedInputs.ts +158 -0
- package/src/domain/shapes/convertToShape.ts +205 -187
- package/src/domain/shapes/dataShape.ts +109 -109
- package/src/domain/shapes/projectShapes.ts +116 -0
- package/src/domain/shapes/shapeEdit.ts +114 -0
- package/src/domain/types.ts +242 -182
- package/src/index.ts +197 -137
- package/src/registry/builtinTransforms.ts +243 -243
- package/src/registry/createValueTransformRegistry.ts +235 -194
|
@@ -1,102 +1,102 @@
|
|
|
1
|
-
export type DateParts = { readonly year: string; readonly month: string; readonly day: string };
|
|
2
|
-
|
|
3
|
-
const FORMAT_TOKEN_RE = /YYYY|MM|DD/g;
|
|
4
|
-
|
|
5
|
-
/** Parse a date string with a simple token format (YYYY/MM/DD only). */
|
|
6
|
-
export function parseDateParts(value: string, inputFormat: string): DateParts | undefined {
|
|
7
|
-
const format = inputFormat.trim();
|
|
8
|
-
const raw = value.trim();
|
|
9
|
-
if (!format || !raw) {
|
|
10
|
-
return undefined;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
let year = '';
|
|
14
|
-
let month = '';
|
|
15
|
-
let day = '';
|
|
16
|
-
let cursor = 0;
|
|
17
|
-
|
|
18
|
-
for (let i = 0; i < format.length;) {
|
|
19
|
-
if (format.startsWith('YYYY', i)) {
|
|
20
|
-
year = raw.slice(cursor, cursor + 4);
|
|
21
|
-
if (!/^\d{4}$/.test(year)) {
|
|
22
|
-
return undefined;
|
|
23
|
-
}
|
|
24
|
-
cursor += 4;
|
|
25
|
-
i += 4;
|
|
26
|
-
continue;
|
|
27
|
-
}
|
|
28
|
-
if (format.startsWith('MM', i)) {
|
|
29
|
-
month = raw.slice(cursor, cursor + 2);
|
|
30
|
-
if (!/^\d{2}$/.test(month)) {
|
|
31
|
-
return undefined;
|
|
32
|
-
}
|
|
33
|
-
cursor += 2;
|
|
34
|
-
i += 2;
|
|
35
|
-
continue;
|
|
36
|
-
}
|
|
37
|
-
if (format.startsWith('DD', i)) {
|
|
38
|
-
day = raw.slice(cursor, cursor + 2);
|
|
39
|
-
if (!/^\d{2}$/.test(day)) {
|
|
40
|
-
return undefined;
|
|
41
|
-
}
|
|
42
|
-
cursor += 2;
|
|
43
|
-
i += 2;
|
|
44
|
-
continue;
|
|
45
|
-
}
|
|
46
|
-
if (raw[cursor] !== format[i]) {
|
|
47
|
-
return undefined;
|
|
48
|
-
}
|
|
49
|
-
cursor += 1;
|
|
50
|
-
i += 1;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
if (cursor !== raw.length || !year || !month || !day) {
|
|
54
|
-
return undefined;
|
|
55
|
-
}
|
|
56
|
-
return { year, month, day };
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export function formatDateParts(parts: DateParts, outputFormat: string): string {
|
|
60
|
-
return outputFormat.replace(FORMAT_TOKEN_RE, (token) => {
|
|
61
|
-
if (token === 'YYYY') {
|
|
62
|
-
return parts.year;
|
|
63
|
-
}
|
|
64
|
-
if (token === 'MM') {
|
|
65
|
-
return parts.month;
|
|
66
|
-
}
|
|
67
|
-
return parts.day;
|
|
68
|
-
});
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
export function reformatDateString(
|
|
72
|
-
value: string,
|
|
73
|
-
inputFormat: string,
|
|
74
|
-
outputFormat: string,
|
|
75
|
-
): string | undefined {
|
|
76
|
-
const parts = parseDateParts(value, inputFormat);
|
|
77
|
-
if (!parts) {
|
|
78
|
-
return undefined;
|
|
79
|
-
}
|
|
80
|
-
return formatDateParts(parts, outputFormat);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/** Split `YYYY-MM-DDTHH:mm:ss` / `YYYY-MM-DD HH:mm:ss` / date-only. */
|
|
84
|
-
export function splitDateTimeString(value: string): { date: string; time: string } | undefined {
|
|
85
|
-
const raw = value.trim();
|
|
86
|
-
if (!raw) {
|
|
87
|
-
return undefined;
|
|
88
|
-
}
|
|
89
|
-
const tIndex = raw.indexOf('T');
|
|
90
|
-
const spaceIndex = raw.indexOf(' ');
|
|
91
|
-
const splitAt = tIndex >= 0 ? tIndex : spaceIndex;
|
|
92
|
-
if (splitAt < 0) {
|
|
93
|
-
if (/^\d{4}-\d{2}-\d{2}$/.test(raw) || /^\d{8}$/.test(raw)) {
|
|
94
|
-
return { date: raw, time: '' };
|
|
95
|
-
}
|
|
96
|
-
return undefined;
|
|
97
|
-
}
|
|
98
|
-
return {
|
|
99
|
-
date: raw.slice(0, splitAt),
|
|
100
|
-
time: raw.slice(splitAt + 1),
|
|
101
|
-
};
|
|
102
|
-
}
|
|
1
|
+
export type DateParts = { readonly year: string; readonly month: string; readonly day: string };
|
|
2
|
+
|
|
3
|
+
const FORMAT_TOKEN_RE = /YYYY|MM|DD/g;
|
|
4
|
+
|
|
5
|
+
/** Parse a date string with a simple token format (YYYY/MM/DD only). */
|
|
6
|
+
export function parseDateParts(value: string, inputFormat: string): DateParts | undefined {
|
|
7
|
+
const format = inputFormat.trim();
|
|
8
|
+
const raw = value.trim();
|
|
9
|
+
if (!format || !raw) {
|
|
10
|
+
return undefined;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
let year = '';
|
|
14
|
+
let month = '';
|
|
15
|
+
let day = '';
|
|
16
|
+
let cursor = 0;
|
|
17
|
+
|
|
18
|
+
for (let i = 0; i < format.length;) {
|
|
19
|
+
if (format.startsWith('YYYY', i)) {
|
|
20
|
+
year = raw.slice(cursor, cursor + 4);
|
|
21
|
+
if (!/^\d{4}$/.test(year)) {
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
cursor += 4;
|
|
25
|
+
i += 4;
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
if (format.startsWith('MM', i)) {
|
|
29
|
+
month = raw.slice(cursor, cursor + 2);
|
|
30
|
+
if (!/^\d{2}$/.test(month)) {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
cursor += 2;
|
|
34
|
+
i += 2;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (format.startsWith('DD', i)) {
|
|
38
|
+
day = raw.slice(cursor, cursor + 2);
|
|
39
|
+
if (!/^\d{2}$/.test(day)) {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
cursor += 2;
|
|
43
|
+
i += 2;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (raw[cursor] !== format[i]) {
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
cursor += 1;
|
|
50
|
+
i += 1;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (cursor !== raw.length || !year || !month || !day) {
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
return { year, month, day };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function formatDateParts(parts: DateParts, outputFormat: string): string {
|
|
60
|
+
return outputFormat.replace(FORMAT_TOKEN_RE, (token) => {
|
|
61
|
+
if (token === 'YYYY') {
|
|
62
|
+
return parts.year;
|
|
63
|
+
}
|
|
64
|
+
if (token === 'MM') {
|
|
65
|
+
return parts.month;
|
|
66
|
+
}
|
|
67
|
+
return parts.day;
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function reformatDateString(
|
|
72
|
+
value: string,
|
|
73
|
+
inputFormat: string,
|
|
74
|
+
outputFormat: string,
|
|
75
|
+
): string | undefined {
|
|
76
|
+
const parts = parseDateParts(value, inputFormat);
|
|
77
|
+
if (!parts) {
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
return formatDateParts(parts, outputFormat);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Split `YYYY-MM-DDTHH:mm:ss` / `YYYY-MM-DD HH:mm:ss` / date-only. */
|
|
84
|
+
export function splitDateTimeString(value: string): { date: string; time: string } | undefined {
|
|
85
|
+
const raw = value.trim();
|
|
86
|
+
if (!raw) {
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
const tIndex = raw.indexOf('T');
|
|
90
|
+
const spaceIndex = raw.indexOf(' ');
|
|
91
|
+
const splitAt = tIndex >= 0 ? tIndex : spaceIndex;
|
|
92
|
+
if (splitAt < 0) {
|
|
93
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(raw) || /^\d{8}$/.test(raw)) {
|
|
94
|
+
return { date: raw, time: '' };
|
|
95
|
+
}
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
date: raw.slice(0, splitAt),
|
|
100
|
+
time: raw.slice(splitAt + 1),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
@@ -1,90 +1,90 @@
|
|
|
1
|
-
import type { MappingEdge, SourceField, TargetSlot } from '../types.js';
|
|
2
|
-
import { flattenSourceFields, flattenTargetSlots } from './treeUtils.js';
|
|
3
|
-
|
|
4
|
-
export interface MappingConflict {
|
|
5
|
-
readonly kind: 'parent-child-source' | 'parent-child-target';
|
|
6
|
-
readonly parentId: string;
|
|
7
|
-
readonly childId: string;
|
|
8
|
-
readonly parentEdgeId: string;
|
|
9
|
-
readonly childEdgeId: string;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
function isAncestorId(ancestorId: string, descendantId: string): boolean {
|
|
13
|
-
return (
|
|
14
|
-
descendantId.startsWith(`${ancestorId}.`) || descendantId.startsWith(`${ancestorId}.item.`)
|
|
15
|
-
);
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Detect edges that map both a parent object/array and one of its descendants.
|
|
20
|
-
* Hosts should warn — writing both usually overwrites or double-defines output.
|
|
21
|
-
*/
|
|
22
|
-
export function findParentChildMappingConflicts(
|
|
23
|
-
edges: readonly MappingEdge[],
|
|
24
|
-
sources: readonly SourceField[],
|
|
25
|
-
targets: readonly TargetSlot[],
|
|
26
|
-
): MappingConflict[] {
|
|
27
|
-
const sourceIds = new Set(flattenSourceFields(sources).map((field) => field.id));
|
|
28
|
-
const targetIds = new Set(flattenTargetSlots(targets).map((slot) => slot.id));
|
|
29
|
-
const conflicts: MappingConflict[] = [];
|
|
30
|
-
|
|
31
|
-
for (let i = 0; i < edges.length; i += 1) {
|
|
32
|
-
const a = edges[i]!;
|
|
33
|
-
for (let j = i + 1; j < edges.length; j += 1) {
|
|
34
|
-
const b = edges[j]!;
|
|
35
|
-
if (
|
|
36
|
-
sourceIds.has(a.sourceFieldId) &&
|
|
37
|
-
sourceIds.has(b.sourceFieldId) &&
|
|
38
|
-
isAncestorId(a.sourceFieldId, b.sourceFieldId)
|
|
39
|
-
) {
|
|
40
|
-
conflicts.push({
|
|
41
|
-
kind: 'parent-child-source',
|
|
42
|
-
parentId: a.sourceFieldId,
|
|
43
|
-
childId: b.sourceFieldId,
|
|
44
|
-
parentEdgeId: a.id,
|
|
45
|
-
childEdgeId: b.id,
|
|
46
|
-
});
|
|
47
|
-
} else if (
|
|
48
|
-
sourceIds.has(a.sourceFieldId) &&
|
|
49
|
-
sourceIds.has(b.sourceFieldId) &&
|
|
50
|
-
isAncestorId(b.sourceFieldId, a.sourceFieldId)
|
|
51
|
-
) {
|
|
52
|
-
conflicts.push({
|
|
53
|
-
kind: 'parent-child-source',
|
|
54
|
-
parentId: b.sourceFieldId,
|
|
55
|
-
childId: a.sourceFieldId,
|
|
56
|
-
parentEdgeId: b.id,
|
|
57
|
-
childEdgeId: a.id,
|
|
58
|
-
});
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
if (
|
|
62
|
-
targetIds.has(a.targetSlotId) &&
|
|
63
|
-
targetIds.has(b.targetSlotId) &&
|
|
64
|
-
isAncestorId(a.targetSlotId, b.targetSlotId)
|
|
65
|
-
) {
|
|
66
|
-
conflicts.push({
|
|
67
|
-
kind: 'parent-child-target',
|
|
68
|
-
parentId: a.targetSlotId,
|
|
69
|
-
childId: b.targetSlotId,
|
|
70
|
-
parentEdgeId: a.id,
|
|
71
|
-
childEdgeId: b.id,
|
|
72
|
-
});
|
|
73
|
-
} else if (
|
|
74
|
-
targetIds.has(a.targetSlotId) &&
|
|
75
|
-
targetIds.has(b.targetSlotId) &&
|
|
76
|
-
isAncestorId(b.targetSlotId, a.targetSlotId)
|
|
77
|
-
) {
|
|
78
|
-
conflicts.push({
|
|
79
|
-
kind: 'parent-child-target',
|
|
80
|
-
parentId: b.targetSlotId,
|
|
81
|
-
childId: a.targetSlotId,
|
|
82
|
-
parentEdgeId: b.id,
|
|
83
|
-
childEdgeId: a.id,
|
|
84
|
-
});
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
return conflicts;
|
|
90
|
-
}
|
|
1
|
+
import type { MappingEdge, SourceField, TargetSlot } from '../types.js';
|
|
2
|
+
import { flattenSourceFields, flattenTargetSlots } from './treeUtils.js';
|
|
3
|
+
|
|
4
|
+
export interface MappingConflict {
|
|
5
|
+
readonly kind: 'parent-child-source' | 'parent-child-target';
|
|
6
|
+
readonly parentId: string;
|
|
7
|
+
readonly childId: string;
|
|
8
|
+
readonly parentEdgeId: string;
|
|
9
|
+
readonly childEdgeId: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function isAncestorId(ancestorId: string, descendantId: string): boolean {
|
|
13
|
+
return (
|
|
14
|
+
descendantId.startsWith(`${ancestorId}.`) || descendantId.startsWith(`${ancestorId}.item.`)
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Detect edges that map both a parent object/array and one of its descendants.
|
|
20
|
+
* Hosts should warn — writing both usually overwrites or double-defines output.
|
|
21
|
+
*/
|
|
22
|
+
export function findParentChildMappingConflicts(
|
|
23
|
+
edges: readonly MappingEdge[],
|
|
24
|
+
sources: readonly SourceField[],
|
|
25
|
+
targets: readonly TargetSlot[],
|
|
26
|
+
): MappingConflict[] {
|
|
27
|
+
const sourceIds = new Set(flattenSourceFields(sources).map((field) => field.id));
|
|
28
|
+
const targetIds = new Set(flattenTargetSlots(targets).map((slot) => slot.id));
|
|
29
|
+
const conflicts: MappingConflict[] = [];
|
|
30
|
+
|
|
31
|
+
for (let i = 0; i < edges.length; i += 1) {
|
|
32
|
+
const a = edges[i]!;
|
|
33
|
+
for (let j = i + 1; j < edges.length; j += 1) {
|
|
34
|
+
const b = edges[j]!;
|
|
35
|
+
if (
|
|
36
|
+
sourceIds.has(a.sourceFieldId) &&
|
|
37
|
+
sourceIds.has(b.sourceFieldId) &&
|
|
38
|
+
isAncestorId(a.sourceFieldId, b.sourceFieldId)
|
|
39
|
+
) {
|
|
40
|
+
conflicts.push({
|
|
41
|
+
kind: 'parent-child-source',
|
|
42
|
+
parentId: a.sourceFieldId,
|
|
43
|
+
childId: b.sourceFieldId,
|
|
44
|
+
parentEdgeId: a.id,
|
|
45
|
+
childEdgeId: b.id,
|
|
46
|
+
});
|
|
47
|
+
} else if (
|
|
48
|
+
sourceIds.has(a.sourceFieldId) &&
|
|
49
|
+
sourceIds.has(b.sourceFieldId) &&
|
|
50
|
+
isAncestorId(b.sourceFieldId, a.sourceFieldId)
|
|
51
|
+
) {
|
|
52
|
+
conflicts.push({
|
|
53
|
+
kind: 'parent-child-source',
|
|
54
|
+
parentId: b.sourceFieldId,
|
|
55
|
+
childId: a.sourceFieldId,
|
|
56
|
+
parentEdgeId: b.id,
|
|
57
|
+
childEdgeId: a.id,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (
|
|
62
|
+
targetIds.has(a.targetSlotId) &&
|
|
63
|
+
targetIds.has(b.targetSlotId) &&
|
|
64
|
+
isAncestorId(a.targetSlotId, b.targetSlotId)
|
|
65
|
+
) {
|
|
66
|
+
conflicts.push({
|
|
67
|
+
kind: 'parent-child-target',
|
|
68
|
+
parentId: a.targetSlotId,
|
|
69
|
+
childId: b.targetSlotId,
|
|
70
|
+
parentEdgeId: a.id,
|
|
71
|
+
childEdgeId: b.id,
|
|
72
|
+
});
|
|
73
|
+
} else if (
|
|
74
|
+
targetIds.has(a.targetSlotId) &&
|
|
75
|
+
targetIds.has(b.targetSlotId) &&
|
|
76
|
+
isAncestorId(b.targetSlotId, a.targetSlotId)
|
|
77
|
+
) {
|
|
78
|
+
conflicts.push({
|
|
79
|
+
kind: 'parent-child-target',
|
|
80
|
+
parentId: b.targetSlotId,
|
|
81
|
+
childId: a.targetSlotId,
|
|
82
|
+
parentEdgeId: b.id,
|
|
83
|
+
childEdgeId: a.id,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return conflicts;
|
|
90
|
+
}
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal n→m operators (combine / split) evaluated beside MappingEdge[].
|
|
3
|
+
* Document v2 may persist these via `operators[]`; hosts may also call
|
|
4
|
+
* {@link applyMappingOperators} explicitly without persisting.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
canonicalizeTransformId,
|
|
9
|
+
IDENTITY_TRANSFORM_ID,
|
|
10
|
+
MAX_TRANSFORM_CHAIN,
|
|
11
|
+
} from '../constants.js';
|
|
12
|
+
import { throwIfAborted } from '../abort.js';
|
|
13
|
+
import type {
|
|
14
|
+
CombineMappingOperator,
|
|
15
|
+
MappingOperator,
|
|
16
|
+
SourceField,
|
|
17
|
+
SplitMappingOperator,
|
|
18
|
+
TargetSlot,
|
|
19
|
+
TransformContext,
|
|
20
|
+
ValueTransformRegistry,
|
|
21
|
+
} from '../types.js';
|
|
22
|
+
import { applyTransformChain } from '../../registry/createValueTransformRegistry.js';
|
|
23
|
+
import { findSourceField, findTargetSlot } from './resolveMappedValue.js';
|
|
24
|
+
import { isPlainObject, readObjectPath, writeObjectPath } from './pathUtils.js';
|
|
25
|
+
|
|
26
|
+
/** Max inputs on a combine operator. */
|
|
27
|
+
export const MAX_MAPPING_FAN_IN = 8;
|
|
28
|
+
/** Max outputs on a split operator. */
|
|
29
|
+
export const MAX_MAPPING_FAN_OUT = 8;
|
|
30
|
+
|
|
31
|
+
export type { CombineMappingOperator, MappingOperator, SplitMappingOperator };
|
|
32
|
+
|
|
33
|
+
export type ApplyMappingOperatorsInput = {
|
|
34
|
+
readonly operators: readonly MappingOperator[];
|
|
35
|
+
readonly sources: readonly SourceField[];
|
|
36
|
+
readonly targets: readonly TargetSlot[];
|
|
37
|
+
/** Named input bags keyed by source shape id (same contract as convertToShape). */
|
|
38
|
+
readonly inputs: Readonly<Record<string, unknown>>;
|
|
39
|
+
readonly transforms: ValueTransformRegistry;
|
|
40
|
+
/** Existing nested output to merge into (usually `{}` or convertToShape output). */
|
|
41
|
+
readonly output?: Readonly<Record<string, unknown>>;
|
|
42
|
+
readonly context?: TransformContext;
|
|
43
|
+
readonly signal?: AbortSignal;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export type ApplyMappingOperatorsResult = {
|
|
47
|
+
readonly output: Record<string, unknown>;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export class MappingOperatorError extends Error {
|
|
51
|
+
readonly code = 'mapping_operator_error' as const;
|
|
52
|
+
readonly operatorId: string;
|
|
53
|
+
|
|
54
|
+
constructor(operatorId: string, message: string) {
|
|
55
|
+
super(message);
|
|
56
|
+
this.name = 'MappingOperatorError';
|
|
57
|
+
this.operatorId = operatorId;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function leafKey(field: {
|
|
62
|
+
readonly path?: string;
|
|
63
|
+
readonly label: string;
|
|
64
|
+
readonly id: string;
|
|
65
|
+
}): string {
|
|
66
|
+
const path = field.path?.trim();
|
|
67
|
+
if (path) {
|
|
68
|
+
const parts = path.split('.').filter(Boolean);
|
|
69
|
+
return parts[parts.length - 1] ?? path;
|
|
70
|
+
}
|
|
71
|
+
const idParts = field.id.split('.').filter(Boolean);
|
|
72
|
+
return idParts[idParts.length - 1] ?? field.label;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function outputPathForTarget(slot: TargetSlot): string {
|
|
76
|
+
const path = slot.path?.trim();
|
|
77
|
+
if (path) {
|
|
78
|
+
return path;
|
|
79
|
+
}
|
|
80
|
+
const id = slot.id.trim();
|
|
81
|
+
if (id.includes('.')) {
|
|
82
|
+
const parts = id.split('.').filter(Boolean);
|
|
83
|
+
if (parts.length >= 2) {
|
|
84
|
+
return parts.slice(1).join('.');
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return slot.label.trim() || id;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function readFieldValue(field: SourceField, inputs: Readonly<Record<string, unknown>>): unknown {
|
|
91
|
+
const shapeId = field.shapeId?.trim();
|
|
92
|
+
const bag =
|
|
93
|
+
shapeId && Object.prototype.hasOwnProperty.call(inputs, shapeId)
|
|
94
|
+
? inputs[shapeId]
|
|
95
|
+
: Object.keys(inputs).length === 1
|
|
96
|
+
? inputs[Object.keys(inputs)[0]!]
|
|
97
|
+
: inputs;
|
|
98
|
+
|
|
99
|
+
const path = field.path?.trim();
|
|
100
|
+
if (path) {
|
|
101
|
+
if (shapeId && Object.prototype.hasOwnProperty.call(inputs, shapeId)) {
|
|
102
|
+
return readObjectPath(bag, path);
|
|
103
|
+
}
|
|
104
|
+
const fromBag = readObjectPath(bag, path);
|
|
105
|
+
if (fromBag !== undefined) {
|
|
106
|
+
return fromBag;
|
|
107
|
+
}
|
|
108
|
+
return readObjectPath(inputs, path);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return field.sampleValue;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function sanitizeOperatorTransformIds(ids: readonly string[] | undefined): string[] | undefined {
|
|
115
|
+
const cleaned = ids
|
|
116
|
+
?.map((id) => canonicalizeTransformId(id))
|
|
117
|
+
.filter((id) => id.length > 0 && id !== IDENTITY_TRANSFORM_ID);
|
|
118
|
+
if (!cleaned || cleaned.length === 0) {
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
return cleaned.slice(0, MAX_TRANSFORM_CHAIN);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function validateCombine(operator: CombineMappingOperator): void {
|
|
125
|
+
if (operator.inputFieldIds.length < 2) {
|
|
126
|
+
throw new MappingOperatorError(operator.id, 'combine requires at least 2 inputFieldIds.');
|
|
127
|
+
}
|
|
128
|
+
if (operator.inputFieldIds.length > MAX_MAPPING_FAN_IN) {
|
|
129
|
+
throw new MappingOperatorError(
|
|
130
|
+
operator.id,
|
|
131
|
+
`combine fan-in exceeds MAX_MAPPING_FAN_IN (${MAX_MAPPING_FAN_IN}).`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function validateSplit(operator: SplitMappingOperator): void {
|
|
137
|
+
if (operator.outputSlotIds.length < 2) {
|
|
138
|
+
throw new MappingOperatorError(operator.id, 'split requires at least 2 outputSlotIds.');
|
|
139
|
+
}
|
|
140
|
+
if (operator.outputSlotIds.length > MAX_MAPPING_FAN_OUT) {
|
|
141
|
+
throw new MappingOperatorError(
|
|
142
|
+
operator.id,
|
|
143
|
+
`split fan-out exceeds MAX_MAPPING_FAN_OUT (${MAX_MAPPING_FAN_OUT}).`,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Normalize persisted / host-supplied operators for document v2.
|
|
150
|
+
* Drops malformed entries; clamps fan-in/out and transform chains.
|
|
151
|
+
*/
|
|
152
|
+
export function normalizeMappingOperators(
|
|
153
|
+
operators: readonly MappingOperator[] | undefined,
|
|
154
|
+
): MappingOperator[] | undefined {
|
|
155
|
+
if (!operators || operators.length === 0) {
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const next: MappingOperator[] = [];
|
|
160
|
+
for (const operator of operators) {
|
|
161
|
+
if (!operator || typeof operator !== 'object') {
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
const id = typeof operator.id === 'string' ? operator.id.trim() : '';
|
|
165
|
+
if (!id) {
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (operator.kind === 'combine') {
|
|
170
|
+
const inputFieldIds = (operator.inputFieldIds ?? [])
|
|
171
|
+
.map((fieldId) => (typeof fieldId === 'string' ? fieldId.trim() : ''))
|
|
172
|
+
.filter(Boolean)
|
|
173
|
+
.slice(0, MAX_MAPPING_FAN_IN);
|
|
174
|
+
const outputSlotId =
|
|
175
|
+
typeof operator.outputSlotId === 'string' ? operator.outputSlotId.trim() : '';
|
|
176
|
+
if (inputFieldIds.length < 2 || !outputSlotId) {
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
const combineTransforms = sanitizeOperatorTransformIds(operator.transformIds);
|
|
180
|
+
next.push({
|
|
181
|
+
kind: 'combine',
|
|
182
|
+
id,
|
|
183
|
+
inputFieldIds,
|
|
184
|
+
outputSlotId,
|
|
185
|
+
...(combineTransforms ? { transformIds: combineTransforms } : {}),
|
|
186
|
+
});
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (operator.kind === 'split') {
|
|
191
|
+
const inputFieldId =
|
|
192
|
+
typeof operator.inputFieldId === 'string' ? operator.inputFieldId.trim() : '';
|
|
193
|
+
const outputSlotIds = (operator.outputSlotIds ?? [])
|
|
194
|
+
.map((slotId) => (typeof slotId === 'string' ? slotId.trim() : ''))
|
|
195
|
+
.filter(Boolean)
|
|
196
|
+
.slice(0, MAX_MAPPING_FAN_OUT);
|
|
197
|
+
if (!inputFieldId || outputSlotIds.length < 2) {
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
const splitTransforms = sanitizeOperatorTransformIds(operator.transformIds);
|
|
201
|
+
next.push({
|
|
202
|
+
kind: 'split',
|
|
203
|
+
id,
|
|
204
|
+
inputFieldId,
|
|
205
|
+
outputSlotIds,
|
|
206
|
+
...(splitTransforms ? { transformIds: splitTransforms } : {}),
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return next.length > 0 ? next : undefined;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Evaluate combine/split operators and merge writes into a target-shaped object.
|
|
216
|
+
* Deterministic: operators run in array order; later writes overwrite earlier paths.
|
|
217
|
+
*/
|
|
218
|
+
export async function applyMappingOperators(
|
|
219
|
+
input: ApplyMappingOperatorsInput,
|
|
220
|
+
): Promise<ApplyMappingOperatorsResult> {
|
|
221
|
+
const signal = input.signal ?? input.context?.signal;
|
|
222
|
+
const context: TransformContext = {
|
|
223
|
+
...input.context,
|
|
224
|
+
...(signal ? { signal } : {}),
|
|
225
|
+
};
|
|
226
|
+
throwIfAborted(signal);
|
|
227
|
+
|
|
228
|
+
let output: Record<string, unknown> = input.output ? { ...input.output } : {};
|
|
229
|
+
|
|
230
|
+
for (const operator of input.operators) {
|
|
231
|
+
throwIfAborted(signal);
|
|
232
|
+
|
|
233
|
+
if (operator.kind === 'combine') {
|
|
234
|
+
validateCombine(operator);
|
|
235
|
+
const bag: Record<string, unknown> = {};
|
|
236
|
+
for (const fieldId of operator.inputFieldIds) {
|
|
237
|
+
const field = findSourceField(input.sources, fieldId);
|
|
238
|
+
if (!field) {
|
|
239
|
+
throw new MappingOperatorError(operator.id, `Unknown source field "${fieldId}".`);
|
|
240
|
+
}
|
|
241
|
+
bag[leafKey(field)] = readFieldValue(field, input.inputs);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const target = findTargetSlot(input.targets, operator.outputSlotId);
|
|
245
|
+
if (!target) {
|
|
246
|
+
throw new MappingOperatorError(
|
|
247
|
+
operator.id,
|
|
248
|
+
`Unknown target slot "${operator.outputSlotId}".`,
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const value = await applyTransformChain(
|
|
253
|
+
input.transforms,
|
|
254
|
+
operator.transformIds ?? [],
|
|
255
|
+
bag,
|
|
256
|
+
context,
|
|
257
|
+
);
|
|
258
|
+
output = writeObjectPath(output, outputPathForTarget(target), value);
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
validateSplit(operator);
|
|
263
|
+
const source = findSourceField(input.sources, operator.inputFieldId);
|
|
264
|
+
if (!source) {
|
|
265
|
+
throw new MappingOperatorError(
|
|
266
|
+
operator.id,
|
|
267
|
+
`Unknown source field "${operator.inputFieldId}".`,
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
let current = readFieldValue(source, input.inputs);
|
|
272
|
+
current = await applyTransformChain(
|
|
273
|
+
input.transforms,
|
|
274
|
+
operator.transformIds ?? [],
|
|
275
|
+
current,
|
|
276
|
+
context,
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
if (!isPlainObject(current)) {
|
|
280
|
+
throw new MappingOperatorError(
|
|
281
|
+
operator.id,
|
|
282
|
+
'split requires a plain object source value (after transforms).',
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
for (const slotId of operator.outputSlotIds) {
|
|
287
|
+
const target = findTargetSlot(input.targets, slotId);
|
|
288
|
+
if (!target) {
|
|
289
|
+
throw new MappingOperatorError(operator.id, `Unknown target slot "${slotId}".`);
|
|
290
|
+
}
|
|
291
|
+
const key = leafKey(target);
|
|
292
|
+
output = writeObjectPath(output, outputPathForTarget(target), current[key]);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return { output };
|
|
297
|
+
}
|