@murumets-ee/imports 0.48.0 → 0.51.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/carmaker-feed-C_MnDaOC.mjs +2 -0
- package/dist/{carmaker-feed-CxhZBrkO.mjs.map → carmaker-feed-C_MnDaOC.mjs.map} +1 -1
- package/dist/carmaker-feed-DlD2xPs0.d.mts +58 -0
- package/dist/carmaker-feed-DlD2xPs0.d.mts.map +1 -0
- package/dist/{cleanup-CHjDgvKT.mjs → cleanup-DdILFoAP.mjs} +2 -2
- package/dist/{cleanup-CHjDgvKT.mjs.map → cleanup-DdILFoAP.mjs.map} +1 -1
- package/dist/index.d.mts +5 -3
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/index.mjs.map +1 -1
- package/dist/mapping/index.d.mts +2 -359
- package/dist/mapping/index.d.mts.map +1 -1
- package/dist/mapping/index.mjs +1 -1
- package/dist/{mapping-transform-D76b-P-p.mjs → mapping-transform-AMFEg1rS.mjs} +2 -2
- package/dist/{mapping-transform-D76b-P-p.mjs.map → mapping-transform-AMFEg1rS.mjs.map} +1 -1
- package/dist/{parts-schema-D0gMhNWj.mjs → parts-schema-BBUINSdN.mjs} +2 -2
- package/dist/{parts-schema-D0gMhNWj.mjs.map → parts-schema-BBUINSdN.mjs.map} +1 -1
- package/dist/{parts-schema-x8_GRYWc.d.mts → parts-schema-u5kr5zFg.d.mts} +359 -3
- package/dist/parts-schema-u5kr5zFg.d.mts.map +1 -0
- package/dist/plugin.d.mts +33 -6
- package/dist/plugin.d.mts.map +1 -1
- package/dist/plugin.mjs +1 -1
- package/dist/plugin.mjs.map +1 -1
- package/dist/runner-wPnCZlB4.mjs +2 -0
- package/dist/runner-wPnCZlB4.mjs.map +1 -0
- package/dist/{schema-registry-C_lQje-K.mjs → schema-registry-B_7AweTa.mjs} +2 -2
- package/dist/{schema-registry-C_lQje-K.mjs.map → schema-registry-B_7AweTa.mjs.map} +1 -1
- package/dist/storage-resolver.d.mts +1 -1
- package/dist/transform-DwAQ4b-j.mjs +2 -0
- package/dist/transform-DwAQ4b-j.mjs.map +1 -0
- package/dist/{transform-CABBaYmF.d.mts → transform-WeiiLdYP.d.mts} +12 -1
- package/dist/transform-WeiiLdYP.d.mts.map +1 -0
- package/dist/transforms.d.mts +2 -46
- package/dist/transforms.mjs +1 -1
- package/dist/{worker-DRTgbtT7.d.mts → worker-DH3SN-91.d.mts} +189 -4
- package/dist/worker-DH3SN-91.d.mts.map +1 -0
- package/dist/worker.d.mts +1 -1
- package/dist/worker.mjs +1 -1
- package/dist/worker.mjs.map +1 -1
- package/dist/{xlsx-reader-Ccesszbc.mjs → xlsx-reader-Dsq_ERiB.mjs} +2 -2
- package/dist/{xlsx-reader-Ccesszbc.mjs.map → xlsx-reader-Dsq_ERiB.mjs.map} +1 -1
- package/package.json +8 -8
- package/dist/carmaker-feed-CxhZBrkO.mjs +0 -2
- package/dist/parts-schema-x8_GRYWc.d.mts.map +0 -1
- package/dist/runner-C-s6SD7t.mjs +0 -2
- package/dist/runner-C-s6SD7t.mjs.map +0 -1
- package/dist/transform-CABBaYmF.d.mts.map +0 -1
- package/dist/transform-DIzWppgJ.mjs +0 -2
- package/dist/transform-DIzWppgJ.mjs.map +0 -1
- package/dist/transforms.d.mts.map +0 -1
- package/dist/worker-DRTgbtT7.d.mts.map +0 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"transform-WeiiLdYP.d.mts","names":[],"sources":["../src/transform.ts"],"mappings":";;AA0BA;;;;AAAyB;AAGzB;;;;;;;;;;AAQW;AAoBX;;;;AAII;AAgBJ;;;KAnDY,aAAA;;UAGK,gBAAA;EA6DW;EA3D1B,WAAA;EA8CwB;EA5CxB,MAAA,EAAQ,MAAM;EAgDd;EA9CA,QAAA;EA8Ca;EA5Cb,SAAA;AAAA;;AAqD0B;AA6C5B;;;;;;;;;;;;;;;UA9EiB,eAAA;EAmFX;EAjFJ,IAAA;EAkFI;EAhFJ,EAAE;AAAA;;;;;;;;;;AAoFgC;AAGpC;;;UAvEiB,QAAA;EAwEf;EAtEA,EAAA;EAwEA;EAtEA,GAAA,EAAK,OAAA,CAAQ,IAAA;EAsER;AASP;;;;;;;EAtEE,aAAA,GAAgB,UAAA;AAAA;AAiFlB;;;;;;;;;;;;;;;;;;;AAG2B;AAoB3B;;;;;;;;;;;;;;;;;;;;;;AAvBA,KApCY,SAAA;EAEN,IAAA;EACA,EAAA;EACA,GAAA,EAAK,IAAA;EACL,QAAA,YAAoB,UAAA;EACpB,KAAA,YAAiB,eAAA;EACjB,WAAA;AAAA;EAEA,IAAA;EAAc,MAAA;EAAgB,OAAA,YAAmB,QAAA,CAAS,IAAA;AAAA;EAC1D,IAAA;EAAe,KAAA,EAAO,QAAA;AAAA;;UAGX,QAAA;EACf,SAAA;EACA,OAAA;EACA,KAAA;AAAA;;;;AAoHuD;AAiCzD;;UA5IiB,UAAA;EACf,WAAA;EACA,OAAA;EACA,KAAA;AAAA;;;;;;KAQU,YAAA,UACV,MAAA,EAAQ,MAAA,kBACR,GAAA,EAAK,gBAAA,KACF,OAAA,CAAQ,SAAA,CAAU,IAAA;;;AAgIQ;;;;;;;;;;;;;;;;cA5GlB,iBAAA;EAAA,iBACM,OAAA;;;;;;;EAQjB,QAAA,MAAA,CAAe,IAAA,EAAM,aAAA,EAAe,SAAA,EAAW,YAAA,CAAa,IAAA;;;;;;;;;;;;EAoB5D,GAAA,MAAA,CAAU,IAAA,EAAM,aAAA,EAAe,SAAA,EAAW,YAAA,CAAa,IAAA;;EAKvD,GAAA,CAAI,IAAA,EAAM,aAAA,GAAgB,YAAA;;EAK1B,GAAA,CAAI,IAAA,EAAM,aAAA;;EAKV,IAAA,CAAA,GAAQ,aAAA;;EAKR,KAAA,CAAA;AAAA;;iBAwBc,oBAAA,CAAA,GAAwB,iBAAiB;;;;;;;;;;;;;;;;;;;;;;;;iBAiCzC,uBAAA,MAAA,CACd,IAAA,EAAM,aAAA,EACN,SAAA,EAAW,YAAA,CAAa,IAAA"}
|
package/dist/transforms.d.mts
CHANGED
|
@@ -1,46 +1,2 @@
|
|
|
1
|
-
import { i as
|
|
2
|
-
|
|
3
|
-
import { PartsDocument } from "@murumets-ee/search-elasticsearch";
|
|
4
|
-
|
|
5
|
-
//#region src/transforms/carmaker-feed.d.ts
|
|
6
|
-
/** Stable name under which this transform is registered. */
|
|
7
|
-
declare const CARMAKER_FEED_TRANSFORM_NAME = "commerce:carmaker-feed";
|
|
8
|
-
/**
|
|
9
|
-
* Per-job params — the per-(supplier, brand) identity resolved at enqueue time.
|
|
10
|
-
* This IS the parts schema's params (re-exported), so brand/supplier injection +
|
|
11
|
-
* the optional `supplierCodePrefix` strip validate identically everywhere.
|
|
12
|
-
*/
|
|
13
|
-
declare const carmakerFeedParamsSchema: import("zod").ZodObject<{
|
|
14
|
-
brandId: import("zod").ZodString;
|
|
15
|
-
brandSlug: import("zod").ZodString;
|
|
16
|
-
supplierId: import("zod").ZodString;
|
|
17
|
-
supplierDisplayName: import("zod").ZodString;
|
|
18
|
-
importBatchId: import("zod").ZodString;
|
|
19
|
-
supplierCodePrefix: import("zod").ZodOptional<import("zod").ZodString>;
|
|
20
|
-
discountGroups: import("zod").ZodOptional<import("zod").ZodRecord<import("zod").ZodString, import("zod").ZodNumber>>;
|
|
21
|
-
}, "strip", import("zod").ZodTypeAny, {
|
|
22
|
-
supplierId: string;
|
|
23
|
-
brandId: string;
|
|
24
|
-
brandSlug: string;
|
|
25
|
-
supplierDisplayName: string;
|
|
26
|
-
importBatchId: string;
|
|
27
|
-
supplierCodePrefix?: string | undefined;
|
|
28
|
-
discountGroups?: Record<string, number> | undefined;
|
|
29
|
-
}, {
|
|
30
|
-
supplierId: string;
|
|
31
|
-
brandId: string;
|
|
32
|
-
brandSlug: string;
|
|
33
|
-
supplierDisplayName: string;
|
|
34
|
-
importBatchId: string;
|
|
35
|
-
supplierCodePrefix?: string | undefined;
|
|
36
|
-
discountGroups?: Record<string, number> | undefined;
|
|
37
|
-
}>;
|
|
38
|
-
type CarmakerFeedParams = PartsParams;
|
|
39
|
-
/**
|
|
40
|
-
* The carmaker transform — built once at module load from the static mapping. The
|
|
41
|
-
* build validates every target against the `parts` whitelist (throws on a typo).
|
|
42
|
-
*/
|
|
43
|
-
declare const carmakerFeedTransform: RowTransform<PartsDocument>;
|
|
44
|
-
//#endregion
|
|
45
|
-
export { CARMAKER_FEED_TRANSFORM_NAME, type CarmakerFeedParams, carmakerFeedParamsSchema, carmakerFeedTransform };
|
|
46
|
-
//# sourceMappingURL=transforms.d.mts.map
|
|
1
|
+
import { a as carmakerFeedTransform, i as carmakerFeedParamsSchema, r as CarmakerFeedParams, t as CARMAKER_FEED_TRANSFORM_NAME } from "./carmaker-feed-DlD2xPs0.mjs";
|
|
2
|
+
export { CARMAKER_FEED_TRANSFORM_NAME, type CarmakerFeedParams, carmakerFeedParamsSchema, carmakerFeedTransform };
|
package/dist/transforms.mjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{
|
|
1
|
+
import{i as e,r as t,t as n}from"./carmaker-feed-C_MnDaOC.mjs";export{n as CARMAKER_FEED_TRANSFORM_NAME,t as carmakerFeedParamsSchema,e as carmakerFeedTransform};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { c as TransformRegistry, i as RowTransform, t as DirectionalPair } from "./transform-
|
|
1
|
+
import { c as TransformRegistry, i as RowTransform, t as DirectionalPair } from "./transform-WeiiLdYP.mjs";
|
|
2
2
|
import { Logger } from "@murumets-ee/core";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { EsClientLike } from "@murumets-ee/search-elasticsearch";
|
|
@@ -86,7 +86,32 @@ declare const ImportRun: import("@murumets-ee/entity").Entity<{
|
|
|
86
86
|
* JSON reuses its field names — the `totalErrors` key inside this blob is
|
|
87
87
|
* really the WARNING count (and `patterns[].errorType` the warning type).
|
|
88
88
|
*/
|
|
89
|
-
warningSummary: import("@murumets-ee/entity").JsonField & Partial<import("@murumets-ee/entity").JsonField>;
|
|
89
|
+
warningSummary: import("@murumets-ee/entity").JsonField & Partial<import("@murumets-ee/entity").JsonField>;
|
|
90
|
+
/**
|
|
91
|
+
* Price-distribution snapshot (F010 / issue #602) — `{ count, nullRate,
|
|
92
|
+
* min, median, max }`, computed over ES-accepted rows' derived purchase
|
|
93
|
+
* price (`PartsDocument.purchase_price_minor`, integer minor units) in
|
|
94
|
+
* the SAME streaming pass that builds `totals`. See
|
|
95
|
+
* `@murumets-ee/imports`' `price-stats.ts` for the exact shape and the
|
|
96
|
+
* bounded-sample cap.
|
|
97
|
+
*
|
|
98
|
+
* The pipeline's existing 6b header-drift check (commerce's
|
|
99
|
+
* register-handler.ts) only catches STRUCTURAL drift — a mapped column
|
|
100
|
+
* vanishing from the header row. It does NOT catch a supplier silently
|
|
101
|
+
* flipping their decimal/thousands separator, which changes no header
|
|
102
|
+
* at all and produces numerically valid but silently WRONG prices. The
|
|
103
|
+
* worker compares THIS run's `priceStats.median` against the most
|
|
104
|
+
* recent prior SUCCEEDED run for the same (transformName, brandId,
|
|
105
|
+
* supplierId) and, on a magnitude-scale shift (>=10x or <=0.1x — see
|
|
106
|
+
* `PRICE_ANOMALY_THRESHOLD_MULTIPLIER`), appends a NON-FATAL
|
|
107
|
+
* `price_anomaly` entry to {@link warningSummary}. Unlike the 6b 409,
|
|
108
|
+
* this never blocks the run — a real repricing is a legitimate reason
|
|
109
|
+
* prices would shift, so it's an advisory only.
|
|
110
|
+
*
|
|
111
|
+
* `null`/absent for runs written before this field existed, or for a
|
|
112
|
+
* schema/transform whose rows never carry a price sample.
|
|
113
|
+
*/
|
|
114
|
+
priceStats: import("@murumets-ee/entity").JsonField & Partial<import("@murumets-ee/entity").JsonField>; /** Set when the worker picks up the job. */
|
|
90
115
|
startedAt: import("@murumets-ee/entity").DateField & Partial<import("@murumets-ee/entity").DateField>; /** Set when the worker finishes (success OR fatal failure). */
|
|
91
116
|
finishedAt: import("@murumets-ee/entity").DateField & Partial<import("@murumets-ee/entity").DateField>;
|
|
92
117
|
/**
|
|
@@ -371,6 +396,151 @@ interface ErrorTrackerSnapshot {
|
|
|
371
396
|
droppedSignatures: number;
|
|
372
397
|
patterns: ErrorPattern[];
|
|
373
398
|
}
|
|
399
|
+
/**
|
|
400
|
+
* Merge two independently-produced `ErrorTrackerSnapshot`s into one — e.g.
|
|
401
|
+
* the runner's per-row `warningSummary` plus a worker-level signal computed
|
|
402
|
+
* AFTER the run completes (F010 / #602: the price-drift anomaly check runs
|
|
403
|
+
* in `worker.ts`, after `runImport` returns, using a fresh single-pattern
|
|
404
|
+
* tracker of its own). Patterns concatenate rather than re-dedup: the two
|
|
405
|
+
* snapshots are assumed to use disjoint `errorType` signature spaces (a
|
|
406
|
+
* worker-level tracker uses a type — `price_anomaly` — the runner's own
|
|
407
|
+
* warning aggregation never emits), so counts sum directly with no risk of
|
|
408
|
+
* double-counting one bucket as two.
|
|
409
|
+
*/
|
|
410
|
+
declare function mergeErrorTrackerSnapshots(a: ErrorTrackerSnapshot, b: ErrorTrackerSnapshot): ErrorTrackerSnapshot;
|
|
411
|
+
//#endregion
|
|
412
|
+
//#region src/price-stats.d.ts
|
|
413
|
+
/**
|
|
414
|
+
* Price-distribution stats + prior-run anomaly detection (F010 / issue #602).
|
|
415
|
+
*
|
|
416
|
+
* The import pipeline's existing anomaly detection — register-handler.ts's
|
|
417
|
+
* "6b. Header-presence drift check" — only catches STRUCTURAL drift: a
|
|
418
|
+
* mapped column vanishing (or getting renamed) from the header row. It does
|
|
419
|
+
* NOT catch VALUE drift: a supplier silently flipping their decimal
|
|
420
|
+
* separator (e.g. `1.234,56` → `1234.56`) or thousands separator WITHOUT
|
|
421
|
+
* touching a single column header. Since the saved
|
|
422
|
+
* `import_mapping.numberFormat` (`{decimal, thousands}`) is unchanged,
|
|
423
|
+
* every price in that run parses as a NUMERICALLY VALID but WRONG value —
|
|
424
|
+
* silently corrupting the catalog's prices. Nothing upstream of this module
|
|
425
|
+
* flags it.
|
|
426
|
+
*
|
|
427
|
+
* This module computes a per-run price-distribution snapshot (`PriceStats`)
|
|
428
|
+
* over `PartsDocument.purchase_price_minor` — the DERIVED cost field,
|
|
429
|
+
* rather than the raw net/list price columns, because both the direct-net
|
|
430
|
+
* AND two-file derivation paths in `mapping/parts-schema.ts` (step 4) funnel
|
|
431
|
+
* through it, so a decimal-format flip on EITHER source column surfaces
|
|
432
|
+
* here regardless of which pricing path a supplier's feed uses.
|
|
433
|
+
*
|
|
434
|
+
* Collection (`PriceStatsCollector`) happens in the SAME streaming pass
|
|
435
|
+
* `runner.ts` already uses to build `totals` — one `.record()` call per
|
|
436
|
+
* ES-accepted row, no second pass over the file, no extra DB round-trip.
|
|
437
|
+
* The prior-run comparison (`detectPriceAnomaly`) happens in `worker.ts`
|
|
438
|
+
* AFTER the run completes, because it needs `AdminClient` to look the
|
|
439
|
+
* prior run up — this module stays DB-agnostic, pure data + pure functions.
|
|
440
|
+
*/
|
|
441
|
+
/** A run's price-distribution snapshot. Values are in MINOR units (cents), matching `PartsDocument.purchase_price_minor`'s own scale. */
|
|
442
|
+
interface PriceStats {
|
|
443
|
+
/** ES-accepted rows considered — same accounting as `RunImportResult.rowsSucceeded`. */
|
|
444
|
+
count: number;
|
|
445
|
+
/** Fraction (0..1) of `count` whose price sample was absent (e.g. a list-only row with no resolvable purchase cost — the same rows that carry a `no_purchase_price` warning). `0` when `count` is `0`. */
|
|
446
|
+
nullRate: number;
|
|
447
|
+
/** Minimum of the non-null samples, in minor units. `null` when there are zero non-null samples. */
|
|
448
|
+
min: number | null;
|
|
449
|
+
/** Median of the non-null samples, in minor units. `null` when there are zero non-null samples. */
|
|
450
|
+
median: number | null;
|
|
451
|
+
/** Maximum of the non-null samples, in minor units. `null` when there are zero non-null samples. */
|
|
452
|
+
max: number | null;
|
|
453
|
+
}
|
|
454
|
+
/** The all-zero/empty snapshot — a run that produced zero ES-accepted rows. */
|
|
455
|
+
declare const EMPTY_PRICE_STATS: PriceStats;
|
|
456
|
+
/**
|
|
457
|
+
* Bounded sample cap for the min/median/max computation — the same
|
|
458
|
+
* "bounded, uniform sample" philosophy `ErrorTracker` uses top-N patterns
|
|
459
|
+
* for. 50,000 floats is ~400KB, trivial next to the ES bulk-write buffers
|
|
460
|
+
* already in flight for a run this size, and far more than needed for a
|
|
461
|
+
* stable median estimate. A feed with more ES-accepted rows than this cap
|
|
462
|
+
* still gets an EXACT `count` / `nullRate` (those are running counters, not
|
|
463
|
+
* sample-bounded) — only min/median/max become an approximation over a
|
|
464
|
+
* UNIFORM RANDOM sample of `PRICE_SAMPLE_CAP` non-null values, not "the
|
|
465
|
+
* first N encountered" (see `record`'s reservoir-sampling note for why
|
|
466
|
+
* order-independence matters specifically for this module's job).
|
|
467
|
+
*/
|
|
468
|
+
declare const PRICE_SAMPLE_CAP = 50000;
|
|
469
|
+
/**
|
|
470
|
+
* Accumulates price samples during a run's streaming pass. `record` is O(1)
|
|
471
|
+
* (amortized array push below the cap, one RNG draw above it); `snapshot`
|
|
472
|
+
* sorts the retained samples once at the end — cheap relative to the ES
|
|
473
|
+
* bulk-write the run already performs every batch.
|
|
474
|
+
*/
|
|
475
|
+
declare class PriceStatsCollector {
|
|
476
|
+
private count;
|
|
477
|
+
private nullCount;
|
|
478
|
+
private nonNullSeen;
|
|
479
|
+
private readonly samples;
|
|
480
|
+
/**
|
|
481
|
+
* Record one ES-accepted row's price sample. Call exactly once per
|
|
482
|
+
* accepted row (mirrors the `rowsWarned` / `pairs` accounting in
|
|
483
|
+
* `runner.ts`'s flush loop) — `undefined` means "this row had no price to
|
|
484
|
+
* sample" and counts toward `nullRate` rather than being skipped.
|
|
485
|
+
*
|
|
486
|
+
* Reservoir sampling (Algorithm R) past the cap, not "first N wins": each
|
|
487
|
+
* new non-null value replaces a uniformly-random existing slot with
|
|
488
|
+
* probability `PRICE_SAMPLE_CAP / nonNullSeen`, giving every non-null
|
|
489
|
+
* sample seen so far an equal chance of surviving into the final
|
|
490
|
+
* reservoir. Order-independence matters here specifically — the anomaly
|
|
491
|
+
* this module exists to catch (a supplier's decimal-format flip) could
|
|
492
|
+
* start partway through a feed larger than the cap (e.g. a corrupted
|
|
493
|
+
* re-export appended to an otherwise-normal catalogue), and a first-N
|
|
494
|
+
* sample would never see a single row past the cutover point, silently
|
|
495
|
+
* missing exactly the drift F010 was built to detect.
|
|
496
|
+
*/
|
|
497
|
+
record(value: number | undefined): void;
|
|
498
|
+
snapshot(): PriceStats;
|
|
499
|
+
}
|
|
500
|
+
/**
|
|
501
|
+
* Anomaly threshold: a run whose median purchase price is >= 10x higher OR
|
|
502
|
+
* <= 1/10 (0.1x) of the prior successful run's median (for the same
|
|
503
|
+
* transform + supplier + brand) is flagged.
|
|
504
|
+
*
|
|
505
|
+
* Chosen generously above the range a real repricing plausibly covers
|
|
506
|
+
* (even an aggressive markup/discount renegotiation is a low-single-digit
|
|
507
|
+
* multiplier — 2x-3x would already be an extreme outlier for a legitimate
|
|
508
|
+
* price change) but comfortably BELOW the ~100x-1000x magnitude a
|
|
509
|
+
* decimal/thousands-separator flip produces for typical EUR part prices:
|
|
510
|
+
* `"1.234,56"` (a European-format €1234.56) misparsed with dot-decimal
|
|
511
|
+
* conventions reads as `1.234,56` → `1234.56`, a ~1000x inflation if the
|
|
512
|
+
* true value was e.g. €1.23; a thousands-separator flip on `"1234,56"`
|
|
513
|
+
* (true €1234.56) misparsed as `123456` (dropping the decimal entirely) is
|
|
514
|
+
* a ~100x inflation. 10x leaves a wide safety margin against false
|
|
515
|
+
* positives on genuine price changes while still comfortably catching
|
|
516
|
+
* every realistic decimal/thousands-separator flip.
|
|
517
|
+
*/
|
|
518
|
+
declare const PRICE_ANOMALY_THRESHOLD_MULTIPLIER = 10;
|
|
519
|
+
interface PriceAnomaly {
|
|
520
|
+
/** `curr.median / prev.median`. */
|
|
521
|
+
ratio: number;
|
|
522
|
+
/** The prior run's median (minor units). */
|
|
523
|
+
prevMedian: number;
|
|
524
|
+
/** This run's median (minor units). */
|
|
525
|
+
currMedian: number;
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* Compare two runs' price stats for a magnitude-scale anomaly. Returns
|
|
529
|
+
* `null` when there's nothing to compare (either side has no median — zero
|
|
530
|
+
* non-null price samples, e.g. a schema/transform that never emits a price
|
|
531
|
+
* sample, or a run with zero ES-accepted rows) or the ratio falls within
|
|
532
|
+
* the safe band.
|
|
533
|
+
*/
|
|
534
|
+
declare function detectPriceAnomaly(prev: PriceStats, curr: PriceStats): PriceAnomaly | null;
|
|
535
|
+
/**
|
|
536
|
+
* Narrow an `unknown` value (a `field.json()` column read back through
|
|
537
|
+
* `AdminClient`, typed `JsonValue | undefined` on the DTO) to `PriceStats`,
|
|
538
|
+
* or `null` when it doesn't structurally match — e.g. a run written before
|
|
539
|
+
* this field existed, or a run whose transform never emitted a price
|
|
540
|
+
* sample so `min`/`median`/`max` serialised as `null`. Defensive, matching
|
|
541
|
+
* the `params` narrowing pattern already used in `worker.ts`.
|
|
542
|
+
*/
|
|
543
|
+
declare function parsePriceStats(value: unknown): PriceStats | null;
|
|
374
544
|
//#endregion
|
|
375
545
|
//#region src/runner.d.ts
|
|
376
546
|
/** Soft default; chosen to match giga-test. ES bulk requests over ~5MB get split server-side anyway. */
|
|
@@ -412,6 +582,14 @@ interface RunImportOptions<TDoc> {
|
|
|
412
582
|
* doc is indexed. Default: a fresh tracker with the same caps.
|
|
413
583
|
*/
|
|
414
584
|
warningTracker?: ErrorTracker;
|
|
585
|
+
/**
|
|
586
|
+
* Optional price-stats collector (F010 / #602) — accumulates a
|
|
587
|
+
* min/median/max/nullRate distribution over ES-accepted rows' optional
|
|
588
|
+
* `priceSample`s in the SAME streaming pass that builds `totals`. Default:
|
|
589
|
+
* a fresh collector. Tests can inject their own to inspect it directly —
|
|
590
|
+
* mirrors the {@link errorTracker} / {@link warningTracker} DI shape.
|
|
591
|
+
*/
|
|
592
|
+
priceStats?: PriceStatsCollector;
|
|
415
593
|
/**
|
|
416
594
|
* Optional structural logger for per-batch telemetry. Receives
|
|
417
595
|
* `{ batch, ms, succeeded, failed, rowsRead, rowsPerSecond }` after
|
|
@@ -511,6 +689,13 @@ interface RunImportResult {
|
|
|
511
689
|
*/
|
|
512
690
|
patchesApplied: number;
|
|
513
691
|
patchesOrphaned: number;
|
|
692
|
+
/**
|
|
693
|
+
* Price-distribution snapshot (F010 / #602) accumulated from ES-accepted
|
|
694
|
+
* rows' `priceSample`s during this same streaming pass. See `price-stats.ts`.
|
|
695
|
+
* `count` is `0` (all fields `null`/`0`) for a run whose schema never emits
|
|
696
|
+
* a price sample.
|
|
697
|
+
*/
|
|
698
|
+
priceStats: PriceStats;
|
|
514
699
|
}
|
|
515
700
|
/**
|
|
516
701
|
* Apply the runner against a feed file. Stops on rowLimit OR end-of-file
|
|
@@ -735,5 +920,5 @@ interface ImportsRunDeadListenerConfig {
|
|
|
735
920
|
*/
|
|
736
921
|
declare function createImportsRunDeadListener(config: ImportsRunDeadListenerConfig): QueueTerminalNotifier;
|
|
737
922
|
//#endregion
|
|
738
|
-
export {
|
|
739
|
-
//# sourceMappingURL=worker-
|
|
923
|
+
export { ErrorSample as A, StreamFeedOptions as B, PriceStats as C, DEFAULT_MAX_PATTERNS as D, parsePriceStats as E, FeedReaderConfig as F, ImportRun as G, countDataRows as H, ReadFeedOptions as I, ImportRunClient as K, feedReaderConfigSchema as L, ErrorTrackerConfig as M, ErrorTrackerSnapshot as N, DEFAULT_MAX_SAMPLES_PER_PATTERN as O, mergeErrorTrackerSnapshots as P, readFeed as R, PriceAnomaly as S, detectPriceAnomaly as T, streamFeed as U, StreamFeedRow as V, IMPORT_RUN_STATUSES as W, RunImportResult as _, ImportsRunJobContext as a, PRICE_ANOMALY_THRESHOLD_MULTIPLIER as b, PairSinkContext as c, createRunImportHandler as d, importsRunJob as f, RunImportOptions as g, ImportRunProgress as h, ImportsRunDeadListenerConfig as i, ErrorTracker as j, ErrorPattern as k, RunImportHandlerConfig as l, DEFAULT_BATCH_SIZE as m, EsClientResolver as n, ImportsRunJobPayload as o, importsRunJobPayloadSchema as p, ImportRunStatus as q, FilePathResolver as r, PairSink as s, EnsureIndexResolver as t, createImportsRunDeadListener as u, runImport as v, PriceStatsCollector as w, PRICE_SAMPLE_CAP as x, EMPTY_PRICE_STATS as y, readPersistedFeedConfig as z };
|
|
924
|
+
//# sourceMappingURL=worker-DH3SN-91.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"worker-DH3SN-91.d.mts","names":[],"sources":["../src/entities/import-run.ts","../src/streaming.ts","../src/read-feed.ts","../src/error-tracker.ts","../src/price-stats.ts","../src/runner.ts","../src/worker.ts"],"mappings":";;;;;;;;AAoCyD;AAEzD;;;;;;;;AAFyD,cAP5C,mBAAA;AAAA,KAOD,eAAA,WAA0B,mBAAmB;AAAA,cAE5C,SAAA,gCAAS,MAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAuHtB;;;;;;;;AAAoE;;;;AC9FpE;;;;;;;;;AAGU;AAkBV;;;;;;;;;;;;0GA6CiB;;;EAEf;;;;;;;;;KD0BU,eAAA,GAAkB,WAAW,QAAQ,SAAA,CAAU,SAAA;;;;;;;;;;;;;;;;;;;;;;;;iBC9FrC,aAAA,CACpB,QAAA,UACA,OAAA;EAAW,SAAA;AAAA,IACV,OAAO;AAAA,UAkBO,iBAAA;;EAEf,QAAA;;;;;EAKA,SAAA;;;;;;;EAOA,SAAA;;;;;;;EAOA,OAAA,GAAU,aAAa;;;;;;EAMvB,gBAAA;;;;;;;;;;;AD8CF;;;;EC/BE,KAAA;AAAA;AAAA,UAGe,aAAA;ED4BmD;EC1BlE,SAAA;;;;AApEF;;;;;;;;;AAGU;AAkBV;EA8DE,GAAA,EAAK,MAAM;AAAA;;;;;;;;;iBAWU,UAAA,CAAW,OAAA,EAAS,iBAAA,GAAoB,aAAA,CAAc,aAAA;;;;ADzHpB;AAEzD;;;;;;cEZa,sBAAA,EAAsB,CAAA,CAAA,SAAA;;;;;;;;;;;;;;;;;;;KASvB,gBAAA,GAAmB,CAAA,CAAE,KAAK,QAAQ,sBAAA;;;;;;;;;;;;;;;iBAgB9B,uBAAA,CACd,MAAA,YACC,UAAU,QAAQ,sBAAA,CAAuB,SAAA;AAAA,UAY3B,eAAA;;EAEf,QAAA;;EAEA,MAAA;;EAEA,SAAA;;EAEA,SAAA;;EAEA,cAAA;;EAEA,KAAA;;EAEA,OAAA,GAAU,aAAa;;EAEvB,gBAAA;;EAEA,KAAA;AAAA;;iBAiBqB,QAAA,CAAS,OAAA,EAAS,eAAA,GAAkB,aAAA,CAAc,aAAA;;;;;;;;;;;AFvEzE;;;;AAMU;AACV;;;;AAAyD;AAEzD;;;;;;;;;;;;;KGNY,eAAA,sCAKR,eAAA;EAAA,CACG,GAAA,WAAc,eAAe;AAAA;;UAGnB,WAAA;EACf,SAAA;EACA,OAAA,EAAS,eAAe;AAAA;;UAIT,YAAA;EACf,SAAA;EACA,KAAA;EACA,OAAA;EACA,KAAA;EACA,eAAA;EACA,cAAA;EACA,OAAA,EAAS,aAAa,CAAC,WAAA;;EAEvB,UAAA;AAAA;AAAA,UAGe,kBAAA;;EAEf,WAAA;;EAEA,oBAAoB;AAAA;;cAIT,oBAAA;AAAA,cACA,+BAAA;AAAA,cAYA,YAAA;EAAA,iBACM,QAAA;EAAA,iBACA,WAAA;EAAA,iBACA,oBAAA;EAAA,QACT,iBAAA;cAEI,MAAA,GAAQ,kBAAA;;;;;;;;;;EAcpB,QAAA,CACE,SAAA,UACA,SAAA,UACA,OAAA,UACA,KAAA,sBACA,OAAA,EAAS,eAAA;;EA+BX,kBAAA,CAAA;;EAOA,uBAAA,CAAA;;;;;;EASA,wBAAA,CAAA;;;;;AHKF;EGIE,cAAA,CAAA,GAAkB,YAAA;;;;;;EAoBlB,QAAA,CAAA,GAAY,oBAAA;AAAA;AAAA,UAUG,oBAAA;EACf,WAAA;EACA,gBAAA;EACA,iBAAA;EACA,QAAA,EAAU,YAAY;AAAA;;;;;;;AFjId;AAkBV;;;;iBE6HgB,0BAAA,CACd,CAAA,EAAG,oBAAA,EACH,CAAA,EAAG,oBAAA,GACF,oBAAA;;;;;;;;;;;AHvLH;;;;AAMU;AACV;;;;AAAyD;AAEzD;;;;;;;;;;;UIRiB,UAAA;EJQK;EINpB,KAAA;;EAEA,QAAA;;EAEA,GAAA;;EAEA,MAAA;;EAEA,GAAA;AAAA;;cAIW,iBAAA,EAAmB,UAM/B;;;;;;;;;;;;;cAcY,gBAAA;;;;;;;cAQA,mBAAA;EAAA,QACH,KAAA;EAAA,QACA,SAAA;EAAA,QACA,WAAA;EAAA,iBACS,OAAA;;;;;;;;;;;;;;;;;;EAmBjB,MAAA,CAAO,KAAA;EAeP,QAAA,CAAA,GAAY,UAAU;AAAA;;;;AJ+CxB;;;;;;;;AAAoE;;;;AC9FpE;;;cGuFa,kCAAA;AAAA,UAEI,YAAA;EHvFJ;EGyFX,KAAA;EHxFC;EG0FD,UAAA;EH1FQ;EG4FR,UAAA;AAAA;;;;;;;;iBAUc,kBAAA,CAAmB,IAAA,EAAM,UAAA,EAAY,IAAA,EAAM,UAAA,GAAa,YAAA;;;AH1CjE;AAGP;;;;;iBG+DgB,eAAA,CAAgB,KAAA,YAAiB,UAAU;;;;cCrJ9C,kBAAA;AAAA,UAEI,gBAAA;;EAEf,WAAA;;EAEA,QAAA;;EAEA,MAAA,EAAQ,MAAA;;EAER,SAAA,EAAW,YAAA,CAAa,IAAA;;EAExB,IAAA,EAAM,eAAA;;EAEN,QAAA,EAAU,YAAA;;EAEV,OAAA;;EAEA,SAAA;;;;;;;EAOA,UAAA,IAAc,QAAA,EAAU,iBAAA;;EAExB,QAAA;;EAEA,MAAA,GAAS,WAAA;;EAET,YAAA,GAAe,YAAA;;;;;;;EAOf,cAAA,GAAiB,YAAA;;;;;;;;EAQjB,UAAA,GAAa,mBAAA;;;;;;;;;;;;;;;ALoEf;EKpDE,MAAA;IACE,IAAA,GAAO,IAAA,EAAM,MAAA,mBAAyB,GAAA;IACtC,IAAA,GAAO,IAAA,EAAM,MAAA,mBAAyB,GAAA;EAAA;AAAA;;;ALkD0B;;;UKzCnD,iBAAA;EACf,QAAA;EACA,aAAA;EACA,UAAA;EACA,WAAA;EJxDA;EI0DA,UAAA;EACA,gBAAA;EJzDC;EI2DD,cAAA;EJ3DQ;EI6DR,aAAA;EJ3CgC;EI6ChC,qBAAA;EJxBuB;;;;;;EI+BvB,SAAA;AAAA;;AJVK;AAGP;;UIciB,eAAA;EJGJ;EIDX,QAAA;EJCA;EICA,aAAA;EJDW;AAAA;AAWb;;;EIJE,UAAA;EJI2E;EIF3E,WAAA;EJE0E;;;;;EII1E,UAAA;EJJwF;EIMxF,gBAAA;;EAEA,MAAA,EAAQ,UAAA,CAAW,YAAA;;EAEnB,QAAA,EAAU,UAAA,CAAW,YAAA;EHrIZ;;;;;;;;EG8IT,KAAA,WAAgB,eAAA;;;;;;;;;;;;;EAahB,cAAA;EACA,eAAA;;;;;;;EAOA,UAAA,EAAY,UAAA;AAAA;;;;;;;;iBAUQ,SAAA,MAAA,CAAgB,OAAA,EAAS,gBAAA,CAAiB,IAAA,IAAQ,OAAA,CAAQ,eAAA;;;;;;;;;;;;;UC9J/D,oBAAA;EACf,EAAA;EACA,OAAA,EAAS,oBAAA;EACT,cAAA,CAAe,IAAA,EAAM,iBAAiB;AAAA;;;;;;;cAS3B,0BAAA,EAA0B,CAAA,CAAA,SAAA;;;;;;;KAG3B,oBAAA,GAAuB,CAAA,CAAE,KAAK,QAAQ,0BAAA;;;;;;;;;;;;cAarC,aAAA,EAAe,aAAa,CAAC,oBAAA;;;;;;;KAa9B,gBAAA,SAAyB,YAAA,GAAe,OAAA,CAAQ,YAAA;;;;;;;;;;;;;;;KAgBhD,mBAAA,SAA4B,OAAO;;;;;;;ANmD/C;;;;;;;;AAAoE;;;;AC9FpE;;;;;;;KKsEY,gBAAA,IACV,UAAA,aACG,OAAO;EAAG,SAAA;EAAmB,OAAA,SAAgB,OAAA;AAAA;;;;;;UAOjC,eAAA;EACf,WAAA;ELtCU;EKwCV,MAAA,EAAQ,MAAA;ELnBR;;AAAK;AAGP;;;EKuBE,MAAA,GAAS,MAAM;AAAA;;;;ALNJ;AAWb;;;;;;;;;;;;;KKeY,QAAA,IAAY,KAAA,WAAgB,eAAA,IAAmB,GAAA,EAAK,eAAA,KAAoB,OAAA;AAAA,UAEnE,sBAAA;;EAEf,UAAA,EAAY,eAAA;EJtJD;EIwJX,UAAA,EAAY,iBAAA;;EAEZ,QAAA,EAAU,gBAAA;;EAEV,OAAA;;;;;;EAMA,WAAA,GAAc,mBAAA;;;;;;EAMd,eAAA,GAAkB,gBAAA;EJxKe;;;;;;EI+KjC,QAAA,GAAW,QAAA;;EAEX,MAAA,GAAS,MAAA;AAAA;;;;;;;;;;;iBAaK,sBAAA,CACd,MAAA,EAAQ,sBAAA,IACN,GAAA,EAAK,oBAAA,KAAyB,OAAA;AAAA,UAqZjB,4BAAA;EACf,UAAA,EAAY,eAAA;;EAEZ,MAAA,GAAS,MAAM;AAAA;;;;;;;;;;;;;;;AJ/kBjB;;;;;;;;AAAoE;AAgBpE;;;;;;;;;AAEqD;AAYrD;;;;iBIylBgB,4BAAA,CACd,MAAA,EAAQ,4BAAA,GACP,qBAAqB"}
|
package/dist/worker.d.mts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { a as ImportsRunJobContext, c as PairSinkContext, d as createRunImportHandler, f as importsRunJob, i as ImportsRunDeadListenerConfig, l as RunImportHandlerConfig, n as EsClientResolver, o as ImportsRunJobPayload, p as importsRunJobPayloadSchema, r as FilePathResolver, s as PairSink, t as EnsureIndexResolver, u as createImportsRunDeadListener } from "./worker-
|
|
1
|
+
import { a as ImportsRunJobContext, c as PairSinkContext, d as createRunImportHandler, f as importsRunJob, i as ImportsRunDeadListenerConfig, l as RunImportHandlerConfig, n as EsClientResolver, o as ImportsRunJobPayload, p as importsRunJobPayloadSchema, r as FilePathResolver, s as PairSink, t as EnsureIndexResolver, u as createImportsRunDeadListener } from "./worker-DH3SN-91.mjs";
|
|
2
2
|
export { EnsureIndexResolver, EsClientResolver, FilePathResolver, ImportsRunDeadListenerConfig, ImportsRunJobContext, ImportsRunJobPayload, PairSink, PairSinkContext, RunImportHandlerConfig, createImportsRunDeadListener, createRunImportHandler, importsRunJob, importsRunJobPayloadSchema };
|
package/dist/worker.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import{
|
|
1
|
+
import{_ as e,c as t,d as n,f as r,h as i,n as a,s as o}from"./runner-wPnCZlB4.mjs";import{a as s,i as c,r as l}from"./publish-DCXIPwTE.mjs";import{runAsCli as u}from"@murumets-ee/core";import{z as d}from"zod";import{and as f,desc as p,eq as m}from"drizzle-orm";import{defineJob as h}from"@murumets-ee/queue/client";const g=d.object({importRunId:d.string().uuid()}),_=h({name:`imports:run`,description:`Stream a feed file, transform rows, bulk-write to Elasticsearch.`,schema:g,defaultRetries:0});function v(l){let{importRuns:d,transforms:h,esClient:g,esIndex:_,resolveFilePath:v,ensureIndex:b,pairSink:x,logger:S}=l;return async l=>u(async()=>{let{importRunId:u}=l.payload,C=S?.child({jobId:l.id,importRunId:u,type:`imports:run`}),w=await d.findById(u);if(!w){C?.warn(`import_run row not found — skipping`);return}w.status===`running`&&C?.warn({previousQueueJobId:w.queueJobId,previousStartedAt:w.startedAt},`imports: re-claiming a row left in running by a prior worker — likely a crash recovered by queue lock-recovery`);let T=h.get(w.transformName);if(!T)throw await y(d,u,`Unknown transform "${w.transformName}"`),Error(`No transform registered for name "${w.transformName}"`);let E=w.params&&typeof w.params==`object`&&!Array.isArray(w.params)?w.params:{},D=i(E);if(!D.success){let e=D.error.issues[0]?.message??`invalid feed reader config`;throw await y(d,u,`Invalid params.feed: ${e}`),Error(`Invalid params.feed for import_run ${u}: ${e}`)}let O=D.data;await d.update(u,{status:`running`,queueJobId:l.id,startedAt:new Date}),c(u);let k;try{k=await g()}catch(e){throw await y(d,u,`Could not resolve ES client: ${e instanceof Error?e.message:String(e)}`),e}if(b)try{await b()}catch(e){throw await y(d,u,`Could not ensure ES index/alias: ${e instanceof Error?e.message:String(e)}`),e}let A,j;if(v)try{let e=await v(w.filePath);A=e.localPath,j=e.cleanup}catch(e){throw await y(d,u,`Could not resolve filePath: ${e instanceof Error?e.message:String(e)}`),e}else A=w.filePath;let M;if(O.format!==`xlsx`)try{M=await e(A,O.hasHeader===void 0?{}:{hasHeader:O.hasHeader}),C?.info({importRunId:u,totalRows:M},`imports: pre-pass row count`)}catch(e){C?.warn({err:e},`imports: pre-pass row count failed — continuing without total`)}let N;try{let e={filePath:A,...O.format!==void 0&&{format:O.format},...O.delimiter!==void 0&&{delimiter:O.delimiter},...O.hasHeader!==void 0&&{hasHeader:O.hasHeader},...O.headerRowIndex!==void 0&&{headerRowIndex:O.headerRowIndex},...O.sheet!==void 0&&{sheet:O.sheet}};N=await a({importRunId:u,runLabel:w.label,params:E,transform:T,feed:e,esClient:k,esIndex:_,onProgress:e=>l.updateProgress(M===void 0?e:{...e,totalRows:M}),...C!==void 0&&{logger:C}})}catch(e){throw await y(d,u,e instanceof Error?e.message:String(e)),j&&await j().catch(e=>{C?.warn({err:e},`filePath cleanup after failed run threw — ignoring`)}),e}j&&await j().catch(e=>{C?.warn({err:e},`filePath cleanup after successful run threw — ignoring`)});let P=N.warnings,F=typeof E.brandId==`string`?E.brandId:void 0,I=typeof E.supplierId==`string`?E.supplierId:void 0;if(F&&I&&N.priceStats.median!==null)try{let e=d.getTable(),i=await d.findMany({where:f(m(e.transformName,w.transformName),m(e.status,`succeeded`)),orderBy:p(e.createdAt),limit:50}),a=i.find(e=>{if(e.id===u)return!1;let t=e.params;if(!t||typeof t!=`object`||Array.isArray(t))return!1;let n=t;return n.brandId===F&&n.supplierId===I});!a&&i.length>=50&&C?.warn({brandId:F,supplierId:I,transformName:w.transformName},`imports: price-drift check found no prior run for this (brand, supplier) within the 50 most recent succeeded runs for this transform — a real prior run may exist beyond the bound, not necessarily absent`);let s=a?t(a.priceStats):null,c=s?o(s,N.priceStats):null;if(c){let e=c.ratio>=1,t=new n;t.addError(-1,`price_anomaly`,`Median purchase price is ${(e?c.ratio:1/c.ratio).toFixed(1)}x ${e?`higher`:`lower`} than the previous successful run for this supplier/brand (previous run ${a?.id??`unknown`}: median ${c.prevMedian}; this run: median ${c.currMedian}, minor units). Check for a decimal or thousands-separator format change in the source feed.`,`priceStats.median`,{previousImportRunId:a?.id??null,previousMedian:c.prevMedian,currentMedian:c.currMedian,ratio:c.ratio}),P=r(N.warnings,t.snapshot()),C?.warn({previousImportRunId:a?.id,ratio:c.ratio},`imports: price_anomaly — median purchase price shifted by a magnitude vs the prior run`)}}catch(e){C?.warn({err:e},`imports: price-drift comparison failed — continuing without it`)}if(await d.update(u,{status:`succeeded`,finishedAt:new Date,totals:{rowsRead:N.rowsRead,rowsSucceeded:N.rowsSucceeded,rowsFailed:N.rowsFailed,rowsSkipped:N.rowsSkipped,rowsWarned:N.rowsWarned,batchesCompleted:N.batchesCompleted,patchesApplied:N.patchesApplied,patchesOrphaned:N.patchesOrphaned},errorSummary:JSON.parse(JSON.stringify(N.errors)),warningSummary:JSON.parse(JSON.stringify(P)),priceStats:JSON.parse(JSON.stringify(N.priceStats))}),s(u,{rowsRead:N.rowsRead,rowsSucceeded:N.rowsSucceeded,rowsFailed:N.rowsFailed}),x&&N.pairs.length>0)try{await x(N.pairs,{importRunId:u,params:E,...C!==void 0&&{logger:C}}),C?.info({importRunId:u,pairs:N.pairs.length},`imports: pairSink invoked`)}catch(e){C?.warn({importRunId:u,pairs:N.pairs.length,err:e},`imports: pairSink threw — replacement ingestion incomplete, catalogue import unaffected`)}C?.info({rowsRead:N.rowsRead,rowsSucceeded:N.rowsSucceeded,rowsFailed:N.rowsFailed,rowsSkipped:N.rowsSkipped,batches:N.batchesCompleted},`import_run completed`)})}async function y(e,t,n){try{await e.update(t,{status:`failed`,finishedAt:new Date,errorSummary:{fatal:n}}),l(t,n)}catch{}}function b(e){let{importRuns:t,logger:n}=e;return{onJobDead:async e=>{e.jobType===`imports:run`&&await u(async()=>{let r=n?.child({event:`imports.run.dead`,jobId:e.jobId,type:`imports:run`}),i=t.getTable(),a=(await t.findMany({where:m(i.queueJobId,e.jobId),limit:1}))[0];if(!a){r?.warn(`queue.job.dead with no matching import_run — skipping`);return}if(a.status!==`running`&&a.status!==`pending`){r?.info({importRunId:a.id,status:a.status},`queue.job.dead arrived for a row already in a terminal state — leaving as-is`);return}a.status===`pending`&&r?.warn({importRunId:a.id,attempts:e.attempts},`queue.job.dead arrived for an import_run still in pending — worker crashed BEFORE claim/lookup`),await y(t,a.id,e.error),r?.info({importRunId:a.id,attempts:e.attempts,priorStatus:a.status},`queue.job.dead → import_run marked failed`)})}}}export{b as createImportsRunDeadListener,v as createRunImportHandler,_ as importsRunJob,g as importsRunJobPayloadSchema};
|
|
2
2
|
//# sourceMappingURL=worker.mjs.map
|
package/dist/worker.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"worker.mjs","names":[],"sources":["../src/worker.ts"],"sourcesContent":["/**\n * Queue handler for `imports:run`. The route handler that creates the\n * `import_run` row enqueues this job carrying `{ importRunId }`; the\n * worker module here resolves the run, the transform, and the ES\n * client, then runs `runImport` and writes results back.\n *\n * Per PLAN-ECOMMERCE.md PR 7 (PoC scope): one job type, no resumability.\n * A failed handler attempt re-runs from row 1 on retry — fine for the\n * tens-of-MB feed sizes the PoC exercises.\n *\n * Per CLAUDE.md \"leaf packages\" boundary rule: this handler reads the\n * import_run row via an injected `AdminClient`, NOT a direct `db.select`.\n * Construction happens at plugin init in `plugin.ts`.\n */\n\nimport { type Logger, runAsCli } from '@murumets-ee/core'\nimport {\n defineJob,\n type JobDefinition,\n type QueueJobDeadEvent,\n type QueueTerminalNotifier,\n} from '@murumets-ee/queue/client'\nimport type { EsClientLike } from '@murumets-ee/search-elasticsearch'\nimport { eq } from 'drizzle-orm'\nimport type { PgColumn } from 'drizzle-orm/pg-core'\nimport { z } from 'zod'\nimport type { ImportRunClient, ImportRunStatus } from './entities/import-run.js'\nimport { type ReadFeedOptions, readPersistedFeedConfig } from './read-feed.js'\nimport {\n publishImportRunFailed,\n publishImportRunStarted,\n publishImportRunSucceeded,\n} from './realtime/publish.js'\nimport { type ImportRunProgress, runImport } from './runner.js'\nimport { countDataRows } from './streaming.js'\nimport type { DirectionalPair, TransformRegistry } from './transform.js'\n\n/**\n * Structural subset of `JobContext` the handler reads. Declared\n * locally so we don't depend on a `JobHandler` import — the queue\n * package doesn't re-export it from any subpath today (matches the\n * `SendEmailJobContext` pattern in `@murumets-ee/notifications`).\n *\n * Function-param contravariance lets a handler returned with this\n * narrower shape be assigned to the wider `JobHandler<TPayload>` that\n * `registerJob` expects.\n */\nexport interface ImportsRunJobContext {\n id: string\n payload: ImportsRunJobPayload\n updateProgress(data: ImportRunProgress): void\n}\n\n/**\n * Payload schema. `importRunId` is the `import_run.id` UUID — the worker\n * looks up everything else (filePath, transformName, params, …) from\n * that row. Keeping the payload tiny means the queue's progress JSON\n * column never bloats with feed metadata duplicated across `toolkit_jobs`.\n */\nexport const importsRunJobPayloadSchema = z.object({\n importRunId: z.string().uuid(),\n})\nexport type ImportsRunJobPayload = z.infer<typeof importsRunJobPayloadSchema>\n\n/**\n * Job definition. Consumers register their handler against this with\n * `registerJob(importsRunJob, createRunImportHandler({...}))`.\n *\n * `defaultRetries: 0` — re-running a multi-batch import against the\n * same `import_run.id` while the previous handler may still be writing\n * is a footgun (duplicate batches, double-counted progress). The\n * operator handles retries explicitly via PR 8a's \"retry\" button by\n * creating a new `import_run` row. When the resumable design lands\n * (post-PoC), retries become safe to enable.\n */\nexport const importsRunJob: JobDefinition<ImportsRunJobPayload> = defineJob({\n name: 'imports:run',\n description: 'Stream a feed file, transform rows, bulk-write to Elasticsearch.',\n schema: importsRunJobPayloadSchema,\n defaultRetries: 0,\n})\n\n/**\n * Resolves the ES client at handler-invocation time. A function (rather\n * than the bare `EsClientLike`) so the consumer can lazy-construct the\n * client — typical Next.js setups create the ES connection in a\n * route-handler initialiser, not at plugin-init time.\n */\nexport type EsClientResolver = () => EsClientLike | Promise<EsClientLike>\n\n/**\n * Ensure the destination index/alias exists before the first bulk-write.\n * Invoked once per job, BEFORE {@link runImport}. Idempotent: the canonical\n * implementation (`ensureAliasedIndex` in `@murumets-ee/search-elasticsearch`)\n * no-ops when the alias already resolves.\n *\n * **Why it lives here.** This is the home of the foot-gun it guards: a worker\n * that bulk-writes to alias `<x>` before the alias exists makes ES\n * auto-create a physical `<x>` index with default `text` mappings, breaking\n * aggregations forever. The bootstrap used to run at app boot, which turned\n * \"ES is down\" into a full-admin 500 (issue #610). Moving it onto the import\n * path keeps the guarantee while scoping ES availability to imports/search —\n * the rest of the admin boots and runs with ES down.\n */\nexport type EnsureIndexResolver = () => Promise<unknown>\n\n/**\n * Resolves the value of `import_run.filePath` (whatever the upload\n * route persisted there — typically a storage adapter key) to a\n * readable LOCAL filesystem path that {@link runImport} can hand to\n * `node:fs.createReadStream`.\n *\n * **Why this exists:** the streaming reader (`streamFeed`) reads from\n * local disk via `createReadStream`. The upload route may persist a\n * remote-storage object key (R2, S3, …) on `import_run.filePath`\n * because that's what `@murumets-ee/storage` returns. Without a\n * resolver, `createReadStream('uploads/2026/05/<uuid>/feed.txt')`\n * crashes with `ENOENT`. The resolver is the documented integration\n * point — typical wiring downloads the storage object to a tmpfile\n * and returns its path.\n *\n * **`cleanup`** runs after the run finishes (success OR failure). The\n * worker awaits it best-effort — a failed cleanup logs but does not\n * crash the run.\n *\n * **No-resolver fallback:** when `resolveFilePath` is unset, the\n * worker treats `import_run.filePath` as already a local FS path\n * (back-compat with the original PoC design where uploads landed on\n * local disk). This stays valid for fixture-driven tests + on-disk\n * deployments.\n */\nexport type FilePathResolver = (\n storageKey: string,\n) => Promise<{ localPath: string; cleanup?: () => Promise<void> }>\n\n/**\n * Context handed to a {@link PairSink} alongside the harvested pairs. Carries the\n * run identity so the sink can build `source` attribution\n * (`feed:<supplierId>:<importBatchId>`, D20) and scope its writes.\n */\nexport interface PairSinkContext {\n importRunId: string\n /** The `import_run.params` object — the sink reads `supplierId` / `importBatchId`. */\n params: Record<string, unknown>\n /**\n * The run's structured logger (child), forwarded so the sink's INTERNAL telemetry —\n * a params-parse miss that skips ingestion, an over-merge flag — is visible. Without it\n * a misconfigured run looks successful from the worker's vantage (a params-miss returns\n * normally, so the worker logs \"applied\"). Optional; the sink no-ops its logs if absent.\n */\n logger?: Logger\n}\n\n/**\n * Optional post-run consumer for the directional supersession pairs the runner\n * harvested as a side-output (commerce-demo PR6, SD007). Invoked ONCE after a\n * successful run with the full in-memory pair buffer (read-once / merge-after) so\n * the consumer (the commerce replacement-merge engine) can build equivalence groups\n * and bulk-write them to Postgres.\n *\n * **Why injected, not imported.** The merge writes the CONSUMER's Postgres tables,\n * but the runner + this worker live in the `@murumets-ee/imports` leaf, which must\n * not depend on commerce. The app composes the sink (commerce exports it, the app\n * passes it to `imports({ pairSink })`) — the same injection shape as `esClient` /\n * `resolveFilePath` / `ensureIndex`. The boundary holds.\n *\n * **Best-effort.** A sink throw is logged and SWALLOWED — the catalogue import\n * (the ES write) has already succeeded and must not be undone by a secondary\n * replacement-ingestion failure. The merge is idempotent, so a re-import recovers.\n */\nexport type PairSink = (pairs: readonly DirectionalPair[], ctx: PairSinkContext) => Promise<void>\n\nexport interface RunImportHandlerConfig {\n /** AdminClient over the `import_run` entity. */\n importRuns: ImportRunClient\n /** Transform registry to dispatch against. Defaults to the process-global singleton. */\n transforms: TransformRegistry\n /** Resolver for the ES client. */\n esClient: EsClientResolver\n /** ES alias / index to bulk-write into. Per D6 callers always pass an alias. */\n esIndex: string\n /**\n * Optional bootstrap for the destination index/alias, run once per job\n * before any bulk-write. See {@link EnsureIndexResolver}. Omit for\n * deployments that bootstrap the index out-of-band (e.g. a reindex job).\n */\n ensureIndex?: EnsureIndexResolver\n /**\n * Optional resolver for `import_run.filePath`. See {@link FilePathResolver}.\n * Required when uploads land in remote storage (R2/S3); optional for\n * on-disk PoC setups.\n */\n resolveFilePath?: FilePathResolver\n /**\n * Optional consumer for the directional supersession pairs harvested as a\n * side-output (commerce-demo PR6, SD007). See {@link PairSink}. Omit for\n * deployments that don't ingest replacement codes — the pairs are simply\n * discarded after the run.\n */\n pairSink?: PairSink\n /** Optional structured logger. Defaults to silent. */\n logger?: Logger\n}\n\n/**\n * Build the `JobHandler` for {@link importsRunJob}. The returned\n * handler is what gets passed to `registerJob`.\n *\n * The body is wrapped in `runAsCli` so AdminClient calls inside have a\n * synthetic `cli` admin context — `auditable()` records `updatedBy:\n * 'cli'` rather than NULL, and the firewall checker passes. This is\n * the documented worker entry-point pattern (see `runAsCli` JSDoc in\n * `@murumets-ee/core`).\n */\nexport function createRunImportHandler(\n config: RunImportHandlerConfig,\n): (job: ImportsRunJobContext) => Promise<void> {\n const {\n importRuns,\n transforms,\n esClient,\n esIndex,\n resolveFilePath,\n ensureIndex,\n pairSink,\n logger,\n } = config\n\n return async (job: ImportsRunJobContext): Promise<void> =>\n runAsCli(async () => {\n const { importRunId } = job.payload\n const log = logger?.child({ jobId: job.id, importRunId, type: 'imports:run' })\n\n const run = await importRuns.findById(importRunId)\n if (!run) {\n // Row was deleted between enqueue and dispatch — nothing to do.\n // Don't throw: a thrown handler retries with no payoff because\n // the row stays gone. Log + return so the queue marks the job\n // completed.\n log?.warn('import_run row not found — skipping')\n return\n }\n\n // Re-claim detection. The queue's `recoverStuckJobs` sweep flips a\n // crashed worker's `toolkit_jobs` row from `processing` back to\n // `pending` — but the queue layer has no knowledge of `import_run`,\n // so this row stays at `status='running'` from the prior attempt.\n // When the queue re-dispatches the job, we land here with a row\n // that the operator-facing Active panel has been showing as live\n // for however long lock-recovery took (default 30 min). The\n // existing `update` below overwrites `queueJobId` + `startedAt` so\n // there is no STATE corruption — but emit a structured warning so\n // ops can correlate this re-run with the worker crash that\n // produced it. The sibling `sweepStaleImportRuns` job below catches\n // the case where re-dispatch never happens (queue marks the job\n // dead, or the queue worker is offline long-term).\n if (run.status === 'running') {\n log?.warn(\n {\n previousQueueJobId: run.queueJobId,\n previousStartedAt: run.startedAt,\n },\n 'imports: re-claiming a row left in running by a prior worker — likely a crash recovered by queue lock-recovery',\n )\n }\n\n // `transformName`, `filePath`, `label` are declared `field.text({\n // required: true })` so the inferred DTO already types them as\n // `string` — no cast needed. `params` is `field.json()` which\n // infers as `JsonValue | undefined`; narrow to a record so the\n // transform receives an object.\n const transform = transforms.get(run.transformName)\n if (!transform) {\n await markFailed(importRuns, importRunId, `Unknown transform \"${run.transformName}\"`)\n throw new Error(`No transform registered for name \"${run.transformName}\"`)\n }\n const params: Record<string, unknown> =\n run.params && typeof run.params === 'object' && !Array.isArray(run.params)\n ? (run.params as Record<string, unknown>)\n : {}\n\n // Reader config (format/delimiter/header/sheet) travels in\n // `params.feed` for mapping-driven runs (commerce-demo PR3). Carmaker\n // runs omit it → empty config → tab-delimited default, so the existing\n // path is byte-for-byte unchanged. Re-validate here as defense-in-depth\n // even though the enqueuing route already validated the saved mapping.\n // Shared with the #842 audit, which has to rehydrate IDENTICALLY or it\n // re-reads a past run with options that run never used.\n const feedParsed = readPersistedFeedConfig(params)\n if (!feedParsed.success) {\n const reason = feedParsed.error.issues[0]?.message ?? 'invalid feed reader config'\n await markFailed(importRuns, importRunId, `Invalid params.feed: ${reason}`)\n throw new Error(`Invalid params.feed for import_run ${importRunId}: ${reason}`)\n }\n const feedConfig = feedParsed.data\n\n await importRuns.update(importRunId, {\n status: 'running' satisfies ImportRunStatus,\n queueJobId: job.id,\n startedAt: new Date(),\n })\n // Realtime: row transitioned pending → running. Subscribers\n // refetch to surface the change in the imports admin page.\n // Best-effort — a publish failure is logged inside `publishSafe`\n // and does NOT roll back the row update.\n publishImportRunStarted(importRunId)\n\n let resolvedClient: EsClientLike\n try {\n resolvedClient = await esClient()\n } catch (err) {\n const reason = err instanceof Error ? err.message : String(err)\n await markFailed(importRuns, importRunId, `Could not resolve ES client: ${reason}`)\n throw err\n }\n\n // Ensure the destination alias exists BEFORE any bulk-write. Moved here\n // from app boot (issue #610): the foot-gun is the worker auto-creating a\n // mis-mapped physical index, so the guard belongs on the import path —\n // not gating the whole app on ES. Idempotent + scoped: an ES outage (or\n // the `ensureAliasedIndex` physical-collision guard firing) fails THIS\n // job loudly while the rest of the admin keeps working.\n if (ensureIndex) {\n try {\n await ensureIndex()\n } catch (err) {\n const reason = err instanceof Error ? err.message : String(err)\n await markFailed(importRuns, importRunId, `Could not ensure ES index/alias: ${reason}`)\n throw err\n }\n }\n\n // Materialise the file locally if the consumer wired a resolver.\n // The `cleanup` callback (when present) runs in the `finally`\n // below regardless of run outcome so a tmpfile created here\n // doesn't outlive the handler.\n let localPath: string\n let fileCleanup: (() => Promise<void>) | undefined\n if (resolveFilePath) {\n try {\n const resolved = await resolveFilePath(run.filePath)\n localPath = resolved.localPath\n fileCleanup = resolved.cleanup\n } catch (err) {\n const reason = err instanceof Error ? err.message : String(err)\n await markFailed(importRuns, importRunId, `Could not resolve filePath: ${reason}`)\n throw err\n }\n } else {\n // Back-compat: no resolver wired → treat `filePath` as already\n // a local FS path. Fine for fixture-driven tests + the original\n // on-disk PoC design.\n localPath = run.filePath\n }\n\n // Pre-pass line count: a 16 MB tab-delimited feed counts in\n // ~50ms; a 500 MB feed in <2s. Cheap relative to the full\n // import. Gives the operator a denominator (`processed / total`)\n // so the imports-page panel can render an actual percent\n // instead of just \"rows so far\".\n // Pre-pass row count is a `\\n`-tally over a delimited file — meaningless\n // on an xlsx binary, so skip it there (the UI falls back to bare counts).\n // `hasHeader` from the feed config keeps the header-exclusion correct for\n // headerless mappings.\n let totalRows: number | undefined\n if (feedConfig.format !== 'xlsx') {\n try {\n totalRows = await countDataRows(\n localPath,\n feedConfig.hasHeader !== undefined ? { hasHeader: feedConfig.hasHeader } : {},\n )\n log?.info({ importRunId, totalRows }, 'imports: pre-pass row count')\n } catch (err) {\n // Don't fail the run on a count miss — the actual import is\n // unaffected; the operator just loses the percent denominator.\n log?.warn({ err }, 'imports: pre-pass row count failed — continuing without total')\n }\n }\n\n let result: Awaited<ReturnType<typeof runImport>>\n try {\n // Build feed options with the conditional-spread idiom (NOT\n // `...feedConfig`) so absent optionals stay ABSENT — spreading a\n // `T | undefined` value would set the key to `undefined`, which\n // `exactOptionalPropertyTypes` rejects against `ReadFeedOptions`.\n const feedOpts: ReadFeedOptions = {\n filePath: localPath,\n ...(feedConfig.format !== undefined && { format: feedConfig.format }),\n ...(feedConfig.delimiter !== undefined && { delimiter: feedConfig.delimiter }),\n ...(feedConfig.hasHeader !== undefined && { hasHeader: feedConfig.hasHeader }),\n ...(feedConfig.headerRowIndex !== undefined && {\n headerRowIndex: feedConfig.headerRowIndex,\n }),\n ...(feedConfig.sheet !== undefined && { sheet: feedConfig.sheet }),\n }\n result = await runImport({\n importRunId,\n runLabel: run.label,\n params,\n transform,\n feed: feedOpts,\n esClient: resolvedClient,\n esIndex,\n onProgress: (progress) =>\n job.updateProgress(totalRows !== undefined ? { ...progress, totalRows } : progress),\n ...(log !== undefined && { logger: log }),\n })\n } catch (err) {\n const reason = err instanceof Error ? err.message : String(err)\n await markFailed(importRuns, importRunId, reason)\n if (fileCleanup) {\n await fileCleanup().catch((cleanupErr: unknown) => {\n log?.warn({ err: cleanupErr }, 'filePath cleanup after failed run threw — ignoring')\n })\n }\n throw err\n }\n\n // Cleanup runs before the success-update so a cleanup throw\n // surfaces as a logged warning rather than a crashed handler at\n // the worst possible moment (just after the row was about to be\n // marked succeeded).\n if (fileCleanup) {\n await fileCleanup().catch((cleanupErr: unknown) => {\n log?.warn({ err: cleanupErr }, 'filePath cleanup after successful run threw — ignoring')\n })\n }\n\n await importRuns.update(importRunId, {\n status: 'succeeded' satisfies ImportRunStatus,\n finishedAt: new Date(),\n totals: {\n rowsRead: result.rowsRead,\n rowsSucceeded: result.rowsSucceeded,\n rowsFailed: result.rowsFailed,\n rowsSkipped: result.rowsSkipped,\n rowsWarned: result.rowsWarned,\n batchesCompleted: result.batchesCompleted,\n // Phase-2 core-charge attach (commerce-demo PR7) — durable on the run row so\n // an operator sees how many `*S` deposits attached vs orphaned, not just the\n // orphan warnings in `warningSummary`.\n patchesApplied: result.patchesApplied,\n patchesOrphaned: result.patchesOrphaned,\n },\n // The typed `ErrorTrackerSnapshot` is fully JSON-serialisable but\n // doesn't structurally satisfy entity's recursive `JsonValue`\n // (objects without an index signature aren't `Record<string,\n // JsonValue>`). Round-trip through `JSON.parse(JSON.stringify(...))`\n // to land as the recursive JSON shape — the runtime cost is one\n // serialise per import_run completion, the type-cast cost is zero.\n errorSummary: JSON.parse(JSON.stringify(result.errors)),\n // Non-fatal warnings (commerce-demo PR4) — kept separate from\n // `errorSummary` so a `no_purchase_price` advisory never reads as a\n // failed row. Same serialise-roundtrip rationale as `errorSummary`.\n warningSummary: JSON.parse(JSON.stringify(result.warnings)),\n })\n // Realtime: row transitioned to terminal-success. Subscribers\n // refetch to move the row from Active → Recent.\n publishImportRunSucceeded(importRunId, {\n rowsRead: result.rowsRead,\n rowsSucceeded: result.rowsSucceeded,\n rowsFailed: result.rowsFailed,\n })\n\n // Side-output: hand the harvested supersession pairs to the injected sink\n // (commerce-demo PR6, SD007) AFTER the catalogue run is marked succeeded — the\n // ES import is primary; replacement ingestion is secondary. Best-effort: a sink\n // throw is logged + swallowed so a Postgres hiccup can't undo a successful\n // catalogue import (the merge is idempotent → a re-import recovers).\n if (pairSink && result.pairs.length > 0) {\n try {\n await pairSink(result.pairs, {\n importRunId,\n params,\n ...(log !== undefined && { logger: log }),\n })\n // \"invoked\" not \"applied\": the sink may internally skip (e.g. missing run params);\n // it logs its own applied/skipped outcome via the forwarded logger.\n log?.info({ importRunId, pairs: result.pairs.length }, 'imports: pairSink invoked')\n } catch (err) {\n log?.warn(\n { importRunId, pairs: result.pairs.length, err },\n // NOT \"skipped\": a sink may apply part of the batch before failing (the commerce\n // replacement sink commits per batch), so some pairs can already have landed.\n // \"Incomplete\" is the only claim this boundary can honestly make about a sink\n // whose internals it does not know.\n 'imports: pairSink threw — replacement ingestion incomplete, catalogue import unaffected',\n )\n }\n }\n\n log?.info(\n {\n rowsRead: result.rowsRead,\n rowsSucceeded: result.rowsSucceeded,\n rowsFailed: result.rowsFailed,\n rowsSkipped: result.rowsSkipped,\n batches: result.batchesCompleted,\n },\n 'import_run completed',\n )\n })\n}\n\n/** Write a `failed` row with a fatal error reason. Best-effort — logs but does not throw on DB failure. */\nasync function markFailed(importRuns: ImportRunClient, id: string, reason: string): Promise<void> {\n try {\n await importRuns.update(id, {\n status: 'failed' satisfies ImportRunStatus,\n finishedAt: new Date(),\n errorSummary: { fatal: reason },\n })\n // Realtime: row transitioned to terminal-failure. Subscribers\n // refetch to surface the failure in the Recent panel. Publish\n // is best-effort and only fires when the persist succeeds —\n // skipping it on persist failure keeps subscribers consistent\n // with the DB (no event for a row that didn't transition).\n publishImportRunFailed(id, reason)\n } catch {\n // The handler already threw / will throw — the queue will surface\n // the original error. A second failure here just logs noise.\n }\n}\n\nexport interface ImportsRunDeadListenerConfig {\n importRuns: ImportRunClient\n /** Optional structured logger. */\n logger?: Logger\n}\n\n/**\n * Build a {@link QueueTerminalNotifier} that mirrors `imports:run`\n * dead-job events into `import_run.status='failed'`.\n *\n * **Why this exists.** The `imports:run` handler's normal failure path\n * already calls `markFailed` before re-throwing, so a synchronous\n * handler error reaches the operator-visible `failed` status without\n * help from anyone. The case THIS listener catches is the one\n * `markFailed` never gets to: the worker process is killed mid-handler\n * (OOM, container restart, kill -9), the queue's lock-recovery sweep\n * eventually reclaims the row, and the queue ultimately marks the job\n * `dead` (defaultRetries: 0 + repeated process death = dead-letter\n * with `import_run.status` still `running`). Without this listener\n * the row sits in the operator's Active panel forever.\n *\n * **Generic primitive.** This is a thin subscriber over the queue's\n * multi-subscriber `QueueTerminalNotifier` registry — the same shape\n * graphile-worker (`job:failed`) and BullMQ (`failed`) expose. Other\n * domain plugins that mirror queue state (ticketing-reply, future\n * exports/reports/...) can register their own listeners exactly the\n * same way without coordinating via a per-domain sweep job.\n *\n * **Filtering.** The listener is invoked for EVERY dead event in the\n * process. We early-return on any `jobType` other than `imports:run`\n * so the per-event cost on unrelated jobs is a single property read.\n *\n * **Idempotency.** A dead event delivered after the row has already\n * reached its own terminal state (succeeded / failed / cancelled) is\n * ignored — the only transition we make is `running → failed`. This\n * keeps a late replay or out-of-order delivery from clobbering a\n * row whose worker reached `markFailed` faster than the queue's\n * dead-letter dispatch.\n *\n * The handler is wrapped in `runAsCli` so AdminClient calls inside\n * have a synthetic CLI admin context — same pattern as\n * {@link createRunImportHandler}.\n */\nexport function createImportsRunDeadListener(\n config: ImportsRunDeadListenerConfig,\n): QueueTerminalNotifier {\n const { importRuns, logger } = config\n\n return {\n onJobDead: async (event: QueueJobDeadEvent): Promise<void> => {\n if (event.jobType !== 'imports:run') return\n\n await runAsCli(async () => {\n const log = logger?.child({\n event: 'imports.run.dead',\n jobId: event.jobId,\n type: 'imports:run',\n })\n\n // Look up the mirror row by `queueJobId`. Cap to 1 — the\n // upload route writes one `import_run` per enqueued job.\n //\n // `AdminClient.getTable()` returns `PgTableWithColumns<any>`\n // because entity tables have dynamic column shapes (see the\n // `getTable()` JSDoc in `packages/entity/src/admin/client.ts`\n // and the documented-`any`-cases list in CLAUDE.md). Cast\n // the table to a structural shape covering only the column\n // we touch, so both `eq()` and type-coverage see a typed\n // operand instead of an `any`-derived property access.\n const table = importRuns.getTable() as unknown as { queueJobId: PgColumn }\n const matches = await importRuns.findMany({\n where: eq(table.queueJobId, event.jobId),\n limit: 1,\n })\n const row = matches[0]\n if (!row) {\n // No mirror row for this dead job. Either it was deleted\n // by the operator, or the dead event is for a job\n // unrelated to imports despite the type filter\n // (shouldn't happen, but is cheap to log).\n log?.warn('queue.job.dead with no matching import_run — skipping')\n return\n }\n\n // Only transition rows that haven't already reached their\n // own terminal state. A late dead-event must not overwrite\n // a `succeeded` row that the worker reached via the normal\n // path before the queue's dead-letter dispatch fired.\n if (row.status !== 'running' && row.status !== 'pending') {\n log?.info(\n { importRunId: row.id, status: row.status },\n 'queue.job.dead arrived for a row already in a terminal state — leaving as-is',\n )\n return\n }\n\n // Dead-from-`pending` is the rarer + more interesting case:\n // the worker process died (or never claimed) BEFORE it could\n // flip the row to `running`. Log it at warn so ops can spot\n // a queue+worker-startup pathology that the standard `running\n // → failed` transition wouldn't surface. Dead-from-`running`\n // is the documented \"crash mid-import\" case — info is enough.\n if (row.status === 'pending') {\n log?.warn(\n { importRunId: row.id, attempts: event.attempts },\n 'queue.job.dead arrived for an import_run still in pending — worker crashed BEFORE claim/lookup',\n )\n }\n\n await markFailed(importRuns, row.id, event.error)\n log?.info(\n { importRunId: row.id, attempts: event.attempts, priorStatus: row.status },\n 'queue.job.dead → import_run marked failed',\n )\n })\n },\n }\n}\n"],"mappings":"wQA2DA,MAAa,EAA6B,EAAE,OAAO,CACjD,YAAa,EAAE,OAAO,CAAC,CAAC,KAAK,CAC/B,CAAC,EAcY,EAAqD,EAAU,CAC1E,KAAM,cACN,YAAa,mEACb,OAAQ,EACR,eAAgB,CAClB,CAAC,EAsID,SAAgB,EACd,EAC8C,CAC9C,GAAM,CACJ,aACA,aACA,WACA,UACA,kBACA,cACA,WACA,UACE,EAEJ,OAAO,KAAO,IACZ,EAAS,SAAY,CACnB,GAAM,CAAE,eAAgB,EAAI,QACtB,EAAM,GAAQ,MAAM,CAAE,MAAO,EAAI,GAAI,cAAa,KAAM,aAAc,CAAC,EAEvE,EAAM,MAAM,EAAW,SAAS,CAAW,EACjD,GAAI,CAAC,EAAK,CAKR,GAAK,KAAK,qCAAqC,EAC/C,MACF,CAeI,EAAI,SAAW,WACjB,GAAK,KACH,CACE,mBAAoB,EAAI,WACxB,kBAAmB,EAAI,SACzB,EACA,gHACF,EAQF,IAAM,EAAY,EAAW,IAAI,EAAI,aAAa,EAClD,GAAI,CAAC,EAEH,MADA,MAAM,EAAW,EAAY,EAAa,sBAAsB,EAAI,cAAc,EAAE,EAC1E,MAAM,qCAAqC,EAAI,cAAc,EAAE,EAE3E,IAAM,EACJ,EAAI,QAAU,OAAO,EAAI,QAAW,UAAY,CAAC,MAAM,QAAQ,EAAI,MAAM,EACpE,EAAI,OACL,CAAC,EASD,EAAa,EAAwB,CAAM,EACjD,GAAI,CAAC,EAAW,QAAS,CACvB,IAAM,EAAS,EAAW,MAAM,OAAO,EAAE,EAAE,SAAW,6BAEtD,MADA,MAAM,EAAW,EAAY,EAAa,wBAAwB,GAAQ,EAChE,MAAM,sCAAsC,EAAY,IAAI,GAAQ,CAChF,CACA,IAAM,EAAa,EAAW,KAE9B,MAAM,EAAW,OAAO,EAAa,CACnC,OAAQ,UACR,WAAY,EAAI,GAChB,UAAW,IAAI,IACjB,CAAC,EAKD,EAAwB,CAAW,EAEnC,IAAI,EACJ,GAAI,CACF,EAAiB,MAAM,EAAS,CAClC,OAAS,EAAK,CAGZ,MADA,MAAM,EAAW,EAAY,EAAa,gCAD3B,aAAe,MAAQ,EAAI,QAAU,OAAO,CAAG,GACoB,EAC5E,CACR,CAQA,GAAI,EACF,GAAI,CACF,MAAM,EAAY,CACpB,OAAS,EAAK,CAGZ,MADA,MAAM,EAAW,EAAY,EAAa,oCAD3B,aAAe,MAAQ,EAAI,QAAU,OAAO,CAAG,GACwB,EAChF,CACR,CAOF,IAAI,EACA,EACJ,GAAI,EACF,GAAI,CACF,IAAM,EAAW,MAAM,EAAgB,EAAI,QAAQ,EACnD,EAAY,EAAS,UACrB,EAAc,EAAS,OACzB,OAAS,EAAK,CAGZ,MADA,MAAM,EAAW,EAAY,EAAa,+BAD3B,aAAe,MAAQ,EAAI,QAAU,OAAO,CAAG,GACmB,EAC3E,CACR,KAKA,GAAY,EAAI,SAYlB,IAAI,EACJ,GAAI,EAAW,SAAW,OACxB,GAAI,CACF,EAAY,MAAM,EAChB,EACA,EAAW,YAAc,IAAA,GAAkD,CAAC,EAAvC,CAAE,UAAW,EAAW,SAAU,CACzE,EACA,GAAK,KAAK,CAAE,cAAa,WAAU,EAAG,6BAA6B,CACrE,OAAS,EAAK,CAGZ,GAAK,KAAK,CAAE,KAAI,EAAG,+DAA+D,CACpF,CAGF,IAAI,EACJ,GAAI,CAKF,IAAM,EAA4B,CAChC,SAAU,EACV,GAAI,EAAW,SAAW,IAAA,IAAa,CAAE,OAAQ,EAAW,MAAO,EACnE,GAAI,EAAW,YAAc,IAAA,IAAa,CAAE,UAAW,EAAW,SAAU,EAC5E,GAAI,EAAW,YAAc,IAAA,IAAa,CAAE,UAAW,EAAW,SAAU,EAC5E,GAAI,EAAW,iBAAmB,IAAA,IAAa,CAC7C,eAAgB,EAAW,cAC7B,EACA,GAAI,EAAW,QAAU,IAAA,IAAa,CAAE,MAAO,EAAW,KAAM,CAClE,EACA,EAAS,MAAM,EAAU,CACvB,cACA,SAAU,EAAI,MACd,SACA,YACA,KAAM,EACN,SAAU,EACV,UACA,WAAa,GACX,EAAI,eAAe,IAAc,IAAA,GAAyC,EAA7B,CAAE,GAAG,EAAU,WAAU,CAAY,EACpF,GAAI,IAAQ,IAAA,IAAa,CAAE,OAAQ,CAAI,CACzC,CAAC,CACH,OAAS,EAAK,CAQZ,MANA,MAAM,EAAW,EAAY,EADd,aAAe,MAAQ,EAAI,QAAU,OAAO,CAAG,CACd,EAC5C,GACF,MAAM,EAAY,CAAC,CAAC,MAAO,GAAwB,CACjD,GAAK,KAAK,CAAE,IAAK,CAAW,EAAG,oDAAoD,CACrF,CAAC,EAEG,CACR,CAqDA,GA/CI,GACF,MAAM,EAAY,CAAC,CAAC,MAAO,GAAwB,CACjD,GAAK,KAAK,CAAE,IAAK,CAAW,EAAG,wDAAwD,CACzF,CAAC,EAGH,MAAM,EAAW,OAAO,EAAa,CACnC,OAAQ,YACR,WAAY,IAAI,KAChB,OAAQ,CACN,SAAU,EAAO,SACjB,cAAe,EAAO,cACtB,WAAY,EAAO,WACnB,YAAa,EAAO,YACpB,WAAY,EAAO,WACnB,iBAAkB,EAAO,iBAIzB,eAAgB,EAAO,eACvB,gBAAiB,EAAO,eAC1B,EAOA,aAAc,KAAK,MAAM,KAAK,UAAU,EAAO,MAAM,CAAC,EAItD,eAAgB,KAAK,MAAM,KAAK,UAAU,EAAO,QAAQ,CAAC,CAC5D,CAAC,EAGD,EAA0B,EAAa,CACrC,SAAU,EAAO,SACjB,cAAe,EAAO,cACtB,WAAY,EAAO,UACrB,CAAC,EAOG,GAAY,EAAO,MAAM,OAAS,EACpC,GAAI,CACF,MAAM,EAAS,EAAO,MAAO,CAC3B,cACA,SACA,GAAI,IAAQ,IAAA,IAAa,CAAE,OAAQ,CAAI,CACzC,CAAC,EAGD,GAAK,KAAK,CAAE,cAAa,MAAO,EAAO,MAAM,MAAO,EAAG,2BAA2B,CACpF,OAAS,EAAK,CACZ,GAAK,KACH,CAAE,cAAa,MAAO,EAAO,MAAM,OAAQ,KAAI,EAK/C,yFACF,CACF,CAGF,GAAK,KACH,CACE,SAAU,EAAO,SACjB,cAAe,EAAO,cACtB,WAAY,EAAO,WACnB,YAAa,EAAO,YACpB,QAAS,EAAO,gBAClB,EACA,sBACF,CACF,CAAC,CACL,CAGA,eAAe,EAAW,EAA6B,EAAY,EAA+B,CAChG,GAAI,CACF,MAAM,EAAW,OAAO,EAAI,CAC1B,OAAQ,SACR,WAAY,IAAI,KAChB,aAAc,CAAE,MAAO,CAAO,CAChC,CAAC,EAMD,EAAuB,EAAI,CAAM,CACnC,MAAQ,CAGR,CACF,CA6CA,SAAgB,EACd,EACuB,CACvB,GAAM,CAAE,aAAY,UAAW,EAE/B,MAAO,CACL,UAAW,KAAO,IAA4C,CACxD,EAAM,UAAY,eAEtB,MAAM,EAAS,SAAY,CACzB,IAAM,EAAM,GAAQ,MAAM,CACxB,MAAO,mBACP,MAAO,EAAM,MACb,KAAM,aACR,CAAC,EAYK,EAAQ,EAAW,SAAS,EAK5B,GAAM,MAJU,EAAW,SAAS,CACxC,MAAO,EAAG,EAAM,WAAY,EAAM,KAAK,EACvC,MAAO,CACT,CAAC,EAAA,CACmB,GACpB,GAAI,CAAC,EAAK,CAKR,GAAK,KAAK,uDAAuD,EACjE,MACF,CAMA,GAAI,EAAI,SAAW,WAAa,EAAI,SAAW,UAAW,CACxD,GAAK,KACH,CAAE,YAAa,EAAI,GAAI,OAAQ,EAAI,MAAO,EAC1C,8EACF,EACA,MACF,CAQI,EAAI,SAAW,WACjB,GAAK,KACH,CAAE,YAAa,EAAI,GAAI,SAAU,EAAM,QAAS,EAChD,gGACF,EAGF,MAAM,EAAW,EAAY,EAAI,GAAI,EAAM,KAAK,EAChD,GAAK,KACH,CAAE,YAAa,EAAI,GAAI,SAAU,EAAM,SAAU,YAAa,EAAI,MAAO,EACzE,2CACF,CACF,CAAC,CACH,CACF,CACF"}
|
|
1
|
+
{"version":3,"file":"worker.mjs","names":[],"sources":["../src/worker.ts"],"sourcesContent":["/**\n * Queue handler for `imports:run`. The route handler that creates the\n * `import_run` row enqueues this job carrying `{ importRunId }`; the\n * worker module here resolves the run, the transform, and the ES\n * client, then runs `runImport` and writes results back.\n *\n * Per PLAN-ECOMMERCE.md PR 7 (PoC scope): one job type, no resumability.\n * A failed handler attempt re-runs from row 1 on retry — fine for the\n * tens-of-MB feed sizes the PoC exercises.\n *\n * Per CLAUDE.md \"leaf packages\" boundary rule: this handler reads the\n * import_run row via an injected `AdminClient`, NOT a direct `db.select`.\n * Construction happens at plugin init in `plugin.ts`.\n */\n\nimport { type Logger, runAsCli } from '@murumets-ee/core'\nimport {\n defineJob,\n type JobDefinition,\n type QueueJobDeadEvent,\n type QueueTerminalNotifier,\n} from '@murumets-ee/queue/client'\nimport type { EsClientLike } from '@murumets-ee/search-elasticsearch'\nimport { and, desc, eq } from 'drizzle-orm'\nimport type { PgColumn } from 'drizzle-orm/pg-core'\nimport { z } from 'zod'\nimport type { ImportRunClient, ImportRunStatus } from './entities/import-run.js'\nimport { ErrorTracker, mergeErrorTrackerSnapshots } from './error-tracker.js'\nimport { detectPriceAnomaly, parsePriceStats } from './price-stats.js'\nimport { type ReadFeedOptions, readPersistedFeedConfig } from './read-feed.js'\nimport {\n publishImportRunFailed,\n publishImportRunStarted,\n publishImportRunSucceeded,\n} from './realtime/publish.js'\nimport { type ImportRunProgress, runImport } from './runner.js'\nimport { countDataRows } from './streaming.js'\nimport type { DirectionalPair, TransformRegistry } from './transform.js'\n\n/**\n * Structural subset of `JobContext` the handler reads. Declared\n * locally so we don't depend on a `JobHandler` import — the queue\n * package doesn't re-export it from any subpath today (matches the\n * `SendEmailJobContext` pattern in `@murumets-ee/notifications`).\n *\n * Function-param contravariance lets a handler returned with this\n * narrower shape be assigned to the wider `JobHandler<TPayload>` that\n * `registerJob` expects.\n */\nexport interface ImportsRunJobContext {\n id: string\n payload: ImportsRunJobPayload\n updateProgress(data: ImportRunProgress): void\n}\n\n/**\n * Payload schema. `importRunId` is the `import_run.id` UUID — the worker\n * looks up everything else (filePath, transformName, params, …) from\n * that row. Keeping the payload tiny means the queue's progress JSON\n * column never bloats with feed metadata duplicated across `toolkit_jobs`.\n */\nexport const importsRunJobPayloadSchema = z.object({\n importRunId: z.string().uuid(),\n})\nexport type ImportsRunJobPayload = z.infer<typeof importsRunJobPayloadSchema>\n\n/**\n * Job definition. Consumers register their handler against this with\n * `registerJob(importsRunJob, createRunImportHandler({...}))`.\n *\n * `defaultRetries: 0` — re-running a multi-batch import against the\n * same `import_run.id` while the previous handler may still be writing\n * is a footgun (duplicate batches, double-counted progress). The\n * operator handles retries explicitly via PR 8a's \"retry\" button by\n * creating a new `import_run` row. When the resumable design lands\n * (post-PoC), retries become safe to enable.\n */\nexport const importsRunJob: JobDefinition<ImportsRunJobPayload> = defineJob({\n name: 'imports:run',\n description: 'Stream a feed file, transform rows, bulk-write to Elasticsearch.',\n schema: importsRunJobPayloadSchema,\n defaultRetries: 0,\n})\n\n/**\n * Resolves the ES client at handler-invocation time. A function (rather\n * than the bare `EsClientLike`) so the consumer can lazy-construct the\n * client — typical Next.js setups create the ES connection in a\n * route-handler initialiser, not at plugin-init time.\n */\nexport type EsClientResolver = () => EsClientLike | Promise<EsClientLike>\n\n/**\n * Ensure the destination index/alias exists before the first bulk-write.\n * Invoked once per job, BEFORE {@link runImport}. Idempotent: the canonical\n * implementation (`ensureAliasedIndex` in `@murumets-ee/search-elasticsearch`)\n * no-ops when the alias already resolves.\n *\n * **Why it lives here.** This is the home of the foot-gun it guards: a worker\n * that bulk-writes to alias `<x>` before the alias exists makes ES\n * auto-create a physical `<x>` index with default `text` mappings, breaking\n * aggregations forever. The bootstrap used to run at app boot, which turned\n * \"ES is down\" into a full-admin 500 (issue #610). Moving it onto the import\n * path keeps the guarantee while scoping ES availability to imports/search —\n * the rest of the admin boots and runs with ES down.\n */\nexport type EnsureIndexResolver = () => Promise<unknown>\n\n/**\n * Resolves the value of `import_run.filePath` (whatever the upload\n * route persisted there — typically a storage adapter key) to a\n * readable LOCAL filesystem path that {@link runImport} can hand to\n * `node:fs.createReadStream`.\n *\n * **Why this exists:** the streaming reader (`streamFeed`) reads from\n * local disk via `createReadStream`. The upload route may persist a\n * remote-storage object key (R2, S3, …) on `import_run.filePath`\n * because that's what `@murumets-ee/storage` returns. Without a\n * resolver, `createReadStream('uploads/2026/05/<uuid>/feed.txt')`\n * crashes with `ENOENT`. The resolver is the documented integration\n * point — typical wiring downloads the storage object to a tmpfile\n * and returns its path.\n *\n * **`cleanup`** runs after the run finishes (success OR failure). The\n * worker awaits it best-effort — a failed cleanup logs but does not\n * crash the run.\n *\n * **No-resolver fallback:** when `resolveFilePath` is unset, the\n * worker treats `import_run.filePath` as already a local FS path\n * (back-compat with the original PoC design where uploads landed on\n * local disk). This stays valid for fixture-driven tests + on-disk\n * deployments.\n */\nexport type FilePathResolver = (\n storageKey: string,\n) => Promise<{ localPath: string; cleanup?: () => Promise<void> }>\n\n/**\n * Context handed to a {@link PairSink} alongside the harvested pairs. Carries the\n * run identity so the sink can build `source` attribution\n * (`feed:<supplierId>:<importBatchId>`, D20) and scope its writes.\n */\nexport interface PairSinkContext {\n importRunId: string\n /** The `import_run.params` object — the sink reads `supplierId` / `importBatchId`. */\n params: Record<string, unknown>\n /**\n * The run's structured logger (child), forwarded so the sink's INTERNAL telemetry —\n * a params-parse miss that skips ingestion, an over-merge flag — is visible. Without it\n * a misconfigured run looks successful from the worker's vantage (a params-miss returns\n * normally, so the worker logs \"applied\"). Optional; the sink no-ops its logs if absent.\n */\n logger?: Logger\n}\n\n/**\n * Optional post-run consumer for the directional supersession pairs the runner\n * harvested as a side-output (commerce-demo PR6, SD007). Invoked ONCE after a\n * successful run with the full in-memory pair buffer (read-once / merge-after) so\n * the consumer (the commerce replacement-merge engine) can build equivalence groups\n * and bulk-write them to Postgres.\n *\n * **Why injected, not imported.** The merge writes the CONSUMER's Postgres tables,\n * but the runner + this worker live in the `@murumets-ee/imports` leaf, which must\n * not depend on commerce. The app composes the sink (commerce exports it, the app\n * passes it to `imports({ pairSink })`) — the same injection shape as `esClient` /\n * `resolveFilePath` / `ensureIndex`. The boundary holds.\n *\n * **Best-effort.** A sink throw is logged and SWALLOWED — the catalogue import\n * (the ES write) has already succeeded and must not be undone by a secondary\n * replacement-ingestion failure. The merge is idempotent, so a re-import recovers.\n */\nexport type PairSink = (pairs: readonly DirectionalPair[], ctx: PairSinkContext) => Promise<void>\n\nexport interface RunImportHandlerConfig {\n /** AdminClient over the `import_run` entity. */\n importRuns: ImportRunClient\n /** Transform registry to dispatch against. Defaults to the process-global singleton. */\n transforms: TransformRegistry\n /** Resolver for the ES client. */\n esClient: EsClientResolver\n /** ES alias / index to bulk-write into. Per D6 callers always pass an alias. */\n esIndex: string\n /**\n * Optional bootstrap for the destination index/alias, run once per job\n * before any bulk-write. See {@link EnsureIndexResolver}. Omit for\n * deployments that bootstrap the index out-of-band (e.g. a reindex job).\n */\n ensureIndex?: EnsureIndexResolver\n /**\n * Optional resolver for `import_run.filePath`. See {@link FilePathResolver}.\n * Required when uploads land in remote storage (R2/S3); optional for\n * on-disk PoC setups.\n */\n resolveFilePath?: FilePathResolver\n /**\n * Optional consumer for the directional supersession pairs harvested as a\n * side-output (commerce-demo PR6, SD007). See {@link PairSink}. Omit for\n * deployments that don't ingest replacement codes — the pairs are simply\n * discarded after the run.\n */\n pairSink?: PairSink\n /** Optional structured logger. Defaults to silent. */\n logger?: Logger\n}\n\n/**\n * Build the `JobHandler` for {@link importsRunJob}. The returned\n * handler is what gets passed to `registerJob`.\n *\n * The body is wrapped in `runAsCli` so AdminClient calls inside have a\n * synthetic `cli` admin context — `auditable()` records `updatedBy:\n * 'cli'` rather than NULL, and the firewall checker passes. This is\n * the documented worker entry-point pattern (see `runAsCli` JSDoc in\n * `@murumets-ee/core`).\n */\nexport function createRunImportHandler(\n config: RunImportHandlerConfig,\n): (job: ImportsRunJobContext) => Promise<void> {\n const {\n importRuns,\n transforms,\n esClient,\n esIndex,\n resolveFilePath,\n ensureIndex,\n pairSink,\n logger,\n } = config\n\n return async (job: ImportsRunJobContext): Promise<void> =>\n runAsCli(async () => {\n const { importRunId } = job.payload\n const log = logger?.child({ jobId: job.id, importRunId, type: 'imports:run' })\n\n const run = await importRuns.findById(importRunId)\n if (!run) {\n // Row was deleted between enqueue and dispatch — nothing to do.\n // Don't throw: a thrown handler retries with no payoff because\n // the row stays gone. Log + return so the queue marks the job\n // completed.\n log?.warn('import_run row not found — skipping')\n return\n }\n\n // Re-claim detection. The queue's `recoverStuckJobs` sweep flips a\n // crashed worker's `toolkit_jobs` row from `processing` back to\n // `pending` — but the queue layer has no knowledge of `import_run`,\n // so this row stays at `status='running'` from the prior attempt.\n // When the queue re-dispatches the job, we land here with a row\n // that the operator-facing Active panel has been showing as live\n // for however long lock-recovery took (default 30 min). The\n // existing `update` below overwrites `queueJobId` + `startedAt` so\n // there is no STATE corruption — but emit a structured warning so\n // ops can correlate this re-run with the worker crash that\n // produced it. The sibling `sweepStaleImportRuns` job below catches\n // the case where re-dispatch never happens (queue marks the job\n // dead, or the queue worker is offline long-term).\n if (run.status === 'running') {\n log?.warn(\n {\n previousQueueJobId: run.queueJobId,\n previousStartedAt: run.startedAt,\n },\n 'imports: re-claiming a row left in running by a prior worker — likely a crash recovered by queue lock-recovery',\n )\n }\n\n // `transformName`, `filePath`, `label` are declared `field.text({\n // required: true })` so the inferred DTO already types them as\n // `string` — no cast needed. `params` is `field.json()` which\n // infers as `JsonValue | undefined`; narrow to a record so the\n // transform receives an object.\n const transform = transforms.get(run.transformName)\n if (!transform) {\n await markFailed(importRuns, importRunId, `Unknown transform \"${run.transformName}\"`)\n throw new Error(`No transform registered for name \"${run.transformName}\"`)\n }\n const params: Record<string, unknown> =\n run.params && typeof run.params === 'object' && !Array.isArray(run.params)\n ? (run.params as Record<string, unknown>)\n : {}\n\n // Reader config (format/delimiter/header/sheet) travels in\n // `params.feed` for mapping-driven runs (commerce-demo PR3). Carmaker\n // runs omit it → empty config → tab-delimited default, so the existing\n // path is byte-for-byte unchanged. Re-validate here as defense-in-depth\n // even though the enqueuing route already validated the saved mapping.\n // Shared with the #842 audit, which has to rehydrate IDENTICALLY or it\n // re-reads a past run with options that run never used.\n const feedParsed = readPersistedFeedConfig(params)\n if (!feedParsed.success) {\n const reason = feedParsed.error.issues[0]?.message ?? 'invalid feed reader config'\n await markFailed(importRuns, importRunId, `Invalid params.feed: ${reason}`)\n throw new Error(`Invalid params.feed for import_run ${importRunId}: ${reason}`)\n }\n const feedConfig = feedParsed.data\n\n await importRuns.update(importRunId, {\n status: 'running' satisfies ImportRunStatus,\n queueJobId: job.id,\n startedAt: new Date(),\n })\n // Realtime: row transitioned pending → running. Subscribers\n // refetch to surface the change in the imports admin page.\n // Best-effort — a publish failure is logged inside `publishSafe`\n // and does NOT roll back the row update.\n publishImportRunStarted(importRunId)\n\n let resolvedClient: EsClientLike\n try {\n resolvedClient = await esClient()\n } catch (err) {\n const reason = err instanceof Error ? err.message : String(err)\n await markFailed(importRuns, importRunId, `Could not resolve ES client: ${reason}`)\n throw err\n }\n\n // Ensure the destination alias exists BEFORE any bulk-write. Moved here\n // from app boot (issue #610): the foot-gun is the worker auto-creating a\n // mis-mapped physical index, so the guard belongs on the import path —\n // not gating the whole app on ES. Idempotent + scoped: an ES outage (or\n // the `ensureAliasedIndex` physical-collision guard firing) fails THIS\n // job loudly while the rest of the admin keeps working.\n if (ensureIndex) {\n try {\n await ensureIndex()\n } catch (err) {\n const reason = err instanceof Error ? err.message : String(err)\n await markFailed(importRuns, importRunId, `Could not ensure ES index/alias: ${reason}`)\n throw err\n }\n }\n\n // Materialise the file locally if the consumer wired a resolver.\n // The `cleanup` callback (when present) runs in the `finally`\n // below regardless of run outcome so a tmpfile created here\n // doesn't outlive the handler.\n let localPath: string\n let fileCleanup: (() => Promise<void>) | undefined\n if (resolveFilePath) {\n try {\n const resolved = await resolveFilePath(run.filePath)\n localPath = resolved.localPath\n fileCleanup = resolved.cleanup\n } catch (err) {\n const reason = err instanceof Error ? err.message : String(err)\n await markFailed(importRuns, importRunId, `Could not resolve filePath: ${reason}`)\n throw err\n }\n } else {\n // Back-compat: no resolver wired → treat `filePath` as already\n // a local FS path. Fine for fixture-driven tests + the original\n // on-disk PoC design.\n localPath = run.filePath\n }\n\n // Pre-pass line count: a 16 MB tab-delimited feed counts in\n // ~50ms; a 500 MB feed in <2s. Cheap relative to the full\n // import. Gives the operator a denominator (`processed / total`)\n // so the imports-page panel can render an actual percent\n // instead of just \"rows so far\".\n // Pre-pass row count is a `\\n`-tally over a delimited file — meaningless\n // on an xlsx binary, so skip it there (the UI falls back to bare counts).\n // `hasHeader` from the feed config keeps the header-exclusion correct for\n // headerless mappings.\n let totalRows: number | undefined\n if (feedConfig.format !== 'xlsx') {\n try {\n totalRows = await countDataRows(\n localPath,\n feedConfig.hasHeader !== undefined ? { hasHeader: feedConfig.hasHeader } : {},\n )\n log?.info({ importRunId, totalRows }, 'imports: pre-pass row count')\n } catch (err) {\n // Don't fail the run on a count miss — the actual import is\n // unaffected; the operator just loses the percent denominator.\n log?.warn({ err }, 'imports: pre-pass row count failed — continuing without total')\n }\n }\n\n let result: Awaited<ReturnType<typeof runImport>>\n try {\n // Build feed options with the conditional-spread idiom (NOT\n // `...feedConfig`) so absent optionals stay ABSENT — spreading a\n // `T | undefined` value would set the key to `undefined`, which\n // `exactOptionalPropertyTypes` rejects against `ReadFeedOptions`.\n const feedOpts: ReadFeedOptions = {\n filePath: localPath,\n ...(feedConfig.format !== undefined && { format: feedConfig.format }),\n ...(feedConfig.delimiter !== undefined && { delimiter: feedConfig.delimiter }),\n ...(feedConfig.hasHeader !== undefined && { hasHeader: feedConfig.hasHeader }),\n ...(feedConfig.headerRowIndex !== undefined && {\n headerRowIndex: feedConfig.headerRowIndex,\n }),\n ...(feedConfig.sheet !== undefined && { sheet: feedConfig.sheet }),\n }\n result = await runImport({\n importRunId,\n runLabel: run.label,\n params,\n transform,\n feed: feedOpts,\n esClient: resolvedClient,\n esIndex,\n onProgress: (progress) =>\n job.updateProgress(totalRows !== undefined ? { ...progress, totalRows } : progress),\n ...(log !== undefined && { logger: log }),\n })\n } catch (err) {\n const reason = err instanceof Error ? err.message : String(err)\n await markFailed(importRuns, importRunId, reason)\n if (fileCleanup) {\n await fileCleanup().catch((cleanupErr: unknown) => {\n log?.warn({ err: cleanupErr }, 'filePath cleanup after failed run threw — ignoring')\n })\n }\n throw err\n }\n\n // Cleanup runs before the success-update so a cleanup throw\n // surfaces as a logged warning rather than a crashed handler at\n // the worst possible moment (just after the row was about to be\n // marked succeeded).\n if (fileCleanup) {\n await fileCleanup().catch((cleanupErr: unknown) => {\n log?.warn({ err: cleanupErr }, 'filePath cleanup after successful run threw — ignoring')\n })\n }\n\n // Value-drift detection (F010 / #602). The 6b header-presence drift check\n // (commerce's register-handler.ts, upload-time) only catches a STRUCTURAL\n // change — a mapped column vanishing from the header row. It cannot catch a\n // supplier silently flipping their decimal/thousands separator: no header\n // changes, and `import_mapping.numberFormat` stays the same, so every price\n // parses as a numerically valid but silently WRONG value. Compare THIS run's\n // price distribution against the most recent prior SUCCEEDED run for the same\n // (transformName, brandId, supplierId) and, on a magnitude-scale shift, append\n // a NON-FATAL `price_anomaly` warning. Unlike 6b's 409, this never blocks the\n // run — a genuine repricing is a legitimate reason the median would move, so\n // this is advisory only.\n let warningsToWrite = result.warnings\n const brandIdForDrift = typeof params.brandId === 'string' ? params.brandId : undefined\n const supplierIdForDrift =\n typeof params.supplierId === 'string' ? params.supplierId : undefined\n if (brandIdForDrift && supplierIdForDrift && result.priceStats.median !== null) {\n try {\n // `getTable()` is `PgTableWithColumns<any>` (dynamic entity columns —\n // see the documented-`any` list in CLAUDE.md); cast to the columns we\n // filter/sort on, same pattern as `createImportsRunDeadListener` below.\n const table = importRuns.getTable() as unknown as {\n transformName: PgColumn\n status: PgColumn\n createdAt: PgColumn\n }\n // Bounded lookup (limit 50), filtered to this transform + succeeded runs\n // in SQL, then narrowed to the same (brandId, supplierId) in memory —\n // `params` is an opaque per-transform JSONB blob (CLAUDE.md \"no raw\n // SQL\": no `params->>'brandId'` jsonb operator here). 50 candidates is\n // far more than any operator re-runs between two imports for the same\n // supplier/brand pair before this one.\n const candidates = await importRuns.findMany({\n where: and(eq(table.transformName, run.transformName), eq(table.status, 'succeeded')),\n orderBy: desc(table.createdAt),\n limit: 50,\n })\n const prior = candidates.find((c) => {\n if (c.id === importRunId) return false\n const p = c.params\n if (!p || typeof p !== 'object' || Array.isArray(p)) return false\n const pr = p as Record<string, unknown>\n return pr.brandId === brandIdForDrift && pr.supplierId === supplierIdForDrift\n })\n // CLAUDE.md \"No Unbounded Fan-Out\": \"log() whatever you drop — silent\n // truncation reads as 'covered everything' when it didn't.\" `transformName`\n // is shared across every (brand, supplier) pair using that mapping (e.g.\n // all 17 GIGA brands share `commerce:mapping`), so a genuinely prior run\n // for THIS pair can fall outside the 50-candidate window as import cadence\n // and supplier count grow. Distinguish that from \"no prior run exists\" —\n // both look identical downstream (no anomaly check runs) but only one is\n // an operator-actionable coverage gap.\n if (!prior && candidates.length >= 50) {\n log?.warn(\n {\n brandId: brandIdForDrift,\n supplierId: supplierIdForDrift,\n transformName: run.transformName,\n },\n 'imports: price-drift check found no prior run for this (brand, supplier) within the 50 most recent succeeded runs for this transform — a real prior run may exist beyond the bound, not necessarily absent',\n )\n }\n const priorStats = prior ? parsePriceStats(prior.priceStats) : null\n const anomaly = priorStats ? detectPriceAnomaly(priorStats, result.priceStats) : null\n if (anomaly) {\n const higher = anomaly.ratio >= 1\n const anomalyTracker = new ErrorTracker()\n anomalyTracker.addError(\n -1,\n 'price_anomaly',\n `Median purchase price is ${(higher ? anomaly.ratio : 1 / anomaly.ratio).toFixed(1)}x ${higher ? 'higher' : 'lower'} than the previous successful run for this supplier/brand (previous run ${prior?.id ?? 'unknown'}: median ${anomaly.prevMedian}; this run: median ${anomaly.currMedian}, minor units). Check for a decimal or thousands-separator format change in the source feed.`,\n 'priceStats.median',\n {\n previousImportRunId: prior?.id ?? null,\n previousMedian: anomaly.prevMedian,\n currentMedian: anomaly.currMedian,\n ratio: anomaly.ratio,\n },\n )\n warningsToWrite = mergeErrorTrackerSnapshots(result.warnings, anomalyTracker.snapshot())\n log?.warn(\n { previousImportRunId: prior?.id, ratio: anomaly.ratio },\n 'imports: price_anomaly — median purchase price shifted by a magnitude vs the prior run',\n )\n }\n } catch (err) {\n // Best-effort — a comparison failure must never fail an otherwise-\n // successful catalogue import. Same \"log + continue\" discipline as the\n // pairSink / cleanup best-effort paths elsewhere in this handler.\n log?.warn({ err }, 'imports: price-drift comparison failed — continuing without it')\n }\n }\n\n await importRuns.update(importRunId, {\n status: 'succeeded' satisfies ImportRunStatus,\n finishedAt: new Date(),\n totals: {\n rowsRead: result.rowsRead,\n rowsSucceeded: result.rowsSucceeded,\n rowsFailed: result.rowsFailed,\n rowsSkipped: result.rowsSkipped,\n rowsWarned: result.rowsWarned,\n batchesCompleted: result.batchesCompleted,\n // Phase-2 core-charge attach (commerce-demo PR7) — durable on the run row so\n // an operator sees how many `*S` deposits attached vs orphaned, not just the\n // orphan warnings in `warningSummary`.\n patchesApplied: result.patchesApplied,\n patchesOrphaned: result.patchesOrphaned,\n },\n // The typed `ErrorTrackerSnapshot` is fully JSON-serialisable but\n // doesn't structurally satisfy entity's recursive `JsonValue`\n // (objects without an index signature aren't `Record<string,\n // JsonValue>`). Round-trip through `JSON.parse(JSON.stringify(...))`\n // to land as the recursive JSON shape — the runtime cost is one\n // serialise per import_run completion, the type-cast cost is zero.\n errorSummary: JSON.parse(JSON.stringify(result.errors)),\n // Non-fatal warnings (commerce-demo PR4) — kept separate from\n // `errorSummary` so a `no_purchase_price` advisory never reads as a\n // failed row. Same serialise-roundtrip rationale as `errorSummary`.\n // `warningsToWrite` is `result.warnings` UNLESS the price-drift check\n // above appended a `price_anomaly` entry (F010 / #602).\n warningSummary: JSON.parse(JSON.stringify(warningsToWrite)),\n // Price-distribution snapshot (F010 / #602) — read back by the NEXT\n // run for this (transformName, brandId, supplierId) to detect drift.\n priceStats: JSON.parse(JSON.stringify(result.priceStats)),\n })\n // Realtime: row transitioned to terminal-success. Subscribers\n // refetch to move the row from Active → Recent.\n publishImportRunSucceeded(importRunId, {\n rowsRead: result.rowsRead,\n rowsSucceeded: result.rowsSucceeded,\n rowsFailed: result.rowsFailed,\n })\n\n // Side-output: hand the harvested supersession pairs to the injected sink\n // (commerce-demo PR6, SD007) AFTER the catalogue run is marked succeeded — the\n // ES import is primary; replacement ingestion is secondary. Best-effort: a sink\n // throw is logged + swallowed so a Postgres hiccup can't undo a successful\n // catalogue import (the merge is idempotent → a re-import recovers).\n if (pairSink && result.pairs.length > 0) {\n try {\n await pairSink(result.pairs, {\n importRunId,\n params,\n ...(log !== undefined && { logger: log }),\n })\n // \"invoked\" not \"applied\": the sink may internally skip (e.g. missing run params);\n // it logs its own applied/skipped outcome via the forwarded logger.\n log?.info({ importRunId, pairs: result.pairs.length }, 'imports: pairSink invoked')\n } catch (err) {\n log?.warn(\n { importRunId, pairs: result.pairs.length, err },\n // NOT \"skipped\": a sink may apply part of the batch before failing (the commerce\n // replacement sink commits per batch), so some pairs can already have landed.\n // \"Incomplete\" is the only claim this boundary can honestly make about a sink\n // whose internals it does not know.\n 'imports: pairSink threw — replacement ingestion incomplete, catalogue import unaffected',\n )\n }\n }\n\n log?.info(\n {\n rowsRead: result.rowsRead,\n rowsSucceeded: result.rowsSucceeded,\n rowsFailed: result.rowsFailed,\n rowsSkipped: result.rowsSkipped,\n batches: result.batchesCompleted,\n },\n 'import_run completed',\n )\n })\n}\n\n/** Write a `failed` row with a fatal error reason. Best-effort — logs but does not throw on DB failure. */\nasync function markFailed(importRuns: ImportRunClient, id: string, reason: string): Promise<void> {\n try {\n await importRuns.update(id, {\n status: 'failed' satisfies ImportRunStatus,\n finishedAt: new Date(),\n errorSummary: { fatal: reason },\n })\n // Realtime: row transitioned to terminal-failure. Subscribers\n // refetch to surface the failure in the Recent panel. Publish\n // is best-effort and only fires when the persist succeeds —\n // skipping it on persist failure keeps subscribers consistent\n // with the DB (no event for a row that didn't transition).\n publishImportRunFailed(id, reason)\n } catch {\n // The handler already threw / will throw — the queue will surface\n // the original error. A second failure here just logs noise.\n }\n}\n\nexport interface ImportsRunDeadListenerConfig {\n importRuns: ImportRunClient\n /** Optional structured logger. */\n logger?: Logger\n}\n\n/**\n * Build a {@link QueueTerminalNotifier} that mirrors `imports:run`\n * dead-job events into `import_run.status='failed'`.\n *\n * **Why this exists.** The `imports:run` handler's normal failure path\n * already calls `markFailed` before re-throwing, so a synchronous\n * handler error reaches the operator-visible `failed` status without\n * help from anyone. The case THIS listener catches is the one\n * `markFailed` never gets to: the worker process is killed mid-handler\n * (OOM, container restart, kill -9), the queue's lock-recovery sweep\n * eventually reclaims the row, and the queue ultimately marks the job\n * `dead` (defaultRetries: 0 + repeated process death = dead-letter\n * with `import_run.status` still `running`). Without this listener\n * the row sits in the operator's Active panel forever.\n *\n * **Generic primitive.** This is a thin subscriber over the queue's\n * multi-subscriber `QueueTerminalNotifier` registry — the same shape\n * graphile-worker (`job:failed`) and BullMQ (`failed`) expose. Other\n * domain plugins that mirror queue state (ticketing-reply, future\n * exports/reports/...) can register their own listeners exactly the\n * same way without coordinating via a per-domain sweep job.\n *\n * **Filtering.** The listener is invoked for EVERY dead event in the\n * process. We early-return on any `jobType` other than `imports:run`\n * so the per-event cost on unrelated jobs is a single property read.\n *\n * **Idempotency.** A dead event delivered after the row has already\n * reached its own terminal state (succeeded / failed / cancelled) is\n * ignored — the only transition we make is `running → failed`. This\n * keeps a late replay or out-of-order delivery from clobbering a\n * row whose worker reached `markFailed` faster than the queue's\n * dead-letter dispatch.\n *\n * The handler is wrapped in `runAsCli` so AdminClient calls inside\n * have a synthetic CLI admin context — same pattern as\n * {@link createRunImportHandler}.\n */\nexport function createImportsRunDeadListener(\n config: ImportsRunDeadListenerConfig,\n): QueueTerminalNotifier {\n const { importRuns, logger } = config\n\n return {\n onJobDead: async (event: QueueJobDeadEvent): Promise<void> => {\n if (event.jobType !== 'imports:run') return\n\n await runAsCli(async () => {\n const log = logger?.child({\n event: 'imports.run.dead',\n jobId: event.jobId,\n type: 'imports:run',\n })\n\n // Look up the mirror row by `queueJobId`. Cap to 1 — the\n // upload route writes one `import_run` per enqueued job.\n //\n // `AdminClient.getTable()` returns `PgTableWithColumns<any>`\n // because entity tables have dynamic column shapes (see the\n // `getTable()` JSDoc in `packages/entity/src/admin/client.ts`\n // and the documented-`any`-cases list in CLAUDE.md). Cast\n // the table to a structural shape covering only the column\n // we touch, so both `eq()` and type-coverage see a typed\n // operand instead of an `any`-derived property access.\n const table = importRuns.getTable() as unknown as { queueJobId: PgColumn }\n const matches = await importRuns.findMany({\n where: eq(table.queueJobId, event.jobId),\n limit: 1,\n })\n const row = matches[0]\n if (!row) {\n // No mirror row for this dead job. Either it was deleted\n // by the operator, or the dead event is for a job\n // unrelated to imports despite the type filter\n // (shouldn't happen, but is cheap to log).\n log?.warn('queue.job.dead with no matching import_run — skipping')\n return\n }\n\n // Only transition rows that haven't already reached their\n // own terminal state. A late dead-event must not overwrite\n // a `succeeded` row that the worker reached via the normal\n // path before the queue's dead-letter dispatch fired.\n if (row.status !== 'running' && row.status !== 'pending') {\n log?.info(\n { importRunId: row.id, status: row.status },\n 'queue.job.dead arrived for a row already in a terminal state — leaving as-is',\n )\n return\n }\n\n // Dead-from-`pending` is the rarer + more interesting case:\n // the worker process died (or never claimed) BEFORE it could\n // flip the row to `running`. Log it at warn so ops can spot\n // a queue+worker-startup pathology that the standard `running\n // → failed` transition wouldn't surface. Dead-from-`running`\n // is the documented \"crash mid-import\" case — info is enough.\n if (row.status === 'pending') {\n log?.warn(\n { importRunId: row.id, attempts: event.attempts },\n 'queue.job.dead arrived for an import_run still in pending — worker crashed BEFORE claim/lookup',\n )\n }\n\n await markFailed(importRuns, row.id, event.error)\n log?.info(\n { importRunId: row.id, attempts: event.attempts, priorStatus: row.status },\n 'queue.job.dead → import_run marked failed',\n )\n })\n },\n }\n}\n"],"mappings":"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"}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import{
|
|
2
|
-
//# sourceMappingURL=xlsx-reader-
|
|
1
|
+
import{g as e}from"./runner-wPnCZlB4.mjs";import{open as t}from"node:fs/promises";var n=class extends Error{headerRowNumber;constructor(e){super(`xlsx header row ${e} has no readable columns — the file may be corrupt, or its shared strings failed to load. Refusing rather than importing empty rows.`),this.name=`XlsxEmptyHeaderError`,this.headerRowNumber=e}},r=class extends Error{constructor(e){super(`xlsx path "${e}" is not a regular file`),this.name=`XlsxNotAFileError`}};function i(e){if(e==null)return``;if(typeof e==`string`)return e;if(typeof e==`number`||typeof e==`boolean`)return String(e);if(e instanceof Date)return e.toISOString();if(`richText`in e&&Array.isArray(e.richText))return e.richText.map(e=>e.text).join(``);if(`text`in e&&typeof e.text==`string`)return e.text;if(`result`in e&&e.result!==void 0&&e.result!==null){let t=e.result;return t instanceof Date?t.toISOString():typeof t==`object`?``:String(t)}return``}function a(e){let t=[];if(Array.isArray(e))for(let n=1;n<e.length;n+=1)t[n-1]=i(e[n]);else for(let[n,r]of Object.entries(e)){let e=Number.parseInt(n,10);!Number.isNaN(e)&&e>=1&&(t[e-1]=i(r))}for(let e=0;e<t.length;e+=1)t[e]===void 0&&(t[e]=``);return t}async function*o(i){let{filePath:o,hasHeader:s=!0,headerRowIndex:c=0,sheet:l}=i,u=await import(`exceljs`),d;if(l!==void 0&&(d=Number.parseInt(l,10),Number.isNaN(d)||d<1))throw Error(`Invalid xlsx sheet "${l}" — expected a 1-based worksheet index`);let f=await t(o,`r`),p;try{if(!(await f.stat()).isFile())throw new r(o);p=f.createReadStream({autoClose:!1});let t=new u.stream.xlsx.WorkbookReader(p,{worksheets:`emit`,sharedStrings:`cache`,styles:`ignore`,hyperlinks:`ignore`,entries:`ignore`});t.model={sheets:[]};let i=0,l=0,m=!1,h=t[Symbol.asyncIterator](),g=!1;try{for(;;){let t=await h.next();if(t.done){g=!0;break}let r=t.value;if(l+=1,!(d===void 0?l===1:l===d)){for await(let e of r);continue}m=!0;let o=0,u=null;for await(let t of r){o+=1;let r=a(t.values);if(s&&u===null){if(o<c+1)continue;if(u=r.map(e=>e.trim()),u.every(e=>e===``))throw new n(o);e(u);continue}i+=1;let l={};if(u)for(let e=0;e<u.length;e+=1){let t=u[e];t&&(l[t]=r[e]??``)}else for(let e=0;e<r.length;e+=1)l[String(e)]=r[e]??``;yield{rowNumber:i,row:l}}break}}finally{try{for(;!g;){let e=await h.next();if(e.done)break;for await(let t of e.value);}}catch{}}if(d!==void 0&&!m)throw Error(`xlsx worksheet ${d} not found`)}finally{p?.destroy(),await f.close().catch(()=>{})}}export{n as XlsxEmptyHeaderError,r as XlsxNotAFileError,o as streamXlsx};
|
|
2
|
+
//# sourceMappingURL=xlsx-reader-Dsq_ERiB.mjs.map
|