@murumets-ee/imports 0.0.0-preview-20261014110621

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.
Files changed (72) hide show
  1. package/LICENSE +94 -0
  2. package/dist/analyze/index.d.mts +265 -0
  3. package/dist/analyze/index.d.mts.map +1 -0
  4. package/dist/analyze/index.mjs +2 -0
  5. package/dist/analyze/index.mjs.map +1 -0
  6. package/dist/carmaker-feed-B-QYr3PI.mjs +2 -0
  7. package/dist/carmaker-feed-B-QYr3PI.mjs.map +1 -0
  8. package/dist/carmaker-feed-CWrZLYUt.d.mts +59 -0
  9. package/dist/carmaker-feed-CWrZLYUt.d.mts.map +1 -0
  10. package/dist/cleanup-DDljBe0Z.mjs +2 -0
  11. package/dist/cleanup-DDljBe0Z.mjs.map +1 -0
  12. package/dist/engine-B0ExE4jL.mjs +2 -0
  13. package/dist/engine-B0ExE4jL.mjs.map +1 -0
  14. package/dist/engine-tkbUXKxB.d.mts +609 -0
  15. package/dist/engine-tkbUXKxB.d.mts.map +1 -0
  16. package/dist/index-Bm69N5DW.d.mts +97 -0
  17. package/dist/index-Bm69N5DW.d.mts.map +1 -0
  18. package/dist/index.d.mts +390 -0
  19. package/dist/index.d.mts.map +1 -0
  20. package/dist/index.mjs +2 -0
  21. package/dist/index.mjs.map +1 -0
  22. package/dist/mapping/index.d.mts +42 -0
  23. package/dist/mapping/index.d.mts.map +1 -0
  24. package/dist/mapping/index.mjs +1 -0
  25. package/dist/mapping-transform-CO4MNhMe.mjs +2 -0
  26. package/dist/mapping-transform-CO4MNhMe.mjs.map +1 -0
  27. package/dist/matching/index.d.mts +2 -0
  28. package/dist/matching/index.mjs +1 -0
  29. package/dist/matching-Sr0z4HvN.mjs +2 -0
  30. package/dist/matching-Sr0z4HvN.mjs.map +1 -0
  31. package/dist/parts-schema-M1AtjNJt.d.mts +61 -0
  32. package/dist/parts-schema-M1AtjNJt.d.mts.map +1 -0
  33. package/dist/parts-schema-QqLci_Pl.mjs +2 -0
  34. package/dist/parts-schema-QqLci_Pl.mjs.map +1 -0
  35. package/dist/plugin.d.mts +148 -0
  36. package/dist/plugin.d.mts.map +1 -0
  37. package/dist/plugin.mjs +2 -0
  38. package/dist/plugin.mjs.map +1 -0
  39. package/dist/publish-DCXIPwTE.mjs +2 -0
  40. package/dist/publish-DCXIPwTE.mjs.map +1 -0
  41. package/dist/read-feed-DsLgRDNR.mjs +2 -0
  42. package/dist/read-feed-DsLgRDNR.mjs.map +1 -0
  43. package/dist/realtime/index.d.mts +123 -0
  44. package/dist/realtime/index.d.mts.map +1 -0
  45. package/dist/realtime/index.mjs +1 -0
  46. package/dist/rolldown-runtime-DK3Fl9T5.mjs +1 -0
  47. package/dist/runner-BnFvID1z.mjs +2 -0
  48. package/dist/runner-BnFvID1z.mjs.map +1 -0
  49. package/dist/schema-registry-DB5aIZMN.mjs +2 -0
  50. package/dist/schema-registry-DB5aIZMN.mjs.map +1 -0
  51. package/dist/storage-resolver.d.mts +32 -0
  52. package/dist/storage-resolver.d.mts.map +1 -0
  53. package/dist/storage-resolver.mjs +2 -0
  54. package/dist/storage-resolver.mjs.map +1 -0
  55. package/dist/storage.d.mts +31 -0
  56. package/dist/storage.d.mts.map +1 -0
  57. package/dist/storage.mjs +2 -0
  58. package/dist/storage.mjs.map +1 -0
  59. package/dist/transform-Cu5YEoOl.mjs +2 -0
  60. package/dist/transform-Cu5YEoOl.mjs.map +1 -0
  61. package/dist/transform-WeiiLdYP.d.mts +246 -0
  62. package/dist/transform-WeiiLdYP.d.mts.map +1 -0
  63. package/dist/transforms.d.mts +2 -0
  64. package/dist/transforms.mjs +1 -0
  65. package/dist/worker-CRH_PjKO.d.mts +940 -0
  66. package/dist/worker-CRH_PjKO.d.mts.map +1 -0
  67. package/dist/worker.d.mts +2 -0
  68. package/dist/worker.mjs +2 -0
  69. package/dist/worker.mjs.map +1 -0
  70. package/dist/xlsx-reader-D9Ntxm29.mjs +2 -0
  71. package/dist/xlsx-reader-D9Ntxm29.mjs.map +1 -0
  72. package/package.json +81 -0
@@ -0,0 +1,940 @@
1
+ import { c as TransformRegistry, i as RowTransform, t as DirectionalPair } from "./transform-WeiiLdYP.mjs";
2
+ import { Logger } from "@murumets-ee/core";
3
+ import { z } from "zod";
4
+ import { EsClientLike } from "@murumets-ee/search-elasticsearch";
5
+ import { JobDefinition, QueueTerminalNotifier } from "@murumets-ee/queue/client";
6
+ import { AdminClient } from "@murumets-ee/entity/admin";
7
+
8
+ //#region src/entities/import-run.d.ts
9
+ /**
10
+ * Lifecycle stages.
11
+ *
12
+ * - `pending` — row exists, queue job has been enqueued, worker hasn't picked it up yet.
13
+ * - `running` — worker is streaming + bulk-writing.
14
+ * - `succeeded` — worker finished cleanly (note: per-row failures still possible — see `totals.failed`).
15
+ * - `failed` — worker threw and the queue marked the job dead. `errorSummary.fatal` carries the cause.
16
+ * - `cancelled` — manual operator action via PR 8a (out of scope for PR 7).
17
+ */
18
+ declare const IMPORT_RUN_STATUSES: readonly ["pending", "running", "succeeded", "failed", "cancelled"];
19
+ type ImportRunStatus = (typeof IMPORT_RUN_STATUSES)[number];
20
+ declare const ImportRun: import("@murumets-ee/entity").Entity<{
21
+ id: import("@murumets-ee/entity").IdField;
22
+ } & import("@murumets-ee/entity").AuditableFields & {
23
+ /** Operator-visible label, e.g. `"MERCEDES — ME_20251027075918.txt"`. Free-form. */label: import("@murumets-ee/entity").TextField & {
24
+ readonly required: true;
25
+ readonly maxLength: 255;
26
+ }; /** See {@link IMPORT_RUN_STATUSES}. */
27
+ status: import("@murumets-ee/entity").SelectField & {
28
+ options: readonly ["pending", "running", "succeeded", "failed", "cancelled"];
29
+ } & {
30
+ readonly options: readonly ["pending", "running", "succeeded", "failed", "cancelled"];
31
+ readonly default: "pending";
32
+ readonly indexed: true;
33
+ };
34
+ /**
35
+ * Path or storage key of the uploaded feed file. Generic string —
36
+ * could be `/var/lumi/uploads/<id>.txt` for the local-disk PoC or an
37
+ * S3 object key once storage adapter integration lands. The worker
38
+ * reads this with the configured `readFeed` resolver.
39
+ */
40
+ filePath: import("@murumets-ee/entity").TextField & {
41
+ readonly required: true;
42
+ readonly maxLength: 2048;
43
+ };
44
+ /**
45
+ * Name of the registered transform applied to each row. Resolved at
46
+ * worker-dispatch time against the transform registry contributed by
47
+ * the consumer. PoC: `'commerce:carmaker-feed'`.
48
+ */
49
+ transformName: import("@murumets-ee/entity").TextField & {
50
+ readonly required: true;
51
+ readonly maxLength: 128;
52
+ readonly indexed: true;
53
+ };
54
+ /**
55
+ * Opaque per-transform parameters. The carmaker transform expects
56
+ * `{ brandId, supplierId, codePrefix? }`. Validation happens inside
57
+ * the transform — the imports package never inspects this shape so
58
+ * a new transform with different params doesn't require a schema
59
+ * migration.
60
+ */
61
+ params: import("@murumets-ee/entity").JsonField & Partial<import("@murumets-ee/entity").JsonField>;
62
+ /**
63
+ * Row counters: `{ submitted, succeeded, failed, skipped, batches }`.
64
+ * Updated by the worker as each batch completes. Final values are
65
+ * what the operator reads; intermediate progress comes from
66
+ * `toolkit_jobs.progress` via the queue UI.
67
+ */
68
+ totals: import("@murumets-ee/entity").JsonField & Partial<import("@murumets-ee/entity").JsonField>;
69
+ /**
70
+ * Output of `ErrorTracker.getTopPatterns(totalRows)` — the top-50
71
+ * error signatures with up to 5 sample rows each. Empty `[]` until
72
+ * the worker writes it on completion.
73
+ */
74
+ errorSummary: import("@murumets-ee/entity").JsonField & Partial<import("@murumets-ee/entity").JsonField>;
75
+ /**
76
+ * NON-FATAL warning patterns (commerce-demo PR4) — same
77
+ * `ErrorTrackerSnapshot` shape as {@link errorSummary} but for advisories
78
+ * on rows that STILL succeeded (their doc was indexed), e.g.
79
+ * `no_purchase_price` (a part imported list-only, cost pending). Kept
80
+ * separate from `errorSummary` so a warning never reads as a failed row.
81
+ * Null/absent until a SUCCESSFUL run that produced warnings writes it — a
82
+ * fatal failure (`markFailed`) abandons the run mid-stream and only sets
83
+ * `errorSummary.fatal`, so the two summaries are not co-present.
84
+ *
85
+ * NOTE: because the warning aggregator reuses `ErrorTracker`, the persisted
86
+ * JSON reuses its field names — the `totalErrors` key inside this blob is
87
+ * really the WARNING count (and `patterns[].errorType` the warning type).
88
+ */
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. */
115
+ startedAt: import("@murumets-ee/entity").DateField & Partial<import("@murumets-ee/entity").DateField>; /** Set when the worker finishes (success OR fatal failure). */
116
+ finishedAt: import("@murumets-ee/entity").DateField & Partial<import("@murumets-ee/entity").DateField>;
117
+ /**
118
+ * `toolkit_jobs.id` of the queue job processing this run — link
119
+ * back so PR 8a can show live progress without a second lookup.
120
+ */
121
+ queueJobId: import("@murumets-ee/entity").TextField & {
122
+ readonly maxLength: 64;
123
+ readonly indexed: true;
124
+ };
125
+ }, "import_run", [import("@murumets-ee/entity").Behavior<import("@murumets-ee/entity").AuditableFields, string>, import("@murumets-ee/entity").SearchableBehavior]>;
126
+ type ImportRunClient = AdminClient<typeof ImportRun.allFields>;
127
+ //#endregion
128
+ //#region src/streaming.d.ts
129
+ /**
130
+ * Pre-pass: count data rows in a delimited file (excluding the header
131
+ * row when `hasHeader` is true; default `true` to match `streamFeed`).
132
+ *
133
+ * Implementation: stream the file as raw bytes and tally `\n`. NOT a
134
+ * full parse — escaped newlines inside quoted fields would over-count.
135
+ * That was unconditionally fine while `quote: false` was the only
136
+ * behaviour available; a mapping can now declare RFC 4180 (`quote`),
137
+ * and an embedded newline in a quoted field then makes this an UPPER
138
+ * BOUND rather than a count. It is used only as the progress
139
+ * denominator (`totalRows`), so the visible consequence is a bar that
140
+ * stops short of 100%, never a wrong import. The parsed row count from
141
+ * `streamFeed` is authoritative and is what the run records.
142
+ *
143
+ * Why bytes-only and not csv-parse: a 16 MB feed counts in ~50 ms with
144
+ * `\n` tally vs ~500 ms with csv-parse parsing. For 500 MB the gap
145
+ * matters — pre-pass needs to be a small fraction of the actual import
146
+ * or the operator perceives the run as "stuck on pre-pass" with no UI.
147
+ *
148
+ * Returns the number of DATA rows (header excluded when present).
149
+ * Empty trailing lines (no `\n` after the final row, or a single
150
+ * trailing `\n`) are handled identically to how csv-parse treats them
151
+ * with `skip_empty_lines: true`.
152
+ */
153
+ declare function countDataRows(filePath: string, options?: {
154
+ hasHeader?: boolean;
155
+ }): Promise<number>;
156
+ interface StreamFeedOptions {
157
+ /** Path to the file on disk. The PoC uploads land on local disk; S3-keyed reads come later. */
158
+ filePath: string;
159
+ /**
160
+ * Single-character field delimiter. Default `\t` (the carmaker feed
161
+ * format). Pass `,` for CSV, `;` for some European dialects.
162
+ */
163
+ delimiter?: string;
164
+ /**
165
+ * `true` (default): the first row is the header and column names come
166
+ * from it. `false`: rows are emitted as positional `{ "0": ..., "1": ... }`
167
+ * and the transform reads by index — useful for headerless feeds that
168
+ * commit to a documented column order.
169
+ */
170
+ hasHeader?: boolean;
171
+ /**
172
+ * Optional explicit column-name list. When provided, takes precedence
173
+ * over `hasHeader` (header row, if present, is skipped but its values
174
+ * are ignored). Useful when the upstream header is unstable but the
175
+ * positional shape isn't.
176
+ */
177
+ columns?: ReadonlyArray<string>;
178
+ /**
179
+ * Forward to `csv-parse` `relax_column_count`. Default `false` —
180
+ * a row whose column count doesn't match the header surfaces as a
181
+ * parser error so the transform isn't silently fed truncated data.
182
+ */
183
+ relaxColumnCount?: boolean;
184
+ /**
185
+ * Forward to `csv-parse` `quote`. The character (or `false`) that
186
+ * encloses fields containing the delimiter. Default `false` — most
187
+ * supplier feeds in this package's target use case are tab-delimited
188
+ * exports that contain literal `"` characters in description fields
189
+ * (e.g. `LIVRE "INVERSION D'IMAGE"` from the Mercedes carmaker feed)
190
+ * with NO quote-as-field-wrapper convention. csv-parse's library
191
+ * default `'"'` then misreads the literal `"` as an opening quote
192
+ * and rejects the row with `Invalid Opening Quote`.
193
+ *
194
+ * Pass `'"'` (or any character) to opt back into RFC-4180-style
195
+ * CSV parsing for genuine quoted-CSV inputs. Pass `'\''` for
196
+ * single-quoted dialects.
197
+ */
198
+ quote?: string | false;
199
+ }
200
+ interface StreamFeedRow {
201
+ /** 1-based row number. Header (when present) is row 0; first data row is row 1. */
202
+ rowNumber: number;
203
+ /**
204
+ * Cell values keyed by column name (or string-position when
205
+ * `hasHeader: false` AND no `columns`).
206
+ *
207
+ * **Cell-value invariants:**
208
+ * - Empty cells (`A\t\tC`) → `''` (empty string).
209
+ * - Missing TRAILING cells in `relaxColumnCount: true` mode → the
210
+ * key is **absent** from the object, not present-with-`''`. csv-parse
211
+ * does not emit keys for short rows. Transforms reading those
212
+ * columns get `undefined` from `row['col']` and must handle it
213
+ * (`row['col'] ?? ''` is the canonical idiom).
214
+ * - With the default `relaxColumnCount: false`, short rows reject at
215
+ * the parser, so this case never reaches the transform.
216
+ */
217
+ row: Record<string, string>;
218
+ }
219
+ /**
220
+ * Async-iterable over the parsed rows of a delimited file. Use with
221
+ * `for await (const { rowNumber, row } of streamFeed({ filePath, ... }))`.
222
+ *
223
+ * The iterator owns its file descriptor — the `for await` loop closes
224
+ * the underlying stream when it returns or breaks. Aborting mid-stream
225
+ * (`break`, `throw`, signal) is safe; csv-parse propagates the close.
226
+ */
227
+ declare function streamFeed(options: StreamFeedOptions): AsyncIterable<StreamFeedRow>;
228
+ //#endregion
229
+ //#region src/read-feed.d.ts
230
+ /**
231
+ * The reader-config subset safe to persist in `import_run.params.feed` and rehydrate
232
+ * in the worker (commerce-demo PR3). It is exactly the {@link ReadFeedOptions} fields
233
+ * that describe HOW to parse a feed — `filePath` is supplied by the worker after it
234
+ * materialises the upload, never carried in params. Validated at BOTH ends: the
235
+ * commerce run route builds it from a saved `import_mapping`, the worker re-parses it
236
+ * before handing it to {@link readFeed} (defense-in-depth against a tampered row).
237
+ */
238
+ declare const feedReaderConfigSchema: z.ZodObject<{
239
+ format: z.ZodOptional<z.ZodEnum<["csv", "tsv", "xlsx"]>>;
240
+ delimiter: z.ZodOptional<z.ZodString>;
241
+ hasHeader: z.ZodOptional<z.ZodBoolean>;
242
+ headerRowIndex: z.ZodOptional<z.ZodNumber>;
243
+ sheet: z.ZodOptional<z.ZodString>;
244
+ /**
245
+ * csv-parse's `quote`. Absent (or `false`) = quotes are ordinary data, which is this
246
+ * package's long-standing default and what the tab-delimited supplier dumps require. A
247
+ * single character opts into RFC 4180 for feeds that genuinely quote their fields.
248
+ *
249
+ * Persisted deliberately: the two shapes are mutually exclusive on real data (R027 —
250
+ * each of the three ground-truth feeds hard-fails under the other's setting), so this
251
+ * is a per-file fact a mapping has to carry. Deriving it from `format` would be the
252
+ * same class of mistake as a global decimal separator.
253
+ */
254
+ quote: z.ZodOptional<z.ZodUnion<[z.ZodString, z.ZodLiteral<false>]>>;
255
+ }, "strict", z.ZodTypeAny, {
256
+ hasHeader?: boolean | undefined;
257
+ delimiter?: string | undefined;
258
+ quote?: string | false | undefined;
259
+ headerRowIndex?: number | undefined;
260
+ sheet?: string | undefined;
261
+ format?: "csv" | "tsv" | "xlsx" | undefined;
262
+ }, {
263
+ hasHeader?: boolean | undefined;
264
+ delimiter?: string | undefined;
265
+ quote?: string | false | undefined;
266
+ headerRowIndex?: number | undefined;
267
+ sheet?: string | undefined;
268
+ format?: "csv" | "tsv" | "xlsx" | undefined;
269
+ }>;
270
+ type FeedReaderConfig = z.infer<typeof feedReaderConfigSchema>;
271
+ /**
272
+ * Rehydrate the reader config a run persisted in `import_run.params.feed`.
273
+ *
274
+ * ONE implementation, because two would drift and drift here is silent: the
275
+ * worker uses it to decide how to READ a feed, and the #842 audit uses it to
276
+ * re-read the same file the same way. An audit that rehydrates differently from
277
+ * the run it is auditing invents corruption, or misses it.
278
+ *
279
+ * ABSENT means "the carmaker default", i.e. tab-delimited, which is what an
280
+ * empty config parses to — so a non-object `params`, or `params` with no
281
+ * `feed`, is a real answer rather than a gap. A PRESENT `feed` is validated
282
+ * whatever its shape: a scalar or an array there is a malformed persisted blob,
283
+ * and reading it as "no config" would silently import an xlsx run as tsv.
284
+ */
285
+ declare function readPersistedFeedConfig(params: unknown): ReturnType<typeof feedReaderConfigSchema.safeParse>;
286
+ interface ReadFeedOptions {
287
+ /** Path to the file on local disk. */
288
+ filePath: string;
289
+ /** Feed format. Omitted → tab-delimited (tsv), matching the legacy carmaker reader. */
290
+ format?: 'csv' | 'tsv' | 'xlsx';
291
+ /** Explicit delimiter override (csv/tsv). Defaults from `format` (`csv` → `,`, else `\t`). */
292
+ delimiter?: string;
293
+ /** First row is the header. Default `true`. */
294
+ hasHeader?: boolean;
295
+ /** 0-based header row index (xlsx). */
296
+ headerRowIndex?: number;
297
+ /** xlsx worksheet as a 1-based index string. Omit → first sheet. */
298
+ sheet?: string;
299
+ /** Explicit column-name list (csv/tsv) — see `streamFeed`. */
300
+ columns?: ReadonlyArray<string>;
301
+ /** Forward to csv-parse `relax_column_count` (csv/tsv). */
302
+ relaxColumnCount?: boolean;
303
+ /** Forward to csv-parse `quote` (csv/tsv). */
304
+ quote?: string | false;
305
+ }
306
+ /** Stream a feed file's data rows, dispatching on `format`. */
307
+ declare function readFeed(options: ReadFeedOptions): AsyncIterable<StreamFeedRow>;
308
+ //#endregion
309
+ //#region src/error-tracker.d.ts
310
+ /**
311
+ * Aggregates per-row errors into top-N pattern buckets so a feed of 1M
312
+ * malformed rows surfaces as a handful of actionable signatures rather
313
+ * than a million identical strings.
314
+ *
315
+ * Lifted from giga-test (`backend/src/workers/csv-importer.ts`) and
316
+ * generalized:
317
+ * - Configurable caps so the importer can tune memory bounds per run.
318
+ * - Pure data — no logging side effects, no I/O.
319
+ * - JSON-serialisable output via {@link ErrorTracker.snapshot} for the
320
+ * `import_run.errorSummary` column.
321
+ *
322
+ * Pattern signature shape: `${errorType}:${field || 'GENERAL'}:${message}`.
323
+ * Same `errorType + field + message` collapses to one bucket; differing
324
+ * messages stay separate. This is intentional: a parser error on column
325
+ * `NetPrice/Discount` ("invalid number 'NA,5'") and the same on column
326
+ * `GrossPrice` are operationally distinct even if the parser is the same.
327
+ *
328
+ * Memory bounds: the patterns map is capped at `maxPatterns`. Once full,
329
+ * additional NEW signatures are dropped — known patterns keep accumulating
330
+ * counts. This is the "top-N most common" model: rare-but-novel errors
331
+ * past the cap are invisible, but the cap protects against a runaway
332
+ * adversarial feed exploding the map. Sample arrays are independently
333
+ * capped at `maxSamplesPerPattern`.
334
+ */
335
+ /**
336
+ * Recursively-defined JSON-serialisable value. Mirrors the shape of
337
+ * `JsonValue` in `@murumets-ee/entity` without taking a dependency on
338
+ * that package — error-tracker is otherwise standalone, and the
339
+ * `ImportRun.errorSummary` JSONB column accepts anything in this shape.
340
+ */
341
+ type ImportJsonValue = string | number | boolean | null | ImportJsonValue[] | {
342
+ [key: string]: ImportJsonValue;
343
+ };
344
+ /** One sample row attached to a pattern. `rowData` is the originally-parsed row. */
345
+ interface ErrorSample {
346
+ rowNumber: number;
347
+ rowData: ImportJsonValue;
348
+ }
349
+ /** Public shape of an aggregated pattern as returned by {@link ErrorTracker.getTopPatterns}. */
350
+ interface ErrorPattern {
351
+ errorType: string;
352
+ field: string | null;
353
+ message: string;
354
+ count: number;
355
+ firstOccurrence: number;
356
+ lastOccurrence: number;
357
+ samples: ReadonlyArray<ErrorSample>;
358
+ /** `count / totalErrors`, scaled 0..100. `0` when there are zero errors total. */
359
+ percentage: number;
360
+ }
361
+ interface ErrorTrackerConfig {
362
+ /** Hard cap on distinct signatures. Defaults to 50. New signatures past the cap are dropped. */
363
+ maxPatterns?: number;
364
+ /** Hard cap on samples retained per pattern. Defaults to 5. Excess samples are dropped. */
365
+ maxSamplesPerPattern?: number;
366
+ }
367
+ /** Default caps — match giga-test for compatibility. */
368
+ declare const DEFAULT_MAX_PATTERNS = 50;
369
+ declare const DEFAULT_MAX_SAMPLES_PER_PATTERN = 5;
370
+ declare class ErrorTracker {
371
+ private readonly patterns;
372
+ private readonly maxPatterns;
373
+ private readonly maxSamplesPerPattern;
374
+ private droppedSignatures;
375
+ constructor(config?: ErrorTrackerConfig);
376
+ /**
377
+ * Record one error. Same `(errorType, field, message)` triple bumps the
378
+ * existing bucket; a new triple opens a new one (subject to {@link maxPatterns}).
379
+ *
380
+ * `field` is optional — pass `undefined` for errors not tied to a single
381
+ * column (e.g. parse errors at row level). Internally normalised to the
382
+ * literal string `'GENERAL'` so it shares a bucket with other genericised
383
+ * errors of the same type+message.
384
+ */
385
+ addError(rowNumber: number, errorType: string, message: string, field: string | undefined, rowData: ImportJsonValue): void;
386
+ /** Total count across every pattern. Counts errors, not patterns. */
387
+ getTotalErrorCount(): number;
388
+ /** Number of distinct signatures retained in the map (≤ `maxPatterns`). */
389
+ getDistinctPatternCount(): number;
390
+ /**
391
+ * Number of NEW signatures dropped because the map was already at
392
+ * capacity. Surfacing this in the import_run summary tells the operator
393
+ * "the top-N was saturated — there's a long tail you're not seeing".
394
+ */
395
+ getDroppedSignatureCount(): number;
396
+ /**
397
+ * Top patterns sorted by descending count, capped at `maxPatterns`.
398
+ * Stable secondary order is insertion order (Map iteration order is
399
+ * insertion order; Array.sort is stable in V8).
400
+ */
401
+ getTopPatterns(): ErrorPattern[];
402
+ /**
403
+ * Compact JSON-serialisable snapshot for `import_run.errorSummary`.
404
+ * Aside from the patterns array, includes the totals so a reader of
405
+ * just this column doesn't have to re-derive them.
406
+ */
407
+ snapshot(): ErrorTrackerSnapshot;
408
+ }
409
+ interface ErrorTrackerSnapshot {
410
+ totalErrors: number;
411
+ distinctPatterns: number;
412
+ droppedSignatures: number;
413
+ patterns: ErrorPattern[];
414
+ }
415
+ /**
416
+ * Merge two independently-produced `ErrorTrackerSnapshot`s into one — e.g.
417
+ * the runner's per-row `warningSummary` plus a worker-level signal computed
418
+ * AFTER the run completes (F010 / #602: the price-drift anomaly check runs
419
+ * in `worker.ts`, after `runImport` returns, using a fresh single-pattern
420
+ * tracker of its own). Patterns concatenate rather than re-dedup: the two
421
+ * snapshots are assumed to use disjoint `errorType` signature spaces (a
422
+ * worker-level tracker uses a type — `price_anomaly` — the runner's own
423
+ * warning aggregation never emits), so counts sum directly with no risk of
424
+ * double-counting one bucket as two.
425
+ */
426
+ declare function mergeErrorTrackerSnapshots(a: ErrorTrackerSnapshot, b: ErrorTrackerSnapshot): ErrorTrackerSnapshot;
427
+ //#endregion
428
+ //#region src/price-stats.d.ts
429
+ /**
430
+ * Price-distribution stats + prior-run anomaly detection (F010 / issue #602).
431
+ *
432
+ * The import pipeline's existing anomaly detection — register-handler.ts's
433
+ * "6b. Header-presence drift check" — only catches STRUCTURAL drift: a
434
+ * mapped column vanishing (or getting renamed) from the header row. It does
435
+ * NOT catch VALUE drift: a supplier silently flipping their decimal
436
+ * separator (e.g. `1.234,56` → `1234.56`) or thousands separator WITHOUT
437
+ * touching a single column header. Since the saved
438
+ * `import_mapping.numberFormat` (`{decimal, thousands}`) is unchanged,
439
+ * every price in that run parses as a NUMERICALLY VALID but WRONG value —
440
+ * silently corrupting the catalog's prices. Nothing upstream of this module
441
+ * flags it.
442
+ *
443
+ * This module computes a per-run price-distribution snapshot (`PriceStats`)
444
+ * over `PartsDocument.purchase_price_minor` — the DERIVED cost field,
445
+ * rather than the raw net/list price columns, because both the direct-net
446
+ * AND two-file derivation paths in `mapping/parts-schema.ts` (step 4) funnel
447
+ * through it, so a decimal-format flip on EITHER source column surfaces
448
+ * here regardless of which pricing path a supplier's feed uses.
449
+ *
450
+ * Collection (`PriceStatsCollector`) happens in the SAME streaming pass
451
+ * `runner.ts` already uses to build `totals` — one `.record()` call per
452
+ * ES-accepted row, no second pass over the file, no extra DB round-trip.
453
+ * The prior-run comparison (`detectPriceAnomaly`) happens in `worker.ts`
454
+ * AFTER the run completes, because it needs `AdminClient` to look the
455
+ * prior run up — this module stays DB-agnostic, pure data + pure functions.
456
+ */
457
+ /** A run's price-distribution snapshot. Values are in MINOR units (cents), matching `PartsDocument.purchase_price_minor`'s own scale. */
458
+ interface PriceStats {
459
+ /** ES-accepted rows considered — same accounting as `RunImportResult.rowsSucceeded`. */
460
+ count: number;
461
+ /** 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`. */
462
+ nullRate: number;
463
+ /** Minimum of the non-null samples, in minor units. `null` when there are zero non-null samples. */
464
+ min: number | null;
465
+ /** Median of the non-null samples, in minor units. `null` when there are zero non-null samples. */
466
+ median: number | null;
467
+ /** Maximum of the non-null samples, in minor units. `null` when there are zero non-null samples. */
468
+ max: number | null;
469
+ }
470
+ /** The all-zero/empty snapshot — a run that produced zero ES-accepted rows. */
471
+ declare const EMPTY_PRICE_STATS: PriceStats;
472
+ /**
473
+ * Bounded sample cap for the min/median/max computation — the same
474
+ * "bounded, uniform sample" philosophy `ErrorTracker` uses top-N patterns
475
+ * for. 50,000 floats is ~400KB, trivial next to the ES bulk-write buffers
476
+ * already in flight for a run this size, and far more than needed for a
477
+ * stable median estimate. A feed with more ES-accepted rows than this cap
478
+ * still gets an EXACT `count` / `nullRate` (those are running counters, not
479
+ * sample-bounded) — only min/median/max become an approximation over a
480
+ * UNIFORM RANDOM sample of `PRICE_SAMPLE_CAP` non-null values, not "the
481
+ * first N encountered" (see `record`'s reservoir-sampling note for why
482
+ * order-independence matters specifically for this module's job).
483
+ */
484
+ declare const PRICE_SAMPLE_CAP = 50000;
485
+ /**
486
+ * Accumulates price samples during a run's streaming pass. `record` is O(1)
487
+ * (amortized array push below the cap, one RNG draw above it); `snapshot`
488
+ * sorts the retained samples once at the end — cheap relative to the ES
489
+ * bulk-write the run already performs every batch.
490
+ */
491
+ declare class PriceStatsCollector {
492
+ private count;
493
+ private nullCount;
494
+ private nonNullSeen;
495
+ private readonly samples;
496
+ /**
497
+ * Record one ES-accepted row's price sample. Call exactly once per
498
+ * accepted row (mirrors the `rowsWarned` / `pairs` accounting in
499
+ * `runner.ts`'s flush loop) — `undefined` means "this row had no price to
500
+ * sample" and counts toward `nullRate` rather than being skipped.
501
+ *
502
+ * Reservoir sampling (Algorithm R) past the cap, not "first N wins": each
503
+ * new non-null value replaces a uniformly-random existing slot with
504
+ * probability `PRICE_SAMPLE_CAP / nonNullSeen`, giving every non-null
505
+ * sample seen so far an equal chance of surviving into the final
506
+ * reservoir. Order-independence matters here specifically — the anomaly
507
+ * this module exists to catch (a supplier's decimal-format flip) could
508
+ * start partway through a feed larger than the cap (e.g. a corrupted
509
+ * re-export appended to an otherwise-normal catalogue), and a first-N
510
+ * sample would never see a single row past the cutover point, silently
511
+ * missing exactly the drift F010 was built to detect.
512
+ */
513
+ record(value: number | undefined): void;
514
+ snapshot(): PriceStats;
515
+ }
516
+ /**
517
+ * Anomaly threshold: a run whose median purchase price is >= 10x higher OR
518
+ * <= 1/10 (0.1x) of the prior successful run's median (for the same
519
+ * transform + supplier + brand) is flagged.
520
+ *
521
+ * Chosen generously above the range a real repricing plausibly covers
522
+ * (even an aggressive markup/discount renegotiation is a low-single-digit
523
+ * multiplier — 2x-3x would already be an extreme outlier for a legitimate
524
+ * price change) but comfortably BELOW the ~100x-1000x magnitude a
525
+ * decimal/thousands-separator flip produces for typical EUR part prices:
526
+ * `"1.234,56"` (a European-format €1234.56) misparsed with dot-decimal
527
+ * conventions reads as `1.234,56` → `1234.56`, a ~1000x inflation if the
528
+ * true value was e.g. €1.23; a thousands-separator flip on `"1234,56"`
529
+ * (true €1234.56) misparsed as `123456` (dropping the decimal entirely) is
530
+ * a ~100x inflation. 10x leaves a wide safety margin against false
531
+ * positives on genuine price changes while still comfortably catching
532
+ * every realistic decimal/thousands-separator flip.
533
+ */
534
+ declare const PRICE_ANOMALY_THRESHOLD_MULTIPLIER = 10;
535
+ interface PriceAnomaly {
536
+ /** `curr.median / prev.median`. */
537
+ ratio: number;
538
+ /** The prior run's median (minor units). */
539
+ prevMedian: number;
540
+ /** This run's median (minor units). */
541
+ currMedian: number;
542
+ }
543
+ /**
544
+ * Compare two runs' price stats for a magnitude-scale anomaly. Returns
545
+ * `null` when there's nothing to compare (either side has no median — zero
546
+ * non-null price samples, e.g. a schema/transform that never emits a price
547
+ * sample, or a run with zero ES-accepted rows) or the ratio falls within
548
+ * the safe band.
549
+ */
550
+ declare function detectPriceAnomaly(prev: PriceStats, curr: PriceStats): PriceAnomaly | null;
551
+ /**
552
+ * Narrow an `unknown` value (a `field.json()` column read back through
553
+ * `AdminClient`, typed `JsonValue | undefined` on the DTO) to `PriceStats`,
554
+ * or `null` when it doesn't structurally match — e.g. a run written before
555
+ * this field existed, or a run whose transform never emitted a price
556
+ * sample so `min`/`median`/`max` serialised as `null`. Defensive, matching
557
+ * the `params` narrowing pattern already used in `worker.ts`.
558
+ */
559
+ declare function parsePriceStats(value: unknown): PriceStats | null;
560
+ //#endregion
561
+ //#region src/runner.d.ts
562
+ /** Soft default; chosen to match giga-test. ES bulk requests over ~5MB get split server-side anyway. */
563
+ declare const DEFAULT_BATCH_SIZE = 1000;
564
+ interface RunImportOptions<TDoc> {
565
+ /** UUID of the `import_run` row driving this run. Forwarded to every transform invocation. */
566
+ importRunId: string;
567
+ /** Operator-supplied label for the run. Forwarded to the transform context. */
568
+ runLabel: string;
569
+ /** Opaque per-run params copied from `import_run.params`. */
570
+ params: Record<string, unknown>;
571
+ /** Transform applied to every parsed row. */
572
+ transform: RowTransform<TDoc>;
573
+ /** Feed reader options — file path + format (csv/tsv/xlsx) + delimiter/header config. */
574
+ feed: ReadFeedOptions;
575
+ /** ES client (low-level shape from `@murumets-ee/search-elasticsearch`). */
576
+ esClient: EsClientLike;
577
+ /** Index alias to write to. Per D6, callers always pass an alias, never a physical index. */
578
+ esIndex: string;
579
+ /** Rows per `bulkUpsert` call. Default {@link DEFAULT_BATCH_SIZE}. */
580
+ batchSize?: number;
581
+ /**
582
+ * Callback invoked after every batch. The handler in `worker.ts`
583
+ * forwards this to `ctx.updateProgress` for the queue UI; tests
584
+ * inspect it directly. Synchronous + cheap so a slow callback can't
585
+ * back-pressure the importer.
586
+ */
587
+ onProgress?: (progress: ImportRunProgress) => void;
588
+ /** Optional: stop processing after this many rows. Tests use it; production passes `undefined`. */
589
+ rowLimit?: number;
590
+ /** Abort signal threaded into the underlying ES client request — cooperative cancel. */
591
+ signal?: AbortSignal;
592
+ /** Optional ErrorTracker config (caps). Default: top-50 patterns × 5 samples. */
593
+ errorTracker?: ErrorTracker;
594
+ /**
595
+ * Optional separate tracker for NON-FATAL row warnings (commerce-demo PR4 —
596
+ * `no_purchase_price`). Distinct from {@link errorTracker} so warnings never
597
+ * inflate `totals.failed` / the error summary: a warned row is a SUCCESS whose
598
+ * doc is indexed. Default: a fresh tracker with the same caps.
599
+ */
600
+ warningTracker?: ErrorTracker;
601
+ /**
602
+ * Optional price-stats collector (F010 / #602) — accumulates a
603
+ * min/median/max/nullRate distribution over ES-accepted rows' optional
604
+ * `priceSample`s in the SAME streaming pass that builds `totals`. Default:
605
+ * a fresh collector. Tests can inject their own to inspect it directly —
606
+ * mirrors the {@link errorTracker} / {@link warningTracker} DI shape.
607
+ */
608
+ priceStats?: PriceStatsCollector;
609
+ /**
610
+ * Optional structural logger for per-batch telemetry. Receives
611
+ * `{ batch, ms, succeeded, failed, rowsRead, rowsPerSecond }` after
612
+ * every flush, plus a one-shot `started` and `finished` event. The
613
+ * worker forwards `app.logger.child({ pkg: 'imports' })`; tests
614
+ * leave it undefined.
615
+ *
616
+ * Why: without per-batch visibility, a stalled run looks identical
617
+ * to "first batch in progress" forever — the only feedback is the
618
+ * queue's `progress` jsonb (which writes after each batch) and the
619
+ * eventual `succeeded`/`failed` job event. Adding this lets the
620
+ * operator tail the worker log and immediately see whether ES is
621
+ * rate-limiting, the transform is rejecting every row, or the
622
+ * stream is actually progressing.
623
+ */
624
+ logger?: {
625
+ info: (data: Record<string, unknown>, msg: string) => void;
626
+ warn: (data: Record<string, unknown>, msg: string) => void;
627
+ };
628
+ }
629
+ /**
630
+ * Progress payload written to `toolkit_jobs.progress` after every batch.
631
+ * Caps + flush rules live on the queue's `updateProgress` debounce —
632
+ * callers don't need to throttle.
633
+ */
634
+ interface ImportRunProgress {
635
+ rowsRead: number;
636
+ rowsSucceeded: number;
637
+ rowsFailed: number;
638
+ rowsSkipped: number;
639
+ /** Rows indexed WITH a non-fatal warning (commerce-demo PR4 — `no_purchase_price`). */
640
+ rowsWarned: number;
641
+ batchesCompleted: number;
642
+ /** Wall-clock seconds since the runner started. */
643
+ elapsedSeconds: number;
644
+ /** Rows / second, computed at every batch. */
645
+ rowsPerSecond: number;
646
+ /** Distinct error patterns currently held by the tracker. Saturates at the cap. */
647
+ distinctErrorPatterns: number;
648
+ /**
649
+ * Total data-row count from a pre-pass scan, when the worker computed one.
650
+ * Lets the UI render `processed / total` percent. Absent when the worker
651
+ * skipped the count (file too large for the count budget, count failed,
652
+ * etc.) — UI must treat it as optional.
653
+ */
654
+ totalRows?: number;
655
+ }
656
+ /**
657
+ * Final result returned by {@link runImport}. The handler writes these
658
+ * onto the `import_run` row alongside the ErrorTracker snapshot.
659
+ */
660
+ interface RunImportResult {
661
+ /** Total rows read from the file (excludes skipped empty lines). */
662
+ rowsRead: number;
663
+ /** Rows the transform turned into a successful doc AND the ES cluster acknowledged. */
664
+ rowsSucceeded: number;
665
+ /**
666
+ * Rows that the transform rejected (`{ kind: 'error' }`) OR that ES
667
+ * rejected on bulk-write (per-doc failure). Both are aggregated by
668
+ * `errorTracker` for the import_run summary.
669
+ */
670
+ rowsFailed: number;
671
+ /** Rows that the transform skipped (`{ kind: 'skip' }`) — header noise, blank lines, intentional drop. */
672
+ rowsSkipped: number;
673
+ /**
674
+ * Rows that succeeded (doc indexed) but carried ≥1 non-fatal warning —
675
+ * counted SEPARATELY from `rowsSucceeded`/`rowsFailed` (a warned row is also
676
+ * counted in `rowsSucceeded`). commerce-demo PR4: `no_purchase_price`.
677
+ */
678
+ rowsWarned: number;
679
+ /** Number of `bulkUpsert` calls made. */
680
+ batchesCompleted: number;
681
+ /** Final value of {@link ErrorTracker.snapshot}. */
682
+ errors: ReturnType<ErrorTracker['snapshot']>;
683
+ /** Aggregated NON-FATAL warnings (commerce-demo PR4). Empty when no row warned. */
684
+ warnings: ReturnType<ErrorTracker['snapshot']>;
685
+ /**
686
+ * Directional supersession pairs harvested as a side-output (commerce-demo PR6,
687
+ * SD007). Buffered in memory from ES-ACCEPTED rows only (same accounting as
688
+ * warnings — a row whose doc was rejected by ES contributes no pair). Empty unless
689
+ * a schema emits them (the `parts` schema, when a row's `superseding_code` is set).
690
+ * The worker hands these to an injected `pairSink` AFTER the run for the commerce
691
+ * replacement-merge engine; the runner itself never writes the consumer's tables.
692
+ */
693
+ pairs: readonly DirectionalPair[];
694
+ /**
695
+ * Doc-patches applied by the phase-2 pass (commerce-demo PR7): a `<code>*S`
696
+ * core-fee row is not a saleable doc — it emits a {@link DocPatch} that merges its
697
+ * deposit onto the PARENT part's `core_charge_minor`. The runner buffers these
698
+ * during the stream (sparse) and applies them via `bulkPartialUpdate` AFTER the ES
699
+ * pass. `patchesApplied` = targets the cluster merged; `patchesOrphaned` = patches
700
+ * whose parent genuinely wasn't in the index (a `document_missing`/404 → an
701
+ * `orphan_core_charge` warning). An INFRA failure (whole-batch transport error, or a
702
+ * non-404 per-doc failure) is NOT counted as orphaned — it lands in a distinct
703
+ * `patch_attach_failed` warning bucket so it can't masquerade as a data orphan. Zero
704
+ * unless a schema emits patches.
705
+ */
706
+ patchesApplied: number;
707
+ patchesOrphaned: number;
708
+ /**
709
+ * Price-distribution snapshot (F010 / #602) accumulated from ES-accepted
710
+ * rows' `priceSample`s during this same streaming pass. See `price-stats.ts`.
711
+ * `count` is `0` (all fields `null`/`0`) for a run whose schema never emits
712
+ * a price sample.
713
+ */
714
+ priceStats: PriceStats;
715
+ }
716
+ /**
717
+ * Apply the runner against a feed file. Stops on rowLimit OR end-of-file
718
+ * OR if `signal` aborts. Throws if the streaming reader / ES client
719
+ * throws — caller (the queue handler) catches that and writes
720
+ * `import_run.status = 'failed'` with the error message in
721
+ * `errorSummary.fatal`.
722
+ */
723
+ declare function runImport<TDoc>(options: RunImportOptions<TDoc>): Promise<RunImportResult>;
724
+ //#endregion
725
+ //#region src/worker.d.ts
726
+ /**
727
+ * Structural subset of `JobContext` the handler reads. Declared
728
+ * locally so we don't depend on a `JobHandler` import — the queue
729
+ * package doesn't re-export it from any subpath today (matches the
730
+ * `SendEmailJobContext` pattern in `@murumets-ee/notifications`).
731
+ *
732
+ * Function-param contravariance lets a handler returned with this
733
+ * narrower shape be assigned to the wider `JobHandler<TPayload>` that
734
+ * `registerJob` expects.
735
+ */
736
+ interface ImportsRunJobContext {
737
+ id: string;
738
+ payload: ImportsRunJobPayload;
739
+ updateProgress(data: ImportRunProgress): void;
740
+ }
741
+ /**
742
+ * Payload schema. `importRunId` is the `import_run.id` UUID — the worker
743
+ * looks up everything else (filePath, transformName, params, …) from
744
+ * that row. Keeping the payload tiny means the queue's progress JSON
745
+ * column never bloats with feed metadata duplicated across `toolkit_jobs`.
746
+ */
747
+ declare const importsRunJobPayloadSchema: z.ZodObject<{
748
+ importRunId: z.ZodString;
749
+ }, "strip", z.ZodTypeAny, {
750
+ importRunId: string;
751
+ }, {
752
+ importRunId: string;
753
+ }>;
754
+ type ImportsRunJobPayload = z.infer<typeof importsRunJobPayloadSchema>;
755
+ /**
756
+ * Job definition. Consumers register their handler against this with
757
+ * `registerJob(importsRunJob, createRunImportHandler({...}))`.
758
+ *
759
+ * `defaultRetries: 0` — re-running a multi-batch import against the
760
+ * same `import_run.id` while the previous handler may still be writing
761
+ * is a footgun (duplicate batches, double-counted progress). The
762
+ * operator handles retries explicitly via PR 8a's "retry" button by
763
+ * creating a new `import_run` row. When the resumable design lands
764
+ * (post-PoC), retries become safe to enable.
765
+ */
766
+ declare const importsRunJob: JobDefinition<ImportsRunJobPayload>;
767
+ /**
768
+ * Resolves the ES client at handler-invocation time. A function (rather
769
+ * than the bare `EsClientLike`) so the consumer can lazy-construct the
770
+ * client — typical Next.js setups create the ES connection in a
771
+ * route-handler initialiser, not at plugin-init time.
772
+ */
773
+ type EsClientResolver = () => EsClientLike | Promise<EsClientLike>;
774
+ /**
775
+ * Ensure the destination index/alias exists before the first bulk-write.
776
+ * Invoked once per job, BEFORE {@link runImport}. Idempotent: the canonical
777
+ * implementation (`ensureAliasedIndex` in `@murumets-ee/search-elasticsearch`)
778
+ * no-ops when the alias already resolves.
779
+ *
780
+ * **Why it lives here.** This is the home of the foot-gun it guards: a worker
781
+ * that bulk-writes to alias `<x>` before the alias exists makes ES
782
+ * auto-create a physical `<x>` index with default `text` mappings, breaking
783
+ * aggregations forever. The bootstrap used to run at app boot, which turned
784
+ * "ES is down" into a full-admin 500 (issue #610). Moving it onto the import
785
+ * path keeps the guarantee while scoping ES availability to imports/search —
786
+ * the rest of the admin boots and runs with ES down.
787
+ */
788
+ type EnsureIndexResolver = () => Promise<unknown>;
789
+ /**
790
+ * Resolves the value of `import_run.filePath` (whatever the upload
791
+ * route persisted there — typically a storage adapter key) to a
792
+ * readable LOCAL filesystem path that {@link runImport} can hand to
793
+ * `node:fs.createReadStream`.
794
+ *
795
+ * **Why this exists:** the streaming reader (`streamFeed`) reads from
796
+ * local disk via `createReadStream`. The upload route may persist a
797
+ * remote-storage object key (R2, S3, …) on `import_run.filePath`
798
+ * because that's what `@murumets-ee/storage` returns. Without a
799
+ * resolver, `createReadStream('uploads/2026/05/<uuid>/feed.txt')`
800
+ * crashes with `ENOENT`. The resolver is the documented integration
801
+ * point — typical wiring downloads the storage object to a tmpfile
802
+ * and returns its path.
803
+ *
804
+ * **`cleanup`** runs after the run finishes (success OR failure). The
805
+ * worker awaits it best-effort — a failed cleanup logs but does not
806
+ * crash the run.
807
+ *
808
+ * **No-resolver fallback:** when `resolveFilePath` is unset, the
809
+ * worker treats `import_run.filePath` as already a local FS path
810
+ * (back-compat with the original PoC design where uploads landed on
811
+ * local disk). This stays valid for fixture-driven tests + on-disk
812
+ * deployments.
813
+ */
814
+ type FilePathResolver = (storageKey: string) => Promise<{
815
+ localPath: string;
816
+ cleanup?: () => Promise<void>;
817
+ }>;
818
+ /**
819
+ * Context handed to a {@link PairSink} alongside the harvested pairs. Carries the
820
+ * run identity so the sink can build `source` attribution
821
+ * (`feed:<supplierId>:<importBatchId>`, D20) and scope its writes.
822
+ */
823
+ interface PairSinkContext {
824
+ importRunId: string;
825
+ /** The `import_run.params` object — the sink reads `supplierId` / `importBatchId`. */
826
+ params: Record<string, unknown>;
827
+ /**
828
+ * The run's structured logger (child), forwarded so the sink's INTERNAL telemetry —
829
+ * a params-parse miss that skips ingestion, an over-merge flag — is visible. Without it
830
+ * a misconfigured run looks successful from the worker's vantage (a params-miss returns
831
+ * normally, so the worker logs "applied"). Optional; the sink no-ops its logs if absent.
832
+ */
833
+ logger?: Logger;
834
+ }
835
+ /**
836
+ * Optional post-run consumer for the directional supersession pairs the runner
837
+ * harvested as a side-output (commerce-demo PR6, SD007). Invoked ONCE after a
838
+ * successful run with the full in-memory pair buffer (read-once / merge-after) so
839
+ * the consumer (the commerce replacement-merge engine) can build equivalence groups
840
+ * and bulk-write them to Postgres.
841
+ *
842
+ * **Why injected, not imported.** The merge writes the CONSUMER's Postgres tables,
843
+ * but the runner + this worker live in the `@murumets-ee/imports` leaf, which must
844
+ * not depend on commerce. The app composes the sink (commerce exports it, the app
845
+ * passes it to `imports({ pairSink })`) — the same injection shape as `esClient` /
846
+ * `resolveFilePath` / `ensureIndex`. The boundary holds.
847
+ *
848
+ * **Best-effort.** A sink throw is logged and SWALLOWED — the catalogue import
849
+ * (the ES write) has already succeeded and must not be undone by a secondary
850
+ * replacement-ingestion failure. The merge is idempotent, so a re-import recovers.
851
+ */
852
+ type PairSink = (pairs: readonly DirectionalPair[], ctx: PairSinkContext) => Promise<void>;
853
+ interface RunImportHandlerConfig {
854
+ /** AdminClient over the `import_run` entity. */
855
+ importRuns: ImportRunClient;
856
+ /** Transform registry to dispatch against. Defaults to the process-global singleton. */
857
+ transforms: TransformRegistry;
858
+ /** Resolver for the ES client. */
859
+ esClient: EsClientResolver;
860
+ /** ES alias / index to bulk-write into. Per D6 callers always pass an alias. */
861
+ esIndex: string;
862
+ /**
863
+ * Optional bootstrap for the destination index/alias, run once per job
864
+ * before any bulk-write. See {@link EnsureIndexResolver}. Omit for
865
+ * deployments that bootstrap the index out-of-band (e.g. a reindex job).
866
+ */
867
+ ensureIndex?: EnsureIndexResolver;
868
+ /**
869
+ * Optional resolver for `import_run.filePath`. See {@link FilePathResolver}.
870
+ * Required when uploads land in remote storage (R2/S3); optional for
871
+ * on-disk PoC setups.
872
+ */
873
+ resolveFilePath?: FilePathResolver;
874
+ /**
875
+ * Optional consumer for the directional supersession pairs harvested as a
876
+ * side-output (commerce-demo PR6, SD007). See {@link PairSink}. Omit for
877
+ * deployments that don't ingest replacement codes — the pairs are simply
878
+ * discarded after the run.
879
+ */
880
+ pairSink?: PairSink;
881
+ /** Optional structured logger. Defaults to silent. */
882
+ logger?: Logger;
883
+ }
884
+ /**
885
+ * Build the `JobHandler` for {@link importsRunJob}. The returned
886
+ * handler is what gets passed to `registerJob`.
887
+ *
888
+ * The body is wrapped in `runAsCli` so AdminClient calls inside have a
889
+ * synthetic `cli` admin context — `auditable()` records `updatedBy:
890
+ * 'cli'` rather than NULL, and the firewall checker passes. This is
891
+ * the documented worker entry-point pattern (see `runAsCli` JSDoc in
892
+ * `@murumets-ee/core`).
893
+ */
894
+ declare function createRunImportHandler(config: RunImportHandlerConfig): (job: ImportsRunJobContext) => Promise<void>;
895
+ interface ImportsRunDeadListenerConfig {
896
+ importRuns: ImportRunClient;
897
+ /** Optional structured logger. */
898
+ logger?: Logger;
899
+ }
900
+ /**
901
+ * Build a {@link QueueTerminalNotifier} that mirrors `imports:run`
902
+ * dead-job events into `import_run.status='failed'`.
903
+ *
904
+ * **Why this exists.** The `imports:run` handler's normal failure path
905
+ * already calls `markFailed` before re-throwing, so a synchronous
906
+ * handler error reaches the operator-visible `failed` status without
907
+ * help from anyone. The case THIS listener catches is the one
908
+ * `markFailed` never gets to: the worker process is killed mid-handler
909
+ * (OOM, container restart, kill -9), the queue's lock-recovery sweep
910
+ * eventually reclaims the row, and the queue ultimately marks the job
911
+ * `dead` (defaultRetries: 0 + repeated process death = dead-letter
912
+ * with `import_run.status` still `running`). Without this listener
913
+ * the row sits in the operator's Active panel forever.
914
+ *
915
+ * **Generic primitive.** This is a thin subscriber over the queue's
916
+ * multi-subscriber `QueueTerminalNotifier` registry — the same shape
917
+ * graphile-worker (`job:failed`) and BullMQ (`failed`) expose. Other
918
+ * domain plugins that mirror queue state (ticketing-reply, future
919
+ * exports/reports/...) can register their own listeners exactly the
920
+ * same way without coordinating via a per-domain sweep job.
921
+ *
922
+ * **Filtering.** The listener is invoked for EVERY dead event in the
923
+ * process. We early-return on any `jobType` other than `imports:run`
924
+ * so the per-event cost on unrelated jobs is a single property read.
925
+ *
926
+ * **Idempotency.** A dead event delivered after the row has already
927
+ * reached its own terminal state (succeeded / failed / cancelled) is
928
+ * ignored — the only transition we make is `running → failed`. This
929
+ * keeps a late replay or out-of-order delivery from clobbering a
930
+ * row whose worker reached `markFailed` faster than the queue's
931
+ * dead-letter dispatch.
932
+ *
933
+ * The handler is wrapped in `runAsCli` so AdminClient calls inside
934
+ * have a synthetic CLI admin context — same pattern as
935
+ * {@link createRunImportHandler}.
936
+ */
937
+ declare function createImportsRunDeadListener(config: ImportsRunDeadListenerConfig): QueueTerminalNotifier;
938
+ //#endregion
939
+ 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 };
940
+ //# sourceMappingURL=worker-CRH_PjKO.d.mts.map