@contrail/flexplm 1.6.0-alpha.6f15d4e → 1.6.0-alpha.8e73fa3

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.
@@ -44,10 +44,41 @@ export interface DirectionalSection {
44
44
  * (e.g. a REKEY task with `rekeyTransformersKey: 'rekey'` reads
45
45
  * {@link DirectionalSection.rekey}).
46
46
  *
47
+ * Ordering matters — tasks run top-to-bottom and operate on whatever
48
+ * the previous task left behind. Also there can be multiple tasks of
49
+ * the same processor type, as long as they have different config keys.
50
+ * This allows you to split up different logical steps (e.g. remove most
51
+ * unwanted fields before a value transform. Where the value transform
52
+ * needs a field to be present, but it must be removed before sending.
53
+ * Conventional order:
54
+ * 1. **REMOVE** — drop keys you don't want carried forward, while the
55
+ * row is still in its input shape (so REMOVE keys match the input
56
+ * system's slugs).
57
+ * 2. **VALUE_TRANSFORM** / **MORPH** — coerce / normalize values
58
+ * while the keys are still recognizable.
59
+ * 3. **REKEY** — last, so the remaining keys are renamed to the
60
+ * output system. After REKEY runs with `rekeyDelete: true`, the
61
+ * input-side slugs are gone and any later REMOVE / VALUE_TRANSFORM
62
+ * would need to be written against the output-side slugs instead.
63
+ *
64
+ * Swapping REKEY before REMOVE is a common cause of "my removeKey isn't
65
+ * doing anything" — by the time REMOVE runs, the keys have already
66
+ * been renamed.
67
+ *
68
+ * **Only processors listed in `transformOrder` actually run.** A
69
+ * `rekey` / `removeKey` / `valueTransform` table sitting on the
70
+ * directional section is inert unless a matching task references it
71
+ * here — defining `rekey: {...}` without a REKEY entry in
72
+ * `transformOrder` is silently a no-op. If a step seems to be doing
73
+ * nothing, check that its processor is in this list before assuming
74
+ * the lookup table is wrong.
75
+ *
47
76
  * @example
77
+ * // Conventional: REMOVE → VALUE_TRANSFORM → REKEY.
48
78
  * transformOrder: [
49
- * { processor: 'REKEY', rekeyDelete: true, rekeyKeepEmptyValues: true, rekeyTransformersKey: 'rekey' },
50
79
  * { processor: 'REMOVE', removeKeysKey: 'removeKey' },
80
+ * { processor: 'VALUE_TRANSFORM', functionTransformersKey: 'valueTransform' },
81
+ * { processor: 'REKEY', rekeyDelete: true, rekeyKeepEmptyValues: true, rekeyTransformersKey: 'rekey' },
51
82
  * ]
52
83
  */
53
84
  transformOrder?: TransformTask[];
@@ -119,8 +150,42 @@ export interface DirectionalSection {
119
150
  * Names of keys to delete from each row. Referenced by `removeKeysKey`
120
151
  * on the REMOVE task.
121
152
  *
153
+ * On the `vibe2flex` side, this is the **outbound filter** —
154
+ * {@link MappingSection.vibeOwningKeys} only governs inbound
155
+ * protection, so anything you want kept out of the FlexPLM payload
156
+ * has to be listed here. Typical entries:
157
+ * - Vibe-internal fields with no FlexPLM equivalent
158
+ * (`federatedId`, computed totals, planning-only properties).
159
+ * - FlexPLM-owned inbound properties under their Vibe-side slug,
160
+ * so they aren't echoed back to FlexPLM after a prior inbound sync.
161
+ *
162
+ * Keys are matched against the row in its **input** shape, so on
163
+ * `vibe2flex` list Vibe-side slugs (REMOVE runs before REKEY in the
164
+ * conventional ordering) and on `flex2vibe` list FlexPLM field names.
165
+ *
166
+ * `removeKey` vs {@link MappingSection.vibeOwningKeys} — different
167
+ * jobs, not redundant:
168
+ * - `vibeOwningKeys` = **refuse to accept on inbound**.
169
+ * - `vibe2flex.removeKey` = **refuse to send on outbound**.
170
+ *
171
+ * A property can belong in one, both, or neither — decide per
172
+ * property based on which system owns it:
173
+ * - `targetIntroDate` (Vibe-owned, no Flex counterpart)
174
+ * → in **both** (protect inbound, don't bother sending).
175
+ * - `materialDescription` (Flex-owned, Vibe stores it after a
176
+ * prior inbound sync) → in **`removeKey` only**, so the value
177
+ * isn't echoed back to Flex; Flex still owns updates inbound.
178
+ * - `event` (Vibe-owned, published to Flex) → in
179
+ * **`vibeOwningKeys` only**; we want to keep sending it.
180
+ *
122
181
  * @example
123
- * removeKey: ['patternReference', 'vibeOnlyProp']
182
+ * // vibe2flex.removeKey strip Vibe-internal + Flex-owned-inbound
183
+ * // properties before publish.
184
+ * removeKey: [
185
+ * 'federatedId',
186
+ * 'targetIntroDate', // Vibe-owned, no Flex counterpart
187
+ * 'materialDescription', // Vibe-side slug of a Flex-owned inbound field
188
+ * ]
124
189
  */
125
190
  removeKey?: string[];
126
191
  /**
@@ -185,12 +250,17 @@ export interface DirectionalSection {
185
250
  *
186
251
  * @example
187
252
  * LCSProduct: {
188
- * vibeOwningKeys: ['itemNumber', 'lifecycleStage'],
253
+ * // Identifier — used for lookup / create-vs-update routing.
189
254
  * getIdentifierProperties: () => ['styleNumber'],
255
+ * // Vibe-owned attributes.
256
+ * vibeOwningKeys: ['lifecycleStage', 'targetIntroDate'],
190
257
  * vibe2flex: {
191
258
  * transformOrder: [
259
+ * { processor: 'REMOVE', removeKeysKey: 'removeKey' },
192
260
  * { processor: 'REKEY', rekeyDelete: true, rekeyKeepEmptyValues: true, rekeyTransformersKey: 'rekey' },
193
261
  * ],
262
+ * // Strip Vibe-only / Flex-owned-inbound props before publish.
263
+ * removeKey: ['federatedId'],
194
264
  * // destination: source -> flexKey: 'vibeKey'
195
265
  * rekey: { productName: 'name' },
196
266
  * },
@@ -205,12 +275,46 @@ export interface DirectionalSection {
205
275
  */
206
276
  export interface MappingSection {
207
277
  /**
208
- * These are the slugs of the properties that the VibeIQ system owns. So
209
- * even if data comes in from an external source, it is ignored and the
210
- * values are not overwritten.
278
+ * Slugs of the VibeIQ properties that VibeIQ owns. During inbound
279
+ * (flex2vibe) processing, any value FlexPLM tries to send for one
280
+ * of these slugs is ignored — the existing VibeIQ value is preserved.
281
+ *
282
+ * Scope: **inbound only**. This list does *not* filter outbound
283
+ * payloads — Vibe-only properties still flow to FlexPLM unless you
284
+ * strip them in {@link DirectionalSection.removeKey} on the
285
+ * `vibe2flex` side.
286
+ *
287
+ * What `vibeOwningKeys` does **not** do — reach for these fields
288
+ * instead:
289
+ * - Strip fields from the outbound payload → use
290
+ * {@link DirectionalSection.removeKey} on `vibe2flex`.
291
+ * - Block inbound entity creation → use
292
+ * {@link MappingSection.isInboundCreatable}.
293
+ * - Drive lookup / matching / dedupe → use
294
+ * {@link MappingSection.getIdentifierProperties}.
295
+ * - Skip value transformations or rekeying → those run regardless;
296
+ * omit the property from `rekey` / `valueTransform` if you want
297
+ * it left alone.
298
+ *
299
+ * `vibeOwningKeys` vs {@link DirectionalSection.removeKey} —
300
+ * different jobs, not redundant:
301
+ * - `vibeOwningKeys` = **refuse to accept on inbound**.
302
+ * - `vibe2flex.removeKey` = **refuse to send on outbound**.
303
+ *
304
+ * A property can belong in one, both, or neither — decide per
305
+ * property based on which system owns it:
306
+ * - `targetIntroDate` (Vibe-owned, no Flex counterpart)
307
+ * → in **both** (protect inbound, don't bother sending).
308
+ * - `materialDescription` (Flex-owned, Vibe stores it after a
309
+ * prior inbound sync) → in **`removeKey` only**, so the value
310
+ * isn't echoed back to Flex; Flex still owns updates inbound.
311
+ * - `event` (Vibe-owned, published to Flex) → in
312
+ * **`vibeOwningKeys` only**; we want to keep sending it.
211
313
  *
212
314
  * @example
213
- * vibeOwningKeys: ['itemNumber', 'lifecycleStage']
315
+ * // Right: Vibe-owned attributes;
316
+ * // If Vibe owns the identifier, include it here as well.
317
+ * vibeOwningKeys: ['itemNumber', 'lifecycleStage', 'targetIntroDate']
214
318
  */
215
319
  vibeOwningKeys?: string[];
216
320
  /**
@@ -228,8 +332,43 @@ export interface MappingSection {
228
332
  * one. Consulted by `TypeConversionUtils.isInboundCreatableFromObject`;
229
333
  * defaults to `false` when the function is absent.
230
334
  *
335
+ * The function receives the inbound row, so the decision can be
336
+ * per-record (e.g. only allow inbound creation when a status or
337
+ * type-discriminator flag is set). Common patterns:
338
+ * - `() => true` — FlexPLM owns creation for this entity type.
339
+ * - `() => false` — VibeIQ owns creation; inbound rows can only
340
+ * update existing entities.
341
+ * - Conditional — inspect the row and allow / disallow per record.
342
+ *
343
+ * On the **season-link** sections (`LCSProductSeasonLink` /
344
+ * `LCSSKUSeasonLink`), `() => true` enables the add-to-project
345
+ * flow: FlexPLM-driven inbound events can add an item to a Vibe
346
+ * project (not just update an existing project-item).
347
+ * Pair with {@link MappingSection.isOutboundCreatable} returning
348
+ * `false` so the outbound side becomes update-only. This pattern
349
+ * also requires matching FlexPLM-side configuration; see the
350
+ * project documentation for the full setup.
351
+ *
231
352
  * @example
353
+ * // Flex owns creation.
232
354
  * isInboundCreatable: () => true
355
+ *
356
+ * @example
357
+ * // Conditional: allow inbound creation only when the row is active
358
+ * // and the type-path matches a class we accept from FlexPLM.
359
+ * isInboundCreatable: (object) => {
360
+ * if (object?.['status']?.value?.toLowerCase?.() !== 'active') return false;
361
+ * return object?.['flexPLMTypePath'] === 'Color\\Solid';
362
+ * }
363
+ *
364
+ * @example
365
+ * // add-to-project flow — Flex drives project-item creation.
366
+ * // Paired with isOutboundCreatable: () => false to make
367
+ * // outbound update-only.
368
+ * LCSProductSeasonLink: {
369
+ * isInboundCreatable: () => true,
370
+ * isOutboundCreatable: () => false,
371
+ * }
233
372
  */
234
373
  isInboundCreatable?: (entity: Record<string, unknown>, context?: unknown) => boolean;
235
374
  /**
@@ -239,8 +378,24 @@ export interface MappingSection {
239
378
  * `TypeConversionUtils.isOutboundCreatableFromEntity`; defaults to
240
379
  * `true` when the function is absent.
241
380
  *
381
+ * On the **season-link** sections (`LCSProductSeasonLink` /
382
+ * `LCSSKUSeasonLink`), `() => false` converts outbound publish events
383
+ * to `UPDATE_ON_SEASON`: they only update an existing season-link,
384
+ * never create one. If the Product / Colorway isn't already on the
385
+ * season, **that specific event fails** while other events continue
386
+ * to process — the failure is per-record, not fatal to the batch.
387
+ *
388
+ * Pair with {@link MappingSection.isInboundCreatable} returning
389
+ * `true` to delegate add-to-project creation to FlexPLM. This pattern
390
+ * also requires matching FlexPLM-side configuration; see the project
391
+ * documentation for the full setup.
392
+ *
242
393
  * @example
243
- * isOutboundCreatable: () => false
394
+ * // add-to-project flow — Vibe never adds items to seasons; Flex does.
395
+ * LCSProductSeasonLink: {
396
+ * isInboundCreatable: () => true,
397
+ * isOutboundCreatable: () => false, // becomes UPDATE_ON_SEASON
398
+ * }
244
399
  */
245
400
  isOutboundCreatable?: (entity: Record<string, unknown>, context?: unknown) => boolean;
246
401
  /**
@@ -272,8 +427,55 @@ export interface MappingSection {
272
427
  * `vibeIQIdentifierProperties` is not present on the row, falling
273
428
  * back to `TypeDefaults` when this is absent.
274
429
  *
430
+ * Identifier properties are the *business key* used to answer
431
+ * "does this inbound payload refer to an existing entity, or do we
432
+ * need to create one?" The connector uses them for entity lookup,
433
+ * create-vs-update routing, and the deterministic targeting of
434
+ * retry / resend events.
435
+ *
436
+ * Inbound lookup outcomes drive routing:
437
+ * - **0 matches** → row is treated as a new entity; creation
438
+ * proceeds only if {@link MappingSection.isInboundCreatable}
439
+ * allows it, otherwise the row is dropped.
440
+ * - **1 match** → existing entity is updated.
441
+ * - **>1 match** → processing fails for that row to avoid
442
+ * ambiguity. Multiple matches means the identifier isn't
443
+ * actually unique; fix the data or pick a tighter key rather
444
+ * than letting the merge happen silently.
445
+ *
446
+ * Multiple slugs form a **composite key** — all of them must match
447
+ * the same existing entity for it to count as the same record. If
448
+ * any value differs, the row is treated as a different entity.
449
+ *
450
+ * Best practices:
451
+ * - Pick the **minimum set** of slugs that uniquely identifies the
452
+ * entity. Multiple slugs are treated as a composite key — all of
453
+ * them must match for the lookup to succeed.
454
+ * - Prefer **stable** business identifiers (item / style / season
455
+ * numbers) over frequently-changing fields. Renames of an
456
+ * identifier are a data-migration event, not a routine update.
457
+ * - Ensure the identifier exists in **both** systems and on
458
+ * historical records. If it's missing on legacy data the lookup
459
+ * will silently create duplicates.
460
+ *
461
+ * When the connector app config sets
462
+ * `search.<entityType>.useIdentityServiceForInboundData: true` for
463
+ * the entity type (e.g. `item`, `color`, `custom-entity`), inbound
464
+ * identifier matching switches from a direct-property query to the
465
+ * platform identity service. This is forward-looking: enable it only
466
+ * after the identifier property has been configured as unique in
467
+ * Vibe and the historical-data backfill is complete. {@link
468
+ * MappingSection.uniquenessPool} declares which identity-service
469
+ * uniqueness pool the entity participates in.
470
+ *
275
471
  * @example
472
+ * // Single business key
276
473
  * getIdentifierProperties: () => ['itemNumber']
474
+ *
475
+ * @example
476
+ * // Composite key — both slugs must match for a row to count as the
477
+ * // same entity.
478
+ * getIdentifierProperties: () => ['seasonName', 'year']
277
479
  */
278
480
  getIdentifierProperties?: (entity: Record<string, unknown>) => string[];
279
481
  /**
@@ -282,8 +484,35 @@ export interface MappingSection {
282
484
  * Consulted by `TypeConversionUtils.getInformationalProperties` with
283
485
  * the same lookup precedence as {@link MappingSection.getIdentifierProperties}.
284
486
  *
487
+ * Purpose is **observability**, not behavior. Informational properties:
488
+ * - ride along in retry / resend event payloads so the retry target
489
+ * is human-readable, not just a numeric ID;
490
+ * - surface in logs, error messages, and monitoring dashboards so
491
+ * failures can be triaged without opening a debugger;
492
+ * - give engineers and ops a way to recognize "which entity is this"
493
+ * at a glance.
494
+ *
495
+ * They are **not** used for entity matching, create-vs-update routing,
496
+ * uniqueness enforcement, or duplicate prevention. If a property
497
+ * needs to influence any of those, put it in
498
+ * {@link MappingSection.getIdentifierProperties} instead.
499
+ *
500
+ * Prefer stable, readable, scalar fields (names, numbers). Avoid
501
+ * large objects, references, or frequently-changing values — they'll
502
+ * bloat retry payloads and clutter logs without adding clarity.
503
+ *
285
504
  * @example
286
- * getInformationalProperties: () => ['longName']
505
+ * // SKU: matched by colorwaySeqID, but log lines show the itemNumber.
506
+ * // "Failed to upsert SKU — colorwaySeqID=12345 itemNumber=NB-574-BLU"
507
+ * LCSSKU: {
508
+ * getIdentifierProperties: () => ['colorwaySeqID'],
509
+ * getInformationalProperties: () => ['itemNumber'],
510
+ * }
511
+ *
512
+ * @example
513
+ * // Multiple informational properties — all carried together; none
514
+ * // affect matching.
515
+ * getInformationalProperties: () => ['itemNumber', 'skuName']
287
516
  */
288
517
  getInformationalProperties?: (entity: Record<string, unknown>) => string[];
289
518
  /**
@@ -292,6 +521,23 @@ export interface MappingSection {
292
521
  * FlexPLM shape, so inside `rekey` the destination (left) is the FlexPLM
293
522
  * key and the source (right) is the VibeIQ key — see
294
523
  * {@link DirectionalSection.rekey}.
524
+ *
525
+ * Responsibilities:
526
+ * - Rename Vibe attribute slugs to Flex field names (`rekey`).
527
+ * - Remove Vibe-only attributes from the payload
528
+ * ({@link DirectionalSection.removeKey}).
529
+ * - Convert / coerce values to formats FlexPLM accepts
530
+ * ({@link DirectionalSection.valueTransform},
531
+ * {@link DirectionalSection.morphTransform}).
532
+ * - Produce a Flex-valid payload ready for publish.
533
+ *
534
+ * Note the asymmetry with `flex2vibe`: the inbound ownership and
535
+ * creation gates ({@link MappingSection.vibeOwningKeys},
536
+ * {@link MappingSection.isInboundCreatable}) do **not** apply here.
537
+ * `vibe2flex` is the side that *sends* Vibe data out — protecting
538
+ * Vibe data from being overwritten only makes sense on the inbound
539
+ * side. To keep Vibe-only fields from leaking outbound, list them in
540
+ * `removeKey`.
295
541
  */
296
542
  vibe2flex?: DirectionalSection;
297
543
  /**
@@ -300,6 +546,21 @@ export interface MappingSection {
300
546
  * VibeIQ shape, so inside `rekey` the destination (left) is the VibeIQ
301
547
  * key and the source (right) is the FlexPLM key — see
302
548
  * {@link DirectionalSection.rekey}.
549
+ *
550
+ * Responsibilities:
551
+ * - Rename Flex field names to Vibe attribute slugs (`rekey`).
552
+ * - Normalize / coerce inbound values into Vibe-compatible formats
553
+ * ({@link DirectionalSection.valueTransform},
554
+ * {@link DirectionalSection.morphTransform}).
555
+ * - Produce a row ready to hand to `setEntityValues()` /
556
+ * inbound persistence.
557
+ *
558
+ * This is the direction where the protective gates fire:
559
+ * - {@link MappingSection.vibeOwningKeys} prevents inbound rows
560
+ * from overwriting Vibe-owned attributes.
561
+ * - {@link MappingSection.isInboundCreatable} decides whether an
562
+ * inbound row whose identifier matches nothing should create a
563
+ * new Vibe entity or be dropped.
303
564
  */
304
565
  flex2vibe?: DirectionalSection;
305
566
  }
@@ -375,9 +636,38 @@ export interface TypeConversionSection {
375
636
  * }
376
637
  */
377
638
  export interface TypeConversion {
378
- /** Routes outbound VibeIQ entities to their FlexPLM mapping sections. */
639
+ /**
640
+ * Routes outbound VibeIQ entities to their FlexPLM mapping sections.
641
+ *
642
+ * When no entry matches the row's VibeIQ entity type, the connector
643
+ * falls back to looking up a section by the row's `TypeDefaults`
644
+ * class. The explicit `vibe2flex` entries here override that fallback
645
+ * and are only needed when a single VibeIQ type fans out to multiple
646
+ * mapping sections (e.g. `'custom-entity'` dispatched by `typePath`).
647
+ */
379
648
  vibe2flex: TypeConversionSection;
380
- /** Routes inbound FlexPLM objects to their VibeIQ mapping sections. */
649
+ /**
650
+ * Routes inbound FlexPLM objects to their VibeIQ mapping sections.
651
+ *
652
+ * When no entry matches the row's FlexPLM object class, the connector
653
+ * falls back to using the row's `flexPLMObjectClass` directly as the
654
+ * section key. Explicit `flex2vibe` entries are only needed when one
655
+ * FlexPLM class fans out to multiple mapping sections (e.g.
656
+ * `LCSLifecycleManaged` dispatched by `flexPLMTypePath`).
657
+ *
658
+ * `LCSMaterial` routing — special case: by default `LCSMaterial`
659
+ * maps to `custom-entity` on the VibeIQ side. To route it to
660
+ * `item:material` (treating materials as a subtype of `item`)
661
+ * instead, **both** of the following must be in place:
662
+ * 1. Connector app config sets
663
+ * `{ "LCSMaterial": { "processAsItem": true } }`. The mapping
664
+ * file alone can't change which entity type LCSMaterial maps to.
665
+ * 2. `flex2vibe.LCSMaterial.getMapKey` returns the name of a custom
666
+ * mapping section that declares `getClass: () => 'item'` +
667
+ * `getSoftType: () => 'item:material'` on its `flex2vibe` side.
668
+ * The matching `vibe2flex.item.getMapKey` should route
669
+ * `item:material` typePath back to the same section.
670
+ */
381
671
  flex2vibe: TypeConversionSection;
382
672
  }
383
673
  /** Identifies the application and tenant that owns a {@link MappingFile}. */
@@ -403,6 +693,39 @@ interface MappingFileBase {
403
693
  * {@link TypeConversionEntry.getMapKey} (e.g. `LCSProduct`, `LCSSKU`,
404
694
  * `'Exchange Rate'`).
405
695
  *
696
+ * # Supported entity mappings
697
+ *
698
+ * The tables below describe which VibeIQ entity types and FlexPLM object
699
+ * classes can be synced today and what each direction does behind the
700
+ * scenes. "Not yet supported" entries are listed explicitly so authors
701
+ * know not to attempt them.
702
+ *
703
+ * ## VibeIQ → FlexPLM
704
+ *
705
+ * | VibeIQ entity type | FlexPLM object class | Behavior |
706
+ * |----------------------|----------------------------------|------------------------------------------------------------------------------------------------------------|
707
+ * | `item` | `LCSProduct` / `LCSSKU` | Syncs once `lifecycleStage` is out of `concept`. Sends Primary Viewable as the thumbnail (no other images). Creates `LCSProduct` / `LCSSKU` if missing; auto-retries `LCSSKU` creation when the parent `LCSProduct` isn't ready yet. |
708
+ * | `color` | `LCSColor` | Syncs on create / update. Sends Primary Viewable as the thumbnail. Creates `LCSColor` if missing. |
709
+ * | `custom-entity` | `LCSRevisableEntity` | Syncs on create / update. Creates `LCSRevisableEntity` if missing. Workflows should gate on the custom-entity subtype so only the intended subtypes publish. |
710
+ * | `project-item` | `LCSProductSeasonLink` / `LCSSKUSeasonLink` | Syncs on plan publish. Requires the assortment to have `publishToFlexPLM=true` and `flexPLMSeasonName` set. Adds the Product / SKU to the `LCSSeason` if not already linked. Only project-items changed since the last publish are sent (configurable to reach further back). Processing on the FlexPLM side is async via a Windchill Queue. |
711
+ * | `item:material` | `LCSMaterial` | Supported via custom mapping section + `typeConversion`. Route `item:material` typePath to a mapping section that declares `getClass: () => 'LCSMaterial'` and `getSoftType: () => 'Material'`. See {@link TypeConversion} for the `LCSMaterial.processAsItem` config flag that pairs with this. |
712
+ * | `custom-entity` | `LCSLast` / `LCSLifecycleManaged` | **Not yet supported.** |
713
+ *
714
+ * ## FlexPLM → VibeIQ
715
+ *
716
+ * | FlexPLM object class | VibeIQ entity type | Behavior |
717
+ * |-------------------------|----------------------------|------------------------------------------------------------------------------------------------------------|
718
+ * | `LCSProduct` | `item` (role: `family`) | Configurable via mapping file to create new items; updates existing items if found. Does **not** sync the thumbnail to VibeIQ. |
719
+ * | `LCSSKU` | `item` (role: `option`) | Configurable to create new items; updates existing items. Does **not** sync the thumbnail. |
720
+ * | `LCSProductSeasonLink` | `project-item` (`family`) | Updates existing items if found. Can be configured to add an item to a project, including setting carried-over data — see the add-to-project flow under {@link MappingSection.isInboundCreatable}. |
721
+ * | `LCSSKUSeasonLink` | `project-item` (`option`) | Updates existing items. Add-to-project supported under the same flow. |
722
+ * | `LCSColor` | `color` | Configurable to create new colors; updates existing colors. Does **not** sync the thumbnail. |
723
+ * | `LCSLast` | `custom-entity` | Configurable to create new custom-entities; updates existing. |
724
+ * | `LCSLifecycleManaged` | `custom-entity` | Configurable to create new custom-entities; updates existing. |
725
+ * | `LCSMaterial` | `custom-entity` (default) or `item:material` | Default: custom-entity. To route to `item:material` instead, set `{ "LCSMaterial": { "processAsItem": true } }` in the connector app config and write a mapping section with `getClass: () => 'item'` + `getSoftType: () => 'item:material'`. Wire it via `flex2vibe.LCSMaterial.getMapKey` returning that section's name. |
726
+ * | `LCSRevisableEntity` | `custom-entity` | Configurable to create new custom-entities; updates existing. |
727
+ * | (any) | `item` (direct, no link) | **Not yet supported.** |
728
+ *
406
729
  * @example
407
730
  * export const mapping: MappingFile = {
408
731
  * orgInfo: {
@@ -86,8 +86,10 @@ export declare class BaseProcessPublishAssortment {
86
86
  getResultsCount(events: any): any;
87
87
  private sendEvents;
88
88
  private sendToFlexPLM;
89
+ private buildPublishError;
89
90
  private saveToLocalFile;
90
91
  private handleVibeIQFile;
92
+ private sendPublishPayloadEvent;
91
93
  private getCurrentDateString;
92
94
  getItemFamilyChanges(pcd: PublishChangeData, changeDetail: any, assortmentItemFullChangeMap: Map<string, any>, assortmentItemDeleteMap: Map<string, any>): Map<string, ItemFamilyChanges>;
93
95
  getEventsForPublishChangeData(publishChangeData: PublishChangeData): Promise<SeasonalPayload[]>;
@@ -595,14 +595,37 @@ class BaseProcessPublishAssortment {
595
595
  }
596
596
  }
597
597
  async sendToFlexPLM(events, eventType) {
598
- const asyncEvent = {
598
+ const outboundPublishEvent = {
599
599
  taskId: this.config.taskId,
600
600
  eventType,
601
601
  objectClass: 'LCSSeason',
602
602
  events
603
603
  };
604
604
  const flexPLMConnect = new flexplm_connect_1.FlexPLMConnect(this.config);
605
- return await flexPLMConnect.sendToFlexPLM(asyncEvent);
605
+ const [sendResult, eventResult] = await Promise.allSettled([
606
+ flexPLMConnect.sendToFlexPLM(outboundPublishEvent),
607
+ this.sendPublishPayloadEvent(outboundPublishEvent)
608
+ ]);
609
+ if (sendResult.status === 'rejected' || eventResult.status === 'rejected') {
610
+ throw this.buildPublishError(sendResult, eventResult, outboundPublishEvent);
611
+ }
612
+ const result = sendResult.value;
613
+ const isResultObject = typeof result === 'object' && result !== null;
614
+ return isResultObject
615
+ ? { ...result, outboundPublishEvent }
616
+ : { result, outboundPublishEvent };
617
+ }
618
+ buildPublishError(sendResult, eventResult, outboundPublishEvent) {
619
+ const baseReason = sendResult.status === 'rejected' ? sendResult.reason : eventResult.reason;
620
+ const error = baseReason instanceof Error ? baseReason : new Error(String(baseReason));
621
+ error.outboundPublishEvent = outboundPublishEvent;
622
+ error.sendToFlexPLMResult = sendResult.status === 'fulfilled'
623
+ ? sendResult.value
624
+ : { error: sendResult.reason?.message ?? String(sendResult.reason) };
625
+ error.sendPublishPayloadEventResult = eventResult.status === 'fulfilled'
626
+ ? eventResult.value
627
+ : { error: eventResult.reason?.message ?? String(eventResult.reason) };
628
+ return error;
606
629
  }
607
630
  async saveToLocalFile(events, eventType) {
608
631
  let results = {};
@@ -614,15 +637,16 @@ class BaseProcessPublishAssortment {
614
637
  const eventDirName = 'events-output';
615
638
  const fileName = `${path.sep}${eventDirName}${path.sep}events-${dateString}.json`;
616
639
  fileHandle = await fsPromise.open('.' + fileName, 'a');
617
- const asyncEvent = {
640
+ const outboundPublishEvent = {
618
641
  taskId: this.config.taskId,
619
642
  eventType,
620
643
  objectClass: 'LCSSeason',
621
644
  events
622
645
  };
623
- await fileHandle.writeFile(JSON.stringify(asyncEvent));
646
+ await fileHandle.writeFile(JSON.stringify(outboundPublishEvent));
624
647
  results = {
625
648
  message: 'Successfully Saved File',
649
+ outboundPublishEvent,
626
650
  fileLocation: dirName + fileName
627
651
  };
628
652
  }
@@ -638,7 +662,7 @@ class BaseProcessPublishAssortment {
638
662
  const eventBuffer = Buffer.from(JSON.stringify(events), 'utf-8');
639
663
  const fileName = 'ASYNC_PUBLISH_SEASON-events.json';
640
664
  const uploadFile = await new sdk_1.Files().createAndUploadFileFromBuffer(eventBuffer, 'application/json', fileName, null, this.TTL);
641
- const asyncEvent = {
665
+ const outboundPublishEvent = {
642
666
  taskId: this.config.taskId,
643
667
  eventType,
644
668
  objectClass: 'LCSSeason',
@@ -648,15 +672,46 @@ class BaseProcessPublishAssortment {
648
672
  };
649
673
  if (sendMode === 'vibeiqfile') {
650
674
  const flexPLMConnect = new flexplm_connect_1.FlexPLMConnect(this.config);
651
- return await flexPLMConnect.sendToFlexPLM(asyncEvent);
675
+ const [sendResult, eventResult] = await Promise.allSettled([
676
+ flexPLMConnect.sendToFlexPLM(outboundPublishEvent),
677
+ this.sendPublishPayloadEvent(outboundPublishEvent)
678
+ ]);
679
+ if (sendResult.status === 'rejected' || eventResult.status === 'rejected') {
680
+ throw this.buildPublishError(sendResult, eventResult, outboundPublishEvent);
681
+ }
682
+ const result = sendResult.value;
683
+ const isResultObject = typeof result === 'object' && result !== null;
684
+ return isResultObject
685
+ ? { ...result, outboundPublishEvent }
686
+ : { result, outboundPublishEvent };
652
687
  }
653
688
  else {
689
+ await this.sendPublishPayloadEvent(outboundPublishEvent);
654
690
  return {
655
691
  message: 'Successfully Uploaded File.',
656
- asyncEvent
692
+ outboundPublishEvent
657
693
  };
658
694
  }
659
695
  }
696
+ async sendPublishPayloadEvent(outboundPublishEvent) {
697
+ console.info('sendPublishPayloadEvent()');
698
+ let initialEvent = {};
699
+ if (this.config?.event) {
700
+ initialEvent = (typeof this.config?.event === 'string')
701
+ ? JSON.parse(this.config?.event)
702
+ : this.config?.event;
703
+ }
704
+ const eventBody = {
705
+ originSystemType: 'VibeIQ',
706
+ objectClass: 'AssortmentPublishedToFlexPLM',
707
+ outboundPublishEvent,
708
+ initialEvent
709
+ };
710
+ await new sdk_1.Entities().create({
711
+ entityName: 'external-event',
712
+ object: eventBody
713
+ });
714
+ }
660
715
  getCurrentDateString() {
661
716
  const d = new Date();
662
717
  return '' + d.getUTCFullYear()
@@ -9,6 +9,7 @@ const sdk_1 = require("@contrail/sdk");
9
9
  const type_conversion_utils_1 = require("../util/type-conversion-utils");
10
10
  const map_utils_1 = require("../util/map-utils");
11
11
  const item_family_changes_1 = require("../interfaces/item-family-changes");
12
+ const flexplm_connect_1 = require("../util/flexplm-connect");
12
13
  let federatedId = '';
13
14
  jest.mock('../util/data-converter', () => {
14
15
  return {
@@ -31,6 +32,13 @@ jest.mock('../util/federation', () => {
31
32
  });
32
33
  let entityObject = {};
33
34
  let getOptionsObject = {};
35
+ let createCallArg = undefined;
36
+ let fileUploadCalls = [];
37
+ let fileUploadResult = {
38
+ id: 'file-123',
39
+ downloadUrl: 'https://download.url',
40
+ adminDownloadUrl: 'https://admin.download.url'
41
+ };
34
42
  jest.mock('@contrail/sdk', () => {
35
43
  return {
36
44
  Entities: class {
@@ -38,6 +46,18 @@ jest.mock('@contrail/sdk', () => {
38
46
  getOptionsObject = _getOtionsObject;
39
47
  return entityObject;
40
48
  }
49
+ create(arg) {
50
+ createCallArg = arg;
51
+ return Promise.resolve({});
52
+ }
53
+ },
54
+ Files: class {
55
+ createAndUploadFileFromBuffer(buffer, mimeType, fileName, _x, ttl) {
56
+ fileUploadCalls.push({ buffer, mimeType, fileName, ttl });
57
+ return Promise.resolve(fileUploadResult);
58
+ }
59
+ },
60
+ Request: class {
41
61
  }
42
62
  };
43
63
  });
@@ -1686,3 +1706,195 @@ describe('getEventsForItemFamilyChanges - conditional eventType', () => {
1686
1706
  expect(type_conversion_utils_1.TypeConversionUtils.isOutboundCreatableFromEntity).toHaveBeenNthCalledWith(2, undefined, mapFileUtil, colorProjectItem, { item: itemData, assortment });
1687
1707
  });
1688
1708
  });
1709
+ describe('sendToFlexPLM / handleVibeIQFile / sendPublishPayloadEvent', () => {
1710
+ const config = {
1711
+ taskId: 'task-abc',
1712
+ event: '{"sourceEventId":"src-1"}'
1713
+ };
1714
+ const mapFileUtil = new transform_data_1.MapFileUtil(new sdk_1.Entities());
1715
+ const dc = new data_converter_1.DataConverter(config, mapFileUtil);
1716
+ const events = [{ objectClass: 'LCSProductSeasonLink' }];
1717
+ const eventType = 'ASYNC_PUBLISH_SEASON';
1718
+ beforeEach(() => {
1719
+ createCallArg = undefined;
1720
+ fileUploadCalls = [];
1721
+ fileUploadResult = {
1722
+ id: 'file-123',
1723
+ downloadUrl: 'https://download.url',
1724
+ adminDownloadUrl: 'https://admin.download.url'
1725
+ };
1726
+ });
1727
+ afterEach(() => {
1728
+ jest.restoreAllMocks();
1729
+ });
1730
+ it('should merge outboundPublishEvent into result and call sendPublishPayloadEvent in parallel', async () => {
1731
+ const bppa = new base_process_publish_assortment_1.BaseProcessPublishAssortment(config, dc, mapFileUtil);
1732
+ const flexResponse = { status: 200, data: { ok: true } };
1733
+ let flexResolved = false;
1734
+ let eventResolved = false;
1735
+ const spyFlex = jest.spyOn(flexplm_connect_1.FlexPLMConnect.prototype, 'sendToFlexPLM')
1736
+ .mockImplementation(async () => {
1737
+ await new Promise(r => setTimeout(r, 5));
1738
+ flexResolved = true;
1739
+ return flexResponse;
1740
+ });
1741
+ const spyEvent = jest.spyOn(bppa, 'sendPublishPayloadEvent')
1742
+ .mockImplementation(async () => {
1743
+ await new Promise(r => setTimeout(r, 5));
1744
+ eventResolved = true;
1745
+ });
1746
+ const result = await bppa.sendToFlexPLM(events, eventType);
1747
+ expect(spyFlex).toHaveBeenCalledTimes(1);
1748
+ expect(spyEvent).toHaveBeenCalledTimes(1);
1749
+ expect(flexResolved).toBe(true);
1750
+ expect(eventResolved).toBe(true);
1751
+ const passedOutboundPublishEvent = spyFlex.mock.calls[0][0];
1752
+ expect(passedOutboundPublishEvent.taskId).toBe('task-abc');
1753
+ expect(passedOutboundPublishEvent.eventType).toBe(eventType);
1754
+ expect(passedOutboundPublishEvent.objectClass).toBe('LCSSeason');
1755
+ expect(passedOutboundPublishEvent.events).toBe(events);
1756
+ expect(spyEvent).toHaveBeenCalledWith(passedOutboundPublishEvent);
1757
+ expect(result).toEqual({ ...flexResponse, outboundPublishEvent: passedOutboundPublishEvent });
1758
+ });
1759
+ it('sendToFlexPLM throws when flexPLMConnect.sendToFlexPLM fails, attaching outboundPublishEvent and both results', async () => {
1760
+ const bppa = new base_process_publish_assortment_1.BaseProcessPublishAssortment(config, dc, mapFileUtil);
1761
+ const flexError = new Error('flex failed');
1762
+ const eventResponse = { eventOk: true };
1763
+ jest.spyOn(flexplm_connect_1.FlexPLMConnect.prototype, 'sendToFlexPLM').mockRejectedValue(flexError);
1764
+ jest.spyOn(bppa, 'sendPublishPayloadEvent').mockResolvedValue(eventResponse);
1765
+ await expect(bppa.sendToFlexPLM(events, eventType)).rejects.toMatchObject({
1766
+ message: 'flex failed',
1767
+ outboundPublishEvent: expect.objectContaining({ taskId: 'task-abc', eventType, objectClass: 'LCSSeason', events }),
1768
+ sendToFlexPLMResult: { error: 'flex failed' },
1769
+ sendPublishPayloadEventResult: eventResponse
1770
+ });
1771
+ });
1772
+ it('sendToFlexPLM throws when sendPublishPayloadEvent fails, with flex result attached', async () => {
1773
+ const bppa = new base_process_publish_assortment_1.BaseProcessPublishAssortment(config, dc, mapFileUtil);
1774
+ const flexResponse = { status: 200 };
1775
+ const eventError = new Error('event failed');
1776
+ jest.spyOn(flexplm_connect_1.FlexPLMConnect.prototype, 'sendToFlexPLM').mockResolvedValue(flexResponse);
1777
+ jest.spyOn(bppa, 'sendPublishPayloadEvent').mockRejectedValue(eventError);
1778
+ await expect(bppa.sendToFlexPLM(events, eventType)).rejects.toMatchObject({
1779
+ message: 'event failed',
1780
+ outboundPublishEvent: expect.objectContaining({ taskId: 'task-abc', eventType }),
1781
+ sendToFlexPLMResult: flexResponse,
1782
+ sendPublishPayloadEventResult: { error: 'event failed' }
1783
+ });
1784
+ });
1785
+ it('sendToFlexPLM throws the flexPLMConnect error when both fail', async () => {
1786
+ const bppa = new base_process_publish_assortment_1.BaseProcessPublishAssortment(config, dc, mapFileUtil);
1787
+ const flexError = new Error('flex failed');
1788
+ const eventError = new Error('event failed');
1789
+ jest.spyOn(flexplm_connect_1.FlexPLMConnect.prototype, 'sendToFlexPLM').mockRejectedValue(flexError);
1790
+ jest.spyOn(bppa, 'sendPublishPayloadEvent').mockRejectedValue(eventError);
1791
+ await expect(bppa.sendToFlexPLM(events, eventType)).rejects.toBe(flexError);
1792
+ expect(flexError.outboundPublishEvent).toBeDefined();
1793
+ expect(flexError.sendToFlexPLMResult).toEqual({ error: 'flex failed' });
1794
+ expect(flexError.sendPublishPayloadEventResult).toEqual({ error: 'event failed' });
1795
+ });
1796
+ it('handleVibeIQFile (vibeiqfile) throws when flexPLMConnect.sendToFlexPLM fails, with both results attached', async () => {
1797
+ const bppa = new base_process_publish_assortment_1.BaseProcessPublishAssortment(config, dc, mapFileUtil);
1798
+ const flexError = new Error('flex failed');
1799
+ const eventResponse = { eventOk: true };
1800
+ jest.spyOn(flexplm_connect_1.FlexPLMConnect.prototype, 'sendToFlexPLM').mockRejectedValue(flexError);
1801
+ jest.spyOn(bppa, 'sendPublishPayloadEvent').mockResolvedValue(eventResponse);
1802
+ await expect(bppa.handleVibeIQFile(events, eventType, 'vibeiqfile')).rejects.toMatchObject({
1803
+ message: 'flex failed',
1804
+ outboundPublishEvent: expect.objectContaining({ eventsFileId: 'file-123' }),
1805
+ sendToFlexPLMResult: { error: 'flex failed' },
1806
+ sendPublishPayloadEventResult: eventResponse
1807
+ });
1808
+ });
1809
+ it('handleVibeIQFile (vibeiqfile) throws when sendPublishPayloadEvent fails, with flex result attached', async () => {
1810
+ const bppa = new base_process_publish_assortment_1.BaseProcessPublishAssortment(config, dc, mapFileUtil);
1811
+ const flexResponse = { status: 200 };
1812
+ const eventError = new Error('event failed');
1813
+ jest.spyOn(flexplm_connect_1.FlexPLMConnect.prototype, 'sendToFlexPLM').mockResolvedValue(flexResponse);
1814
+ jest.spyOn(bppa, 'sendPublishPayloadEvent').mockRejectedValue(eventError);
1815
+ await expect(bppa.handleVibeIQFile(events, eventType, 'vibeiqfile')).rejects.toMatchObject({
1816
+ message: 'event failed',
1817
+ outboundPublishEvent: expect.objectContaining({ eventsFileId: 'file-123' }),
1818
+ sendToFlexPLMResult: flexResponse,
1819
+ sendPublishPayloadEventResult: { error: 'event failed' }
1820
+ });
1821
+ });
1822
+ it('handleVibeIQFile (vibeiqfile) throws the flexPLMConnect error when both fail', async () => {
1823
+ const bppa = new base_process_publish_assortment_1.BaseProcessPublishAssortment(config, dc, mapFileUtil);
1824
+ const flexError = new Error('flex failed');
1825
+ const eventError = new Error('event failed');
1826
+ jest.spyOn(flexplm_connect_1.FlexPLMConnect.prototype, 'sendToFlexPLM').mockRejectedValue(flexError);
1827
+ jest.spyOn(bppa, 'sendPublishPayloadEvent').mockRejectedValue(eventError);
1828
+ await expect(bppa.handleVibeIQFile(events, eventType, 'vibeiqfile')).rejects.toBe(flexError);
1829
+ expect(flexError.sendToFlexPLMResult).toEqual({ error: 'flex failed' });
1830
+ expect(flexError.sendPublishPayloadEventResult).toEqual({ error: 'event failed' });
1831
+ });
1832
+ it('should merge outboundPublishEvent into FlexPLM result and call sendPublishPayloadEvent when mode is vibeiqfile', async () => {
1833
+ const bppa = new base_process_publish_assortment_1.BaseProcessPublishAssortment(config, dc, mapFileUtil);
1834
+ const flexResponse = { status: 200, data: { ok: true } };
1835
+ const spyFlex = jest.spyOn(flexplm_connect_1.FlexPLMConnect.prototype, 'sendToFlexPLM')
1836
+ .mockResolvedValue(flexResponse);
1837
+ const spyEvent = jest.spyOn(bppa, 'sendPublishPayloadEvent')
1838
+ .mockResolvedValue(undefined);
1839
+ const result = await bppa.handleVibeIQFile(events, eventType, 'vibeiqfile');
1840
+ expect(fileUploadCalls).toHaveLength(1);
1841
+ expect(fileUploadCalls[0].fileName).toBe('ASYNC_PUBLISH_SEASON-events.json');
1842
+ expect(fileUploadCalls[0].mimeType).toBe('application/json');
1843
+ const passedOutboundPublishEvent = spyFlex.mock.calls[0][0];
1844
+ expect(passedOutboundPublishEvent.taskId).toBe('task-abc');
1845
+ expect(passedOutboundPublishEvent.eventType).toBe(eventType);
1846
+ expect(passedOutboundPublishEvent.objectClass).toBe('LCSSeason');
1847
+ expect(passedOutboundPublishEvent.eventsFileId).toBe('file-123');
1848
+ expect(passedOutboundPublishEvent.eventsDownloadLink).toBe('https://download.url');
1849
+ expect(passedOutboundPublishEvent.eventsAdminDownloadLink).toBe('https://admin.download.url');
1850
+ expect(spyFlex).toHaveBeenCalledTimes(1);
1851
+ expect(spyEvent).toHaveBeenCalledTimes(1);
1852
+ expect(spyEvent).toHaveBeenCalledWith(passedOutboundPublishEvent);
1853
+ expect(result).toEqual({ ...flexResponse, outboundPublishEvent: passedOutboundPublishEvent });
1854
+ });
1855
+ it('should skip FlexPLM but still call sendPublishPayloadEvent when mode is vibeiqfile-dontsendtoflexplm', async () => {
1856
+ const bppa = new base_process_publish_assortment_1.BaseProcessPublishAssortment(config, dc, mapFileUtil);
1857
+ const spyFlex = jest.spyOn(flexplm_connect_1.FlexPLMConnect.prototype, 'sendToFlexPLM')
1858
+ .mockResolvedValue({});
1859
+ const spyEvent = jest.spyOn(bppa, 'sendPublishPayloadEvent')
1860
+ .mockResolvedValue(undefined);
1861
+ const result = await bppa.handleVibeIQFile(events, eventType, 'vibeiqfile-dontsendtoflexplm');
1862
+ expect(spyFlex).not.toHaveBeenCalled();
1863
+ expect(spyEvent).toHaveBeenCalledTimes(1);
1864
+ const passedOutboundPublishEvent = spyEvent.mock.calls[0][0];
1865
+ expect(passedOutboundPublishEvent.eventsFileId).toBe('file-123');
1866
+ expect(result).toEqual({
1867
+ message: 'Successfully Uploaded File.',
1868
+ outboundPublishEvent: passedOutboundPublishEvent
1869
+ });
1870
+ });
1871
+ it('should create an external-event with initialEvent parsed from config', async () => {
1872
+ const bppa = new base_process_publish_assortment_1.BaseProcessPublishAssortment(config, dc, mapFileUtil);
1873
+ const outboundPublishEvent = { foo: 'bar' };
1874
+ await bppa.sendPublishPayloadEvent(outboundPublishEvent);
1875
+ expect(createCallArg).toEqual({
1876
+ entityName: 'external-event',
1877
+ object: {
1878
+ originSystemType: 'VibeIQ',
1879
+ objectClass: 'AssortmentPublishedToFlexPLM',
1880
+ outboundPublishEvent,
1881
+ initialEvent: { sourceEventId: 'src-1' }
1882
+ }
1883
+ });
1884
+ });
1885
+ it('should default initialEvent to {} when config has no event', async () => {
1886
+ const bareConfig = { taskId: 'task-x' };
1887
+ const bppa = new base_process_publish_assortment_1.BaseProcessPublishAssortment(bareConfig, dc, mapFileUtil);
1888
+ const outboundPublishEvent = { hello: 'world' };
1889
+ await bppa.sendPublishPayloadEvent(outboundPublishEvent);
1890
+ expect(createCallArg).toEqual({
1891
+ entityName: 'external-event',
1892
+ object: {
1893
+ originSystemType: 'VibeIQ',
1894
+ objectClass: 'AssortmentPublishedToFlexPLM',
1895
+ outboundPublishEvent,
1896
+ initialEvent: {}
1897
+ }
1898
+ });
1899
+ });
1900
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contrail/flexplm",
3
- "version": "1.6.0-alpha.6f15d4e",
3
+ "version": "1.6.0-alpha.8e73fa3",
4
4
  "description": "Library used for integration with flexplm.",
5
5
  "main": "lib/index.js",
6
6
  "types": "lib/index.d.ts",
@@ -12,7 +12,8 @@
12
12
  "scripts/copy-template.js"
13
13
  ],
14
14
  "scripts": {
15
- "build": "tsc && node scripts/copy-template.js",
15
+ "build": "tsc; node scripts/copy-template.js",
16
+ "build:win": "tsc && node scripts/copy-template.js",
16
17
  "format": "prettier --write \"src/**/*.ts\" \"src/**/*.js\"",
17
18
  "lint": "tslint -p tsconfig.json",
18
19
  "test": "jest",