@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.
@@ -0,0 +1,95 @@
1
+ import {
2
+ createFieldRemapDocument,
3
+ normalizeFieldRemapDocument,
4
+ } from '../document/fieldRemapDocument.js';
5
+ import type { MappingEdge, FieldRemapDocument } from '../types.js';
6
+
7
+ /**
8
+ * Managed conversion between one or more source shapes and a target shape.
9
+ * Edges live in `document`; runtime uses `convertToShape` (not a method on a shape).
10
+ */
11
+ export interface ConversionDefinition {
12
+ readonly id: string;
13
+ readonly label?: string;
14
+ /** One or more managed source shape ids (multi-input). */
15
+ readonly sourceShapeIds: readonly string[];
16
+ readonly targetShapeId: string;
17
+ readonly document: FieldRemapDocument;
18
+ }
19
+
20
+ export interface ConversionRegistry {
21
+ register(conversion: ConversionDefinition): void;
22
+ get(id: string): ConversionDefinition | undefined;
23
+ list(): readonly ConversionDefinition[];
24
+ }
25
+
26
+ export interface DefineConversionInput {
27
+ readonly id: string;
28
+ readonly label?: string;
29
+ readonly sourceShapeIds: readonly string[];
30
+ readonly targetShapeId: string;
31
+ readonly document?: FieldRemapDocument;
32
+ readonly edges?: readonly MappingEdge[];
33
+ }
34
+
35
+ export function defineConversion(input: DefineConversionInput): ConversionDefinition {
36
+ const id = input.id.trim();
37
+ if (!id) {
38
+ throw new Error('ConversionDefinition.id must be a non-empty string.');
39
+ }
40
+ const sourceShapeIds = input.sourceShapeIds.map((item) => item.trim()).filter(Boolean);
41
+ if (sourceShapeIds.length === 0) {
42
+ throw new Error('ConversionDefinition.sourceShapeIds must include at least one shape id.');
43
+ }
44
+ const targetShapeId = input.targetShapeId.trim();
45
+ if (!targetShapeId) {
46
+ throw new Error('ConversionDefinition.targetShapeId must be a non-empty string.');
47
+ }
48
+
49
+ const document = input.document
50
+ ? normalizeFieldRemapDocument(input.document)
51
+ : createFieldRemapDocument(input.edges ?? []);
52
+
53
+ return {
54
+ id,
55
+ ...(input.label?.trim() ? { label: input.label.trim() } : {}),
56
+ sourceShapeIds,
57
+ targetShapeId,
58
+ document,
59
+ };
60
+ }
61
+
62
+ export function createConversionRegistry(
63
+ initial: readonly ConversionDefinition[] = [],
64
+ ): ConversionRegistry {
65
+ const byId = new Map<string, ConversionDefinition>();
66
+
67
+ const api: ConversionRegistry = {
68
+ register(conversion) {
69
+ const defined = defineConversion(conversion);
70
+ byId.set(defined.id, defined);
71
+ },
72
+ get(id) {
73
+ return byId.get(id.trim());
74
+ },
75
+ list() {
76
+ return [...byId.values()];
77
+ },
78
+ };
79
+
80
+ for (const conversion of initial) {
81
+ api.register(conversion);
82
+ }
83
+ return api;
84
+ }
85
+
86
+ /** Replace edges on a conversion (immutable). */
87
+ export function withConversionEdges(
88
+ conversion: ConversionDefinition,
89
+ edges: readonly MappingEdge[],
90
+ ): ConversionDefinition {
91
+ return {
92
+ ...conversion,
93
+ document: createFieldRemapDocument(edges),
94
+ };
95
+ }
@@ -0,0 +1,187 @@
1
+ import type { ConversionDefinition } from './conversionDefinition.js';
2
+ import {
3
+ mergeSourceShapes,
4
+ targetSlotsFromShape,
5
+ type DataShape,
6
+ type DataShapeRegistry,
7
+ } from './dataShape.js';
8
+ import { convertArrayWithItemEdges } from '../mapping/convertItemEdges.js';
9
+ import { isPlainObject, readObjectPath, writeObjectPath } from '../mapping/pathUtils.js';
10
+ import { findSourceField, resolveMappedValue } from '../mapping/resolveMappedValue.js';
11
+ import { findTargetSlot, flattenTargetSlots } from '../mapping/treeUtils.js';
12
+ import type {
13
+ SourceField,
14
+ TargetSlot,
15
+ TransformContext,
16
+ ValueTransformRegistry,
17
+ } from '../types.js';
18
+
19
+ export interface ConvertToShapeInput {
20
+ readonly conversion: ConversionDefinition;
21
+ /** Shape registry, or an explicit list containing at least the referenced shapes. */
22
+ readonly shapes: DataShapeRegistry | readonly DataShape[];
23
+ /**
24
+ * Named input bags keyed by source shape id.
25
+ * Example: `{ order: liveOrder, customer: liveCustomer }`.
26
+ */
27
+ readonly inputs: Readonly<Record<string, unknown>>;
28
+ readonly transforms: ValueTransformRegistry;
29
+ readonly context?: TransformContext;
30
+ }
31
+
32
+ export interface ConvertToShapeSlotResult {
33
+ readonly edgeId: string;
34
+ readonly targetSlotId: string;
35
+ readonly path: string;
36
+ readonly value: unknown;
37
+ }
38
+
39
+ export interface ConvertToShapeResult {
40
+ /** Nested JSON matching target shape paths. */
41
+ readonly output: Record<string, unknown>;
42
+ /** Flat per-slot results (debug / UI panels). */
43
+ readonly slots: readonly ConvertToShapeSlotResult[];
44
+ }
45
+
46
+ function isDataShapeRegistry(
47
+ shapes: DataShapeRegistry | readonly DataShape[],
48
+ ): shapes is DataShapeRegistry {
49
+ return typeof (shapes as DataShapeRegistry).get === 'function';
50
+ }
51
+
52
+ function resolveShape(
53
+ shapes: DataShapeRegistry | readonly DataShape[],
54
+ id: string,
55
+ ): DataShape | undefined {
56
+ if (isDataShapeRegistry(shapes)) {
57
+ return shapes.get(id);
58
+ }
59
+ return shapes.find((shape) => shape.id === id);
60
+ }
61
+
62
+ function outputPathForTarget(slot: TargetSlot): string {
63
+ const path = slot.path?.trim();
64
+ if (path) {
65
+ return path;
66
+ }
67
+ // Prefer last id segment when hosts use dotted ids (`slot.display.timeText`).
68
+ const id = slot.id.trim();
69
+ if (id.includes('.')) {
70
+ const parts = id.split('.').filter(Boolean);
71
+ // Drop a leading registry prefix like `slot` when present with 3+ segments.
72
+ if (parts.length >= 3 && (parts[0] === 'slot' || parts[0] === 'tgt')) {
73
+ return parts.slice(1).join('.');
74
+ }
75
+ if (parts.length >= 2) {
76
+ return parts.slice(1).join('.');
77
+ }
78
+ }
79
+ return slot.label.trim() || id;
80
+ }
81
+
82
+ function readFieldValue(field: SourceField, inputs: Readonly<Record<string, unknown>>): unknown {
83
+ const shapeId = field.shapeId?.trim();
84
+ const bag =
85
+ shapeId && Object.prototype.hasOwnProperty.call(inputs, shapeId)
86
+ ? inputs[shapeId]
87
+ : // Single-bag fallback: use the only input, or the whole inputs record.
88
+ Object.keys(inputs).length === 1
89
+ ? inputs[Object.keys(inputs)[0]!]
90
+ : inputs;
91
+
92
+ const path = field.path?.trim();
93
+ if (path) {
94
+ // When bags are per-shape, paths are relative to that bag.
95
+ // When falling back to the whole inputs record, absolute paths still work.
96
+ if (shapeId && Object.prototype.hasOwnProperty.call(inputs, shapeId)) {
97
+ return readObjectPath(bag, path);
98
+ }
99
+ const fromBag = readObjectPath(bag, path);
100
+ if (fromBag !== undefined) {
101
+ return fromBag;
102
+ }
103
+ // Absolute path into combined inputs (e.g. `order.totalCents`).
104
+ return readObjectPath(inputs, path);
105
+ }
106
+
107
+ return field.sampleValue;
108
+ }
109
+
110
+ /**
111
+ * Apply a conversion to named input bags and build target-shaped JSON.
112
+ *
113
+ * This is the host runtime entry point — not `sourceShape.convert(target, data)`.
114
+ * Multiple source shapes are supported via `inputs[shapeId]`.
115
+ */
116
+ export function convertToShape(input: ConvertToShapeInput): ConvertToShapeResult {
117
+ const sourceShapes: DataShape[] = [];
118
+ for (const shapeId of input.conversion.sourceShapeIds) {
119
+ const shape = resolveShape(input.shapes, shapeId);
120
+ if (!shape) {
121
+ throw new Error(`Unknown source shape "${shapeId}" for conversion "${input.conversion.id}".`);
122
+ }
123
+ if (shape.role === 'target') {
124
+ throw new Error(`Shape "${shapeId}" has role "target" and cannot be used as a source.`);
125
+ }
126
+ sourceShapes.push(shape);
127
+ }
128
+
129
+ const targetShape = resolveShape(input.shapes, input.conversion.targetShapeId);
130
+ if (!targetShape) {
131
+ throw new Error(
132
+ `Unknown target shape "${input.conversion.targetShapeId}" for conversion "${input.conversion.id}".`,
133
+ );
134
+ }
135
+
136
+ const sources = mergeSourceShapes(sourceShapes);
137
+ const targets = targetSlotsFromShape(targetShape);
138
+ const targetLeaves = flattenTargetSlots(targets);
139
+
140
+ let output: Record<string, unknown> = {};
141
+ const slots: ConvertToShapeSlotResult[] = [];
142
+
143
+ for (const edge of input.conversion.document.edges) {
144
+ const sourceField = findSourceField(sources, edge.sourceFieldId);
145
+ if (!sourceField) {
146
+ continue;
147
+ }
148
+ const targetSlot =
149
+ findTargetSlot(targets, edge.targetSlotId) ??
150
+ targetLeaves.find((slot) => slot.id === edge.targetSlotId);
151
+ if (!targetSlot) {
152
+ continue;
153
+ }
154
+
155
+ const sourceValue = readFieldValue(sourceField, input.inputs);
156
+ const value =
157
+ edge.itemEdges && edge.itemEdges.length > 0
158
+ ? convertArrayWithItemEdges({
159
+ items: sourceValue,
160
+ itemEdges: edge.itemEdges,
161
+ sources,
162
+ targets,
163
+ transforms: input.transforms,
164
+ context: input.context,
165
+ })
166
+ : resolveMappedValue(edge, sourceValue, input.transforms, {
167
+ ...input.context,
168
+ sampleValue: sourceValue,
169
+ record: isPlainObject(sourceValue)
170
+ ? sourceValue
171
+ : isPlainObject(input.context?.record)
172
+ ? input.context.record
173
+ : undefined,
174
+ });
175
+
176
+ const path = outputPathForTarget(targetSlot);
177
+ output = writeObjectPath(output, path, value);
178
+ slots.push({
179
+ edgeId: edge.id,
180
+ targetSlotId: edge.targetSlotId,
181
+ path,
182
+ value,
183
+ });
184
+ }
185
+
186
+ return { output, slots };
187
+ }
@@ -0,0 +1,109 @@
1
+ import type { SourceField, TargetSlot } from '../types.js';
2
+
3
+ /** How a managed shape is used in conversions. */
4
+ export type DataShapeRole = 'source' | 'target' | 'both';
5
+
6
+ /**
7
+ * Managed structure descriptor ("class-like" fixed shape).
8
+ * Source/target field trees are host-owned; this wraps them for registry lookup.
9
+ */
10
+ export interface DataShape {
11
+ readonly id: string;
12
+ readonly label: string;
13
+ readonly role: DataShapeRole;
14
+ /**
15
+ * Field tree for source and/or target use.
16
+ * When `role` is `target`, treat as `TargetSlot[]` (compatible structural shape).
17
+ * When `role` is `source` or `both`, treat as `SourceField[]`.
18
+ */
19
+ readonly fields: readonly SourceField[] | readonly TargetSlot[];
20
+ }
21
+
22
+ export interface DataShapeRegistry {
23
+ register(shape: DataShape): void;
24
+ get(id: string): DataShape | undefined;
25
+ list(role?: DataShapeRole): readonly DataShape[];
26
+ }
27
+
28
+ export function defineDataShape(input: DataShape): DataShape {
29
+ const id = input.id.trim();
30
+ if (!id) {
31
+ throw new Error('DataShape.id must be a non-empty string.');
32
+ }
33
+ return {
34
+ id,
35
+ label: input.label.trim() || id,
36
+ role: input.role,
37
+ fields: input.fields,
38
+ };
39
+ }
40
+
41
+ export function createDataShapeRegistry(initial: readonly DataShape[] = []): DataShapeRegistry {
42
+ const byId = new Map<string, DataShape>();
43
+
44
+ const api: DataShapeRegistry = {
45
+ register(shape) {
46
+ const defined = defineDataShape(shape);
47
+ byId.set(defined.id, defined);
48
+ },
49
+ get(id) {
50
+ return byId.get(id.trim());
51
+ },
52
+ list(role) {
53
+ const all = [...byId.values()];
54
+ if (!role) {
55
+ return all;
56
+ }
57
+ return all.filter((shape) => shape.role === role || shape.role === 'both');
58
+ },
59
+ };
60
+
61
+ for (const shape of initial) {
62
+ api.register(shape);
63
+ }
64
+ return api;
65
+ }
66
+
67
+ /** Recursively stamp `shapeId` on source fields (for multi-input convert). */
68
+ export function attachShapeIdToSourceFields(
69
+ fields: readonly SourceField[],
70
+ shapeId: string,
71
+ ): SourceField[] {
72
+ return fields.map((field) => {
73
+ const next: SourceField = {
74
+ ...field,
75
+ shapeId,
76
+ ...(field.children ? { children: attachShapeIdToSourceFields(field.children, shapeId) } : {}),
77
+ };
78
+ return next;
79
+ });
80
+ }
81
+
82
+ /**
83
+ * Merge source shapes into one tree for `FieldRemap` / convert:
84
+ * each shape becomes a non-mappable group whose children carry `shapeId`.
85
+ */
86
+ export function mergeSourceShapes(shapes: readonly DataShape[]): SourceField[] {
87
+ const sources: SourceField[] = [];
88
+ for (const shape of shapes) {
89
+ if (shape.role === 'target') {
90
+ continue;
91
+ }
92
+ const children = attachShapeIdToSourceFields(shape.fields as readonly SourceField[], shape.id);
93
+ sources.push({
94
+ id: `shape.${shape.id}`,
95
+ label: shape.label,
96
+ group: 'Shapes',
97
+ children,
98
+ });
99
+ }
100
+ return sources;
101
+ }
102
+
103
+ /** Target slots from a target (or both) shape. */
104
+ export function targetSlotsFromShape(shape: DataShape): TargetSlot[] {
105
+ if (shape.role === 'source') {
106
+ throw new Error(`DataShape "${shape.id}" has role "source" and cannot provide target slots.`);
107
+ }
108
+ return shape.fields as TargetSlot[];
109
+ }
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Shared field / slot / edge types for field-remap UIs.
3
+ */
4
+
5
+ export type FieldDataType =
6
+ | 'string'
7
+ | 'number'
8
+ | 'boolean'
9
+ | 'date'
10
+ | 'time'
11
+ | 'datetime'
12
+ /**
13
+ * Plain object (record) — e.g. for `string:template` object → string.
14
+ */
15
+ | 'object'
16
+ /**
17
+ * Collection hint — badge + array→array wiring.
18
+ * Optional per-item projection via `MappingEdge.itemSourcePath`.
19
+ */
20
+ | 'array'
21
+ | 'unknown';
22
+
23
+ export interface SourceField {
24
+ readonly id: string;
25
+ readonly label: string;
26
+ /** Dot-path or capability path hint for hosts. */
27
+ readonly path?: string;
28
+ /**
29
+ * Owning managed shape id when fields are merged from multiple source shapes.
30
+ * Used by `convertToShape` to pick the correct named input bag.
31
+ */
32
+ readonly shapeId?: string;
33
+ readonly dataType?: FieldDataType;
34
+ /** Optional sample used by live preview / transform labels. */
35
+ readonly sampleValue?: unknown;
36
+ readonly group?: string;
37
+ readonly children?: readonly SourceField[];
38
+ }
39
+
40
+ export interface TargetSlot {
41
+ readonly id: string;
42
+ readonly label: string;
43
+ /**
44
+ * Dot-path used when assembling nested JSON output via `convertToShape`.
45
+ * When omitted, hosts may fall back to slot id / label.
46
+ */
47
+ readonly path?: string;
48
+ readonly dataType?: FieldDataType;
49
+ readonly required?: boolean;
50
+ readonly description?: string;
51
+ /** Nested slot groups (expand/collapse in the mapper tree). */
52
+ readonly children?: readonly TargetSlot[];
53
+ }
54
+
55
+ export interface MappingEdge {
56
+ readonly id: string;
57
+ readonly sourceFieldId: string;
58
+ readonly targetSlotId: string;
59
+ /**
60
+ * Ordered transform chain (max 3). Empty / omitted means identity.
61
+ * Prefer this over `transformId` for new writers.
62
+ * For arrays: use reduce builtins (`array:join`, `array:first`, …) here after
63
+ * optional item projection / item transforms.
64
+ */
65
+ readonly transformIds?: readonly string[];
66
+ /**
67
+ * Legacy single transform. Prefer `transformIds`.
68
+ * Readers should use `edgeTransformIds` / `normalizeMappingEdge`.
69
+ * `null` / omitted (with no `transformIds`) means identity (pass-through).
70
+ */
71
+ readonly transformId?: string | null;
72
+ /**
73
+ * Per-step options aligned with `transformIds` (index N applies to step N).
74
+ * Prefer this when steps need different bags (e.g. `showSeconds` then `maxLength`).
75
+ */
76
+ readonly transformOptionSteps?: readonly (Readonly<Record<string, unknown>> | undefined)[];
77
+ /**
78
+ * Shared options for all `transformIds` steps (legacy / apply-to-all).
79
+ * Used when `transformOptionSteps` is omitted. Still written as a back-compat
80
+ * summary of step 0 (or the first non-empty step) by `normalizeMappingEdge`.
81
+ */
82
+ readonly transformOptions?: Readonly<Record<string, unknown>>;
83
+ /**
84
+ * When the source is an array of objects, optional dotted path into each item
85
+ * (e.g. `name` or `meta.label`) before the value is written to the target.
86
+ * Omit / empty for whole-array pass-through (or reduce on the full array).
87
+ */
88
+ readonly itemSourcePath?: string;
89
+ /**
90
+ * Ordered per-item transform chain (max 3) applied after `itemSourcePath`
91
+ * projection (or to each element when projecting is a no-op). Empty / omitted
92
+ * means identity per item. Independent of `transformIds` (which run on the
93
+ * whole collection afterward — e.g. reduce).
94
+ */
95
+ readonly itemTransformIds?: readonly string[];
96
+ /**
97
+ * Per-step options aligned with `itemTransformIds`.
98
+ */
99
+ readonly itemTransformOptionSteps?: readonly (Readonly<Record<string, unknown>> | undefined)[];
100
+ /**
101
+ * Shared options for all `itemTransformIds` steps (legacy / apply-to-all).
102
+ * Independent of `transformOptions` / `transformOptionSteps`.
103
+ */
104
+ readonly itemTransformOptions?: Readonly<Record<string, unknown>>;
105
+ /**
106
+ * List-context child bindings (Stedi-style): when source is an array of objects,
107
+ * each element is converted through these edges into a target item object.
108
+ * Child `sourceFieldId` / `targetSlotId` should resolve to item-schema fields
109
+ * (ingest ids like `a.tags.item.name`) whose `path` is item-relative.
110
+ *
111
+ * Takes precedence over `itemSourcePath` / `itemTransformIds` for the outer edge.
112
+ * Nested `itemEdges` on children are ignored (one collection level per edge).
113
+ */
114
+ readonly itemEdges?: readonly MappingEdge[];
115
+ }
116
+
117
+ /**
118
+ * Minimal JSON-serializable mapping document for host persistence.
119
+ * Hosts own schema trees; this document stores the binding graph only.
120
+ */
121
+ export interface FieldRemapDocument {
122
+ readonly version: 1;
123
+ readonly edges: readonly MappingEdge[];
124
+ }
125
+
126
+ export interface TransformContext {
127
+ readonly locale?: string;
128
+ /** Reference instant for time/date presets; defaults to `new Date()`. */
129
+ readonly now?: Date;
130
+ readonly sampleValue?: unknown;
131
+ /**
132
+ * Optional plain object for `string:template` placeholder resolution when the
133
+ * transform input is not itself an object (hosts / demos).
134
+ */
135
+ readonly record?: Readonly<Record<string, unknown>>;
136
+ readonly options?: Readonly<Record<string, unknown>>;
137
+ }
138
+
139
+ /** Declares a host-editable option consumed via `context.options[key]`. */
140
+ export interface TransformOptionField {
141
+ readonly key: string;
142
+ readonly label: string;
143
+ /**
144
+ * - `string` / `number` / `boolean` — single scalar inputs
145
+ * - `stringMap` — key/value row editor for string→string maps (e.g. `codeLabels`),
146
+ * with an optional List / JSON view toggle in `TransformOptionsEditor`
147
+ * - `json` — validated JSON textarea for plain objects (advanced / free-form);
148
+ * drafts commit when parseable and pretty-print on blur
149
+ */
150
+ readonly kind: 'string' | 'number' | 'boolean' | 'stringMap' | 'json';
151
+ }
152
+
153
+ export interface ValueTransformDefinition {
154
+ readonly id: string;
155
+ readonly label: string;
156
+ readonly description?: string;
157
+ readonly category?: string;
158
+ readonly inputTypes?: readonly FieldDataType[];
159
+ readonly outputType?: FieldDataType;
160
+ readonly apply: (value: unknown, context: TransformContext) => unknown;
161
+ /** Optional picker label that includes a live format sample. */
162
+ readonly formatSampleLabel?: (context: TransformContext) => string;
163
+ /**
164
+ * Data-driven option editors (mapped rows / convert panel).
165
+ * Values are stored per step (`transformOptionSteps` / `itemTransformOptionSteps`)
166
+ * or as a shared bag (`transformOptions` / `itemTransformOptions`), or on host
167
+ * `TransformContext.options`.
168
+ */
169
+ readonly optionFields?: readonly TransformOptionField[];
170
+ }
171
+
172
+ export interface ValueTransformListFilter {
173
+ readonly inputType?: FieldDataType;
174
+ readonly category?: string;
175
+ }
176
+
177
+ export interface ValueTransformRegistry {
178
+ list(filter?: ValueTransformListFilter): ValueTransformDefinition[];
179
+ get(id: string): ValueTransformDefinition | undefined;
180
+ apply(id: string, value: unknown, context?: TransformContext): unknown;
181
+ register(definition: ValueTransformDefinition): void;
182
+ }