@workbench-kit/field-remap 0.0.2-prototype.0.2.5 → 0.0.2-prototype.0.2.8

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 CHANGED
@@ -87,7 +87,7 @@ const conversion = defineConversion({
87
87
  ],
88
88
  });
89
89
 
90
- const { output } = convertToShape({
90
+ const { output } = await convertToShape({
91
91
  conversion,
92
92
  shapes,
93
93
  inputs: { a: structureA },
@@ -97,7 +97,17 @@ const { output } = convertToShape({
97
97
  ```
98
98
 
99
99
  Hosts may `registry.register()` additional transforms (the sample registers `expr:jsonata` via
100
- [jsonata](https://jsonata.org/)).
100
+ [jsonata](https://jsonata.org/)). `convertToShape` / `applyTransformChain` are async so Promise-returning
101
+ host transforms (JSONata 2.x) resolve correctly.
102
+
103
+ ### Cancellation
104
+
105
+ Pass `signal` on `convertToShape` (or `TransformContext.signal`) to cancel stale previews.
106
+ Aborted runs reject with `AbortError` and stop further edges / chain steps. The shell Field Remap
107
+ panel wires an `AbortController` to effect cleanup.
108
+
109
+ Host JSONata transforms in `@workbench-kit/shell-react` are bounded by default (`timeoutMs`,
110
+ `maxExpressionLength`, `onError: 'throw'`). Use `createJsonataValueTransform()` to override.
101
111
 
102
112
  ## Layout
103
113
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@workbench-kit/field-remap",
3
- "version": "0.0.2-prototype.0.2.5",
3
+ "version": "0.0.2-prototype.0.2.8",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -0,0 +1,29 @@
1
+ /** Create an Error with `name === 'AbortError'` (DOMException when available). */
2
+ export function createAbortError(message = 'The operation was aborted.'): Error {
3
+ if (typeof DOMException !== 'undefined') {
4
+ return new DOMException(message, 'AbortError');
5
+ }
6
+ const error = new Error(message);
7
+ error.name = 'AbortError';
8
+ return error;
9
+ }
10
+
11
+ export function isAbortError(error: unknown): boolean {
12
+ return (
13
+ (typeof DOMException !== 'undefined' &&
14
+ error instanceof DOMException &&
15
+ error.name === 'AbortError') ||
16
+ (error instanceof Error && error.name === 'AbortError')
17
+ );
18
+ }
19
+
20
+ /** Throw when `signal` is already aborted. */
21
+ export function throwIfAborted(signal?: AbortSignal): void {
22
+ if (!signal?.aborted) {
23
+ return;
24
+ }
25
+ if (signal.reason instanceof Error) {
26
+ throw signal.reason;
27
+ }
28
+ throw createAbortError();
29
+ }
@@ -1,3 +1,4 @@
1
+ import { throwIfAborted } from '../abort.js';
1
2
  import { edgeTransformIds } from '../document/mappingEdge.js';
2
3
  import { applyTransformChain } from '../../registry/createValueTransformRegistry.js';
3
4
  import { BUILTIN_TRANSFORM_IDS } from '../../registry/builtinTransforms.js';
@@ -33,61 +34,65 @@ function itemRelativePath(
33
34
  * Convert each object in a source array through list-context `itemEdges`.
34
35
  * Child field paths are treated as item-relative (ingest array children).
35
36
  */
36
- export function convertArrayWithItemEdges(input: {
37
+ export async function convertArrayWithItemEdges(input: {
37
38
  readonly items: unknown;
38
39
  readonly itemEdges: readonly MappingEdge[];
39
40
  readonly sources: readonly SourceField[];
40
41
  readonly targets: readonly TargetSlot[];
41
42
  readonly transforms: ValueTransformRegistry;
42
43
  readonly context?: TransformContext;
43
- }): unknown[] {
44
+ }): Promise<unknown[]> {
44
45
  if (!Array.isArray(input.items)) {
45
46
  return [];
46
47
  }
47
48
 
48
49
  const targetLeaves = flattenTargetSlots(input.targets);
49
50
 
50
- return input.items.map((rawItem) => {
51
- const item = isPlainObject(rawItem) ? rawItem : {};
52
- let outItem: Record<string, unknown> = {};
51
+ return Promise.all(
52
+ input.items.map(async (rawItem) => {
53
+ const item = isPlainObject(rawItem) ? rawItem : {};
54
+ let outItem: Record<string, unknown> = {};
53
55
 
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
- }
56
+ for (const edge of input.itemEdges) {
57
+ throwIfAborted(input.context?.signal);
62
58
 
63
- const sourcePath = itemRelativePath(sourceField);
64
- const sourceValue = sourcePath ? readObjectPath(item, sourcePath) : undefined;
59
+ const sourceField = findSourceField(input.sources, edge.sourceFieldId);
60
+ const targetSlot =
61
+ findTargetSlot(input.targets, edge.targetSlotId) ??
62
+ targetLeaves.find((slot) => slot.id === edge.targetSlotId);
63
+ if (!sourceField || !targetSlot) {
64
+ continue;
65
+ }
65
66
 
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
- }
67
+ const sourcePath = itemRelativePath(sourceField);
68
+ const sourceValue = sourcePath ? readObjectPath(item, sourcePath) : undefined;
69
+
70
+ const chain = edgeTransformIds(edge);
71
+ let value: unknown;
72
+ if (chain.length === 0) {
73
+ const identity = input.transforms.get(BUILTIN_TRANSFORM_IDS.identity);
74
+ value = identity
75
+ ? await identity.apply(sourceValue, { ...input.context, sampleValue: sourceValue })
76
+ : sourceValue;
77
+ } else {
78
+ const steps = resolveOptionSteps(chain, edge.transformOptionSteps, edge.transformOptions);
79
+ value = await applyTransformChain(
80
+ input.transforms,
81
+ chain,
82
+ sourceValue,
83
+ { ...input.context, sampleValue: sourceValue },
84
+ steps,
85
+ );
86
+ }
83
87
 
84
- const targetPath = itemRelativePath(targetSlot);
85
- if (!targetPath) {
86
- continue;
88
+ const targetPath = itemRelativePath(targetSlot);
89
+ if (!targetPath) {
90
+ continue;
91
+ }
92
+ outItem = writeObjectPath(outItem, targetPath, value);
87
93
  }
88
- outItem = writeObjectPath(outItem, targetPath, value);
89
- }
90
94
 
91
- return outItem;
92
- });
95
+ return outItem;
96
+ }),
97
+ );
93
98
  }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Object-path safety gate for field-remap read/write and template placeholders.
3
+ * Keeps prototype-mutating segments out of dotted path traversal.
4
+ */
5
+
6
+ /** Identifier or dotted path: `city`, `a.b` (no expressions / eval). */
7
+ const SAFE_PATH_RE = /^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*$/;
8
+
9
+ const UNSAFE_OBJECT_PATH_SEGMENTS = new Set(['__proto__', 'constructor', 'prototype']);
10
+
11
+ export class UnsafeObjectPathError extends Error {
12
+ readonly code = 'unsafe_object_path' as const;
13
+ readonly path: string;
14
+ readonly segment: string;
15
+
16
+ constructor(path: string, segment: string) {
17
+ super(`Object path "${path}" contains unsafe segment "${segment}".`);
18
+ this.name = 'UnsafeObjectPathError';
19
+ this.path = path;
20
+ this.segment = segment;
21
+ }
22
+ }
23
+
24
+ function objectPathParts(path: string): string[] {
25
+ return path
26
+ .split('.')
27
+ .map((part) => part.trim())
28
+ .filter((part) => part.length > 0);
29
+ }
30
+
31
+ function findUnsafeObjectPathSegment(parts: readonly string[]): string | undefined {
32
+ return parts.find((part) => UNSAFE_OBJECT_PATH_SEGMENTS.has(part));
33
+ }
34
+
35
+ /** Parse dotted path segments and reject unsafe segments when any parts exist. */
36
+ export function requireObjectPathParts(path: string): string[] {
37
+ const parts = objectPathParts(path);
38
+ const unsafeSegment = findUnsafeObjectPathSegment(parts);
39
+ if (unsafeSegment) {
40
+ throw new UnsafeObjectPathError(path, unsafeSegment);
41
+ }
42
+ return parts;
43
+ }
44
+
45
+ export function isSafeObjectPath(path: string): boolean {
46
+ const trimmed = path.trim();
47
+ return (
48
+ SAFE_PATH_RE.test(trimmed) &&
49
+ findUnsafeObjectPathSegment(objectPathParts(trimmed)) === undefined
50
+ );
51
+ }
@@ -3,8 +3,7 @@
3
3
  * Supports simple dotted paths (`name`, `meta.label`) on plain objects.
4
4
  */
5
5
 
6
- /** Identifier or dotted path: `city`, `a.b` (no expressions / eval). */
7
- const SAFE_PATH_RE = /^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*$/;
6
+ import { isSafeObjectPath, requireObjectPathParts } from './objectPathSafety.js';
8
7
 
9
8
  /** Placeholder matcher: `{city}`, `{a.b}` — rejects expressions / spaces. */
10
9
  const TEMPLATE_PLACEHOLDER_RE = /\{([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*)\}/g;
@@ -13,10 +12,6 @@ export function isPlainObject(value: unknown): value is Record<string, unknown>
13
12
  return value !== null && typeof value === 'object' && !Array.isArray(value);
14
13
  }
15
14
 
16
- export function isSafeObjectPath(path: string): boolean {
17
- return SAFE_PATH_RE.test(path.trim());
18
- }
19
-
20
15
  /**
21
16
  * Fill `{path}` placeholders from a plain object using safe dotted paths only.
22
17
  * Unknown / unsafe placeholders become empty strings. No JS eval.
@@ -45,10 +40,7 @@ export function applyStringTemplate(
45
40
  }
46
41
 
47
42
  export function readObjectPath(value: unknown, path: string): unknown {
48
- const parts = path
49
- .split('.')
50
- .map((part) => part.trim())
51
- .filter((part) => part.length > 0);
43
+ const parts = requireObjectPathParts(path);
52
44
  if (parts.length === 0) {
53
45
  return value;
54
46
  }
@@ -72,10 +64,7 @@ export function writeObjectPath(
72
64
  path: string,
73
65
  value: unknown,
74
66
  ): Record<string, unknown> {
75
- const parts = path
76
- .split('.')
77
- .map((part) => part.trim())
78
- .filter((part) => part.length > 0);
67
+ const parts = requireObjectPathParts(path);
79
68
  if (parts.length === 0) {
80
69
  return isPlainObject(root) ? { ...root } : {};
81
70
  }
@@ -33,12 +33,12 @@ export function findSourceField(
33
33
  * Per-step options (`transformOptionSteps` / `itemTransformOptionSteps`) win when
34
34
  * present; otherwise shared `transformOptions` / `itemTransformOptions` apply to all steps.
35
35
  */
36
- export function resolveMappedValue(
36
+ export async function resolveMappedValue(
37
37
  edge: MappingEdge,
38
38
  sourceValue: unknown,
39
39
  registry: ValueTransformRegistry,
40
40
  context: TransformContext = {},
41
- ): unknown {
41
+ ): Promise<unknown> {
42
42
  let current = edge.itemSourcePath
43
43
  ? projectCollectionItems(sourceValue, edge.itemSourcePath)
44
44
  : sourceValue;
@@ -51,27 +51,27 @@ export function resolveMappedValue(
51
51
  );
52
52
 
53
53
  if (itemChain.length > 0 && Array.isArray(current)) {
54
- current = current.map((item) =>
55
- applyTransformChain(registry, itemChain, item, context, itemSteps),
54
+ current = await Promise.all(
55
+ current.map((item) => applyTransformChain(registry, itemChain, item, context, itemSteps)),
56
56
  );
57
57
  }
58
58
 
59
59
  const chain = edgeTransformIds(edge);
60
60
  if (chain.length === 0) {
61
61
  const identity = registry.get(BUILTIN_TRANSFORM_IDS.identity);
62
- return identity ? identity.apply(current, context) : current;
62
+ return identity ? await identity.apply(current, context) : current;
63
63
  }
64
64
 
65
65
  const valueSteps = resolveOptionSteps(chain, edge.transformOptionSteps, edge.transformOptions);
66
66
  return applyTransformChain(registry, chain, current, context, valueSteps);
67
67
  }
68
68
 
69
- export function resolveEdgePreview(
69
+ export async function resolveEdgePreview(
70
70
  edge: MappingEdge,
71
71
  sources: readonly SourceField[],
72
72
  registry: ValueTransformRegistry,
73
73
  context: TransformContext = {},
74
- ): unknown {
74
+ ): Promise<unknown> {
75
75
  const field = findSourceField(sources, edge.sourceFieldId);
76
76
  const sample =
77
77
  context.sampleValue !== undefined ? context.sampleValue : (field?.sampleValue ?? context.now);
@@ -82,15 +82,17 @@ export function resolveEdgePreview(
82
82
  }
83
83
 
84
84
  /** Resolve every edge into `{ targetSlotId, value }` for live preview panels. */
85
- export function resolveAllEdgePreviews(
85
+ export async function resolveAllEdgePreviews(
86
86
  edges: readonly MappingEdge[],
87
87
  sources: readonly SourceField[],
88
88
  registry: ValueTransformRegistry,
89
89
  context: TransformContext = {},
90
- ): ReadonlyArray<{ edgeId: string; targetSlotId: string; value: unknown }> {
91
- return edges.map((edge) => ({
92
- edgeId: edge.id,
93
- targetSlotId: edge.targetSlotId,
94
- value: resolveEdgePreview(edge, sources, registry, context),
95
- }));
90
+ ): Promise<ReadonlyArray<{ edgeId: string; targetSlotId: string; value: unknown }>> {
91
+ return Promise.all(
92
+ edges.map(async (edge) => ({
93
+ edgeId: edge.id,
94
+ targetSlotId: edge.targetSlotId,
95
+ value: await resolveEdgePreview(edge, sources, registry, context),
96
+ })),
97
+ );
96
98
  }
@@ -1,3 +1,4 @@
1
+ import { throwIfAborted } from '../abort.js';
1
2
  import type { ConversionDefinition } from './conversionDefinition.js';
2
3
  import {
3
4
  mergeSourceShapes,
@@ -27,6 +28,12 @@ export interface ConvertToShapeInput {
27
28
  readonly inputs: Readonly<Record<string, unknown>>;
28
29
  readonly transforms: ValueTransformRegistry;
29
30
  readonly context?: TransformContext;
31
+ /**
32
+ * Optional cancellation. Merged into transform context as `signal` (wins over
33
+ * `context.signal` when both are set). Aborted conversions reject with `AbortError`
34
+ * and do not apply further edges.
35
+ */
36
+ readonly signal?: AbortSignal;
30
37
  }
31
38
 
32
39
  export interface ConvertToShapeSlotResult {
@@ -112,8 +119,17 @@ function readFieldValue(field: SourceField, inputs: Readonly<Record<string, unkn
112
119
  *
113
120
  * This is the host runtime entry point — not `sourceShape.convert(target, data)`.
114
121
  * Multiple source shapes are supported via `inputs[shapeId]`.
122
+ * Awaits Promise-returning host transforms (e.g. JSONata 2.x).
115
123
  */
116
- export function convertToShape(input: ConvertToShapeInput): ConvertToShapeResult {
124
+ export async function convertToShape(input: ConvertToShapeInput): Promise<ConvertToShapeResult> {
125
+ const signal = input.signal ?? input.context?.signal;
126
+ const context: TransformContext = {
127
+ ...input.context,
128
+ ...(signal ? { signal } : {}),
129
+ };
130
+
131
+ throwIfAborted(signal);
132
+
117
133
  const sourceShapes: DataShape[] = [];
118
134
  for (const shapeId of input.conversion.sourceShapeIds) {
119
135
  const shape = resolveShape(input.shapes, shapeId);
@@ -141,6 +157,8 @@ export function convertToShape(input: ConvertToShapeInput): ConvertToShapeResult
141
157
  const slots: ConvertToShapeSlotResult[] = [];
142
158
 
143
159
  for (const edge of input.conversion.document.edges) {
160
+ throwIfAborted(signal);
161
+
144
162
  const sourceField = findSourceField(sources, edge.sourceFieldId);
145
163
  if (!sourceField) {
146
164
  continue;
@@ -155,21 +173,21 @@ export function convertToShape(input: ConvertToShapeInput): ConvertToShapeResult
155
173
  const sourceValue = readFieldValue(sourceField, input.inputs);
156
174
  const value =
157
175
  edge.itemEdges && edge.itemEdges.length > 0
158
- ? convertArrayWithItemEdges({
176
+ ? await convertArrayWithItemEdges({
159
177
  items: sourceValue,
160
178
  itemEdges: edge.itemEdges,
161
179
  sources,
162
180
  targets,
163
181
  transforms: input.transforms,
164
- context: input.context,
182
+ context,
165
183
  })
166
- : resolveMappedValue(edge, sourceValue, input.transforms, {
167
- ...input.context,
184
+ : await resolveMappedValue(edge, sourceValue, input.transforms, {
185
+ ...context,
168
186
  sampleValue: sourceValue,
169
187
  record: isPlainObject(sourceValue)
170
188
  ? sourceValue
171
- : isPlainObject(input.context?.record)
172
- ? input.context.record
189
+ : isPlainObject(context.record)
190
+ ? context.record
173
191
  : undefined,
174
192
  });
175
193
 
@@ -134,6 +134,11 @@ export interface TransformContext {
134
134
  */
135
135
  readonly record?: Readonly<Record<string, unknown>>;
136
136
  readonly options?: Readonly<Record<string, unknown>>;
137
+ /**
138
+ * Optional cancellation signal. `applyTransformChain` / `convertToShape` check
139
+ * between steps and reject with `AbortError` when aborted.
140
+ */
141
+ readonly signal?: AbortSignal;
137
142
  }
138
143
 
139
144
  /** Declares a host-editable option consumed via `context.options[key]`. */
@@ -157,7 +162,11 @@ export interface ValueTransformDefinition {
157
162
  readonly category?: string;
158
163
  readonly inputTypes?: readonly FieldDataType[];
159
164
  readonly outputType?: FieldDataType;
160
- readonly apply: (value: unknown, context: TransformContext) => unknown;
165
+ /**
166
+ * May return a Promise (e.g. host JSONata 2.x). Prefer `applyTransformChain` /
167
+ * `convertToShape`, which always await transform results.
168
+ */
169
+ readonly apply: (value: unknown, context: TransformContext) => unknown | PromiseLike<unknown>;
161
170
  /** Optional picker label that includes a live format sample. */
162
171
  readonly formatSampleLabel?: (context: TransformContext) => string;
163
172
  /**
@@ -177,6 +186,6 @@ export interface ValueTransformListFilter {
177
186
  export interface ValueTransformRegistry {
178
187
  list(filter?: ValueTransformListFilter): ValueTransformDefinition[];
179
188
  get(id: string): ValueTransformDefinition | undefined;
180
- apply(id: string, value: unknown, context?: TransformContext): unknown;
189
+ apply(id: string, value: unknown, context?: TransformContext): unknown | PromiseLike<unknown>;
181
190
  register(definition: ValueTransformDefinition): void;
182
191
  }
package/src/index.ts CHANGED
@@ -38,10 +38,10 @@ export {
38
38
  UnsupportedFieldRemapDocumentVersionError,
39
39
  } from './domain/document/fieldRemapDocument.js';
40
40
 
41
+ export { isSafeObjectPath, UnsafeObjectPathError } from './domain/mapping/objectPathSafety.js';
41
42
  export {
42
43
  applyStringTemplate,
43
44
  isPlainObject,
44
- isSafeObjectPath,
45
45
  listArrayItemProjectionOptions,
46
46
  projectCollectionItems,
47
47
  readObjectPath,
@@ -112,6 +112,8 @@ export type {
112
112
  ConvertToShapeSlotResult,
113
113
  } from './domain/shapes/convertToShape.js';
114
114
 
115
+ export { createAbortError, isAbortError, throwIfAborted } from './domain/abort.js';
116
+
115
117
  export { sourceFieldsFromPlainObject } from './domain/ingest/sourceFieldsFromPlainObject.js';
116
118
  export type { SourceFieldsFromPlainObjectOptions } from './domain/ingest/sourceFieldsFromPlainObject.js';
117
119
 
@@ -1,3 +1,4 @@
1
+ import { throwIfAborted } from '../domain/abort.js';
1
2
  import { canonicalizeTransformId, MAX_TRANSFORM_CHAIN } from '../domain/constants.js';
2
3
  import type {
3
4
  FieldDataType,
@@ -44,17 +45,19 @@ export function createValueTransformRegistry(
44
45
  /**
45
46
  * Apply an ordered transform chain (empty = identity / unchanged).
46
47
  * Optional `optionSteps[i]` merges over `context.options` for step `i` only.
48
+ * Awaits Promise-returning host transforms (e.g. JSONata 2.x).
47
49
  */
48
- export function applyTransformChain(
50
+ export async function applyTransformChain(
49
51
  registry: ValueTransformRegistry,
50
52
  transformIds: readonly string[],
51
53
  value: unknown,
52
54
  context: TransformContext = {},
53
55
  optionSteps?: readonly (Readonly<Record<string, unknown>> | undefined)[],
54
- ): unknown {
56
+ ): Promise<unknown> {
55
57
  let current = value;
56
58
  const ids = transformIds.slice(0, MAX_TRANSFORM_CHAIN);
57
59
  for (let index = 0; index < ids.length; index += 1) {
60
+ throwIfAborted(context.signal);
58
61
  const stepOptions = optionSteps?.[index];
59
62
  const stepContext =
60
63
  stepOptions && Object.keys(stepOptions).length > 0
@@ -66,7 +69,7 @@ export function applyTransformChain(
66
69
  },
67
70
  }
68
71
  : context;
69
- current = registry.apply(ids[index]!, current, stepContext);
72
+ current = await registry.apply(ids[index]!, current, stepContext);
70
73
  }
71
74
  return current;
72
75
  }