@murumets-ee/imports 0.110.0 → 0.112.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/analyze/index.d.mts +0 -1
- package/dist/analyze/index.mjs +0 -1
- package/dist/carmaker-feed-B-QYr3PI.mjs +0 -1
- package/dist/carmaker-feed-CWrZLYUt.d.mts +0 -1
- package/dist/cleanup-DDljBe0Z.mjs +0 -1
- package/dist/engine-B0ExE4jL.mjs +0 -1
- package/dist/engine-tkbUXKxB.d.mts +0 -1
- package/dist/index-Bm69N5DW.d.mts +0 -1
- package/dist/index.d.mts +0 -1
- package/dist/index.mjs +0 -1
- package/dist/mapping/index.d.mts +0 -1
- package/dist/mapping-transform-CO4MNhMe.mjs +0 -1
- package/dist/matching-Sr0z4HvN.mjs +0 -1
- package/dist/parts-schema-M1AtjNJt.d.mts +0 -1
- package/dist/parts-schema-QqLci_Pl.mjs +0 -1
- package/dist/plugin.d.mts +0 -1
- package/dist/plugin.mjs +0 -1
- package/dist/publish-DCXIPwTE.mjs +0 -1
- package/dist/read-feed-DsLgRDNR.mjs +0 -1
- package/dist/realtime/index.d.mts +0 -1
- package/dist/runner-BnFvID1z.mjs +0 -1
- package/dist/schema-registry-DB5aIZMN.mjs +0 -1
- package/dist/storage-resolver.d.mts +0 -1
- package/dist/storage-resolver.mjs +0 -1
- package/dist/storage.d.mts +0 -1
- package/dist/storage.mjs +0 -1
- package/dist/transform-Cu5YEoOl.mjs +0 -1
- package/dist/transform-WeiiLdYP.d.mts +0 -1
- package/dist/worker-CRH_PjKO.d.mts +0 -1
- package/dist/worker.mjs +0 -1
- package/dist/xlsx-reader-D9Ntxm29.mjs +0 -1
- package/package.json +8 -8
- package/dist/analyze/index.d.mts.map +0 -1
- package/dist/analyze/index.mjs.map +0 -1
- package/dist/carmaker-feed-B-QYr3PI.mjs.map +0 -1
- package/dist/carmaker-feed-CWrZLYUt.d.mts.map +0 -1
- package/dist/cleanup-DDljBe0Z.mjs.map +0 -1
- package/dist/engine-B0ExE4jL.mjs.map +0 -1
- package/dist/engine-tkbUXKxB.d.mts.map +0 -1
- package/dist/index-Bm69N5DW.d.mts.map +0 -1
- package/dist/index.d.mts.map +0 -1
- package/dist/index.mjs.map +0 -1
- package/dist/mapping/index.d.mts.map +0 -1
- package/dist/mapping-transform-CO4MNhMe.mjs.map +0 -1
- package/dist/matching-Sr0z4HvN.mjs.map +0 -1
- package/dist/parts-schema-M1AtjNJt.d.mts.map +0 -1
- package/dist/parts-schema-QqLci_Pl.mjs.map +0 -1
- package/dist/plugin.d.mts.map +0 -1
- package/dist/plugin.mjs.map +0 -1
- package/dist/publish-DCXIPwTE.mjs.map +0 -1
- package/dist/read-feed-DsLgRDNR.mjs.map +0 -1
- package/dist/realtime/index.d.mts.map +0 -1
- package/dist/runner-BnFvID1z.mjs.map +0 -1
- package/dist/schema-registry-DB5aIZMN.mjs.map +0 -1
- package/dist/storage-resolver.d.mts.map +0 -1
- package/dist/storage-resolver.mjs.map +0 -1
- package/dist/storage.d.mts.map +0 -1
- package/dist/storage.mjs.map +0 -1
- package/dist/transform-Cu5YEoOl.mjs.map +0 -1
- package/dist/transform-WeiiLdYP.d.mts.map +0 -1
- package/dist/worker-CRH_PjKO.d.mts.map +0 -1
- package/dist/worker.mjs.map +0 -1
- package/dist/xlsx-reader-D9Ntxm29.mjs.map +0 -1
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"runner-BnFvID1z.mjs","names":[],"sources":["../src/error-tracker.ts","../src/price-stats.ts","../src/runner.ts"],"sourcesContent":["/**\n * Aggregates per-row errors into top-N pattern buckets so a feed of 1M\n * malformed rows surfaces as a handful of actionable signatures rather\n * than a million identical strings.\n *\n * Lifted from giga-test (`backend/src/workers/csv-importer.ts`) and\n * generalized:\n * - Configurable caps so the importer can tune memory bounds per run.\n * - Pure data — no logging side effects, no I/O.\n * - JSON-serialisable output via {@link ErrorTracker.snapshot} for the\n * `import_run.errorSummary` column.\n *\n * Pattern signature shape: `${errorType}:${field || 'GENERAL'}:${message}`.\n * Same `errorType + field + message` collapses to one bucket; differing\n * messages stay separate. This is intentional: a parser error on column\n * `NetPrice/Discount` (\"invalid number 'NA,5'\") and the same on column\n * `GrossPrice` are operationally distinct even if the parser is the same.\n *\n * Memory bounds: the patterns map is capped at `maxPatterns`. Once full,\n * additional NEW signatures are dropped — known patterns keep accumulating\n * counts. This is the \"top-N most common\" model: rare-but-novel errors\n * past the cap are invisible, but the cap protects against a runaway\n * adversarial feed exploding the map. Sample arrays are independently\n * capped at `maxSamplesPerPattern`.\n */\n\n/**\n * Recursively-defined JSON-serialisable value. Mirrors the shape of\n * `JsonValue` in `@murumets-ee/entity` without taking a dependency on\n * that package — error-tracker is otherwise standalone, and the\n * `ImportRun.errorSummary` JSONB column accepts anything in this shape.\n */\nexport type ImportJsonValue =\n | string\n | number\n | boolean\n | null\n | ImportJsonValue[]\n | { [key: string]: ImportJsonValue }\n\n/** One sample row attached to a pattern. `rowData` is the originally-parsed row. */\nexport interface ErrorSample {\n rowNumber: number\n rowData: ImportJsonValue\n}\n\n/** Public shape of an aggregated pattern as returned by {@link ErrorTracker.getTopPatterns}. */\nexport interface ErrorPattern {\n errorType: string\n field: string | null\n message: string\n count: number\n firstOccurrence: number\n lastOccurrence: number\n samples: ReadonlyArray<ErrorSample>\n /** `count / totalErrors`, scaled 0..100. `0` when there are zero errors total. */\n percentage: number\n}\n\nexport interface ErrorTrackerConfig {\n /** Hard cap on distinct signatures. Defaults to 50. New signatures past the cap are dropped. */\n maxPatterns?: number\n /** Hard cap on samples retained per pattern. Defaults to 5. Excess samples are dropped. */\n maxSamplesPerPattern?: number\n}\n\n/** Default caps — match giga-test for compatibility. */\nexport const DEFAULT_MAX_PATTERNS = 50\nexport const DEFAULT_MAX_SAMPLES_PER_PATTERN = 5\n\ninterface InternalPattern {\n errorType: string\n field: string | null\n message: string\n count: number\n firstOccurrence: number\n lastOccurrence: number\n samples: ErrorSample[]\n}\n\nexport class ErrorTracker {\n private readonly patterns = new Map<string, InternalPattern>()\n private readonly maxPatterns: number\n private readonly maxSamplesPerPattern: number\n private droppedSignatures = 0\n\n constructor(config: ErrorTrackerConfig = {}) {\n this.maxPatterns = config.maxPatterns ?? DEFAULT_MAX_PATTERNS\n this.maxSamplesPerPattern = config.maxSamplesPerPattern ?? DEFAULT_MAX_SAMPLES_PER_PATTERN\n }\n\n /**\n * Record one error. Same `(errorType, field, message)` triple bumps the\n * existing bucket; a new triple opens a new one (subject to {@link maxPatterns}).\n *\n * `field` is optional — pass `undefined` for errors not tied to a single\n * column (e.g. parse errors at row level). Internally normalised to the\n * literal string `'GENERAL'` so it shares a bucket with other genericised\n * errors of the same type+message.\n */\n addError(\n rowNumber: number,\n errorType: string,\n message: string,\n field: string | undefined,\n rowData: ImportJsonValue,\n ): void {\n const fieldKey = field ?? 'GENERAL'\n const signature = `${errorType}:${fieldKey}:${message}`\n\n let pattern = this.patterns.get(signature)\n if (!pattern) {\n if (this.patterns.size >= this.maxPatterns) {\n this.droppedSignatures += 1\n return\n }\n pattern = {\n errorType,\n field: field ?? null,\n message,\n count: 0,\n firstOccurrence: rowNumber,\n lastOccurrence: rowNumber,\n samples: [],\n }\n this.patterns.set(signature, pattern)\n }\n\n pattern.count += 1\n pattern.lastOccurrence = rowNumber\n if (pattern.samples.length < this.maxSamplesPerPattern) {\n pattern.samples.push({ rowNumber, rowData })\n }\n }\n\n /** Total count across every pattern. Counts errors, not patterns. */\n getTotalErrorCount(): number {\n let sum = 0\n for (const p of this.patterns.values()) sum += p.count\n return sum\n }\n\n /** Number of distinct signatures retained in the map (≤ `maxPatterns`). */\n getDistinctPatternCount(): number {\n return this.patterns.size\n }\n\n /**\n * Number of NEW signatures dropped because the map was already at\n * capacity. Surfacing this in the import_run summary tells the operator\n * \"the top-N was saturated — there's a long tail you're not seeing\".\n */\n getDroppedSignatureCount(): number {\n return this.droppedSignatures\n }\n\n /**\n * Top patterns sorted by descending count, capped at `maxPatterns`.\n * Stable secondary order is insertion order (Map iteration order is\n * insertion order; Array.sort is stable in V8).\n */\n getTopPatterns(): ErrorPattern[] {\n const all = Array.from(this.patterns.values()).sort((a, b) => b.count - a.count)\n const totalErrors = all.reduce((sum, p) => sum + p.count, 0)\n return all.map((p) => ({\n errorType: p.errorType,\n field: p.field,\n message: p.message,\n count: p.count,\n firstOccurrence: p.firstOccurrence,\n lastOccurrence: p.lastOccurrence,\n samples: p.samples.slice(),\n percentage: totalErrors > 0 ? (p.count / totalErrors) * 100 : 0,\n }))\n }\n\n /**\n * Compact JSON-serialisable snapshot for `import_run.errorSummary`.\n * Aside from the patterns array, includes the totals so a reader of\n * just this column doesn't have to re-derive them.\n */\n snapshot(): ErrorTrackerSnapshot {\n return {\n totalErrors: this.getTotalErrorCount(),\n distinctPatterns: this.getDistinctPatternCount(),\n droppedSignatures: this.droppedSignatures,\n patterns: this.getTopPatterns(),\n }\n }\n}\n\nexport interface ErrorTrackerSnapshot {\n totalErrors: number\n distinctPatterns: number\n droppedSignatures: number\n patterns: ErrorPattern[]\n}\n\n/**\n * Merge two independently-produced `ErrorTrackerSnapshot`s into one — e.g.\n * the runner's per-row `warningSummary` plus a worker-level signal computed\n * AFTER the run completes (F010 / #602: the price-drift anomaly check runs\n * in `worker.ts`, after `runImport` returns, using a fresh single-pattern\n * tracker of its own). Patterns concatenate rather than re-dedup: the two\n * snapshots are assumed to use disjoint `errorType` signature spaces (a\n * worker-level tracker uses a type — `price_anomaly` — the runner's own\n * warning aggregation never emits), so counts sum directly with no risk of\n * double-counting one bucket as two.\n */\nexport function mergeErrorTrackerSnapshots(\n a: ErrorTrackerSnapshot,\n b: ErrorTrackerSnapshot,\n): ErrorTrackerSnapshot {\n const totalErrors = a.totalErrors + b.totalErrors\n // Each source snapshot computed `percentage` against its OWN `totalErrors`\n // (see `getTopPatterns` above); the merged total is different, so every\n // pattern's `percentage` must be recomputed against it — a straight\n // concat would leave every value stale (e.g. `20/100=20%` staying `20`\n // once the true merged denominator is `101`). `0` when the merged total\n // is `0`, matching `getTopPatterns`' own zero-total convention.\n const rescale = (p: ErrorPattern): ErrorPattern => ({\n ...p,\n percentage: totalErrors > 0 ? (p.count / totalErrors) * 100 : 0,\n })\n return {\n totalErrors,\n distinctPatterns: a.distinctPatterns + b.distinctPatterns,\n droppedSignatures: a.droppedSignatures + b.droppedSignatures,\n patterns: [...a.patterns.map(rescale), ...b.patterns.map(rescale)],\n }\n}\n","/**\n * Price-distribution stats + prior-run anomaly detection (F010 / issue #602).\n *\n * The import pipeline's existing anomaly detection — register-handler.ts's\n * \"6b. Header-presence drift check\" — only catches STRUCTURAL drift: a\n * mapped column vanishing (or getting renamed) from the header row. It does\n * NOT catch VALUE drift: a supplier silently flipping their decimal\n * separator (e.g. `1.234,56` → `1234.56`) or thousands separator WITHOUT\n * touching a single column header. Since the saved\n * `import_mapping.numberFormat` (`{decimal, thousands}`) is unchanged,\n * every price in that run parses as a NUMERICALLY VALID but WRONG value —\n * silently corrupting the catalog's prices. Nothing upstream of this module\n * flags it.\n *\n * This module computes a per-run price-distribution snapshot (`PriceStats`)\n * over `PartsDocument.purchase_price_minor` — the DERIVED cost field,\n * rather than the raw net/list price columns, because both the direct-net\n * AND two-file derivation paths in `mapping/parts-schema.ts` (step 4) funnel\n * through it, so a decimal-format flip on EITHER source column surfaces\n * here regardless of which pricing path a supplier's feed uses.\n *\n * Collection (`PriceStatsCollector`) happens in the SAME streaming pass\n * `runner.ts` already uses to build `totals` — one `.record()` call per\n * ES-accepted row, no second pass over the file, no extra DB round-trip.\n * The prior-run comparison (`detectPriceAnomaly`) happens in `worker.ts`\n * AFTER the run completes, because it needs `AdminClient` to look the\n * prior run up — this module stays DB-agnostic, pure data + pure functions.\n */\n\n/** A run's price-distribution snapshot. Values are in MINOR units (cents), matching `PartsDocument.purchase_price_minor`'s own scale. */\nexport interface PriceStats {\n /** ES-accepted rows considered — same accounting as `RunImportResult.rowsSucceeded`. */\n count: number\n /** 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`. */\n nullRate: number\n /** Minimum of the non-null samples, in minor units. `null` when there are zero non-null samples. */\n min: number | null\n /** Median of the non-null samples, in minor units. `null` when there are zero non-null samples. */\n median: number | null\n /** Maximum of the non-null samples, in minor units. `null` when there are zero non-null samples. */\n max: number | null\n}\n\n/** The all-zero/empty snapshot — a run that produced zero ES-accepted rows. */\nexport const EMPTY_PRICE_STATS: PriceStats = {\n count: 0,\n nullRate: 0,\n min: null,\n median: null,\n max: null,\n}\n\n/**\n * Bounded sample cap for the min/median/max computation — the same\n * \"bounded, uniform sample\" philosophy `ErrorTracker` uses top-N patterns\n * for. 50,000 floats is ~400KB, trivial next to the ES bulk-write buffers\n * already in flight for a run this size, and far more than needed for a\n * stable median estimate. A feed with more ES-accepted rows than this cap\n * still gets an EXACT `count` / `nullRate` (those are running counters, not\n * sample-bounded) — only min/median/max become an approximation over a\n * UNIFORM RANDOM sample of `PRICE_SAMPLE_CAP` non-null values, not \"the\n * first N encountered\" (see `record`'s reservoir-sampling note for why\n * order-independence matters specifically for this module's job).\n */\nexport const PRICE_SAMPLE_CAP = 50_000\n\n/**\n * Accumulates price samples during a run's streaming pass. `record` is O(1)\n * (amortized array push below the cap, one RNG draw above it); `snapshot`\n * sorts the retained samples once at the end — cheap relative to the ES\n * bulk-write the run already performs every batch.\n */\nexport class PriceStatsCollector {\n private count = 0\n private nullCount = 0\n private nonNullSeen = 0\n private readonly samples: number[] = []\n\n /**\n * Record one ES-accepted row's price sample. Call exactly once per\n * accepted row (mirrors the `rowsWarned` / `pairs` accounting in\n * `runner.ts`'s flush loop) — `undefined` means \"this row had no price to\n * sample\" and counts toward `nullRate` rather than being skipped.\n *\n * Reservoir sampling (Algorithm R) past the cap, not \"first N wins\": each\n * new non-null value replaces a uniformly-random existing slot with\n * probability `PRICE_SAMPLE_CAP / nonNullSeen`, giving every non-null\n * sample seen so far an equal chance of surviving into the final\n * reservoir. Order-independence matters here specifically — the anomaly\n * this module exists to catch (a supplier's decimal-format flip) could\n * start partway through a feed larger than the cap (e.g. a corrupted\n * re-export appended to an otherwise-normal catalogue), and a first-N\n * sample would never see a single row past the cutover point, silently\n * missing exactly the drift F010 was built to detect.\n */\n record(value: number | undefined): void {\n this.count += 1\n if (value === undefined) {\n this.nullCount += 1\n return\n }\n this.nonNullSeen += 1\n if (this.samples.length < PRICE_SAMPLE_CAP) {\n this.samples.push(value)\n return\n }\n const j = Math.floor(Math.random() * this.nonNullSeen)\n if (j < PRICE_SAMPLE_CAP) this.samples[j] = value\n }\n\n snapshot(): PriceStats {\n if (this.count === 0) return { ...EMPTY_PRICE_STATS }\n const nullRate = this.nullCount / this.count\n if (this.samples.length === 0) {\n return { count: this.count, nullRate, min: null, median: null, max: null }\n }\n const sorted = [...this.samples].sort((a, b) => a - b)\n const min = sorted[0] ?? null\n const max = sorted[sorted.length - 1] ?? null\n const mid = Math.floor(sorted.length / 2)\n let median: number | null\n if (sorted.length % 2 === 0) {\n const lo = sorted[mid - 1]\n const hi = sorted[mid]\n median = lo !== undefined && hi !== undefined ? (lo + hi) / 2 : null\n } else {\n median = sorted[mid] ?? null\n }\n return { count: this.count, nullRate, min, median, max }\n }\n}\n\n/**\n * Anomaly threshold: a run whose median purchase price is >= 10x higher OR\n * <= 1/10 (0.1x) of the prior successful run's median (for the same\n * transform + supplier + brand) is flagged.\n *\n * Chosen generously above the range a real repricing plausibly covers\n * (even an aggressive markup/discount renegotiation is a low-single-digit\n * multiplier — 2x-3x would already be an extreme outlier for a legitimate\n * price change) but comfortably BELOW the ~100x-1000x magnitude a\n * decimal/thousands-separator flip produces for typical EUR part prices:\n * `\"1.234,56\"` (a European-format €1234.56) misparsed with dot-decimal\n * conventions reads as `1.234,56` → `1234.56`, a ~1000x inflation if the\n * true value was e.g. €1.23; a thousands-separator flip on `\"1234,56\"`\n * (true €1234.56) misparsed as `123456` (dropping the decimal entirely) is\n * a ~100x inflation. 10x leaves a wide safety margin against false\n * positives on genuine price changes while still comfortably catching\n * every realistic decimal/thousands-separator flip.\n */\nexport const PRICE_ANOMALY_THRESHOLD_MULTIPLIER = 10\n\nexport interface PriceAnomaly {\n /** `curr.median / prev.median`. */\n ratio: number\n /** The prior run's median (minor units). */\n prevMedian: number\n /** This run's median (minor units). */\n currMedian: number\n}\n\n/**\n * Compare two runs' price stats for a magnitude-scale anomaly. Returns\n * `null` when there's nothing to compare (either side has no median — zero\n * non-null price samples, e.g. a schema/transform that never emits a price\n * sample, or a run with zero ES-accepted rows) or the ratio falls within\n * the safe band.\n */\nexport function detectPriceAnomaly(prev: PriceStats, curr: PriceStats): PriceAnomaly | null {\n if (prev.median === null || curr.median === null) return null\n // A zero/negative median can't produce a meaningful ratio. Shouldn't occur\n // for real minor-unit prices (`toMinorUnits` on a positive price); this is\n // defensive-only so a pathological value can't produce Infinity/NaN below.\n if (prev.median <= 0 || curr.median <= 0) return null\n const ratio = curr.median / prev.median\n if (\n ratio >= PRICE_ANOMALY_THRESHOLD_MULTIPLIER ||\n ratio <= 1 / PRICE_ANOMALY_THRESHOLD_MULTIPLIER\n ) {\n return { ratio, prevMedian: prev.median, currMedian: curr.median }\n }\n return null\n}\n\n/**\n * Narrow an `unknown` value (a `field.json()` column read back through\n * `AdminClient`, typed `JsonValue | undefined` on the DTO) to `PriceStats`,\n * or `null` when it doesn't structurally match — e.g. a run written before\n * this field existed, or a run whose transform never emitted a price\n * sample so `min`/`median`/`max` serialised as `null`. Defensive, matching\n * the `params` narrowing pattern already used in `worker.ts`.\n */\nexport function parsePriceStats(value: unknown): PriceStats | null {\n if (typeof value !== 'object' || value === null) return null\n const v = value as Record<string, unknown>\n if (typeof v.count !== 'number' || typeof v.nullRate !== 'number') return null\n return {\n count: v.count,\n nullRate: v.nullRate,\n min: typeof v.min === 'number' ? v.min : null,\n median: typeof v.median === 'number' ? v.median : null,\n max: typeof v.max === 'number' ? v.max : null,\n }\n}\n","/**\n * One-shot importer: stream rows → transform → batched bulk-write to ES,\n * accumulating per-row errors into `ErrorTracker` and reporting progress\n * to the queue every batch.\n *\n * Per PLAN-ECOMMERCE.md PR 7 (PoC scope):\n * - **Batch size 1000.** Matches giga-test precedent. Configurable for\n * integration tests that don't want a 1k row floor.\n * - **No resumability and no automatic retries.** The `imports:run` queue\n * job is registered with `defaultRetries: 0` (see `worker.ts`) so a\n * failed handler does NOT re-enqueue itself — re-running a multi-batch\n * import against the same `import_run.id` while the previous attempt\n * may still be writing is a footgun (duplicate batches, double-counted\n * progress). Operator retries by creating a NEW `import_run` row.\n * - **No per-supplier transform plugin.** The runner takes a single\n * `RowTransform<TDoc>` from the registry and applies it to every row;\n * PR 8 may diverge but only by registering a different transform name.\n * - **Direct `bulkUpsert` into the live aliased index** (D6 alias is\n * set up by PR 4's `ensureAliasedIndex`; the importer doesn't reindex).\n *\n * Per D21 (sanctioned bulk path): this runner intentionally bypasses\n * AdminClient and entity hooks. Per-batch audit / observability lives\n * on the surrounding `import_run` row + queue progress, NOT per-row.\n */\n\nimport {\n type BulkIndexResult,\n bulkPartialUpdate,\n bulkUpsert,\n type EsClientLike,\n} from '@murumets-ee/search-elasticsearch'\nimport { ErrorTracker } from './error-tracker.js'\nimport { type PriceStats, PriceStatsCollector } from './price-stats.js'\nimport { type ReadFeedOptions, readFeed } from './read-feed.js'\nimport type {\n DirectionalPair,\n DocPatch,\n RowTransform,\n RowWarning,\n TransformContext,\n} from './transform.js'\n\n/** Soft default; chosen to match giga-test. ES bulk requests over ~5MB get split server-side anyway. */\nexport const DEFAULT_BATCH_SIZE = 1000\n\nexport interface RunImportOptions<TDoc> {\n /** UUID of the `import_run` row driving this run. Forwarded to every transform invocation. */\n importRunId: string\n /** Operator-supplied label for the run. Forwarded to the transform context. */\n runLabel: string\n /** Opaque per-run params copied from `import_run.params`. */\n params: Record<string, unknown>\n /** Transform applied to every parsed row. */\n transform: RowTransform<TDoc>\n /** Feed reader options — file path + format (csv/tsv/xlsx) + delimiter/header config. */\n feed: ReadFeedOptions\n /** ES client (low-level shape from `@murumets-ee/search-elasticsearch`). */\n esClient: EsClientLike\n /** Index alias to write to. Per D6, callers always pass an alias, never a physical index. */\n esIndex: string\n /** Rows per `bulkUpsert` call. Default {@link DEFAULT_BATCH_SIZE}. */\n batchSize?: number\n /**\n * Callback invoked after every batch. The handler in `worker.ts`\n * forwards this to `ctx.updateProgress` for the queue UI; tests\n * inspect it directly. Synchronous + cheap so a slow callback can't\n * back-pressure the importer.\n */\n onProgress?: (progress: ImportRunProgress) => void\n /** Optional: stop processing after this many rows. Tests use it; production passes `undefined`. */\n rowLimit?: number\n /** Abort signal threaded into the underlying ES client request — cooperative cancel. */\n signal?: AbortSignal\n /** Optional ErrorTracker config (caps). Default: top-50 patterns × 5 samples. */\n errorTracker?: ErrorTracker\n /**\n * Optional separate tracker for NON-FATAL row warnings (commerce-demo PR4 —\n * `no_purchase_price`). Distinct from {@link errorTracker} so warnings never\n * inflate `totals.failed` / the error summary: a warned row is a SUCCESS whose\n * doc is indexed. Default: a fresh tracker with the same caps.\n */\n warningTracker?: ErrorTracker\n /**\n * Optional price-stats collector (F010 / #602) — accumulates a\n * min/median/max/nullRate distribution over ES-accepted rows' optional\n * `priceSample`s in the SAME streaming pass that builds `totals`. Default:\n * a fresh collector. Tests can inject their own to inspect it directly —\n * mirrors the {@link errorTracker} / {@link warningTracker} DI shape.\n */\n priceStats?: PriceStatsCollector\n /**\n * Optional structural logger for per-batch telemetry. Receives\n * `{ batch, ms, succeeded, failed, rowsRead, rowsPerSecond }` after\n * every flush, plus a one-shot `started` and `finished` event. The\n * worker forwards `app.logger.child({ pkg: 'imports' })`; tests\n * leave it undefined.\n *\n * Why: without per-batch visibility, a stalled run looks identical\n * to \"first batch in progress\" forever — the only feedback is the\n * queue's `progress` jsonb (which writes after each batch) and the\n * eventual `succeeded`/`failed` job event. Adding this lets the\n * operator tail the worker log and immediately see whether ES is\n * rate-limiting, the transform is rejecting every row, or the\n * stream is actually progressing.\n */\n logger?: {\n info: (data: Record<string, unknown>, msg: string) => void\n warn: (data: Record<string, unknown>, msg: string) => void\n }\n}\n\n/**\n * Progress payload written to `toolkit_jobs.progress` after every batch.\n * Caps + flush rules live on the queue's `updateProgress` debounce —\n * callers don't need to throttle.\n */\nexport interface ImportRunProgress {\n rowsRead: number\n rowsSucceeded: number\n rowsFailed: number\n rowsSkipped: number\n /** Rows indexed WITH a non-fatal warning (commerce-demo PR4 — `no_purchase_price`). */\n rowsWarned: number\n batchesCompleted: number\n /** Wall-clock seconds since the runner started. */\n elapsedSeconds: number\n /** Rows / second, computed at every batch. */\n rowsPerSecond: number\n /** Distinct error patterns currently held by the tracker. Saturates at the cap. */\n distinctErrorPatterns: number\n /**\n * Total data-row count from a pre-pass scan, when the worker computed one.\n * Lets the UI render `processed / total` percent. Absent when the worker\n * skipped the count (file too large for the count budget, count failed,\n * etc.) — UI must treat it as optional.\n */\n totalRows?: number\n}\n\n/**\n * Final result returned by {@link runImport}. The handler writes these\n * onto the `import_run` row alongside the ErrorTracker snapshot.\n */\nexport interface RunImportResult {\n /** Total rows read from the file (excludes skipped empty lines). */\n rowsRead: number\n /** Rows the transform turned into a successful doc AND the ES cluster acknowledged. */\n rowsSucceeded: number\n /**\n * Rows that the transform rejected (`{ kind: 'error' }`) OR that ES\n * rejected on bulk-write (per-doc failure). Both are aggregated by\n * `errorTracker` for the import_run summary.\n */\n rowsFailed: number\n /** Rows that the transform skipped (`{ kind: 'skip' }`) — header noise, blank lines, intentional drop. */\n rowsSkipped: number\n /**\n * Rows that succeeded (doc indexed) but carried ≥1 non-fatal warning —\n * counted SEPARATELY from `rowsSucceeded`/`rowsFailed` (a warned row is also\n * counted in `rowsSucceeded`). commerce-demo PR4: `no_purchase_price`.\n */\n rowsWarned: number\n /** Number of `bulkUpsert` calls made. */\n batchesCompleted: number\n /** Final value of {@link ErrorTracker.snapshot}. */\n errors: ReturnType<ErrorTracker['snapshot']>\n /** Aggregated NON-FATAL warnings (commerce-demo PR4). Empty when no row warned. */\n warnings: ReturnType<ErrorTracker['snapshot']>\n /**\n * Directional supersession pairs harvested as a side-output (commerce-demo PR6,\n * SD007). Buffered in memory from ES-ACCEPTED rows only (same accounting as\n * warnings — a row whose doc was rejected by ES contributes no pair). Empty unless\n * a schema emits them (the `parts` schema, when a row's `superseding_code` is set).\n * The worker hands these to an injected `pairSink` AFTER the run for the commerce\n * replacement-merge engine; the runner itself never writes the consumer's tables.\n */\n pairs: readonly DirectionalPair[]\n /**\n * Doc-patches applied by the phase-2 pass (commerce-demo PR7): a `<code>*S`\n * core-fee row is not a saleable doc — it emits a {@link DocPatch} that merges its\n * deposit onto the PARENT part's `core_charge_minor`. The runner buffers these\n * during the stream (sparse) and applies them via `bulkPartialUpdate` AFTER the ES\n * pass. `patchesApplied` = targets the cluster merged; `patchesOrphaned` = patches\n * whose parent genuinely wasn't in the index (a `document_missing`/404 → an\n * `orphan_core_charge` warning). An INFRA failure (whole-batch transport error, or a\n * non-404 per-doc failure) is NOT counted as orphaned — it lands in a distinct\n * `patch_attach_failed` warning bucket so it can't masquerade as a data orphan. Zero\n * unless a schema emits patches.\n */\n patchesApplied: number\n patchesOrphaned: number\n /**\n * Price-distribution snapshot (F010 / #602) accumulated from ES-accepted\n * rows' `priceSample`s during this same streaming pass. See `price-stats.ts`.\n * `count` is `0` (all fields `null`/`0`) for a run whose schema never emits\n * a price sample.\n */\n priceStats: PriceStats\n}\n\n/**\n * Apply the runner against a feed file. Stops on rowLimit OR end-of-file\n * OR if `signal` aborts. Throws if the streaming reader / ES client\n * throws — caller (the queue handler) catches that and writes\n * `import_run.status = 'failed'` with the error message in\n * `errorSummary.fatal`.\n */\nexport async function runImport<TDoc>(options: RunImportOptions<TDoc>): Promise<RunImportResult> {\n const {\n importRunId,\n runLabel,\n params,\n transform,\n feed,\n esClient,\n esIndex,\n batchSize = DEFAULT_BATCH_SIZE,\n onProgress,\n rowLimit,\n signal,\n errorTracker = new ErrorTracker(),\n warningTracker = new ErrorTracker(),\n priceStats: priceStatsCollector = new PriceStatsCollector(),\n logger,\n } = options\n\n if (batchSize < 1) {\n throw new Error(`batchSize must be >= 1 (got ${batchSize})`)\n }\n\n const startedAt = Date.now()\n logger?.info({ importRunId, runLabel, batchSize, esIndex }, 'imports.runner: started')\n let rowsRead = 0\n let rowsSucceeded = 0\n let rowsFailed = 0\n let rowsSkipped = 0\n let rowsWarned = 0\n let batchesCompleted = 0\n\n // Side-output buffer (commerce-demo PR6, SD007): directional supersession pairs\n // accumulated from ES-ACCEPTED rows across the whole run. An array append per\n // sparse superseded row — it never blocks the `bulkUpsert` loop. Handed to the\n // injected `pairSink` by the worker AFTER the run (read-once / merge-after).\n const collectedPairs: DirectionalPair[] = []\n\n // Side-output buffer (commerce-demo PR7): doc-patches from `*S` core-fee rows —\n // each attaches a core charge onto a DIFFERENT (parent) doc. Collected off the\n // `skip` result (a `*S` row is never a saleable doc), sparse, applied in a phase-2\n // `bulkPartialUpdate` after the ES pass (below) — order-independent because ES\n // `update` is realtime by `_id`.\n const collectedPatches: DocPatch<TDoc>[] = []\n\n // Warnings + pairs ride on the pending item (with the raw row for the warning\n // sample) so they're accounted at FLUSH time — only for rows ES actually accepted.\n // A warned row is by contract a SUCCESSFUL row, so counting it at push time (before\n // the bulk response) would wrongly keep a later-ES-rejected row in rowsWarned /\n // warnings; pairs follow the same accepted-rows-only rule.\n let pending: Array<{\n id: string\n doc: TDoc\n rowNumber: number\n rowData: Record<string, string>\n warnings?: readonly RowWarning[]\n pairs?: readonly DirectionalPair[]\n priceSample?: number\n }> = []\n\n const flush = async (): Promise<void> => {\n if (pending.length === 0) return\n const batch = pending\n pending = []\n const flushStartedAt = Date.now()\n let result: BulkIndexResult\n try {\n result = await bulkUpsert<TDoc>(esClient, {\n index: esIndex,\n docs: batch.map(({ id, doc }) => ({ id, doc })),\n ...(signal !== undefined && { signal }),\n })\n } catch (err) {\n // Cluster- or transport-level failure — the whole batch is\n // unaccounted for. Distinguish abort (operator-driven cancel) from\n // a real cluster failure so the errorSummary doesn't mislabel a\n // cancelled run as broken cluster connectivity.\n const isAbort =\n signal?.aborted ??\n (err instanceof Error && (err.name === 'AbortError' || /abort/i.test(err.message)))\n const errorType = isAbort ? 'aborted' : 'bulk_request_failed'\n const reason = err instanceof Error ? err.message : String(err)\n for (const { rowNumber } of batch) {\n errorTracker.addError(rowNumber, errorType, reason, undefined, null)\n }\n rowsFailed += batch.length\n throw err\n }\n\n rowsSucceeded += result.succeeded\n rowsFailed += result.failures.length\n batchesCompleted += 1\n\n const flushMs = Date.now() - flushStartedAt\n const elapsedSec = (Date.now() - startedAt) / 1000\n logger?.info(\n {\n importRunId,\n batch: batchesCompleted,\n flushMs,\n submitted: batch.length,\n succeeded: result.succeeded,\n failed: result.failures.length,\n rowsRead,\n rowsPerSecond: elapsedSec > 0 ? Math.round(rowsRead / elapsedSec) : 0,\n },\n 'imports.runner: batch flushed',\n )\n\n if (result.failures.length > 0) {\n // Map each ES failure back to its source row via `id`. The bulk\n // response order matches the request order, but ES doesn't promise\n // that; matching by `id` is the safe path. PoC volume is small\n // enough that the O(failures × batch) cost is irrelevant.\n const byId = new Map(batch.map((b) => [b.id, b.rowNumber]))\n for (const fail of result.failures) {\n const rowNumber = byId.get(fail.id) ?? -1\n errorTracker.addError(rowNumber, fail.type, fail.reason, undefined, { id: fail.id })\n }\n }\n\n // Account non-fatal warnings ONLY for rows ES accepted (commerce-demo PR4) — a\n // warned row is by contract a SUCCESSFUL row, so a row that warned AND then failed\n // the bulk write must NOT land in rowsWarned/warnings (it's already in rowsFailed).\n const failedIds = new Set(result.failures.map((f) => f.id))\n for (const item of batch) {\n if (!item.warnings || item.warnings.length === 0) continue\n if (failedIds.has(item.id)) continue\n rowsWarned += 1\n for (const w of item.warnings) {\n warningTracker.addError(item.rowNumber, w.warningType, w.message, w.field, item.rowData)\n }\n }\n\n // Buffer directional pairs for rows ES accepted (commerce-demo PR6, SD007) — same\n // accepted-rows-only rule as warnings, so a pair from a doc ES rejected is dropped.\n for (const item of batch) {\n if (!item.pairs || item.pairs.length === 0) continue\n if (failedIds.has(item.id)) continue\n collectedPairs.push(...item.pairs)\n }\n\n // Price-distribution sampling (F010 / #602) — same accepted-rows-only rule as\n // warnings/pairs: a row ES rejected contributes no sample. Every ES-accepted row\n // records exactly once (`undefined` counts toward `nullRate` rather than being\n // skipped), so `priceStats.count` tracks `rowsSucceeded` exactly.\n for (const item of batch) {\n if (failedIds.has(item.id)) continue\n priceStatsCollector.record(item.priceSample)\n }\n\n if (onProgress) {\n const elapsedSeconds = (Date.now() - startedAt) / 1000\n onProgress({\n rowsRead,\n rowsSucceeded,\n rowsFailed,\n rowsSkipped,\n rowsWarned,\n batchesCompleted,\n elapsedSeconds,\n rowsPerSecond: elapsedSeconds > 0 ? rowsRead / elapsedSeconds : 0,\n distinctErrorPatterns: errorTracker.getDistinctPatternCount(),\n })\n }\n }\n\n // Streaming heartbeat — sample every Nth row so the operator sees the\n // streamer is alive even when a transform-rejected file would prevent\n // any flush from firing. Without this, a feed that's 100% rejected\n // looks identical to \"stuck\" until end-of-file. Sample frequency is\n // batchSize so the cadence matches the natural batch rhythm.\n const streamSampleEvery = batchSize\n\n // First-error capture per error type — surface what's actually rejecting\n // the rows when 100% of a feed is rejected. Without this, the only signal\n // is `errorTracker.snapshot()` which only writes to import_run.errorSummary\n // at the END of the run. Mid-run we want to know NOW.\n const firstErrorByType = new Map<string, { rowNumber: number; message: string }>()\n\n for await (const { rowNumber, row } of readFeed(feed)) {\n if (signal?.aborted) break\n if (rowLimit !== undefined && rowsRead >= rowLimit) break\n rowsRead += 1\n\n if (rowsRead === 1) {\n logger?.info(\n { importRunId, rowNumber, sampleKeys: Object.keys(row) },\n 'imports.runner: STREAM_FIRST_ROW',\n )\n }\n if (rowsRead % streamSampleEvery === 0) {\n const elapsedSec = (Date.now() - startedAt) / 1000\n // Telemetry only — no row content, no error sample text.\n // PR #262 review (M-5): the prior `firstErrors:\n // Object.fromEntries(firstErrorByType)` payload included the\n // raw error message captured from the transform, which for the\n // carmaker feed embeds supplier codes + price fragments. The\n // detailed first-error breakdown is already persisted on\n // `import_run.errorSummary` via `errorTracker.snapshot()` —\n // operators inspect it there, not in the worker log.\n logger?.info(\n {\n importRunId,\n rowsRead,\n rowsSucceeded,\n rowsFailed,\n rowsSkipped,\n pending: pending.length,\n elapsedSec: Math.round(elapsedSec * 10) / 10,\n rowsPerSecond: elapsedSec > 0 ? Math.round(rowsRead / elapsedSec) : 0,\n distinctFirstErrorTypes: firstErrorByType.size,\n },\n 'imports.runner: STREAM_TICK',\n )\n }\n\n const ctx: TransformContext = { importRunId, params, runLabel, rowNumber }\n let result: Awaited<ReturnType<typeof transform>>\n try {\n result = await transform(row, ctx)\n } catch (err) {\n // A throw from the transform is a programmer error — surface it as\n // a row-level error so the run can continue. (If the bug is\n // catastrophic, the operator sees the same message repeated and\n // can stop the run.)\n const reason = err instanceof Error ? err.message : String(err)\n errorTracker.addError(rowNumber, 'transform_threw', reason, undefined, row)\n if (!firstErrorByType.has('transform_threw')) {\n firstErrorByType.set('transform_threw', { rowNumber, message: reason })\n // PR #262 review (M-5): drop the `row` payload from the\n // log line — supplier feeds carry pricing + internal codes.\n // The full row is on `import_run.errorSummary.samples` for\n // post-mortem review by operators with view permission.\n logger?.warn(\n { importRunId, rowNumber, errorType: 'transform_threw', message: reason },\n 'imports.runner: FIRST_ERROR transform_threw',\n )\n }\n rowsFailed += 1\n continue\n }\n\n if (result.kind === 'skip') {\n rowsSkipped += 1\n // A `*S` core-fee row skips the saleable index but may carry a doc-patch\n // (commerce-demo PR7) — buffer it for the phase-2 attach. Unlike pairs, a\n // patch has no ES write of its own to gate on, so it's collected here\n // unconditionally; its success is decided by the phase-2 update.\n if (result.patches && result.patches.length > 0) {\n collectedPatches.push(...result.patches)\n }\n continue\n }\n if (result.kind === 'error') {\n errorTracker.addError(\n rowNumber,\n result.error.errorType,\n result.error.message,\n result.error.field,\n row,\n )\n if (!firstErrorByType.has(result.error.errorType)) {\n firstErrorByType.set(result.error.errorType, {\n rowNumber,\n message: result.error.message,\n })\n logger?.warn(\n {\n importRunId,\n rowNumber,\n errorType: result.error.errorType,\n field: result.error.field,\n message: result.error.message,\n },\n `imports.runner: FIRST_ERROR ${result.error.errorType}`,\n )\n }\n rowsFailed += 1\n continue\n }\n\n // Non-fatal warnings ride on a SUCCESS — the doc is still indexed\n // (commerce-demo PR4: `no_purchase_price`). Carry them (+ the raw row for the\n // sample) on the pending item; the flush aggregates them into the separate\n // `warningTracker` AFTER the bulk response confirms the row was accepted, so a\n // warned-then-ES-rejected row never inflates rowsWarned / the warning summary.\n pending.push({\n id: result.id,\n doc: result.doc,\n rowNumber,\n rowData: row,\n ...(result.warnings && result.warnings.length > 0 && { warnings: result.warnings }),\n ...(result.pairs && result.pairs.length > 0 && { pairs: result.pairs }),\n ...(result.priceSample !== undefined && { priceSample: result.priceSample }),\n })\n if (pending.length >= batchSize) {\n await flush()\n }\n }\n\n await flush()\n\n // Phase 2 (commerce-demo PR7): attach the buffered `*S` core-charge patches onto\n // their PARENT docs. Runs AFTER the whole stream so every parent that exists in\n // this run is already indexed; ES `update` is realtime by `_id`, so the merge\n // lands regardless of whether the parent streamed before or after its `*S` row\n // (order-independent). Patches are sparse (only reman parts carry `*S`), but batch\n // anyway for a giant catalogue. A patch whose parent isn't in the index comes back\n // as a per-doc failure (`document_missing_exception`) → an `orphan_core_charge`\n // warning, never a phantom doc, never a silent drop (D004). A cluster failure of\n // the whole batch is logged as a warning too — the saleable docs already indexed;\n // only the deferred core-charge attach didn't happen — so we never fail the run\n // over it.\n let patchesApplied = 0\n let patchesOrphaned = 0\n for (let i = 0; i < collectedPatches.length; i += batchSize) {\n // Operator-driven cancel BETWEEN batches: THROW rather than break, so a run\n // cancelled after phase 1 is not marked `succeeded` with core charges left\n // unattached. Mirrors phase-1's flush, which also re-throws on abort. The queue\n // handler catches this and marks the run failed.\n if (signal?.aborted) {\n throw new Error('imports: run aborted during core-charge attach (phase 2)')\n }\n const slice = collectedPatches.slice(i, i + batchSize)\n let updateResult: BulkIndexResult\n try {\n updateResult = await bulkPartialUpdate<TDoc>(esClient, {\n index: esIndex,\n updates: slice.map(({ id, doc }) => ({ id, doc })),\n ...(signal !== undefined && { signal }),\n })\n } catch (err) {\n // An abort surfacing as an AbortError from the ES client is a cancel, NOT an\n // attach failure — re-throw so the run fails (same as phase 1), never a silent\n // \"succeeded with patches unapplied\".\n const isAbort =\n signal?.aborted ??\n (err instanceof Error && (err.name === 'AbortError' || /abort/i.test(err.message)))\n if (isAbort) throw err\n // Whole-batch transport/cluster failure (ES down, timeout). The parents may\n // well exist — the attach simply didn't happen — so this is an INFRA failure,\n // NOT a data orphan: it gets the distinct `patch_attach_failed` bucket and is\n // NOT counted in patchesOrphaned. Best-effort: the saleable docs already\n // indexed, and a re-import re-attaches, so we warn + continue rather than fail.\n const reason = err instanceof Error ? err.message : String(err)\n for (const patch of slice) {\n warningTracker.addError(-1, 'patch_attach_failed', reason, patch.orphanWarning?.field, {\n target: patch.id,\n })\n }\n continue\n }\n patchesApplied += updateResult.succeeded\n if (updateResult.failures.length > 0) {\n const byId = new Map(slice.map((p) => [p.id, p]))\n for (const fail of updateResult.failures) {\n const w = byId.get(fail.id)?.orphanWarning\n // A missing target (`document_missing_exception` / 404) is a genuine data\n // orphan — the referenced parent isn't in this import (D004: warn, never a\n // phantom doc). A stable message (from the patch's orphanWarning) aggregates\n // orphans; the missing parent id rides in the sample. Any OTHER per-doc\n // failure (mapper error, etc.) is an infra/attach failure kept in a distinct\n // bucket so it can't masquerade as a data orphan.\n const isOrphan = fail.status === 404 || fail.type === 'document_missing_exception'\n if (isOrphan) {\n patchesOrphaned += 1\n warningTracker.addError(\n -1,\n w?.warningType ?? 'orphan_patch',\n w?.message ?? fail.reason,\n w?.field,\n {\n target: fail.id,\n },\n )\n } else {\n warningTracker.addError(-1, 'patch_attach_failed', fail.reason, w?.field, {\n target: fail.id,\n })\n }\n }\n }\n }\n\n const totalSec = (Date.now() - startedAt) / 1000\n logger?.info(\n {\n importRunId,\n rowsRead,\n rowsSucceeded,\n rowsFailed,\n rowsSkipped,\n batchesCompleted,\n patchesApplied,\n patchesOrphaned,\n totalSec: Math.round(totalSec * 10) / 10,\n rowsPerSecond: totalSec > 0 ? Math.round(rowsRead / totalSec) : 0,\n },\n 'imports.runner: finished',\n )\n\n return {\n rowsRead,\n rowsSucceeded,\n rowsFailed,\n rowsSkipped,\n rowsWarned,\n batchesCompleted,\n errors: errorTracker.snapshot(),\n warnings: warningTracker.snapshot(),\n pairs: collectedPairs,\n patchesApplied,\n patchesOrphaned,\n priceStats: priceStatsCollector.snapshot(),\n }\n}\n"],"mappings":"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"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"schema-registry-DB5aIZMN.mjs","names":[],"sources":["../src/mapping/discount-table-schema.ts","../src/mapping/schema-registry.ts"],"sourcesContent":["/**\n * `discount_table` target schema — the non-parts feed kind (commerce-demo phase\n * 02 / 03). A discount table (R03 Sample B: `Code,Discount`) maps to\n * `{ discountGroup, discountPct }` rows.\n *\n * PR2 ships the schema (whitelist + `buildRow`) so the engine can parse + validate\n * a discount feed; the PERSISTENCE sink (upserting `supplier_discount_group` rows\n * + the two-file purchase-price derivation it feeds) lands in PR4. The `id` is the\n * discount group token so a re-import idempotently overwrites.\n */\n\nimport {\n type BuildRowContext,\n type MappedRow,\n numberCell,\n rowError,\n type TargetField,\n type TargetSchema,\n} from './target-schema.js'\n\n/** One parsed discount-table row: a group token + the purchase discount % off list. */\nexport interface DiscountTableRow {\n discountGroup: string\n discountPct: number\n}\n\nconst DISCOUNT_TABLE_FIELDS: readonly TargetField[] = [\n { key: 'discount_group', label: 'Discount group', required: true, type: 'string' },\n { key: 'discount_pct', label: 'Discount %', required: true, type: 'number' },\n]\n\nexport const discountTableSchema: TargetSchema<DiscountTableRow> = {\n name: 'discount_table',\n fields: DISCOUNT_TABLE_FIELDS,\n // params unused — a discount table is supplier-scoped at the run level, not per row.\n buildRow(mapped: MappedRow, _ctx: BuildRowContext) {\n // Group tokens are single tokens INCLUDING `'0'` (R03 Sample B) — a non-empty\n // string check, so `'0'` is a valid group.\n const group = typeof mapped.discount_group === 'string' ? mapped.discount_group.trim() : ''\n if (group === '') {\n return rowError('missing_discount_group', 'discount_group is empty', 'discount_group')\n }\n\n const pct = numberCell(mapped.discount_pct)\n if (pct.kind === 'invalid') {\n return rowError('invalid_discount_pct', 'discount_pct is not a number', 'discount_pct')\n }\n if (pct.kind === 'absent') {\n return rowError('missing_discount_pct', 'discount_pct is empty', 'discount_pct')\n }\n if (pct.value < 0 || pct.value > 100) {\n return rowError(\n 'discount_pct_out_of_range',\n `discount_pct ${pct.value} is outside 0–100`,\n 'discount_pct',\n )\n }\n\n return { kind: 'success', id: group, doc: { discountGroup: group, discountPct: pct.value } }\n },\n}\n","/**\n * Registry of the concrete target schemas (commerce-demo phase 02, D005).\n *\n * The engine resolves a mapping's `target_schema` name to its `TargetSchema` here.\n * An unregistered name is rejected (deny-by-default — the first injection-boundary\n * whitelist; the second is each schema's field set).\n */\n\nimport { discountTableSchema } from './discount-table-schema.js'\nimport { partsSchema } from './parts-schema.js'\nimport type { TargetField, TargetSchema } from './target-schema.js'\n\n/**\n * `TargetSchema<TOut>` is covariant in `TOut` (it appears only in `buildRow`'s\n * return), so a concrete schema is assignable to `TargetSchema<unknown>` — the\n * shape callers that don't know the output type (the `commerce:mapping` transform)\n * consume. Callers that DO know the type (the carmaker wrapper) import the concrete\n * schema directly to keep `RowTransform<PartsDocument>`.\n */\nconst SCHEMAS: Readonly<Record<string, TargetSchema<unknown>>> = {\n [partsSchema.name]: partsSchema,\n [discountTableSchema.name]: discountTableSchema,\n}\n\n/** Resolve a registered schema by name, or `undefined` if not registered. */\nexport function getTargetSchema(name: string): TargetSchema<unknown> | undefined {\n return SCHEMAS[name]\n}\n\n/** All registered schema names (for the wizard's schema picker, PR3). */\nexport function listTargetSchemas(): string[] {\n return Object.keys(SCHEMAS)\n}\n\n/** Operator-facing summary of a target schema: its name + the field whitelist (PR3 wizard). */\nexport interface TargetSchemaSummary {\n name: string\n fields: readonly TargetField[]\n}\n\n/**\n * Describe every registered target schema (name + field whitelist) for the PR3\n * mapping-wizard UI. Returns only metadata (no `buildRow`), so the wizard's RSC page\n * can ship it across the client boundary as plain JSON without dragging the engine\n * into the client bundle.\n */\nexport function describeTargetSchemas(): TargetSchemaSummary[] {\n return Object.values(SCHEMAS).map((s) => ({ name: s.name, fields: s.fields }))\n}\n"],"mappings":"8DA+BA,MAAa,EAAsD,CACjE,KAAM,iBACN,OAAQ,CANR,CAAE,IAAK,iBAAkB,MAAO,iBAAkB,SAAU,GAAM,KAAM,QAAS,EACjF,CAAE,IAAK,eAAgB,MAAO,aAAc,SAAU,GAAM,KAAM,QAAS,CAKnE,EAER,SAAS,EAAmB,EAAuB,CAGjD,IAAM,EAAQ,OAAO,EAAO,gBAAmB,SAAW,EAAO,eAAe,KAAK,EAAI,GACzF,GAAI,IAAU,GACZ,OAAO,EAAS,yBAA0B,0BAA2B,gBAAgB,EAGvF,IAAM,EAAM,EAAW,EAAO,YAAY,EAe1C,OAdI,EAAI,OAAS,UACR,EAAS,uBAAwB,+BAAgC,cAAc,EAEpF,EAAI,OAAS,SACR,EAAS,uBAAwB,wBAAyB,cAAc,EAE7E,EAAI,MAAQ,GAAK,EAAI,MAAQ,IACxB,EACL,4BACA,gBAAgB,EAAI,MAAM,mBAC1B,cACF,EAGK,CAAE,KAAM,UAAW,GAAI,EAAO,IAAK,CAAE,cAAe,EAAO,YAAa,EAAI,KAAM,CAAE,CAC7F,CACF,ECzCM,EAA2D,EAC9D,EAAY,MAAO,GACnB,EAAoB,MAAO,CAC9B,EAGA,SAAgB,EAAgB,EAAiD,CAC/E,OAAO,EAAQ,EACjB,CAGA,SAAgB,GAA8B,CAC5C,OAAO,OAAO,KAAK,CAAO,CAC5B,CAcA,SAAgB,GAA+C,CAC7D,OAAO,OAAO,OAAO,CAAO,CAAC,CAAC,IAAK,IAAO,CAAE,KAAM,EAAE,KAAM,OAAQ,EAAE,MAAO,EAAE,CAC/E"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"storage-resolver.d.mts","names":[],"sources":["../src/storage-resolver.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;cA6Da,sBAAA,EAAwB,gBAgDpC"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"storage-resolver.mjs","names":["fs"],"sources":["../src/storage-resolver.ts"],"sourcesContent":["/**\n * Storage-backed `resolveFilePath` for the imports plugin. Downloads\n * an object out of `@murumets-ee/storage` to a tmpfile so the queue\n * worker can stream it via `node:fs.createReadStream`.\n *\n * Wire into the imports plugin like this:\n *\n * ```ts\n * import { imports } from '@murumets-ee/imports/plugin'\n * import { storageResolveFilePath } from '@murumets-ee/imports/storage-resolver'\n *\n * imports({\n * esClient: () => esClient,\n * resolveFilePath: storageResolveFilePath,\n * })\n * ```\n *\n * Why this lives here rather than in the route or in the storage\n * package:\n * - The route shouldn't know about the consumer's runner; it just\n * persists a key on `import_run.filePath`.\n * - The storage package is generic (no opinion on imports).\n * - The imports worker is the natural download point: it owns the\n * run's lifecycle and can guarantee tmpfile cleanup.\n *\n * The helper is in a separate subpath so consumers that use a\n * different upload sink (local disk, S3 with a custom adapter, etc.)\n * don't pull in `@murumets-ee/storage` transitively.\n */\n\nimport { promises as fs } from 'node:fs'\nimport { tmpdir } from 'node:os'\nimport { join } from 'node:path'\nimport { getImportsStorage } from './storage.js'\nimport type { FilePathResolver } from './worker.js'\n\n/**\n * Resolve a storage key to a path the streaming reader can open.\n *\n * Two paths:\n *\n * 1. **Local-disk shortcut** — when the configured adapter implements\n * the optional `getLocalPath(key)` method (LocalDiskAdapter does;\n * R2Adapter does not), the bytes are already on a filesystem\n * visible to this process. Return that path directly with **no\n * cleanup** (the file is the canonical storage object — the\n * cleanup callback's job is to remove the *copy* we made into\n * /tmp; with no copy there's nothing to remove). This eliminates\n * the upload→R2→download→tmpfile round trip on single-VPS / dev\n * setups where it's pure overhead — a 137 MB feed that took ~25s\n * to round-trip completes in the time it takes to `fs.access` the\n * path.\n *\n * 2. **Remote-storage path** (R2/S3/etc.) — download the object into\n * a tmpfile and return that. The caller is required to await the\n * `cleanup` so the tmpfile doesn't outlive the run.\n *\n * Lazy-imports `@murumets-ee/storage` + `@murumets-ee/core` so\n * deployments that don't ingest from remote storage never load the\n * R2 client.\n */\nexport const storageResolveFilePath: FilePathResolver = async (storageKey) => {\n // Consult the imports-specific storage handle (registered by\n // `imports({ storage: ... })`). Falls back to the global\n // `getStorageConfig()` client when no per-imports factory was\n // wired — preserves the original single-storage behaviour for\n // consumers that haven't migrated yet.\n const storage = await getImportsStorage()\n\n // Local-disk shortcut: the adapter is on the same filesystem.\n // `getAdapterLocalPath` is the public hook on `StorageClient` that\n // forwards to `adapter.getLocalPath(key)` when the adapter\n // implements it; absence of that method is the signal to fall\n // through to the remote-download path below.\n const directPath = await storage.getAdapterLocalPath(storageKey)\n if (directPath) {\n return { localPath: directPath }\n }\n\n const { body } = await storage.download(storageKey)\n\n // `DownloadResult.body` is `Buffer | ReadableStream<Uint8Array>` per\n // storage's adapter contract. Normalise to Buffer for the simple\n // tmpfile-write path; the carmaker feeds top out at ~tens of MB,\n // well within memory.\n const buffer = Buffer.isBuffer(body)\n ? body\n : Buffer.from(await new Response(body).arrayBuffer())\n\n // Suffix carries the basename hint (last storage-key segment) so\n // operator-readable temp paths help debugging without leaking the\n // full original filename through the FS.\n const suffix = storageKey.split('/').pop() ?? 'feed'\n const localPath = join(tmpdir(), `imports-${crypto.randomUUID()}-${suffix}`)\n await fs.writeFile(localPath, buffer)\n\n return {\n localPath,\n cleanup: async () => {\n try {\n await fs.unlink(localPath)\n } catch (err) {\n // ENOENT is fine — file already gone (OS cleanup, manual\n // intervention, etc.). Anything else, rethrow so the worker's\n // best-effort cleanup logs a warning.\n if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err\n }\n },\n }\n}\n"],"mappings":"uJA6DA,MAAa,EAA2C,KAAO,IAAe,CAM5E,IAAM,EAAU,MAAM,EAAkB,EAOlC,EAAa,MAAM,EAAQ,oBAAoB,CAAU,EAC/D,GAAI,EACF,MAAO,CAAE,UAAW,CAAW,EAGjC,GAAM,CAAE,QAAS,MAAM,EAAQ,SAAS,CAAU,EAM5C,EAAS,OAAO,SAAS,CAAI,EAC/B,EACA,OAAO,KAAK,MAAM,IAAI,SAAS,CAAI,CAAC,CAAC,YAAY,CAAC,EAKhD,EAAS,EAAW,MAAM,GAAG,CAAC,CAAC,IAAI,GAAK,OACxC,EAAY,EAAK,EAAO,EAAG,WAAW,OAAO,WAAW,EAAE,GAAG,GAAQ,EAG3E,OAFA,MAAMA,EAAG,UAAU,EAAW,CAAM,EAE7B,CACL,YACA,QAAS,SAAY,CACnB,GAAI,CACF,MAAMA,EAAG,OAAO,CAAS,CAC3B,OAAS,EAAK,CAIZ,GAAK,EAA8B,OAAS,SAAU,MAAM,CAC9D,CACF,CACF,CACF"}
|
package/dist/storage.d.mts.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"storage.d.mts","names":[],"sources":["../src/storage.ts"],"mappings":";;;;KA2BY,qBAAA,SAA8B,aAAA,GAAgB,OAAA,CAAQ,aAAA;AA+DlE;;;;AAAgD;;;;;;;AAAhD,iBAhCgB,6BAAA,CAA8B,OAA8B,EAArB,qBAAqB;;;;;;;iBAW5D,iBAAA,IAAqB,OAAO,CAAC,aAAA;;;;iBAqB7B,gCAAA"}
|
package/dist/storage.mjs.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"storage.mjs","names":[],"sources":["../src/storage.ts"],"sourcesContent":["/**\n * Per-feature storage handle for the imports flow.\n *\n * Imports lets the consumer plug in a dedicated `StorageClient` (e.g.\n * a local-disk adapter for single-VPS deployments where worker + web\n * share a filesystem and the R2 round-trip is pure overhead) without\n * disturbing the global `getStorageConfig()` default that media,\n * ticketing attachments, and friends keep using.\n *\n * Wiring: the imports plugin's `init` hook stashes the consumer's\n * `storage` factory on a `globalThis` Symbol; `getImportsStorage()`\n * reads it back and caches the resolved client. When no factory is\n * registered (the legacy back-compat path), the function falls\n * through to `createStorageClient(getStorageConfig())` — so existing\n * consumers that only ever wired one storage layer continue to work\n * unchanged.\n *\n * Why a Symbol on `globalThis` and not module state: matches the\n * `@murumets-ee/storage/plugin` pattern. Turbopack can duplicate the\n * same module across chunks, so module-level singletons can leak into\n * separate-instance pairs that disagree about which adapter to use.\n * The Symbol survives that.\n */\n\nimport type { StorageClient } from '@murumets-ee/storage'\n\n/** Lazy storage-client factory. May be sync or async. */\nexport type ImportsStorageFactory = () => StorageClient | Promise<StorageClient>\n\nconst IMPORTS_STORAGE_FACTORY_KEY = Symbol.for('@murumets-ee/imports:storage-factory')\n// Cache the in-flight Promise rather than the resolved client. Two\n// concurrent first-callers both miss the cache, but only the first\n// creates a Promise; the others await the same one. PR #262 review\n// (M-9): caching the resolved value has a TOCTOU window where two\n// callers each invoke the factory and the loser's client is\n// silently discarded — but any in-flight use that captured the\n// loser keeps reading/writing through it.\nconst IMPORTS_STORAGE_PROMISE_KEY = Symbol.for('@murumets-ee/imports:storage-promise')\n\nfunction readGlobal<T>(key: symbol): T | undefined {\n return (globalThis as Record<symbol, unknown>)[key] as T | undefined\n}\n\nfunction writeGlobal(key: symbol, value: unknown): void {\n ;(globalThis as Record<symbol, unknown>)[key] = value\n}\n\n/**\n * Register a factory for the imports-only `StorageClient`. Called by\n * `imports({ storage: ... })` during plugin init. Subsequent calls\n * overwrite — the last `imports()` plugin instance wins, which\n * matches every other plugin's \"second registration replaces first\"\n * behaviour.\n *\n * Resets the resolved-client cache so a re-registration in dev (HMR\n * re-evaluating `lumi.config.ts`) doesn't keep handing out a stale\n * client built from the previous factory.\n */\nexport function registerImportsStorageFactory(factory: ImportsStorageFactory): void {\n writeGlobal(IMPORTS_STORAGE_FACTORY_KEY, factory)\n writeGlobal(IMPORTS_STORAGE_PROMISE_KEY, undefined)\n}\n\n/**\n * Resolve the imports `StorageClient`. Returns a cached promise after\n * the first call within a process. When no factory was registered (no\n * `imports({ storage })` config), falls back to the global storage\n * default — keeps existing consumers working without a config bump.\n */\nexport function getImportsStorage(): Promise<StorageClient> {\n const cached = readGlobal<Promise<StorageClient>>(IMPORTS_STORAGE_PROMISE_KEY)\n if (cached) return cached\n\n const factory = readGlobal<ImportsStorageFactory>(IMPORTS_STORAGE_FACTORY_KEY)\n const promise = Promise.resolve(factory ? factory() : defaultStorage()).catch(\n (err: unknown): never => {\n // Allow a retry on the next call if the first build failed (e.g.\n // missing env, transient app-not-ready). Without this, a one-off\n // failure pins a rejected promise into the cache forever.\n writeGlobal(IMPORTS_STORAGE_PROMISE_KEY, undefined)\n throw err\n },\n )\n writeGlobal(IMPORTS_STORAGE_PROMISE_KEY, promise)\n return promise\n}\n\n/**\n * @internal — tests reset the registration to a clean state.\n */\nexport function _resetImportsStorageRegistration(): void {\n writeGlobal(IMPORTS_STORAGE_FACTORY_KEY, undefined)\n writeGlobal(IMPORTS_STORAGE_PROMISE_KEY, undefined)\n}\n\n/**\n * Back-compat fallback — build a storage client from the global\n * `getStorageConfig()` (the old single-storage shape). Lazy-imports so\n * deployments that fully wire imports to a different adapter never\n * load the default factory's transitive dependencies.\n */\nasync function defaultStorage(): Promise<StorageClient> {\n const { createStorageClient } = await import('@murumets-ee/storage')\n const { getStorageConfig } = await import('@murumets-ee/storage/plugin')\n const { getApp } = await import('@murumets-ee/core')\n return createStorageClient(getStorageConfig(), { app: getApp() })\n}\n"],"mappings":"AA6BA,MAAM,EAA8B,OAAO,IAAI,sCAAsC,EAQ/E,EAA8B,OAAO,IAAI,sCAAsC,EAErF,SAAS,EAAc,EAA4B,CACjD,OAAQ,WAAuC,EACjD,CAEA,SAAS,EAAY,EAAa,EAAsB,CACrD,WAAwC,GAAO,CAClD,CAaA,SAAgB,EAA8B,EAAsC,CAClF,EAAY,EAA6B,CAAO,EAChD,EAAY,EAA6B,IAAA,EAAS,CACpD,CAQA,SAAgB,GAA4C,CAC1D,IAAM,EAAS,EAAmC,CAA2B,EAC7E,GAAI,EAAQ,OAAO,EAEnB,IAAM,EAAU,EAAkC,CAA2B,EACvE,EAAU,QAAQ,QAAQ,EAAU,EAAQ,EAAI,EAAe,CAAC,CAAC,CAAC,MACrE,GAAwB,CAKvB,MADA,EAAY,EAA6B,IAAA,EAAS,EAC5C,CACR,CACF,EAEA,OADA,EAAY,EAA6B,CAAO,EACzC,CACT,CAKA,SAAgB,GAAyC,CACvD,EAAY,EAA6B,IAAA,EAAS,EAClD,EAAY,EAA6B,IAAA,EAAS,CACpD,CAQA,eAAe,GAAyC,CACtD,GAAM,CAAE,uBAAwB,MAAM,OAAO,wBACvC,CAAE,oBAAqB,MAAM,OAAO,+BACpC,CAAE,UAAW,MAAM,OAAO,qBAChC,OAAO,EAAoB,EAAiB,EAAG,CAAE,IAAK,EAAO,CAAE,CAAC,CAClE"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"transform-Cu5YEoOl.mjs","names":[],"sources":["../src/entities/import-run.ts","../src/entities/import-mapping-validation.ts","../src/entities/import-mapping.ts","../src/transform.ts"],"sourcesContent":["/**\n * ImportRun — one feed-import attempt. Tracks file, transform, status,\n * row totals, and the top error patterns surfaced by `ErrorTracker`.\n *\n * Per PLAN-ECOMMERCE.md PR 7 (PoC scope): generic plumbing entity. The\n * commerce-specific transform parameters (brandId, supplierId, supplier\n * code prefix) live in the opaque `params` JSONB so the imports package\n * stays free of commerce-domain dependencies. PR 8 wires a concrete\n * carmaker-feed transform that reads those params; PR 8a renders a\n * bespoke `/admin/commerce/imports` page over this entity's history.\n *\n * `hideFromMenu: true` because PR 7 ships only the entity + plumbing —\n * the operator-facing surface is PR 8a. The auto-EntityListPage at\n * `/admin/import_run` still resolves for direct lookups, but no sidebar\n * entry points to it.\n */\n\nimport { behavior, defineEntity, field, searchable } from '@murumets-ee/entity'\nimport type { AdminClient } from '@murumets-ee/entity/admin'\n\n/**\n * Lifecycle stages.\n *\n * - `pending` — row exists, queue job has been enqueued, worker hasn't picked it up yet.\n * - `running` — worker is streaming + bulk-writing.\n * - `succeeded` — worker finished cleanly (note: per-row failures still possible — see `totals.failed`).\n * - `failed` — worker threw and the queue marked the job dead. `errorSummary.fatal` carries the cause.\n * - `cancelled` — manual operator action via PR 8a (out of scope for PR 7).\n */\nexport const IMPORT_RUN_STATUSES = [\n 'pending',\n 'running',\n 'succeeded',\n 'failed',\n 'cancelled',\n] as const\nexport type ImportRunStatus = (typeof IMPORT_RUN_STATUSES)[number]\n\nexport const ImportRun = defineEntity({\n name: 'import_run',\n fields: {\n /** Operator-visible label, e.g. `\"MERCEDES — ME_20251027075918.txt\"`. Free-form. */\n label: field.text({ required: true, maxLength: 255 }),\n /** See {@link IMPORT_RUN_STATUSES}. */\n status: field.select({\n options: IMPORT_RUN_STATUSES,\n default: 'pending',\n indexed: true,\n }),\n /**\n * Path or storage key of the uploaded feed file. Generic string —\n * could be `/var/lumi/uploads/<id>.txt` for the local-disk PoC or an\n * S3 object key once storage adapter integration lands. The worker\n * reads this with the configured `readFeed` resolver.\n */\n filePath: field.text({ required: true, maxLength: 2048 }),\n /**\n * Name of the registered transform applied to each row. Resolved at\n * worker-dispatch time against the transform registry contributed by\n * the consumer. PoC: `'commerce:carmaker-feed'`.\n */\n transformName: field.text({ required: true, maxLength: 128, indexed: true }),\n /**\n * Opaque per-transform parameters. The carmaker transform expects\n * `{ brandId, supplierId, codePrefix? }`. Validation happens inside\n * the transform — the imports package never inspects this shape so\n * a new transform with different params doesn't require a schema\n * migration.\n */\n params: field.json(),\n /**\n * Row counters: `{ submitted, succeeded, failed, skipped, batches }`.\n * Updated by the worker as each batch completes. Final values are\n * what the operator reads; intermediate progress comes from\n * `toolkit_jobs.progress` via the queue UI.\n */\n totals: field.json(),\n /**\n * Output of `ErrorTracker.getTopPatterns(totalRows)` — the top-50\n * error signatures with up to 5 sample rows each. Empty `[]` until\n * the worker writes it on completion.\n */\n errorSummary: field.json(),\n /**\n * NON-FATAL warning patterns (commerce-demo PR4) — same\n * `ErrorTrackerSnapshot` shape as {@link errorSummary} but for advisories\n * on rows that STILL succeeded (their doc was indexed), e.g.\n * `no_purchase_price` (a part imported list-only, cost pending). Kept\n * separate from `errorSummary` so a warning never reads as a failed row.\n * Null/absent until a SUCCESSFUL run that produced warnings writes it — a\n * fatal failure (`markFailed`) abandons the run mid-stream and only sets\n * `errorSummary.fatal`, so the two summaries are not co-present.\n *\n * NOTE: because the warning aggregator reuses `ErrorTracker`, the persisted\n * JSON reuses its field names — the `totalErrors` key inside this blob is\n * really the WARNING count (and `patterns[].errorType` the warning type).\n */\n warningSummary: field.json(),\n /**\n * Price-distribution snapshot (F010 / issue #602) — `{ count, nullRate,\n * min, median, max }`, computed over ES-accepted rows' derived purchase\n * price (`PartsDocument.purchase_price_minor`, integer minor units) in\n * the SAME streaming pass that builds `totals`. See\n * `@murumets-ee/imports`' `price-stats.ts` for the exact shape and the\n * bounded-sample cap.\n *\n * The pipeline's existing 6b header-drift check (commerce's\n * register-handler.ts) only catches STRUCTURAL drift — a mapped column\n * vanishing from the header row. It does NOT catch a supplier silently\n * flipping their decimal/thousands separator, which changes no header\n * at all and produces numerically valid but silently WRONG prices. The\n * worker compares THIS run's `priceStats.median` against the most\n * recent prior SUCCEEDED run for the same (transformName, brandId,\n * supplierId) and, on a magnitude-scale shift (>=10x or <=0.1x — see\n * `PRICE_ANOMALY_THRESHOLD_MULTIPLIER`), appends a NON-FATAL\n * `price_anomaly` entry to {@link warningSummary}. Unlike the 6b 409,\n * this never blocks the run — a real repricing is a legitimate reason\n * prices would shift, so it's an advisory only.\n *\n * `null`/absent for runs written before this field existed, or for a\n * schema/transform whose rows never carry a price sample.\n */\n priceStats: field.json(),\n /** Set when the worker picks up the job. */\n startedAt: field.date(),\n /** Set when the worker finishes (success OR fatal failure). */\n finishedAt: field.date(),\n /**\n * `toolkit_jobs.id` of the queue job processing this run — link\n * back so PR 8a can show live progress without a second lookup.\n */\n queueJobId: field.text({ maxLength: 64, indexed: true }),\n },\n behaviors: [\n behavior.auditable(),\n // Operator-facing search: by label (e.g. \"MERCEDES — ME_*.txt\"),\n // transform name, or queue job id. No FTS — labels are short\n // alphanumeric tags, not prose.\n searchable({\n fields: ['label', 'transformName', 'queueJobId', 'status', 'filePath'],\n projection: (row) => ({\n id: row.id as string,\n label: row.label as string,\n description: `${row.transformName} · ${row.status}`,\n }),\n }),\n ],\n scope: 'global',\n admin: {\n group: 'imports',\n label: 'Import runs',\n labelSingular: 'Import run',\n icon: 'upload',\n hideFromMenu: true,\n },\n})\n\nexport type ImportRunClient = AdminClient<typeof ImportRun.allFields>\n","/**\n * Save-time validation for `import_mapping` rows (commerce-demo phase 02 §3/§5).\n *\n * This is the R02 injection boundary realised as an entity behavior: a mapping's\n * `columnMap` may only point at fields the chosen `TargetSchema` whitelists, and a\n * mapping is incomplete unless every REQUIRED target of that schema is mapped. Both\n * checks run on EVERY write path (the PR3 wizard's generic-CRUD `POST`, a future\n * seed, a direct `AdminClient` call) — \"validate at the boundary\", not \"validate in\n * the one UI we happen to ship\".\n *\n * The transform compiler (`buildMappingTransform`) re-checks the whitelist at run\n * time, so this is defense-in-depth — but failing at SAVE gives the operator the\n * error in the wizard (where they can fix it) instead of as a per-row run failure.\n *\n * Lives next to the entity (same package) and imports the mapping engine + schema\n * registry one-directionally (engine/registry never import the entity), so no cycle.\n */\n\nimport type { Behavior } from '@murumets-ee/entity'\nimport { z } from 'zod'\nimport { mappingColumnSchema, numberFormatSchema } from '../mapping/engine.js'\nimport { getTargetSchema, listTargetSchemas } from '../mapping/schema-registry.js'\n\n/** Default applied by the `numberFormat` field — used when a create omits it (defaults run after hooks). */\nconst DEFAULT_NUMBER_FORMAT = { decimal: '.', thousands: '' } as const\n\n// Bounded, like every JSONB the entity accepts: a legitimate column map has at\n// most one entry per source column (same order of magnitude as the snapshot's\n// 256-column cap), so an arbitrarily long array is a bloat vector, not data.\nconst columnMapSchema = z.array(mappingColumnSchema).max(512)\n\n/**\n * File-less edit snapshot (#667) — header columns + a few sample rows, same\n * `{ columns, rows: [{column → cell}] }` shape as the preview endpoint's\n * response. Bounded on every axis (these are DISPLAY samples, not data):\n * a snapshot that exceeds the caps should have been trimmed/omitted by the\n * wizard, so an oversized one here is a bypass attempt or a bug — reject, and\n * additionally hard-cap the serialized size so 256-wide × max-cell rows can't\n * park ~600 KB of JSONB per mapping row.\n *\n * Strictness matters: the behavior hook persists the RAW input, so a\n * non-strict parse would let unknown top-level keys ride into JSONB unmeasured\n * (zod strips them from `snap.data`, but that stripped copy isn't what's\n * stored).\n */\nconst headerSnapshotSchema = z.strictObject({\n columns: z.array(z.string().min(1).max(256)).min(1).max(256),\n rows: z.array(z.record(z.string().max(500))).max(5),\n})\n\n/**\n * Serialized-size ceiling for a stored snapshot (defense-in-depth over the\n * per-axis caps). Measured in UTF-16 code units (`JSON.stringify(...).length`)\n * — multi-byte content can serialize up to ~3× larger on disk; the bound holds\n * either way. Mirrored client-side by `toHeaderSnapshot` in commerce-ui\n * (pinned by its round-trip test).\n */\nexport const HEADER_SNAPSHOT_MAX_CHARS = 100_000\n\n/**\n * Serialized-size ceiling for the RAW `columnMap` input. Same rationale + same\n * raw-not-parsed measurement as the snapshot ceiling: `mappingColumnSchema` is\n * shared with the RUN-TIME params parser (engine.ts), so it can't be made\n * `.strict()` without risking previously-persisted `import_run.params` — this\n * save-path-only ceiling bounds what unknown entry keys / unbounded per-entry\n * `transforms` arrays can park in JSONB without touching run-time parsing. A\n * legitimate 256-column map with transform chains serializes well under this.\n */\nexport const COLUMN_MAP_MAX_CHARS = 100_000\n\n/**\n * Throw a descriptive error unless the mapping is structurally valid AND honours its\n * schema's field whitelist + required targets. The thrown message is operator-facing\n * (surfaced verbatim by the admin CRUD route as a 4xx body).\n */\nexport function assertValidMapping(input: {\n targetSchema: unknown\n numberFormat: unknown\n columnMap: unknown\n headerSnapshot?: unknown\n}): void {\n // Snapshot is optional metadata — validate only when present (absent/null =\n // \"no file-less edit support for this row\", a valid state for pre-#667 rows).\n if (input.headerSnapshot !== undefined && input.headerSnapshot !== null) {\n const snap = headerSnapshotSchema.safeParse(input.headerSnapshot)\n if (!snap.success) {\n throw new Error(`Invalid headerSnapshot: ${snap.error.issues[0]?.message ?? 'bad shape'}`)\n }\n // Measure the RAW input, not `snap.data` — the raw value is what the write\n // path persists, and it's identical to the parse only because the schema\n // is `.strict()`. Measuring the raw keeps that true even if strictness is\n // ever relaxed.\n const serialized = JSON.stringify(input.headerSnapshot).length\n if (serialized > HEADER_SNAPSHOT_MAX_CHARS) {\n throw new Error(\n `headerSnapshot too large (${serialized} > ${HEADER_SNAPSHOT_MAX_CHARS} serialized chars) — trim sample rows/cells`,\n )\n }\n }\n const targetSchema = typeof input.targetSchema === 'string' ? input.targetSchema : ''\n const schema = getTargetSchema(targetSchema)\n if (!schema) {\n throw new Error(\n `Unknown target schema \"${targetSchema}\" (registered: ${listTargetSchemas().join(', ')})`,\n )\n }\n\n const nf = numberFormatSchema.safeParse(input.numberFormat)\n if (!nf.success) {\n throw new Error(`Invalid numberFormat: ${nf.error.issues[0]?.message ?? 'bad shape'}`)\n }\n\n const cm = columnMapSchema.safeParse(input.columnMap)\n if (!cm.success) {\n throw new Error(`Invalid columnMap: ${cm.error.issues[0]?.message ?? 'bad shape'}`)\n }\n const columnMapSerialized = JSON.stringify(input.columnMap).length\n if (columnMapSerialized > COLUMN_MAP_MAX_CHARS) {\n throw new Error(\n `columnMap too large (${columnMapSerialized} > ${COLUMN_MAP_MAX_CHARS} serialized chars)`,\n )\n }\n\n const allowed = new Set(schema.fields.map((f) => f.key))\n const seenTargets = new Set<string>()\n const dupeTargets = new Set<string>()\n for (const col of cm.data) {\n if (!allowed.has(col.target)) {\n throw new Error(\n `Mapping target \"${col.target}\" is not a field of schema \"${schema.name}\" ` +\n `(allowed: ${[...allowed].join(', ')})`,\n )\n }\n // Two source columns mapping to the SAME target collapse last-write-wins at run\n // time (`mapped[col.target] = …`), silently importing wrong data. The wizard\n // blocks this client-side; enforce it here too so a direct API call can't bypass\n // it (validate at the boundary).\n if (seenTargets.has(col.target)) dupeTargets.add(col.target)\n seenTargets.add(col.target)\n }\n if (dupeTargets.size > 0) {\n throw new Error(\n `Mapping for schema \"${schema.name}\" maps a target more than once: ${[...dupeTargets].join(', ')}`,\n )\n }\n\n const mappedTargets = new Set(cm.data.map((c) => c.target))\n const missing = schema.fields\n .filter((f) => f.required && !mappedTargets.has(f.key))\n .map((f) => f.key)\n if (missing.length > 0) {\n throw new Error(\n `Mapping for schema \"${schema.name}\" is missing required target(s): ${missing.join(', ')}`,\n )\n }\n}\n\n/**\n * Behavior wrapping {@link assertValidMapping}. Typed `Behavior<Record<never,\n * never>, …>` (NOT plain `Behavior`, whose wide `fields?` would collapse the\n * entity's inferred field types — the documented no-field-behavior gotcha).\n * Contributes a hook, no fields.\n */\nexport const importMappingValidation: Behavior<\n Record<never, never>,\n 'imports:import-mapping-validation'\n> = {\n name: 'imports:import-mapping-validation',\n hooks: {\n beforeCreate: async (data) => {\n // Field defaults run AFTER hooks, so an omitted numberFormat/columnMap is the\n // field default here — mirror those defaults so a minimal create validates\n // against the same effective config the row will hold.\n assertValidMapping({\n targetSchema: data.targetSchema,\n numberFormat: data.numberFormat ?? DEFAULT_NUMBER_FORMAT,\n columnMap: data.columnMap ?? [],\n headerSnapshot: data.headerSnapshot,\n })\n return normalizeQuote(data)\n },\n beforeUpdate: async (_id, data, ctx) => {\n // A PATCH is partial — validate the EFFECTIVE config (patch overlaid on the\n // current row), so changing only `name` doesn't escape validation and changing\n // only `columnMap` is still checked against the persisted `targetSchema`.\n const current = (await ctx?.loadCurrent?.()) ?? {}\n assertValidMapping({\n targetSchema: 'targetSchema' in data ? data.targetSchema : current.targetSchema,\n numberFormat:\n 'numberFormat' in data\n ? data.numberFormat\n : (current.numberFormat ?? DEFAULT_NUMBER_FORMAT),\n columnMap: 'columnMap' in data ? data.columnMap : (current.columnMap ?? []),\n // Only the incoming value needs checking — the persisted snapshot was\n // validated when it was written.\n headerSnapshot: 'headerSnapshot' in data ? data.headerSnapshot : undefined,\n })\n return normalizeQuote(data)\n },\n },\n}\n\n/**\n * Collapse an empty-string `quote` to NULL before it is written.\n *\n * `quote` is `varchar(1)`, which permits BOTH `NULL` and `''` — two representations of one\n * state (\"quotes are ordinary data\"). Every reader collapses them (`buildFeedSnapshot` omits\n * either), but that collapse living in reader behaviour rather than in the data means every\n * future reader has to know to perform it, and the one who does not gets a bug that looks like\n * a data problem. A convention holds until someone is in a hurry; normalising at the write\n * boundary means the second state never exists to be mishandled.\n *\n * It runs HERE, in a hook, rather than only as a CHECK constraint, and the order matters: an\n * HTML `<select>` cannot hold `null`, so the admin wizard legitimately sends `''` for \"no\n * policy\" and always will. A bare constraint would reject that and 500 an ordinary save. The\n * hook converts; the constraint (see `importMappingQuoteShape`) then only ever sees a value\n * that already conforms, and exists to catch a writer that bypasses the entity layer.\n *\n * Only touches the key when it is PRESENT — a PATCH that never mentions `quote` must leave the\n * persisted value alone, not clear it.\n */\nfunction normalizeQuote<T extends Record<string, unknown>>(data: T): T {\n if (!('quote' in data)) return data\n return data.quote === '' ? { ...data, quote: null } : data\n}\n","/**\n * ImportMapping — a reusable, per-(supplier, brand) column-mapping profile that\n * drives the configurable feed-import engine (commerce-demo D005, phase 02).\n *\n * Replaces the hardcoded 14/15-column carmaker transform with operator-editable\n * mappings: which source column → which whitelisted target field, per-column\n * cleaning transforms, the file format (csv/tsv/xlsx), and the number format\n * (decimal + thousands separators — real supplier feeds vary, see R03).\n *\n * **Why this lives in `@murumets-ee/imports`, not `@murumets-ee/commerce`**\n * (D005 cycle constraint): the engine + the concrete target schemas need the\n * commerce `PartsDocument` shape, and `imports` already depends on\n * `@murumets-ee/search-elasticsearch` for it. Putting the mapping in commerce\n * would force `commerce → imports` and a forbidden import cycle. The entity\n * references the commerce `supplier` / `brand` entities **by name string**\n * (`field.reference({ entity: 'supplier' })`) — no Entity-object import, so the\n * leaf-package boundary holds.\n *\n * **Resolution** (most-specific, D002 ladder): for an upload tagged\n * `(supplier, brand)`, pick the `(supplier, brand)` mapping if present, else the\n * `(supplier, NULL)` supplier-default. See `resolveMapping()` in the engine.\n *\n * `hideFromMenu: true` — the mapping CRUD wizard lands in commerce-demo PR3. The\n * auto-EntityListPage at `/admin/import_mapping` still resolves for direct lookups\n * and for the admin-only AdminClient writes this engine relies on.\n */\n\nimport type { Behavior, SchemaFragment, SchemaTarget } from '@murumets-ee/entity'\nimport { behavior, defineEntity, field, searchable } from '@murumets-ee/entity'\nimport type { AdminClient } from '@murumets-ee/entity/admin'\nimport { sql } from 'drizzle-orm'\nimport type { PgColumn } from 'drizzle-orm/pg-core'\nimport { importMappingValidation } from './import-mapping-validation.js'\n\n/**\n * Registered target schemas a mapping may write into. This is the FIRST of two\n * whitelists (the second is each schema's target-field set) — a mapping whose\n * `targetSchema` is outside this list is rejected (deny-by-default, R02).\n */\nexport const IMPORT_MAPPING_TARGET_SCHEMAS = ['parts', 'discount_table'] as const\nexport type ImportMappingTargetSchema = (typeof IMPORT_MAPPING_TARGET_SCHEMAS)[number]\n\n/** File formats the reader dispatches on. `csv`/`tsv` → csv-parse; `xlsx` → exceljs. */\nexport const IMPORT_MAPPING_FORMATS = ['csv', 'tsv', 'xlsx'] as const\nexport type ImportMappingFormat = (typeof IMPORT_MAPPING_FORMATS)[number]\n\n/**\n * Declarative DDL: one mapping per `(supplier, brand, target_schema)`, enforced by\n * TWO PARTIAL unique indexes rather than one plain composite unique.\n *\n * Why partial: a plain `UNIQUE (supplierId, brandId, targetSchema)` treats NULL\n * `brandId` as distinct (Postgres NULL-distinct semantics), so it would allow MANY\n * supplier-default rows (`brandId IS NULL`) for the same `(supplier, schema)` —\n * ambiguous, since the engine resolves `brandId === null` as THE supplier default\n * (`resolveMapping`). Splitting it:\n * - brand-specific rows (`brandId IS NOT NULL`): unique on `(supplier, brand, schema)`;\n * - supplier-default rows (`brandId IS NULL`): unique on `(supplier, schema)`.\n *\n * The `where` predicates use raw `sql` with the live Drizzle column reference (so the\n * emitted identifier is correct regardless of casing) — `IS NULL` / `IS NOT NULL`,\n * NOT `eq(col, …)` which would serialize as an invalid bound `$1` in DDL.\n *\n * Typed `Behavior<Record<never, never>, …>` (NOT plain `Behavior`, whose wide\n * `fields?` would collapse the entity's inferred field types to `any` — the\n * documented no-field-behavior gotcha). Contributes constraints, no fields.\n */\nconst importMappingUnique: Behavior<Record<never, never>, 'imports:import-mapping-unique'> = {\n name: 'imports:import-mapping-unique',\n constraints: (table: Record<string, PgColumn>, target: SchemaTarget): SchemaFragment[] => {\n if (target !== 'main') return []\n const supplierId = table.supplierId\n const brandId = table.brandId\n const targetSchema = table.targetSchema\n // Defensive: only emit when all three columns exist.\n if (!supplierId || !brandId || !targetSchema) return []\n return [\n {\n kind: 'uniqueIndex',\n name: 'uq_import_mapping_supplier_brand_schema',\n on: [supplierId, brandId, targetSchema],\n where: sql`${brandId} IS NOT NULL`,\n },\n {\n kind: 'uniqueIndex',\n name: 'uq_import_mapping_supplier_schema_default',\n on: [supplierId, targetSchema],\n where: sql`${brandId} IS NULL`,\n },\n ]\n },\n}\n\n/**\n * `quote IS NULL OR length(quote) = 1` — so the column has ONE representation of \"no quoting\n * policy\" rather than two.\n *\n * `varchar(1)` permits a zero-length string, so without this the column can hold NULL *or*\n * `''` for the same state. Every reader collapses them today (`buildFeedSnapshot` omits either),\n * which makes the collapse a CONVENTION living in reader behaviour — and a convention holds\n * until someone is in a hurry, at which point the bug looks like a data problem rather than a\n * schema one. Added while the column was still unmerged and no row existed, which is the\n * cheapest this is ever going to be.\n *\n * It is belt-and-braces rather than the mechanism: `importMappingValidation` normalises `''` to\n * NULL in `beforeCreate`/`beforeUpdate`, because an HTML `<select>` cannot hold `null` and the\n * admin wizard legitimately sends `''` for \"no policy\". So every write through the entity layer\n * already conforms, and this catches a writer that bypasses it — a raw SQL migration, a seed\n * against the table, a future route that builds the row itself.\n *\n * Declared as a `checkConstraint` fragment rather than hand-written into a migration so\n * drizzle's snapshot MODELS it. A constraint the snapshot does not know about is one the next\n * `lumi migrate` will try to add again or drop.\n */\nconst importMappingQuoteShape: Behavior<Record<never, never>, 'imports:import-mapping-quote'> = {\n name: 'imports:import-mapping-quote',\n constraints: (table: Record<string, PgColumn>, target: SchemaTarget): SchemaFragment[] => {\n if (target !== 'main') return []\n const quote = table.quote\n if (!quote) return []\n return [\n {\n kind: 'checkConstraint',\n name: 'ck_import_mapping_quote_single_char',\n expression: sql`${quote} IS NULL OR length(${quote}) = 1`,\n },\n ]\n },\n}\n\nexport const ImportMapping = defineEntity({\n name: 'import_mapping',\n scope: 'global',\n fields: {\n /** FK to commerce `supplier` (by name string — no cross-package import). Required. */\n supplierId: field.reference({ entity: 'supplier', required: true, indexed: true }),\n /** FK to commerce `brand`. NULL = supplier default (applies to all that supplier's brands). */\n brandId: field.reference({ entity: 'brand', indexed: true }),\n /** Which registered target schema this mapping feeds — `parts` or `discount_table`. */\n targetSchema: field.select({\n options: IMPORT_MAPPING_TARGET_SCHEMAS,\n required: true,\n indexed: true,\n }),\n /** Operator-facing profile name (a supplier can have e.g. \"monthly full\" vs \"weekly delta\"). */\n name: field.text({ required: true, maxLength: 255 }),\n /** File format — drives reader dispatch + the default delimiter. */\n format: field.select({ options: IMPORT_MAPPING_FORMATS, required: true }),\n /**\n * Explicit single-char field delimiter override. When omitted, the reader\n * defaults from `format` (`csv` → `,`, `tsv` → `\\t`). Ignored for `xlsx`.\n */\n delimiter: field.text({ maxLength: 4 }),\n /** First row is the header. `false` → positional `{ \"0\": …, \"1\": … }` source keys. */\n hasHeader: field.boolean({ default: true }),\n /** 0-based header row index (header hygiene — R03 feeds have leading/trailing-space headers). */\n headerRowIndex: field.number({ integer: true, min: 0, default: 0 }),\n /** xlsx sheet name (or 1-based index as a string). Ignored for csv/tsv. */\n sheet: field.text({ maxLength: 128 }),\n /**\n * Field-quoting policy for csv/tsv, forwarded to `csv-parse`'s `quote`.\n *\n * NULL — the default, and today's only behaviour — means quotes are ORDINARY DATA.\n * A single character (in practice `\"`) means RFC 4180: that character encloses fields\n * that contain the delimiter, and is doubled to escape itself.\n *\n * **This is per-file evidence, not a house style, and the two settings are not\n * interchangeable.** Measured against the real supplier feeds this pipeline ingests\n * (R027): the Ford CSV has 1,476 rows whose description holds a quoted comma — parsed\n * with quotes as data it dies at row 11,126 of 808,147 with\n * `CSV_RECORD_INCONSISTENT_COLUMNS`. The two tab-delimited feeds carry 2,321 lines with\n * an ODD number of `\"` characters (`\"O\"-RING`, `RACCORDO A \"T`, `ECROU A TOLE \"C`) —\n * parsed as RFC 4180 they die at row 1,476 with `INVALID_OPENING_QUOTE`. Each feed\n * hard-fails under the other's setting, so this cannot be derived from `format` and\n * cannot be a global default. `lumi imports:analyze` decides it per file by trial parse.\n *\n * Ignored for `xlsx`, which has no text quoting.\n */\n quote: field.text({ maxLength: 1 }),\n /**\n * Number format for the `number` per-column transform:\n * `{ decimal: '.'|',', thousands: ''|'.'|','|' ' }`. Opaque json; validated\n * by the engine's Zod schema at consume time (matches `import_run.params`).\n */\n numberFormat: field.json({ default: { decimal: '.', thousands: '' } }),\n /**\n * Ordered column map: `[{ source, target, transforms? }]`. `source` is a\n * header name (or positional index string); `target` MUST be a field in the\n * chosen schema's whitelist (validated by the engine's Zod schema, R02\n * injection boundary); `transforms` is a bounded, enum-keyed list. Opaque\n * json on the entity; typed + validated by the engine.\n */\n columnMap: field.json({ default: [] }),\n /**\n * File-less edit snapshot (#667): `{ columns: string[], rows: [{column →\n * cell}] }` — the sample feed's header + a few sample rows, captured from\n * the wizard's preview at save time. Lets the editor re-open a saved\n * mapping WITHOUT re-uploading the (potentially huge) feed just to tweak a\n * transform. NULL on rows saved before this field existed (and when the\n * preview exceeded the size caps) — the editor falls back to the\n * upload-first flow. Staleness is benign: a source column that later\n * vanishes from the real feed is caught at run time by the 6b drift check\n * (409) / row errors, not by this snapshot. Size-capped by\n * `import-mapping-validation`.\n */\n headerSnapshot: field.json(),\n /** Inactive mappings are skipped by resolution. */\n active: field.boolean({ default: true, indexed: true }),\n },\n behaviors: [\n behavior.auditable(),\n // Operator-facing search (PR3 wizard): by profile name, schema, or format.\n // No `fts` → IlikeProvider, so this adds NO DDL (the 0036 migration is\n // unchanged). Names are short tags, not prose.\n searchable({\n fields: ['name', 'targetSchema', 'format'],\n projection: (row) => ({\n id: row.id as string,\n label: row.name as string,\n description: `${row.targetSchema} · ${row.format}`,\n }),\n }),\n importMappingUnique,\n importMappingQuoteShape,\n // Save-time whitelist + required-target validation (R02 injection boundary), and the\n // `''` -> NULL normalisation that keeps `importMappingQuoteShape` from ever firing on an\n // ordinary wizard save.\n importMappingValidation,\n ],\n admin: {\n group: 'imports',\n label: 'Import mappings',\n labelSingular: 'Import mapping',\n icon: 'table',\n hideFromMenu: true,\n },\n})\n\nexport type ImportMappingClient = AdminClient<typeof ImportMapping.allFields>\n","/**\n * Per-feed transform plugin interface (PLAN-ECOMMERCE.md PR 7 / D14).\n *\n * The streaming reader reads raw rows out of the file (column key →\n * string). The transform turns one row into a typed `OutputDoc` ready\n * for the bulk-write surface (PoC: `PartsDocument` for the parts ES\n * index). The transform is what makes a generic feed-importer\n * commerce-aware — without it, the importer has no opinion on what a\n * row \"means\".\n *\n * Why an interface, not a function:\n * - The transform may need to reject a row (`RowSkip`) without that\n * counting as an error in `ErrorTracker` — e.g. blank lines, header\n * rows mistakenly retained, or \"this is a replacement-code marker\n * row, handled out-of-band\" decisions.\n * - The transform may need to fail a row (`RowError`) with a typed\n * error class so `ErrorTracker` collapses them by `errorType`.\n * - The transform may need access to the run-level params (brand /\n * supplier IDs, code prefix, batch ID) without those being\n * re-derived per row.\n *\n * PR 7 ships only the interface + a registry. PR 8 ships the first\n * concrete carmaker-feed transform.\n */\n\n/** Identifies which transform a registered handler implements. Stable across deploys. */\nexport type TransformName = string\n\n/** Per-run context provided to every row call. Kept narrow on purpose. */\nexport interface TransformContext {\n /** UUID of the `import_run` row. Forward to the output doc as `import_batch_id` for source attribution (D20). */\n importRunId: string\n /** Opaque per-run params copied from `import_run.params`. The transform validates the shape it expects. */\n params: Record<string, unknown>\n /** Operator-supplied label, useful for logging / debugging. */\n runLabel: string\n /** 1-based row number across the whole feed (header counts as row 0). Forward to `RowError.rowNumber`. */\n rowNumber: number\n}\n\n/**\n * A directional supersession pair harvested from a row as a SIDE-OUTPUT\n * (commerce-demo PR6, SD007). The edge ALWAYS runs `from` (older) → `to` (newer/current).\n *\n * Which end the emitting row occupies depends on which supersession column the mapping used,\n * so neither end can be described as \"the row's own code\": a `superseding_code` column names\n * the newer code and puts the row at `from` (the row is the obsolete one), while a\n * `replaces_code` column names the older code and puts the row at `to` (the row is current).\n * Both codes are already NORMALIZED (same normalization as the doc's `code_normalized`) so a\n * downstream equivalence-group merge collides codes correctly.\n *\n * This is PURE DATA. The runner buffers pairs in memory during the single ES pass\n * (an array append — it never blocks the `bulkUpsert` loop) and hands the buffer to\n * an INJECTED post-run sink (the commerce replacement-merge engine). The\n * `@murumets-ee/imports` leaf never writes the consumer's Postgres tables itself —\n * the boundary holds because the sink is injected, not imported.\n */\nexport interface DirectionalPair {\n /** The superseded (older) normalized code. */\n from: string\n /** The superseding (newer / current) normalized code. */\n to: string\n}\n\n/**\n * A partial-document PATCH emitted as a side-output (commerce-demo PR7): the row\n * itself produces no saleable doc, but it carries a partial update that must land\n * on a DIFFERENT doc — identified by `id` (the same `<code_normalized>__<supplier>`\n * key the parts sink uses). The runner buffers these during the single stream and,\n * AFTER the ES pass, applies them via a phase-2 `bulkPartialUpdate` (an ES `update`\n * merge, realtime by `_id` → order-independent). A patch whose target doesn't exist\n * surfaces as an `orphan_*` warning, never a phantom doc.\n *\n * Canonical use: a `<code>*S` core-fee row (D004) attaches its deposit to the parent\n * part's `core_charge_minor`. Kept GENERIC (`Partial<TDoc>`) so the runner never\n * imports a schema-specific doc type — the same leaf-clean shape as {@link DirectionalPair}.\n */\nexport interface DocPatch<TDoc> {\n /** The `_id` of the doc to merge into (a different row's doc than the one that emitted this). */\n id: string\n /** The partial fields to merge onto that doc. */\n doc: Partial<TDoc>\n /**\n * The warning to record if the target `id` isn't in the index (the phase-2\n * update 404s). The runner detects the orphan (it can't be known at buildRow\n * time — the parent may stream later) but the SCHEMA owns the bucket name +\n * message, so this rides on the patch. Absent → a generic `orphan_patch`\n * fallback. For `*S` core-fee rows the parts schema sets `orphan_core_charge`\n * (D004: warn, never silently drop).\n */\n orphanWarning?: RowWarning\n}\n\n/**\n * One of: a successful `OutputDoc` keyed by a stable `id`, an\n * intentional `skip` (counted in `totals.skipped`), or a row-level\n * error (counted in `totals.failed` AND aggregated by `ErrorTracker`).\n *\n * The `id` field on `success` is used as the bulk-upsert primary key\n * (`_id` in ES). Per D4 the parts index uses\n * `<code_normalized>__<supplier_id>` so a re-run of the same feed\n * idempotently overwrites instead of duplicating.\n *\n * **`warnings` on `success`** (commerce-demo PR4): a row can be a valid,\n * indexable doc AND still carry a non-fatal advisory — e.g. a part imported\n * with `list_price` but no resolvable purchase cost (`no_purchase_price`,\n * phase 03 §5). The runner indexes the doc (it's a success, NOT a failure)\n * and separately aggregates the warnings into a `warningSummary`, so the\n * operator sees \"120 rows cost-pending\" without those rows being dropped or\n * mis-counted as failures. Empty/absent `warnings` is the common case.\n *\n * **`pairs` on `success`** (commerce-demo PR6, SD007): a row can ALSO carry\n * directional supersession {@link DirectionalPair}s as a side-output — the `parts`\n * schema emits one when a row's `superseding_code` is present (a SPARSE column on a\n * full manufacturer catalogue, so most rows emit none). The runner buffers these\n * from ES-accepted rows and returns them for the injected `pairSink`. Empty/absent\n * `pairs` is the common case.\n *\n * **`patches` on `skip`** (commerce-demo PR7): a `<code>*S` core-fee row is NOT a\n * saleable doc (so it `skip`s the main index), but it carries a {@link DocPatch}\n * side-output that attaches its deposit to the PARENT part's `core_charge_minor`.\n * The runner buffers these (sparse — only reman parts carry `*S`) and applies them\n * in a phase-2 `bulkPartialUpdate` AFTER the stream; an unresolved parent becomes an\n * `orphan_core_charge` warning (D004). Empty/absent `patches` is the common case.\n *\n * **`priceSample` on `success`** (F010 / #602): an optional numeric price value,\n * in whatever unit scale the schema itself uses (the `parts` schema emits\n * `purchase_price_minor`, integer minor units), sampled for RUN-LEVEL value-drift\n * detection — see `price-stats.ts`. The runner accumulates these from ES-accepted\n * rows into a `PriceStats` distribution snapshot (min/median/max/nullRate) via the\n * SAME streaming pass that builds `totals`, no second pass over the file. A schema\n * that never emits `priceSample` (or a row with no resolvable price) simply omits\n * it — the collector counts that toward `nullRate` rather than treating it as an\n * error. Absent is the common case for schemas with no price notion at all.\n */\nexport type RowResult<TDoc> =\n | {\n kind: 'success'\n id: string\n doc: TDoc\n warnings?: readonly RowWarning[]\n pairs?: readonly DirectionalPair[]\n priceSample?: number\n }\n | { kind: 'skip'; reason: string; patches?: readonly DocPatch<TDoc>[] }\n | { kind: 'error'; error: RowError }\n\n/** Row-level error. The aggregator uses `(errorType, field, message)` as the dedup signature. */\nexport interface RowError {\n errorType: string\n message: string\n field?: string | undefined\n}\n\n/**\n * A non-fatal advisory attached to a SUCCESSFUL row (the doc is still\n * indexed). Same `(warningType, field, message)` dedup signature shape as\n * {@link RowError} so the runner can aggregate warnings through an\n * `ErrorTracker` instance into `import_run.warningSummary`.\n */\nexport interface RowWarning {\n warningType: string\n message: string\n field?: string | undefined\n}\n\n/**\n * The contract every per-feed transform implements. Pure function of\n * `(row, ctx) → RowResult` — no I/O. Async only because the future\n * carmaker variant might consult an in-memory taxonomy lookup.\n */\nexport type RowTransform<TDoc> = (\n rawRow: Record<string, string>,\n ctx: TransformContext,\n) => Promise<RowResult<TDoc>>\n\n/**\n * Holds the registered transforms for the current process. Each plugin\n * that ships a transform calls {@link TransformRegistry.register} from\n * its `init` hook.\n *\n * Per CLAUDE.md \"Whitelist, don't blacklist\": the worker dispatches\n * by `import_run.transformName` against this registry. An unregistered\n * name fails the run rather than running with a default transform.\n *\n * **Production callers MUST go through {@link getTransformRegistry}**, not\n * `new TransformRegistry()`. The constructor is exposed only so tests\n * (and rare custom-worker embeds) can build an isolated instance and\n * inject it into {@link createRunImportHandler} directly. Two `new`\n * instances do NOT share state — registering a transform on one will\n * NOT make it visible to a worker resolving against the other. The\n * package's queue handler (registered by `imports()` plugin) always\n * resolves against the singleton.\n */\nexport class TransformRegistry {\n private readonly entries = new Map<TransformName, RowTransform<unknown>>()\n\n /**\n * Register a transform under a stable name. Throws on duplicate\n * names so two plugins can't silently overwrite each other (same\n * fail-loud collision protection that `combineAdminRoutes` enforces\n * for duplicate `(prefix, method, path)` route registrations).\n */\n register<TDoc>(name: TransformName, transform: RowTransform<TDoc>): void {\n if (this.entries.has(name)) {\n throw new Error(\n `Transform \"${name}\" is already registered — two plugins cannot register the same transform name.`,\n )\n }\n this.entries.set(name, transform as RowTransform<unknown>)\n }\n\n /**\n * Last-wins registration — overwrites an existing entry instead of\n * throwing. The {@link registerImportTransform} consumer-app path uses\n * this (same semantics as `@murumets-ee/queue/client`'s `registerJob`):\n * under dev HMR a re-evaluated consumer module re-registers against the\n * surviving globalThis registry, and an overwrite picks up the edited\n * function where a skip-or-throw would keep dispatching the stale one\n * (the #186 staleness class). Plugin built-ins keep using\n * {@link register} behind `has()` guards so genuine cross-plugin name\n * collisions still fail loud.\n */\n set<TDoc>(name: TransformName, transform: RowTransform<TDoc>): void {\n this.entries.set(name, transform as RowTransform<unknown>)\n }\n\n /** Look up a transform by name. Returns `undefined` if no plugin has registered one. */\n get(name: TransformName): RowTransform<unknown> | undefined {\n return this.entries.get(name)\n }\n\n /** True iff a transform is registered for the given name. */\n has(name: TransformName): boolean {\n return this.entries.has(name)\n }\n\n /** All registered names. Useful for the admin Catalog tab and for tests. */\n list(): TransformName[] {\n return Array.from(this.entries.keys()).sort()\n }\n\n /** Drop all registrations. Tests only. */\n clear(): void {\n this.entries.clear()\n }\n}\n\n// ---------------------------------------------------------------------------\n// Process-global singleton (mirrors `@murumets-ee/queue`'s handler registry).\n// ---------------------------------------------------------------------------\n//\n// Why a singleton: registrations (the plugin's built-ins at init, plus\n// any consumer-app custom transforms) and the worker's `get` happen in\n// different modules that Next.js HMR / Turbopack can re-evaluate\n// independently. Holding the registry on `globalThis` via `Symbol.for`\n// means the same `Map` instance is shared across module evaluations —\n// `register` and `get` see each other regardless of evaluation order.\n// Same fix shape as the queue's handler-registry HMR bug (#186).\n\nconst REGISTRY_KEY = Symbol.for('@murumets-ee/imports:transforms')\n\ninterface GlobalThisWithTransforms {\n [REGISTRY_KEY]?: TransformRegistry\n}\n\n/** Returns the singleton registry, creating it on first access. */\nexport function getTransformRegistry(): TransformRegistry {\n const g = globalThis as GlobalThisWithTransforms\n let reg = g[REGISTRY_KEY]\n if (!reg) {\n reg = new TransformRegistry()\n g[REGISTRY_KEY] = reg\n }\n return reg\n}\n\n/**\n * Convenience wrapper around the singleton registry for CONSUMER apps that\n * ship their own custom transforms — same semantics as\n * `@murumets-ee/queue/client`'s `registerJob`: **last-wins overwrite, never\n * throws on a duplicate name.** Two reasons, both load-bearing:\n *\n * 1. **Upgrade safety.** The `imports()` plugin registers the built-in\n * transforms (`commerce:mapping`, `commerce:carmaker-feed`) during\n * `createApp`, and older hand-wired apps — following this module's\n * previous docs — call this helper UNGUARDED from their bootstrap right\n * after. A duplicate-name throw here would crash boot on the very\n * `pnpm up` upgrade that ships plugin-owned registration; re-registering\n * the identical stock function is a harmless overwrite instead.\n * 2. **HMR freshness.** The registry lives on globalThis and survives dev\n * module re-evaluation; an edited consumer transform re-registers on the\n * next eval and the overwrite picks up the edit — a skip would keep\n * dispatching the stale function forever (the #186 staleness class).\n *\n * Side effect worth knowing: an app can deliberately override a built-in\n * name (its bootstrap runs after plugin init, so last-wins = app wins).\n * Genuine collision protection (two plugins claiming one name) stays on the\n * raw `TransformRegistry.register`.\n */\nexport function registerImportTransform<TDoc>(\n name: TransformName,\n transform: RowTransform<TDoc>,\n): void {\n getTransformRegistry().set(name, transform)\n}\n"],"mappings":"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"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"transform-WeiiLdYP.d.mts","names":[],"sources":["../src/transform.ts"],"mappings":";;AA0BA;;;;AAAyB;AAGzB;;;;;;;;;;AAQW;AAoBX;;;;AAII;AAgBJ;;;KAnDY,aAAA;;UAGK,gBAAA;EA6DW;EA3D1B,WAAA;EA8CwB;EA5CxB,MAAA,EAAQ,MAAM;EAgDd;EA9CA,QAAA;EA8Ca;EA5Cb,SAAA;AAAA;;AAqD0B;AA6C5B;;;;;;;;;;;;;;;UA9EiB,eAAA;EAmFX;EAjFJ,IAAA;EAkFI;EAhFJ,EAAE;AAAA;;;;;;;;;;AAoFgC;AAGpC;;;UAvEiB,QAAA;EAwEf;EAtEA,EAAA;EAwEA;EAtEA,GAAA,EAAK,OAAA,CAAQ,IAAA;EAsER;AASP;;;;;;;EAtEE,aAAA,GAAgB,UAAA;AAAA;AAiFlB;;;;;;;;;;;;;;;;;;;AAG2B;AAoB3B;;;;;;;;;;;;;;;;;;;;;;AAvBA,KApCY,SAAA;EAEN,IAAA;EACA,EAAA;EACA,GAAA,EAAK,IAAA;EACL,QAAA,YAAoB,UAAA;EACpB,KAAA,YAAiB,eAAA;EACjB,WAAA;AAAA;EAEA,IAAA;EAAc,MAAA;EAAgB,OAAA,YAAmB,QAAA,CAAS,IAAA;AAAA;EAC1D,IAAA;EAAe,KAAA,EAAO,QAAA;AAAA;;UAGX,QAAA;EACf,SAAA;EACA,OAAA;EACA,KAAA;AAAA;;;;AAoHuD;AAiCzD;;UA5IiB,UAAA;EACf,WAAA;EACA,OAAA;EACA,KAAA;AAAA;;;;;;KAQU,YAAA,UACV,MAAA,EAAQ,MAAA,kBACR,GAAA,EAAK,gBAAA,KACF,OAAA,CAAQ,SAAA,CAAU,IAAA;;;AAgIQ;;;;;;;;;;;;;;;;cA5GlB,iBAAA;EAAA,iBACM,OAAA;;;;;;;EAQjB,QAAA,OAAe,IAAA,EAAM,aAAA,EAAe,SAAA,EAAW,YAAA,CAAa,IAAA;;;;;;;;;;;;EAoB5D,GAAA,OAAU,IAAA,EAAM,aAAA,EAAe,SAAA,EAAW,YAAA,CAAa,IAAA;;EAKvD,GAAA,CAAI,IAAA,EAAM,aAAA,GAAgB,YAAA;;EAK1B,GAAA,CAAI,IAAA,EAAM,aAAA;;EAKV,IAAA,IAAQ,aAAA;;EAKR,KAAA;AAAA;;iBAwBc,oBAAA,IAAwB,iBAAiB;;;;;;;;;;;;;;;;;;;;;;;;iBAiCzC,uBAAA,OACd,IAAA,EAAM,aAAA,EACN,SAAA,EAAW,YAAA,CAAa,IAAA"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"worker-CRH_PjKO.d.mts","names":[],"sources":["../src/entities/import-run.ts","../src/streaming.ts","../src/read-feed.ts","../src/error-tracker.ts","../src/price-stats.ts","../src/runner.ts","../src/worker.ts"],"mappings":"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"}
|
package/dist/worker.mjs.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"worker.mjs","names":[],"sources":["../src/worker.ts"],"sourcesContent":["/**\n * Queue handler for `imports:run`. The route handler that creates the\n * `import_run` row enqueues this job carrying `{ importRunId }`; the\n * worker module here resolves the run, the transform, and the ES\n * client, then runs `runImport` and writes results back.\n *\n * Per PLAN-ECOMMERCE.md PR 7 (PoC scope): one job type, no resumability.\n * A failed handler attempt re-runs from row 1 on retry — fine for the\n * tens-of-MB feed sizes the PoC exercises.\n *\n * Per CLAUDE.md \"leaf packages\" boundary rule: this handler reads the\n * import_run row via an injected `AdminClient`, NOT a direct `db.select`.\n * Construction happens at plugin init in `plugin.ts`.\n */\n\nimport { type Logger, runAsCli } from '@murumets-ee/core'\nimport {\n defineJob,\n type JobDefinition,\n type QueueJobDeadEvent,\n type QueueTerminalNotifier,\n} from '@murumets-ee/queue/client'\nimport type { EsClientLike } from '@murumets-ee/search-elasticsearch'\nimport { and, desc, eq } from 'drizzle-orm'\nimport type { PgColumn } from 'drizzle-orm/pg-core'\nimport { z } from 'zod'\nimport type { ImportRunClient, ImportRunStatus } from './entities/import-run.js'\nimport { ErrorTracker, mergeErrorTrackerSnapshots } from './error-tracker.js'\nimport { detectPriceAnomaly, parsePriceStats } from './price-stats.js'\nimport { type ReadFeedOptions, readPersistedFeedConfig } from './read-feed.js'\nimport {\n publishImportRunFailed,\n publishImportRunStarted,\n publishImportRunSucceeded,\n} from './realtime/publish.js'\nimport { type ImportRunProgress, runImport } from './runner.js'\nimport { countDataRows } from './streaming.js'\nimport type { DirectionalPair, TransformRegistry } from './transform.js'\n\n/**\n * Structural subset of `JobContext` the handler reads. Declared\n * locally so we don't depend on a `JobHandler` import — the queue\n * package doesn't re-export it from any subpath today (matches the\n * `SendEmailJobContext` pattern in `@murumets-ee/notifications`).\n *\n * Function-param contravariance lets a handler returned with this\n * narrower shape be assigned to the wider `JobHandler<TPayload>` that\n * `registerJob` expects.\n */\nexport interface ImportsRunJobContext {\n id: string\n payload: ImportsRunJobPayload\n updateProgress(data: ImportRunProgress): void\n}\n\n/**\n * Payload schema. `importRunId` is the `import_run.id` UUID — the worker\n * looks up everything else (filePath, transformName, params, …) from\n * that row. Keeping the payload tiny means the queue's progress JSON\n * column never bloats with feed metadata duplicated across `toolkit_jobs`.\n */\nexport const importsRunJobPayloadSchema = z.object({\n importRunId: z.string().uuid(),\n})\nexport type ImportsRunJobPayload = z.infer<typeof importsRunJobPayloadSchema>\n\n/**\n * Job definition. Consumers register their handler against this with\n * `registerJob(importsRunJob, createRunImportHandler({...}))`.\n *\n * `defaultRetries: 0` — re-running a multi-batch import against the\n * same `import_run.id` while the previous handler may still be writing\n * is a footgun (duplicate batches, double-counted progress). The\n * operator handles retries explicitly via PR 8a's \"retry\" button by\n * creating a new `import_run` row. When the resumable design lands\n * (post-PoC), retries become safe to enable.\n */\nexport const importsRunJob: JobDefinition<ImportsRunJobPayload> = defineJob({\n name: 'imports:run',\n description: 'Stream a feed file, transform rows, bulk-write to Elasticsearch.',\n schema: importsRunJobPayloadSchema,\n defaultRetries: 0,\n})\n\n/**\n * Resolves the ES client at handler-invocation time. A function (rather\n * than the bare `EsClientLike`) so the consumer can lazy-construct the\n * client — typical Next.js setups create the ES connection in a\n * route-handler initialiser, not at plugin-init time.\n */\nexport type EsClientResolver = () => EsClientLike | Promise<EsClientLike>\n\n/**\n * Ensure the destination index/alias exists before the first bulk-write.\n * Invoked once per job, BEFORE {@link runImport}. Idempotent: the canonical\n * implementation (`ensureAliasedIndex` in `@murumets-ee/search-elasticsearch`)\n * no-ops when the alias already resolves.\n *\n * **Why it lives here.** This is the home of the foot-gun it guards: a worker\n * that bulk-writes to alias `<x>` before the alias exists makes ES\n * auto-create a physical `<x>` index with default `text` mappings, breaking\n * aggregations forever. The bootstrap used to run at app boot, which turned\n * \"ES is down\" into a full-admin 500 (issue #610). Moving it onto the import\n * path keeps the guarantee while scoping ES availability to imports/search —\n * the rest of the admin boots and runs with ES down.\n */\nexport type EnsureIndexResolver = () => Promise<unknown>\n\n/**\n * Resolves the value of `import_run.filePath` (whatever the upload\n * route persisted there — typically a storage adapter key) to a\n * readable LOCAL filesystem path that {@link runImport} can hand to\n * `node:fs.createReadStream`.\n *\n * **Why this exists:** the streaming reader (`streamFeed`) reads from\n * local disk via `createReadStream`. The upload route may persist a\n * remote-storage object key (R2, S3, …) on `import_run.filePath`\n * because that's what `@murumets-ee/storage` returns. Without a\n * resolver, `createReadStream('uploads/2026/05/<uuid>/feed.txt')`\n * crashes with `ENOENT`. The resolver is the documented integration\n * point — typical wiring downloads the storage object to a tmpfile\n * and returns its path.\n *\n * **`cleanup`** runs after the run finishes (success OR failure). The\n * worker awaits it best-effort — a failed cleanup logs but does not\n * crash the run.\n *\n * **No-resolver fallback:** when `resolveFilePath` is unset, the\n * worker treats `import_run.filePath` as already a local FS path\n * (back-compat with the original PoC design where uploads landed on\n * local disk). This stays valid for fixture-driven tests + on-disk\n * deployments.\n */\nexport type FilePathResolver = (\n storageKey: string,\n) => Promise<{ localPath: string; cleanup?: () => Promise<void> }>\n\n/**\n * Context handed to a {@link PairSink} alongside the harvested pairs. Carries the\n * run identity so the sink can build `source` attribution\n * (`feed:<supplierId>:<importBatchId>`, D20) and scope its writes.\n */\nexport interface PairSinkContext {\n importRunId: string\n /** The `import_run.params` object — the sink reads `supplierId` / `importBatchId`. */\n params: Record<string, unknown>\n /**\n * The run's structured logger (child), forwarded so the sink's INTERNAL telemetry —\n * a params-parse miss that skips ingestion, an over-merge flag — is visible. Without it\n * a misconfigured run looks successful from the worker's vantage (a params-miss returns\n * normally, so the worker logs \"applied\"). Optional; the sink no-ops its logs if absent.\n */\n logger?: Logger\n}\n\n/**\n * Optional post-run consumer for the directional supersession pairs the runner\n * harvested as a side-output (commerce-demo PR6, SD007). Invoked ONCE after a\n * successful run with the full in-memory pair buffer (read-once / merge-after) so\n * the consumer (the commerce replacement-merge engine) can build equivalence groups\n * and bulk-write them to Postgres.\n *\n * **Why injected, not imported.** The merge writes the CONSUMER's Postgres tables,\n * but the runner + this worker live in the `@murumets-ee/imports` leaf, which must\n * not depend on commerce. The app composes the sink (commerce exports it, the app\n * passes it to `imports({ pairSink })`) — the same injection shape as `esClient` /\n * `resolveFilePath` / `ensureIndex`. The boundary holds.\n *\n * **Best-effort.** A sink throw is logged and SWALLOWED — the catalogue import\n * (the ES write) has already succeeded and must not be undone by a secondary\n * replacement-ingestion failure. The merge is idempotent, so a re-import recovers.\n */\nexport type PairSink = (pairs: readonly DirectionalPair[], ctx: PairSinkContext) => Promise<void>\n\nexport interface RunImportHandlerConfig {\n /** AdminClient over the `import_run` entity. */\n importRuns: ImportRunClient\n /** Transform registry to dispatch against. Defaults to the process-global singleton. */\n transforms: TransformRegistry\n /** Resolver for the ES client. */\n esClient: EsClientResolver\n /** ES alias / index to bulk-write into. Per D6 callers always pass an alias. */\n esIndex: string\n /**\n * Optional bootstrap for the destination index/alias, run once per job\n * before any bulk-write. See {@link EnsureIndexResolver}. Omit for\n * deployments that bootstrap the index out-of-band (e.g. a reindex job).\n */\n ensureIndex?: EnsureIndexResolver\n /**\n * Optional resolver for `import_run.filePath`. See {@link FilePathResolver}.\n * Required when uploads land in remote storage (R2/S3); optional for\n * on-disk PoC setups.\n */\n resolveFilePath?: FilePathResolver\n /**\n * Optional consumer for the directional supersession pairs harvested as a\n * side-output (commerce-demo PR6, SD007). See {@link PairSink}. Omit for\n * deployments that don't ingest replacement codes — the pairs are simply\n * discarded after the run.\n */\n pairSink?: PairSink\n /** Optional structured logger. Defaults to silent. */\n logger?: Logger\n}\n\n/**\n * Build the `JobHandler` for {@link importsRunJob}. The returned\n * handler is what gets passed to `registerJob`.\n *\n * The body is wrapped in `runAsCli` so AdminClient calls inside have a\n * synthetic `cli` admin context — `auditable()` records `updatedBy:\n * 'cli'` rather than NULL, and the firewall checker passes. This is\n * the documented worker entry-point pattern (see `runAsCli` JSDoc in\n * `@murumets-ee/core`).\n */\nexport function createRunImportHandler(\n config: RunImportHandlerConfig,\n): (job: ImportsRunJobContext) => Promise<void> {\n const {\n importRuns,\n transforms,\n esClient,\n esIndex,\n resolveFilePath,\n ensureIndex,\n pairSink,\n logger,\n } = config\n\n return async (job: ImportsRunJobContext): Promise<void> =>\n runAsCli(async () => {\n const { importRunId } = job.payload\n const log = logger?.child({ jobId: job.id, importRunId, type: 'imports:run' })\n\n const run = await importRuns.findById(importRunId)\n if (!run) {\n // Row was deleted between enqueue and dispatch — nothing to do.\n // Don't throw: a thrown handler retries with no payoff because\n // the row stays gone. Log + return so the queue marks the job\n // completed.\n log?.warn('import_run row not found — skipping')\n return\n }\n\n // Re-claim detection. The queue's `recoverStuckJobs` sweep flips a\n // crashed worker's `toolkit_jobs` row from `processing` back to\n // `pending` — but the queue layer has no knowledge of `import_run`,\n // so this row stays at `status='running'` from the prior attempt.\n // When the queue re-dispatches the job, we land here with a row\n // that the operator-facing Active panel has been showing as live\n // for however long lock-recovery took (default 30 min). The\n // existing `update` below overwrites `queueJobId` + `startedAt` so\n // there is no STATE corruption — but emit a structured warning so\n // ops can correlate this re-run with the worker crash that\n // produced it. The sibling `sweepStaleImportRuns` job below catches\n // the case where re-dispatch never happens (queue marks the job\n // dead, or the queue worker is offline long-term).\n if (run.status === 'running') {\n log?.warn(\n {\n previousQueueJobId: run.queueJobId,\n previousStartedAt: run.startedAt,\n },\n 'imports: re-claiming a row left in running by a prior worker — likely a crash recovered by queue lock-recovery',\n )\n }\n\n // `transformName`, `filePath`, `label` are declared `field.text({\n // required: true })` so the inferred DTO already types them as\n // `string` — no cast needed. `params` is `field.json()` which\n // infers as `JsonValue | undefined`; narrow to a record so the\n // transform receives an object.\n const transform = transforms.get(run.transformName)\n if (!transform) {\n await markFailed(importRuns, importRunId, `Unknown transform \"${run.transformName}\"`)\n throw new Error(`No transform registered for name \"${run.transformName}\"`)\n }\n const params: Record<string, unknown> =\n run.params && typeof run.params === 'object' && !Array.isArray(run.params)\n ? (run.params as Record<string, unknown>)\n : {}\n\n // Reader config (format/delimiter/header/sheet) travels in\n // `params.feed` for mapping-driven runs (commerce-demo PR3). Carmaker\n // runs omit it → empty config → tab-delimited default, so the existing\n // path is byte-for-byte unchanged. Re-validate here as defense-in-depth\n // even though the enqueuing route already validated the saved mapping.\n // Shared with the #842 audit, which has to rehydrate IDENTICALLY or it\n // re-reads a past run with options that run never used.\n const feedParsed = readPersistedFeedConfig(params)\n if (!feedParsed.success) {\n const reason = feedParsed.error.issues[0]?.message ?? 'invalid feed reader config'\n await markFailed(importRuns, importRunId, `Invalid params.feed: ${reason}`)\n throw new Error(`Invalid params.feed for import_run ${importRunId}: ${reason}`)\n }\n const feedConfig = feedParsed.data\n\n await importRuns.update(importRunId, {\n status: 'running' satisfies ImportRunStatus,\n queueJobId: job.id,\n startedAt: new Date(),\n })\n // Realtime: row transitioned pending → running. Subscribers\n // refetch to surface the change in the imports admin page.\n // Best-effort — a publish failure is logged inside `publishSafe`\n // and does NOT roll back the row update.\n publishImportRunStarted(importRunId)\n\n let resolvedClient: EsClientLike\n try {\n resolvedClient = await esClient()\n } catch (err) {\n const reason = err instanceof Error ? err.message : String(err)\n await markFailed(importRuns, importRunId, `Could not resolve ES client: ${reason}`)\n throw err\n }\n\n // Ensure the destination alias exists BEFORE any bulk-write. Moved here\n // from app boot (issue #610): the foot-gun is the worker auto-creating a\n // mis-mapped physical index, so the guard belongs on the import path —\n // not gating the whole app on ES. Idempotent + scoped: an ES outage (or\n // the `ensureAliasedIndex` physical-collision guard firing) fails THIS\n // job loudly while the rest of the admin keeps working.\n if (ensureIndex) {\n try {\n await ensureIndex()\n } catch (err) {\n const reason = err instanceof Error ? err.message : String(err)\n await markFailed(importRuns, importRunId, `Could not ensure ES index/alias: ${reason}`)\n throw err\n }\n }\n\n // Materialise the file locally if the consumer wired a resolver.\n // The `cleanup` callback (when present) runs in the `finally`\n // below regardless of run outcome so a tmpfile created here\n // doesn't outlive the handler.\n let localPath: string\n let fileCleanup: (() => Promise<void>) | undefined\n if (resolveFilePath) {\n try {\n const resolved = await resolveFilePath(run.filePath)\n localPath = resolved.localPath\n fileCleanup = resolved.cleanup\n } catch (err) {\n const reason = err instanceof Error ? err.message : String(err)\n await markFailed(importRuns, importRunId, `Could not resolve filePath: ${reason}`)\n throw err\n }\n } else {\n // Back-compat: no resolver wired → treat `filePath` as already\n // a local FS path. Fine for fixture-driven tests + the original\n // on-disk PoC design.\n localPath = run.filePath\n }\n\n // Pre-pass line count: a 16 MB tab-delimited feed counts in\n // ~50ms; a 500 MB feed in <2s. Cheap relative to the full\n // import. Gives the operator a denominator (`processed / total`)\n // so the imports-page panel can render an actual percent\n // instead of just \"rows so far\".\n // Pre-pass row count is a `\\n`-tally over a delimited file — meaningless\n // on an xlsx binary, so skip it there (the UI falls back to bare counts).\n // `hasHeader` from the feed config keeps the header-exclusion correct for\n // headerless mappings.\n let totalRows: number | undefined\n if (feedConfig.format !== 'xlsx') {\n try {\n totalRows = await countDataRows(\n localPath,\n feedConfig.hasHeader !== undefined ? { hasHeader: feedConfig.hasHeader } : {},\n )\n log?.info({ importRunId, totalRows }, 'imports: pre-pass row count')\n } catch (err) {\n // Don't fail the run on a count miss — the actual import is\n // unaffected; the operator just loses the percent denominator.\n log?.warn({ err }, 'imports: pre-pass row count failed — continuing without total')\n }\n }\n\n let result: Awaited<ReturnType<typeof runImport>>\n try {\n // Build feed options with the conditional-spread idiom (NOT\n // `...feedConfig`) so absent optionals stay ABSENT — spreading a\n // `T | undefined` value would set the key to `undefined`, which\n // `exactOptionalPropertyTypes` rejects against `ReadFeedOptions`.\n const feedOpts: ReadFeedOptions = {\n filePath: localPath,\n ...(feedConfig.format !== undefined && { format: feedConfig.format }),\n ...(feedConfig.delimiter !== undefined && { delimiter: feedConfig.delimiter }),\n ...(feedConfig.hasHeader !== undefined && { hasHeader: feedConfig.hasHeader }),\n ...(feedConfig.headerRowIndex !== undefined && {\n headerRowIndex: feedConfig.headerRowIndex,\n }),\n ...(feedConfig.sheet !== undefined && { sheet: feedConfig.sheet }),\n ...(feedConfig.quote !== undefined && { quote: feedConfig.quote }),\n }\n result = await runImport({\n importRunId,\n runLabel: run.label,\n params,\n transform,\n feed: feedOpts,\n esClient: resolvedClient,\n esIndex,\n onProgress: (progress) =>\n job.updateProgress(totalRows !== undefined ? { ...progress, totalRows } : progress),\n ...(log !== undefined && { logger: log }),\n })\n } catch (err) {\n const reason = err instanceof Error ? err.message : String(err)\n await markFailed(importRuns, importRunId, reason)\n if (fileCleanup) {\n await fileCleanup().catch((cleanupErr: unknown) => {\n log?.warn({ err: cleanupErr }, 'filePath cleanup after failed run threw — ignoring')\n })\n }\n throw err\n }\n\n // Cleanup runs before the success-update so a cleanup throw\n // surfaces as a logged warning rather than a crashed handler at\n // the worst possible moment (just after the row was about to be\n // marked succeeded).\n if (fileCleanup) {\n await fileCleanup().catch((cleanupErr: unknown) => {\n log?.warn({ err: cleanupErr }, 'filePath cleanup after successful run threw — ignoring')\n })\n }\n\n // Value-drift detection (F010 / #602). The 6b header-presence drift check\n // (commerce's register-handler.ts, upload-time) only catches a STRUCTURAL\n // change — a mapped column vanishing from the header row. It cannot catch a\n // supplier silently flipping their decimal/thousands separator: no header\n // changes, and `import_mapping.numberFormat` stays the same, so every price\n // parses as a numerically valid but silently WRONG value. Compare THIS run's\n // price distribution against the most recent prior SUCCEEDED run for the same\n // (transformName, brandId, supplierId) and, on a magnitude-scale shift, append\n // a NON-FATAL `price_anomaly` warning. Unlike 6b's 409, this never blocks the\n // run — a genuine repricing is a legitimate reason the median would move, so\n // this is advisory only.\n let warningsToWrite = result.warnings\n const brandIdForDrift = typeof params.brandId === 'string' ? params.brandId : undefined\n const supplierIdForDrift =\n typeof params.supplierId === 'string' ? params.supplierId : undefined\n if (brandIdForDrift && supplierIdForDrift && result.priceStats.median !== null) {\n try {\n // `getTable()` is `PgTableWithColumns<any>` (dynamic entity columns —\n // see the documented-`any` list in CLAUDE.md); cast to the columns we\n // filter/sort on, same pattern as `createImportsRunDeadListener` below.\n const table = importRuns.getTable() as unknown as {\n transformName: PgColumn\n status: PgColumn\n createdAt: PgColumn\n }\n // Bounded lookup (limit 50), filtered to this transform + succeeded runs\n // in SQL, then narrowed to the same (brandId, supplierId) in memory —\n // `params` is an opaque per-transform JSONB blob (CLAUDE.md \"no raw\n // SQL\": no `params->>'brandId'` jsonb operator here). 50 candidates is\n // far more than any operator re-runs between two imports for the same\n // supplier/brand pair before this one.\n const candidates = await importRuns.findMany({\n where: and(eq(table.transformName, run.transformName), eq(table.status, 'succeeded')),\n orderBy: desc(table.createdAt),\n limit: 50,\n })\n const prior = candidates.find((c) => {\n if (c.id === importRunId) return false\n const p = c.params\n if (!p || typeof p !== 'object' || Array.isArray(p)) return false\n const pr = p as Record<string, unknown>\n return pr.brandId === brandIdForDrift && pr.supplierId === supplierIdForDrift\n })\n // CLAUDE.md \"No Unbounded Fan-Out\": \"log() whatever you drop — silent\n // truncation reads as 'covered everything' when it didn't.\" `transformName`\n // is shared across every (brand, supplier) pair using that mapping (e.g.\n // all 17 GIGA brands share `commerce:mapping`), so a genuinely prior run\n // for THIS pair can fall outside the 50-candidate window as import cadence\n // and supplier count grow. Distinguish that from \"no prior run exists\" —\n // both look identical downstream (no anomaly check runs) but only one is\n // an operator-actionable coverage gap.\n if (!prior && candidates.length >= 50) {\n log?.warn(\n {\n brandId: brandIdForDrift,\n supplierId: supplierIdForDrift,\n transformName: run.transformName,\n },\n 'imports: price-drift check found no prior run for this (brand, supplier) within the 50 most recent succeeded runs for this transform — a real prior run may exist beyond the bound, not necessarily absent',\n )\n }\n const priorStats = prior ? parsePriceStats(prior.priceStats) : null\n const anomaly = priorStats ? detectPriceAnomaly(priorStats, result.priceStats) : null\n if (anomaly) {\n const higher = anomaly.ratio >= 1\n const anomalyTracker = new ErrorTracker()\n anomalyTracker.addError(\n -1,\n 'price_anomaly',\n `Median purchase price is ${(higher ? anomaly.ratio : 1 / anomaly.ratio).toFixed(1)}x ${higher ? 'higher' : 'lower'} than the previous successful run for this supplier/brand (previous run ${prior?.id ?? 'unknown'}: median ${anomaly.prevMedian}; this run: median ${anomaly.currMedian}, minor units). Check for a decimal or thousands-separator format change in the source feed.`,\n 'priceStats.median',\n {\n previousImportRunId: prior?.id ?? null,\n previousMedian: anomaly.prevMedian,\n currentMedian: anomaly.currMedian,\n ratio: anomaly.ratio,\n },\n )\n warningsToWrite = mergeErrorTrackerSnapshots(result.warnings, anomalyTracker.snapshot())\n log?.warn(\n { previousImportRunId: prior?.id, ratio: anomaly.ratio },\n 'imports: price_anomaly — median purchase price shifted by a magnitude vs the prior run',\n )\n }\n } catch (err) {\n // Best-effort — a comparison failure must never fail an otherwise-\n // successful catalogue import. Same \"log + continue\" discipline as the\n // pairSink / cleanup best-effort paths elsewhere in this handler.\n log?.warn({ err }, 'imports: price-drift comparison failed — continuing without it')\n }\n }\n\n await importRuns.update(importRunId, {\n status: 'succeeded' satisfies ImportRunStatus,\n finishedAt: new Date(),\n totals: {\n rowsRead: result.rowsRead,\n rowsSucceeded: result.rowsSucceeded,\n rowsFailed: result.rowsFailed,\n rowsSkipped: result.rowsSkipped,\n rowsWarned: result.rowsWarned,\n batchesCompleted: result.batchesCompleted,\n // Phase-2 core-charge attach (commerce-demo PR7) — durable on the run row so\n // an operator sees how many `*S` deposits attached vs orphaned, not just the\n // orphan warnings in `warningSummary`.\n patchesApplied: result.patchesApplied,\n patchesOrphaned: result.patchesOrphaned,\n },\n // The typed `ErrorTrackerSnapshot` is fully JSON-serialisable but\n // doesn't structurally satisfy entity's recursive `JsonValue`\n // (objects without an index signature aren't `Record<string,\n // JsonValue>`). Round-trip through `JSON.parse(JSON.stringify(...))`\n // to land as the recursive JSON shape — the runtime cost is one\n // serialise per import_run completion, the type-cast cost is zero.\n errorSummary: JSON.parse(JSON.stringify(result.errors)),\n // Non-fatal warnings (commerce-demo PR4) — kept separate from\n // `errorSummary` so a `no_purchase_price` advisory never reads as a\n // failed row. Same serialise-roundtrip rationale as `errorSummary`.\n // `warningsToWrite` is `result.warnings` UNLESS the price-drift check\n // above appended a `price_anomaly` entry (F010 / #602).\n warningSummary: JSON.parse(JSON.stringify(warningsToWrite)),\n // Price-distribution snapshot (F010 / #602) — read back by the NEXT\n // run for this (transformName, brandId, supplierId) to detect drift.\n priceStats: JSON.parse(JSON.stringify(result.priceStats)),\n })\n // Realtime: row transitioned to terminal-success. Subscribers\n // refetch to move the row from Active → Recent.\n publishImportRunSucceeded(importRunId, {\n rowsRead: result.rowsRead,\n rowsSucceeded: result.rowsSucceeded,\n rowsFailed: result.rowsFailed,\n })\n\n // Side-output: hand the harvested supersession pairs to the injected sink\n // (commerce-demo PR6, SD007) AFTER the catalogue run is marked succeeded — the\n // ES import is primary; replacement ingestion is secondary. Best-effort: a sink\n // throw is logged + swallowed so a Postgres hiccup can't undo a successful\n // catalogue import (the merge is idempotent → a re-import recovers).\n if (pairSink && result.pairs.length > 0) {\n try {\n await pairSink(result.pairs, {\n importRunId,\n params,\n ...(log !== undefined && { logger: log }),\n })\n // \"invoked\" not \"applied\": the sink may internally skip (e.g. missing run params);\n // it logs its own applied/skipped outcome via the forwarded logger.\n log?.info({ importRunId, pairs: result.pairs.length }, 'imports: pairSink invoked')\n } catch (err) {\n log?.warn(\n { importRunId, pairs: result.pairs.length, err },\n // NOT \"skipped\": a sink may apply part of the batch before failing (the commerce\n // replacement sink commits per batch), so some pairs can already have landed.\n // \"Incomplete\" is the only claim this boundary can honestly make about a sink\n // whose internals it does not know.\n 'imports: pairSink threw — replacement ingestion incomplete, catalogue import unaffected',\n )\n }\n }\n\n log?.info(\n {\n rowsRead: result.rowsRead,\n rowsSucceeded: result.rowsSucceeded,\n rowsFailed: result.rowsFailed,\n rowsSkipped: result.rowsSkipped,\n batches: result.batchesCompleted,\n },\n 'import_run completed',\n )\n })\n}\n\n/** Write a `failed` row with a fatal error reason. Best-effort — logs but does not throw on DB failure. */\nasync function markFailed(importRuns: ImportRunClient, id: string, reason: string): Promise<void> {\n try {\n await importRuns.update(id, {\n status: 'failed' satisfies ImportRunStatus,\n finishedAt: new Date(),\n errorSummary: { fatal: reason },\n })\n // Realtime: row transitioned to terminal-failure. Subscribers\n // refetch to surface the failure in the Recent panel. Publish\n // is best-effort and only fires when the persist succeeds —\n // skipping it on persist failure keeps subscribers consistent\n // with the DB (no event for a row that didn't transition).\n publishImportRunFailed(id, reason)\n } catch {\n // The handler already threw / will throw — the queue will surface\n // the original error. A second failure here just logs noise.\n }\n}\n\nexport interface ImportsRunDeadListenerConfig {\n importRuns: ImportRunClient\n /** Optional structured logger. */\n logger?: Logger\n}\n\n/**\n * Build a {@link QueueTerminalNotifier} that mirrors `imports:run`\n * dead-job events into `import_run.status='failed'`.\n *\n * **Why this exists.** The `imports:run` handler's normal failure path\n * already calls `markFailed` before re-throwing, so a synchronous\n * handler error reaches the operator-visible `failed` status without\n * help from anyone. The case THIS listener catches is the one\n * `markFailed` never gets to: the worker process is killed mid-handler\n * (OOM, container restart, kill -9), the queue's lock-recovery sweep\n * eventually reclaims the row, and the queue ultimately marks the job\n * `dead` (defaultRetries: 0 + repeated process death = dead-letter\n * with `import_run.status` still `running`). Without this listener\n * the row sits in the operator's Active panel forever.\n *\n * **Generic primitive.** This is a thin subscriber over the queue's\n * multi-subscriber `QueueTerminalNotifier` registry — the same shape\n * graphile-worker (`job:failed`) and BullMQ (`failed`) expose. Other\n * domain plugins that mirror queue state (ticketing-reply, future\n * exports/reports/...) can register their own listeners exactly the\n * same way without coordinating via a per-domain sweep job.\n *\n * **Filtering.** The listener is invoked for EVERY dead event in the\n * process. We early-return on any `jobType` other than `imports:run`\n * so the per-event cost on unrelated jobs is a single property read.\n *\n * **Idempotency.** A dead event delivered after the row has already\n * reached its own terminal state (succeeded / failed / cancelled) is\n * ignored — the only transition we make is `running → failed`. This\n * keeps a late replay or out-of-order delivery from clobbering a\n * row whose worker reached `markFailed` faster than the queue's\n * dead-letter dispatch.\n *\n * The handler is wrapped in `runAsCli` so AdminClient calls inside\n * have a synthetic CLI admin context — same pattern as\n * {@link createRunImportHandler}.\n */\nexport function createImportsRunDeadListener(\n config: ImportsRunDeadListenerConfig,\n): QueueTerminalNotifier {\n const { importRuns, logger } = config\n\n return {\n onJobDead: async (event: QueueJobDeadEvent): Promise<void> => {\n if (event.jobType !== 'imports:run') return\n\n await runAsCli(async () => {\n const log = logger?.child({\n event: 'imports.run.dead',\n jobId: event.jobId,\n type: 'imports:run',\n })\n\n // Look up the mirror row by `queueJobId`. Cap to 1 — the\n // upload route writes one `import_run` per enqueued job.\n //\n // `AdminClient.getTable()` returns `PgTableWithColumns<any>`\n // because entity tables have dynamic column shapes (see the\n // `getTable()` JSDoc in `packages/entity/src/admin/client.ts`\n // and the documented-`any`-cases list in CLAUDE.md). Cast\n // the table to a structural shape covering only the column\n // we touch, so both `eq()` and type-coverage see a typed\n // operand instead of an `any`-derived property access.\n const table = importRuns.getTable() as unknown as { queueJobId: PgColumn }\n const matches = await importRuns.findMany({\n where: eq(table.queueJobId, event.jobId),\n limit: 1,\n })\n const row = matches[0]\n if (!row) {\n // No mirror row for this dead job. Either it was deleted\n // by the operator, or the dead event is for a job\n // unrelated to imports despite the type filter\n // (shouldn't happen, but is cheap to log).\n log?.warn('queue.job.dead with no matching import_run — skipping')\n return\n }\n\n // Only transition rows that haven't already reached their\n // own terminal state. A late dead-event must not overwrite\n // a `succeeded` row that the worker reached via the normal\n // path before the queue's dead-letter dispatch fired.\n if (row.status !== 'running' && row.status !== 'pending') {\n log?.info(\n { importRunId: row.id, status: row.status },\n 'queue.job.dead arrived for a row already in a terminal state — leaving as-is',\n )\n return\n }\n\n // Dead-from-`pending` is the rarer + more interesting case:\n // the worker process died (or never claimed) BEFORE it could\n // flip the row to `running`. Log it at warn so ops can spot\n // a queue+worker-startup pathology that the standard `running\n // → failed` transition wouldn't surface. Dead-from-`running`\n // is the documented \"crash mid-import\" case — info is enough.\n if (row.status === 'pending') {\n log?.warn(\n { importRunId: row.id, attempts: event.attempts },\n 'queue.job.dead arrived for an import_run still in pending — worker crashed BEFORE claim/lookup',\n )\n }\n\n await markFailed(importRuns, row.id, event.error)\n log?.info(\n { importRunId: row.id, attempts: event.attempts, priorStatus: row.status },\n 'queue.job.dead → import_run marked failed',\n )\n })\n },\n }\n}\n"],"mappings":"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"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"xlsx-reader-D9Ntxm29.mjs","names":[],"sources":["../src/xlsx-reader.ts"],"sourcesContent":["/**\n * Streaming XLSX reader (commerce-demo phase 02, D005) — the exceljs branch of\n * the feed reader. Yields the SAME `AsyncIterable<{ rowNumber, row }>` contract as\n * the csv-parse `streamFeed`, so the runner / transform layer downstream is\n * format-agnostic.\n *\n * **exceljs over SheetJS** (R02): we parse operator-uploaded spreadsheets — the\n * exact threat model of the unfixed npm `xlsx` prototype-pollution CVE\n * (CVE-2023-30533). exceljs is MIT, ships its own types, has a real streaming\n * `WorkbookReader`, and carries no equivalent advisory.\n *\n * **exceljs is `await import()`-ed**, never statically imported, so it stays out\n * of this package's static module graph — heavy + Node-only, it must not be\n * inlinable into any `*-ui` server-pages bundle (CLAUDE.md bundle budgets). The\n * `read-feed` dispatcher additionally only loads THIS module for `format: 'xlsx'`.\n */\n\nimport { type FileHandle, open } from 'node:fs/promises'\nimport { Duplex } from 'node:stream'\nimport type { CellValue } from 'exceljs'\nimport { assertUniqueHeaders, type StreamFeedRow } from './streaming.js'\n\n/**\n * Thrown by {@link streamXlsx} when the configured header row parses to zero\n * readable columns — the signature of the silent-corruption defect documented\n * on the guard below.\n *\n * A distinct CLASS rather than a bare `Error` because the retroactive audit\n * (`auditEmptyHeaderRuns`, #842) has to tell \"this file's header is\n * unreadable\" apart from every other way reading a spreadsheet can fail\n * (missing object, unreadable zip, wrong sheet index). Matching on the message\n * would make that classification a regex over prose that anyone is free to\n * reword — which is exactly the kind of detector that silently stops detecting.\n * The message text is unchanged from the original throw.\n */\nexport class XlsxEmptyHeaderError extends Error {\n /** 1-based worksheet row the empty header was read from. */\n readonly headerRowNumber: number\n\n constructor(headerRowNumber: number) {\n super(\n `xlsx header row ${headerRowNumber} has no readable columns — the file may be corrupt, ` +\n 'or its shared strings failed to load. Refusing rather than importing empty rows.',\n )\n this.name = 'XlsxEmptyHeaderError'\n this.headerRowNumber = headerRowNumber\n }\n}\n\n/**\n * Thrown by {@link streamXlsx} when the path resolves to something that is not a\n * regular file — a directory, most often. `open()` SUCCEEDS on a directory on\n * Linux (POSIX allows `O_RDONLY` on one) and only the read fails, so this is a\n * check the reader has to make itself.\n *\n * Typed for the same reason {@link XlsxEmptyHeaderError} is: the #842 audit has\n * to tell \"the evidence is unreadable\" from \"the evidence is readable and bad\",\n * and matching prose is how a classifier quietly stops classifying.\n */\nexport class XlsxNotAFileError extends Error {\n constructor(filePath: string) {\n super(`xlsx path \"${filePath}\" is not a regular file`)\n this.name = 'XlsxNotAFileError'\n }\n}\n\nexport interface XlsxReadOptions {\n /** Path to the .xlsx file on local disk. */\n filePath: string\n /** First row is the header (column names). `false` → positional `{ \"0\": … }` keys. Default `true`. */\n hasHeader?: boolean\n /** 0-based index of the header row (rows before it are skipped). Default `0`. */\n headerRowIndex?: number\n /** Worksheet to read, as a 1-based index string. Omit → the first worksheet. */\n sheet?: string\n}\n\n/** Convert one exceljs cell value to a plain string (feeds are text/number cells). */\nfunction cellToString(v: CellValue): string {\n if (v === null || v === undefined) return ''\n if (typeof v === 'string') return v\n if (typeof v === 'number' || typeof v === 'boolean') return String(v)\n if (v instanceof Date) return v.toISOString()\n // Object cell values: rich text, hyperlink, formula result, error.\n if ('richText' in v && Array.isArray(v.richText)) {\n return v.richText.map((r) => r.text).join('')\n }\n if ('text' in v && typeof v.text === 'string') return v.text\n if ('result' in v && v.result !== undefined && v.result !== null) {\n const r = v.result\n if (r instanceof Date) return r.toISOString()\n // A nested error object (formula error) surfaces as empty; primitives stringify.\n return typeof r === 'object' ? '' : String(r)\n }\n return ''\n}\n\n/** Normalise an exceljs `row.values` (1-indexed array OR object form) to a 0-indexed string[]. */\nfunction rowCells(values: CellValue[] | { [key: string]: CellValue }): string[] {\n const out: string[] = []\n if (Array.isArray(values)) {\n // exceljs row arrays are 1-indexed (`values[0]` is empty); shift to 0-based.\n for (let i = 1; i < values.length; i += 1) out[i - 1] = cellToString(values[i])\n } else {\n for (const [k, v] of Object.entries(values)) {\n const idx = Number.parseInt(k, 10)\n if (!Number.isNaN(idx) && idx >= 1) out[idx - 1] = cellToString(v)\n }\n }\n // Fill any holes (sparse rows) with empty strings — the contract is \"missing\n // cell = ''\", matching streamFeed.\n for (let i = 0; i < out.length; i += 1) if (out[i] === undefined) out[i] = ''\n return out\n}\n\n/**\n * Stop unzipper's PREMATURE `'end'` from ending exceljs's entry loop while zip\n * entries are still buffered — the cause of the \"clean workbook reads as\n * corrupt\" flake (plan frontend-delivery F067 / F008), and a production hazard\n * for the same reason the `reader.model` seed below is one.\n *\n * The mechanism, read from source and reproduced deterministically:\n *\n * 1. unzipper 0.10's `Parse` (what exceljs pipes our source into,\n * `workbook-reader.js:81`) re-emits its WRITABLE side's `'finish'` as\n * `'end'` (`unzipper/lib/parse.js`, constructor) — i.e. \"all input bytes\n * consumed\", not \"every entry delivered\". Small entries fit their\n * PassThrough buffers, so `Parse` races through the whole zip, pushing\n * entry objects into its readable buffer, regardless of the consumer.\n * 2. exceljs's `iterateStream` PAUSES the zip while it handles an entry, and\n * for a worksheet that precedes `sharedStrings.xml` (exceljs's own write\n * order, and Excel's) \"handling\" is a `tmp.file()` + write — filesystem\n * round-trips. It treats the first `'end'` it hears as the end of the zip\n * and exits with the rest of the entries still sitting in the buffer.\n * 3. So when the temp-file write is slower than inflating the remaining\n * entries, `xl/sharedStrings.xml` is never parsed. Every string cell then\n * comes back as `{ sharedString: n }` (exceljs's no-table shape),\n * `cellToString` renders it `''`, and the header guard in `streamXlsx`\n * refuses a perfectly good file as `XlsxEmptyHeaderError`.\n *\n * Measured by delaying `tmp.file` inside exceljs: 0-1ms reads correctly,\n * 5ms+ loses the shared strings every time. A loaded CI runner or a busy\n * queue worker supplies those milliseconds; the regression test supplies them\n * on purpose (`xlsx-reader.premature-end.test.ts`).\n *\n * The fix drops an `'end'` only while the zip's readable buffer still holds\n * undelivered entries. By the time `Parse` finishes its writable side it has\n * reached the end-of-central-directory record and called `push(null)`, so\n * Node emits the REAL `'end'` itself once those entries have been read — this\n * delays the event, it never loses one. An `'end'` with an empty buffer passes\n * straight through, so a read that was already correct is untouched.\n *\n * Not a `pnpm patch` of exceljs/unzipper: a patch lives in THIS workspace's\n * install only, and `@murumets-ee/imports` is published — every consumer\n * would keep the defect. If a future exceljs stops piping into a `Duplex`,\n * the hook simply does not apply and the regression test goes red.\n */\nfunction holdPrematureZipEnd(source: ReturnType<FileHandle['createReadStream']>): void {\n const pipe = source.pipe.bind(source)\n source.pipe = <T extends NodeJS.WritableStream>(\n destination: T,\n options?: { end?: boolean },\n ): T => {\n if (destination instanceof Duplex) {\n const zip = destination\n const emit = zip.emit.bind(zip)\n zip.emit = (event: string | symbol, ...args: unknown[]): boolean => {\n if (event === 'end' && zip.readableLength > 0) return false\n return emit(event, ...args)\n }\n }\n return pipe(destination, options)\n }\n}\n\n/**\n * Async-iterable over the data rows of an .xlsx file. `rowNumber` is the 1-based\n * DATA row index (header excluded). Cell values are coerced to strings so the\n * mapping engine's per-column transforms (which expect raw string cells) apply\n * uniformly across csv/tsv/xlsx.\n */\nexport async function* streamXlsx(options: XlsxReadOptions): AsyncIterable<StreamFeedRow> {\n const { filePath, hasHeader = true, headerRowIndex = 0, sheet } = options\n const ExcelJS = await import('exceljs')\n\n // `WorksheetReader` doesn't expose its sheet id, so count emitted worksheets\n // (1-based) to match the configured `sheet`. Default: the first worksheet.\n // A provided-but-invalid sheet FAILS FAST rather than silently reading sheet 1\n // — and before we touch the filesystem, so a config typo costs no I/O.\n let targetSheetIndex: number | undefined\n if (sheet !== undefined) {\n targetSheetIndex = Number.parseInt(sheet, 10)\n if (Number.isNaN(targetSheetIndex) || targetSheetIndex < 1) {\n throw new Error(`Invalid xlsx sheet \"${sheet}\" — expected a 1-based worksheet index`)\n }\n }\n\n // OPEN THE FILE OURSELVES rather than handing exceljs a path, because a path\n // makes every filesystem failure an UNCAUGHT EXCEPTION that kills the process\n // instead of a rejection the caller can handle.\n //\n // `_getStream` does `fs.createReadStream(input)` for a string input and then\n // `stream.pipe(zip)` (`workbook-reader.js:38,81`). `pipe()` does not forward\n // SOURCE errors, and exceljs registers no `error` listener on that stream — so\n // an ENOENT / EACCES / EISDIR surfaces as `Unhandled 'error' event`. Verified\n // by execution, not inference: all three crash the process outright. In the\n // queue worker that kills the job mid-flight instead of routing through\n // `markFailed`; in the #842 audit one unreadable file discards the whole sweep.\n //\n // An fd fixes it at the root rather than patching the symptom:\n // - `open()` rejects normally on ENOENT and EACCES.\n // - a DIRECTORY still opens on Linux (checked — O_RDONLY on a dir succeeds\n // and only the read gives EISDIR), so the `isFile()` check below is\n // load-bearing. It runs against the fd, so it cannot be raced.\n // - once the fd is held, the inode survives `unlink` (checked: the read\n // still returns the bytes). That closes the window against the\n // `@every 1h` retention sweep, which deletes exactly these files and which\n // the audit is documented as racing.\n const handle = await open(filePath, 'r')\n // Held so the outer `finally` can destroy it BEFORE the fd goes away. With\n // `autoClose: false` nothing tears this stream down except reaching the end,\n // and the drain below deliberately swallows errors — so a malformed trailing\n // worksheet can leave the source mid-read while `handle.close()` invalidates\n // the descriptor underneath it. exceljs registers no `error` listener on the\n // stream it pipes (the whole reason this function opens the file itself), so\n // that EBADF would arrive as an uncaught event: the exact failure mode the fd\n // was introduced to eliminate, re-entering through the back door.\n let source: ReturnType<typeof handle.createReadStream> | undefined\n try {\n const stats = await handle.stat()\n if (!stats.isFile()) {\n throw new XlsxNotAFileError(filePath)\n }\n\n source = handle.createReadStream({ autoClose: false })\n holdPrematureZipEnd(source)\n const reader = new ExcelJS.stream.xlsx.WorkbookReader(source, {\n worksheets: 'emit',\n sharedStrings: 'cache',\n styles: 'ignore',\n hyperlinks: 'ignore',\n entries: 'ignore',\n })\n\n // Seed `model` so exceljs cannot dereference it before it has parsed\n // `xl/workbook.xml` — an UPSTREAM defect, and the cause of the xlsx-reader\n // \"flake\" that failed CI intermittently for months (#619, #835).\n //\n // Its streaming reader parses a worksheet INLINE, rather than deferring it to\n // a temp file, when `this.sharedStrings && this.workbookRels` are both set as\n // the entry arrives (`workbook-reader.js:109`). Neither implies\n // `xl/workbook.xml` has been read. The inline path then does\n // `matchingRel && (this.model.sheets || []).find(…)` (`:303`) — guarding\n // `this.workbookRels` on the line above and `this.model.sheets` not at all —\n // so any workbook whose zip orders `sharedStrings.xml` before the worksheet,\n // with `workbook.xml` after it, throws `Cannot read properties of undefined\n // (reading 'sheets')`.\n //\n // That is a PRODUCTION hazard, not a test artifact: this function reads\n // operator-uploaded spreadsheets and the writer chooses the entry order. It\n // presented as flakiness only because the fixtures our own tests write happen\n // to sit on the safe side of the condition.\n //\n // An empty `sheets` makes the lookup miss, so the worksheet keeps its default\n // id/name/state until `_parseWorkbook` overwrites `model` with the real one a\n // few entries later — which is exactly what the deferred path already does.\n // Preferred over a `pnpm patch` (exceljs 4.4.0 is the latest release, so there\n // is no upstream fix to take, but a patch is a standing maintenance cost and\n // this is one assignment that stays correct even if upstream fixes it).\n //\n // `sheets: []` rather than `{}` because `(this.model.sheets || [])` is what is\n // read.\n //\n // The double assertion is NOT laziness and a single `as typeof reader.model`\n // does not compile — checked, not assumed:\n //\n // TS2352: Conversion of type '{ sheets: never[]; }' to type 'WorkbookModel'\n // may be a mistake because neither type sufficiently overlaps with the\n // other. … missing the following properties: creator, lastModifiedBy,\n // lastPrinted, created, and 17 more.\n //\n // A deliberately PARTIAL seed is the whole point — exceljs overwrites it with\n // the real model a few entries later, and filling 21 fields with invented\n // values to satisfy the compiler would be worse than the assertion: it would\n // put fabricated workbook metadata one `_parseWorkbook` failure away from\n // being read as real.\n // toolkit-smell-allow: partial seed for a 21-field type; single assertion rejected by TS2352 (see above)\n reader.model = { sheets: [] } as unknown as typeof reader.model\n\n let dataRowNumber = 0\n let sheetIndex = 0\n let matchedTarget = false\n\n // Drive the worksheet iterator BY HAND rather than with `for await`, so the\n // `finally` below can resume it. Abandoning exceljs's `parse()` generator —\n // which `return`/`break` out of a `for await` does — LEAKS a temp file and an\n // fd per deferred worksheet, because `tempFileCleanupCallback()` sits after\n // the `yield*` with no `finally` of its own (`workbook-reader.js:140-149`).\n //\n // Measured, with a negative control: 25 early-returning reads of a two-sheet\n // deferred-order workbook leaked 50 fds and 50 temp files; the draining shape\n // below leaks 0 of each. `tmp`'s graceful-cleanup hook reclaims the files at\n // NORMAL process exit only, which is no help to a long-lived queue worker and\n // no help at all against the fd ceiling: at the #842 audit's 5000-run cap\n // this is an EMFILE, and an EMFILE arrives as the uncaught stream error the\n // `open()` above exists to prevent.\n //\n // THE COST IS PROPORTIONAL TO THE WORKSHEETS AFTER THE TARGET, which is zero\n // for the single-sheet feeds this reader mostly sees and 4.7× on a workbook\n // whose target is sheet 1 and whose sheet 2 holds 40k rows. Advancing the\n // parent iterator WITHOUT consuming each trailing worksheet is far cheaper\n // and does release the temp files — but it strands exceljs's\n // `createReadStream` on each one, i.e. it trades the disk leak for the fd\n // leak, and the fd leak is the one that ends in EMFILE. So: full drain.\n const worksheets = reader[Symbol.asyncIterator]()\n let readerExhausted = false\n try {\n while (true) {\n const next = await worksheets.next()\n if (next.done) {\n readerExhausted = true\n break\n }\n const worksheet = next.value\n sheetIndex += 1\n const isTarget =\n targetSheetIndex === undefined ? sheetIndex === 1 : sheetIndex === targetSheetIndex\n\n if (!isTarget) {\n // Drain a non-target worksheet so the underlying zip stream advances to the\n // next; exceljs's streaming reader stalls if a worksheet is left unconsumed.\n for await (const _skip of worksheet) {\n void _skip\n }\n continue\n }\n matchedTarget = true\n\n let excelRowNum = 0\n let header: string[] | null = null\n for await (const row of worksheet) {\n excelRowNum += 1\n const cells = rowCells(row.values)\n\n if (hasHeader && header === null) {\n // Skip rows before the configured header row, then capture it (trimmed).\n if (excelRowNum < headerRowIndex + 1) continue\n header = cells.map((c) => c.trim())\n // FAIL LOUDLY on an empty header, because the alternative is SILENT DATA\n // CORRUPTION and we have watched it happen.\n //\n // exceljs assigns `this.sharedStrings = []` at the TOP of\n // `_parseSharedStrings`, before parsing a single entry — and an empty\n // array is truthy, which is what the inline-worksheet guard tests. So a\n // shared-strings entry that yields nothing (a truncated or starved read;\n // observed in CI under load) leaves every string cell resolving to\n // `sharedStrings[index]` → `undefined`. The header parses to zero\n // columns, every data row maps to `{}`, and the import reports SUCCESS\n // with every value dropped. Reproduced deterministically by rebuilding a\n // workbook with an empty `sst`: two rows read, no values, no error.\n //\n // A header row with no usable column is never a legitimate import, so\n // there is no case to preserve. Refusing costs an operator a clear error\n // and saves them a silently empty dataset they would have to notice\n // themselves — the same posture the rest of this package takes toward\n // config mistakes.\n if (header.every((c) => c === '')) {\n throw new XlsxEmptyHeaderError(excelRowNum)\n }\n assertUniqueHeaders(header)\n continue\n }\n\n dataRowNumber += 1\n const rec: Record<string, string> = {}\n if (header) {\n for (let i = 0; i < header.length; i += 1) {\n const key = header[i]\n if (key) rec[key] = cells[i] ?? ''\n }\n } else {\n for (let i = 0; i < cells.length; i += 1) rec[String(i)] = cells[i] ?? ''\n }\n yield { rowNumber: dataRowNumber, row: rec }\n }\n\n // Target worksheet fully read. Stop yielding — the `finally` walks the\n // remaining worksheets so exceljs reaches its cleanup.\n break\n }\n } finally {\n // Runs on every exit: target read, an empty-header throw, or the CONSUMER\n // abandoning us mid-stream (a `break` in the caller's `for await` calls\n // `.return()`, which lands here). Whatever the reason, exceljs still owns\n // temp files it only releases as the consumer pulls past each worksheet.\n //\n // The whole body is CONTAINED, for the same reason `handle.close()` below\n // is: a throw from a `finally` REPLACES whatever the try block was\n // propagating. Trailing worksheets are parsed here, so a malformed one\n // would overwrite an `XlsxEmptyHeaderError` with an XML error — turning\n // the #842 audit's `corrupt` verdict into a meaningless `error` and\n // failing an import whose target sheet read perfectly. Measured: two of\n // four malformed-trailing-sheet variants masked the refusal before this\n // catch existed. Cleanup is best-effort by definition; the error the\n // caller came for is not.\n try {\n while (!readerExhausted) {\n const rest = await worksheets.next()\n if (rest.done) break\n for await (const _skip of rest.value) {\n void _skip\n }\n }\n } catch {\n // Deliberately swallowed — see above. A trailing worksheet we only read\n // to satisfy exceljs's cleanup contract cannot be allowed to speak for\n // the read the caller actually asked for. Worst case we leak the temp\n // files this drain existed to release, which is what `main` did on\n // EVERY read.\n }\n }\n\n // Reached only when a specific sheet was requested but never matched — the\n // first-sheet default always matches on a non-empty workbook.\n if (targetSheetIndex !== undefined && !matchedTarget) {\n throw new Error(`xlsx worksheet ${targetSheetIndex} not found`)\n }\n } finally {\n // Order matters: destroy the reader of the fd before closing the fd.\n source?.destroy()\n await handle.close().catch(() => {\n // Swallowed deliberately, and biome's `noUnsafeFinally` agrees: a throw\n // here would REPLACE whatever the try block was propagating, and what it\n // propagates is the point of this reader — an empty-header refusal has to\n // reach the caller intact. Closing a read-only fd we are finished with can\n // fail only in ways that are not actionable, and the fd is reclaimed at\n // process exit regardless.\n })\n }\n}\n"],"mappings":"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"}
|