@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.
- package/LICENSE +94 -0
- package/dist/analyze/index.d.mts +265 -0
- package/dist/analyze/index.d.mts.map +1 -0
- package/dist/analyze/index.mjs +2 -0
- package/dist/analyze/index.mjs.map +1 -0
- package/dist/carmaker-feed-B-QYr3PI.mjs +2 -0
- package/dist/carmaker-feed-B-QYr3PI.mjs.map +1 -0
- package/dist/carmaker-feed-CWrZLYUt.d.mts +59 -0
- package/dist/carmaker-feed-CWrZLYUt.d.mts.map +1 -0
- package/dist/cleanup-DDljBe0Z.mjs +2 -0
- package/dist/cleanup-DDljBe0Z.mjs.map +1 -0
- package/dist/engine-B0ExE4jL.mjs +2 -0
- package/dist/engine-B0ExE4jL.mjs.map +1 -0
- package/dist/engine-tkbUXKxB.d.mts +609 -0
- package/dist/engine-tkbUXKxB.d.mts.map +1 -0
- package/dist/index-Bm69N5DW.d.mts +97 -0
- package/dist/index-Bm69N5DW.d.mts.map +1 -0
- package/dist/index.d.mts +390 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +2 -0
- package/dist/index.mjs.map +1 -0
- package/dist/mapping/index.d.mts +42 -0
- package/dist/mapping/index.d.mts.map +1 -0
- package/dist/mapping/index.mjs +1 -0
- package/dist/mapping-transform-CO4MNhMe.mjs +2 -0
- package/dist/mapping-transform-CO4MNhMe.mjs.map +1 -0
- package/dist/matching/index.d.mts +2 -0
- package/dist/matching/index.mjs +1 -0
- package/dist/matching-Sr0z4HvN.mjs +2 -0
- package/dist/matching-Sr0z4HvN.mjs.map +1 -0
- package/dist/parts-schema-M1AtjNJt.d.mts +61 -0
- package/dist/parts-schema-M1AtjNJt.d.mts.map +1 -0
- package/dist/parts-schema-QqLci_Pl.mjs +2 -0
- package/dist/parts-schema-QqLci_Pl.mjs.map +1 -0
- package/dist/plugin.d.mts +148 -0
- package/dist/plugin.d.mts.map +1 -0
- package/dist/plugin.mjs +2 -0
- package/dist/plugin.mjs.map +1 -0
- package/dist/publish-DCXIPwTE.mjs +2 -0
- package/dist/publish-DCXIPwTE.mjs.map +1 -0
- package/dist/read-feed-DsLgRDNR.mjs +2 -0
- package/dist/read-feed-DsLgRDNR.mjs.map +1 -0
- package/dist/realtime/index.d.mts +123 -0
- package/dist/realtime/index.d.mts.map +1 -0
- package/dist/realtime/index.mjs +1 -0
- package/dist/rolldown-runtime-DK3Fl9T5.mjs +1 -0
- package/dist/runner-BnFvID1z.mjs +2 -0
- package/dist/runner-BnFvID1z.mjs.map +1 -0
- package/dist/schema-registry-DB5aIZMN.mjs +2 -0
- package/dist/schema-registry-DB5aIZMN.mjs.map +1 -0
- package/dist/storage-resolver.d.mts +32 -0
- package/dist/storage-resolver.d.mts.map +1 -0
- package/dist/storage-resolver.mjs +2 -0
- package/dist/storage-resolver.mjs.map +1 -0
- package/dist/storage.d.mts +31 -0
- package/dist/storage.d.mts.map +1 -0
- package/dist/storage.mjs +2 -0
- package/dist/storage.mjs.map +1 -0
- package/dist/transform-Cu5YEoOl.mjs +2 -0
- package/dist/transform-Cu5YEoOl.mjs.map +1 -0
- package/dist/transform-WeiiLdYP.d.mts +246 -0
- package/dist/transform-WeiiLdYP.d.mts.map +1 -0
- package/dist/transforms.d.mts +2 -0
- package/dist/transforms.mjs +1 -0
- package/dist/worker-CRH_PjKO.d.mts +940 -0
- package/dist/worker-CRH_PjKO.d.mts.map +1 -0
- package/dist/worker.d.mts +2 -0
- package/dist/worker.mjs +2 -0
- package/dist/worker.mjs.map +1 -0
- package/dist/xlsx-reader-D9Ntxm29.mjs +2 -0
- package/dist/xlsx-reader-D9Ntxm29.mjs.map +1 -0
- 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
|