@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":"engine-B0ExE4jL.mjs","names":[],"sources":["../src/mapping/column-transforms.ts","../src/mapping/engine.ts"],"sourcesContent":["/**\n * Per-column transforms for the mapping engine (commerce-demo phase 02, D005).\n *\n * A **bounded, enum-keyed** library of cleaning operations applied to a source\n * cell before it lands on a target field. This is the security model from R02:\n * transforms are declarative operations with Zod-validated params, NEVER\n * user-supplied executable code. The carmaker feed's logic decomposes exactly\n * into `stripPrefix` + `europeanNumber` (+ the schema's code-normalize).\n *\n * Number-producing ops (`number`, `europeanNumber`) are TERMINAL by convention —\n * they convert the string cell into `number | null`; string ops after them are\n * no-ops. The engine reads the result as `string | number | null` and hands it to\n * the target schema's `buildRow`.\n */\n\nimport { z } from 'zod'\nimport { type NumberFormat, parseEuropeanNumber, parseNumber } from './number-format.js'\n\n/** Max length for a strip-affix value — bounds the param, no ReDoS surface. */\nconst MAX_AFFIX_LEN = 32\n\n/**\n * Discriminated union of bounded transform ops. The TS type is INFERRED from\n * this schema so there's a single source of truth + the snapshot is validated at\n * the engine boundary.\n */\nexport const columnTransformSchema = z.discriminatedUnion('op', [\n z.object({ op: z.literal('trim') }),\n z.object({ op: z.literal('uppercase') }),\n z.object({ op: z.literal('lowercase') }),\n z.object({ op: z.literal('stripPrefix'), value: z.string().min(1).max(MAX_AFFIX_LEN) }),\n z.object({ op: z.literal('stripSuffix'), value: z.string().min(1).max(MAX_AFFIX_LEN) }),\n z.object({ op: z.literal('defaultIfEmpty'), value: z.string().max(255) }),\n z.object({ op: z.literal('number') }),\n z.object({ op: z.literal('europeanNumber') }),\n])\n\n/** One declarative cleaning operation. */\nexport type ColumnTransform = z.infer<typeof columnTransformSchema>\n\n/** The discriminator literal of a {@link ColumnTransform}. */\nexport type ColumnTransformOp = ColumnTransform['op']\n\n/** UI descriptor for one transform op: its key, a label, and whether it takes a `value` param. */\nexport interface ColumnTransformOpDescriptor {\n op: ColumnTransformOp\n label: string\n /** `true` → the op carries a `value` string param (`stripPrefix`/`stripSuffix`/`defaultIfEmpty`). */\n hasValue: boolean\n}\n\n/**\n * Static catalogue of the available per-column transform ops, for the PR3 wizard's\n * transform picker. The ORDER is the suggested UI order; `number`/`europeanNumber`\n * are terminal (see {@link applyColumnTransforms}) and labelled accordingly.\n */\nexport const COLUMN_TRANSFORM_OPS: readonly ColumnTransformOpDescriptor[] = [\n { op: 'trim', label: 'Trim whitespace', hasValue: false },\n { op: 'uppercase', label: 'Uppercase', hasValue: false },\n { op: 'lowercase', label: 'Lowercase', hasValue: false },\n { op: 'stripPrefix', label: 'Strip prefix', hasValue: true },\n { op: 'stripSuffix', label: 'Strip suffix', hasValue: true },\n { op: 'defaultIfEmpty', label: 'Default if empty', hasValue: true },\n { op: 'number', label: 'Parse number (uses number format)', hasValue: false },\n { op: 'europeanNumber', label: 'Parse European number (1.234,56)', hasValue: false },\n] as const\n\n/** The value a column carries as it moves through the transform pipeline. */\nexport type CellValue = string | number | null\n\n/**\n * Apply a transform pipeline to a raw cell, left-to-right. String ops act on the\n * string value; the `number` / `europeanNumber` ops are terminal (convert to\n * `number | null`, with `NaN` for a malformed numeric — the schema's `buildRow`\n * surfaces that as a row error). Once the value is non-string, later string ops\n * pass through unchanged.\n */\nexport function applyColumnTransforms(\n raw: string,\n transforms: readonly ColumnTransform[],\n format: NumberFormat,\n): CellValue {\n let value: CellValue = raw\n for (const t of transforms) {\n if (typeof value !== 'string') {\n // A prior number op already produced number|null — string ops are no-ops\n // past this point (number ops are terminal by convention).\n continue\n }\n switch (t.op) {\n case 'trim':\n value = value.trim()\n break\n case 'uppercase':\n value = value.toUpperCase()\n break\n case 'lowercase':\n value = value.toLowerCase()\n break\n case 'stripPrefix':\n value = value.startsWith(t.value) ? value.slice(t.value.length) : value\n break\n case 'stripSuffix':\n value = value.endsWith(t.value) ? value.slice(0, value.length - t.value.length) : value\n break\n case 'defaultIfEmpty':\n value = value.trim() === '' ? t.value : value\n break\n case 'number':\n value = parseNumber(value, format)\n break\n case 'europeanNumber':\n value = parseEuropeanNumber(value)\n break\n }\n }\n return value\n}\n","/**\n * Mapping engine (commerce-demo phase 02, D005).\n *\n * Turns a declarative `MappingConfig` (which source column → which whitelisted\n * target field, plus per-column transforms + a number format) into a\n * `RowTransform` the existing runner drives. The pipeline per row is:\n *\n * raw cells → applyColumnTransforms(per column) → MappedRow → schema.buildRow\n *\n * Two whitelists guard the boundary (R02 injection model): the `target_schema`\n * name resolves to a registered `TargetSchema`, and every `column_map[].target`\n * must be one of that schema's fields — enforced here at build time (throws),\n * before any row is processed.\n *\n * `resolveMapping` implements the D002 most-specific ladder used at enqueue time\n * to pick the `(supplier, brand)` mapping over the `(supplier, NULL)` default.\n */\n\nimport { z } from 'zod'\nimport type { RowTransform } from '../transform.js'\nimport { applyColumnTransforms, columnTransformSchema } from './column-transforms.js'\nimport type { MappedRow, TargetSchema } from './target-schema.js'\n\n/** One column-map entry. `target` is checked against the schema whitelist separately. */\nexport const mappingColumnSchema = z.object({\n /** Source header name (or positional index string for headerless feeds). */\n source: z.string().min(1).max(256),\n /** Target field key — must be in the chosen schema's whitelist (enforced in `buildMappingTransform`). */\n target: z.string().min(1).max(64),\n /** Ordered per-column cleaning ops. Defaults to none. */\n transforms: z.array(columnTransformSchema).default([]),\n})\nexport type MappingColumn = z.infer<typeof mappingColumnSchema>\n\n/**\n * Decimal + thousands separators for the `number` transform.\n *\n * The two MUST differ: `parseNumber` strips every thousands separator FIRST, so a\n * config where `decimal === thousands` (e.g. both `,`) would strip the decimal\n * separator too — `326,88` → `32688`, a silent 100× error rather than a row error.\n * Rejected at save (`assertValidMapping`) AND at run (`mappingConfigSchema`).\n */\nexport const numberFormatSchema = z\n .object({\n decimal: z.enum(['.', ',']),\n thousands: z.enum(['', '.', ',', ' ']),\n })\n .refine((f) => f.decimal !== f.thousands, {\n message: 'decimal and thousands separators must differ',\n path: ['thousands'],\n })\n\n/**\n * The portable mapping config — the subset of an `import_mapping` row the engine\n * needs. The enqueuer SNAPSHOTS this into `import_run.params.mapping` so a run\n * records the exact mapping it used (reproducible) and the transform stays a pure\n * function of `(row, ctx)` with no per-row DB I/O.\n */\nexport const mappingConfigSchema = z.object({\n targetSchema: z.string().min(1).max(64),\n numberFormat: numberFormatSchema,\n columnMap: z.array(mappingColumnSchema),\n})\nexport type MappingConfig = z.infer<typeof mappingConfigSchema>\n\n/**\n * Build a `RowTransform` from a validated mapping config + its target schema.\n *\n * Throws at BUILD time if any `column_map` target is outside the schema's\n * whitelist (deny-by-default, R02) — so a misconfigured mapping fails loudly\n * before a single row is written, rather than silently dropping a column.\n *\n * Generic over the schema's output type, so passing the concrete `partsSchema`\n * yields a `RowTransform<PartsDocument>` (the carmaker path keeps its typing); the\n * registry-resolved `TargetSchema<unknown>` yields `RowTransform<unknown>` (the\n * `commerce:mapping` path, where the output type isn't known until runtime).\n */\nexport function buildMappingTransform<TOut>(\n config: MappingConfig,\n schema: TargetSchema<TOut>,\n): RowTransform<TOut> {\n const allowed = new Set(schema.fields.map((f) => f.key))\n for (const col of config.columnMap) {\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 }\n\n const format = config.numberFormat\n const columnMap = config.columnMap\n\n return async (row, ctx) => {\n const mapped: MappedRow = {}\n for (const col of columnMap) {\n // `source` is read as data from the parsed row map — never used as a key\n // into framework objects (R02). A missing source column yields `''`.\n const raw = row[col.source] ?? ''\n mapped[col.target] = applyColumnTransforms(raw, col.transforms, format)\n }\n return schema.buildRow(mapped, {\n importRunId: ctx.importRunId,\n rowNumber: ctx.rowNumber,\n params: ctx.params,\n })\n }\n}\n\n/** The minimal shape `resolveMapping` needs — satisfied by an `import_mapping` row or a test fixture. */\nexport interface ResolvableMapping {\n /** `null` = supplier default (applies to all brands). */\n brandId: string | null\n active: boolean\n}\n\n/**\n * Most-specific resolution (D002 ladder): given the candidate mappings for one\n * `(supplier, target_schema)` and the requested brand, return the brand-specific\n * mapping if one exists, else the supplier-default (`brandId === null`). Inactive\n * mappings are skipped. Returns `undefined` when nothing matches.\n */\nexport function resolveMapping<M extends ResolvableMapping>(\n mappings: readonly M[],\n brandId: string | null,\n): M | undefined {\n const active = mappings.filter((m) => m.active)\n if (brandId !== null) {\n const specific = active.find((m) => m.brandId === brandId)\n if (specific) return specific\n }\n return active.find((m) => m.brandId === null)\n}\n"],"mappings":"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"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"engine-tkbUXKxB.d.mts","names":[],"sources":["../src/mapping/number-format.ts","../src/mapping/column-transforms.ts","../src/mapping/target-schema.ts","../src/mapping/engine.ts"],"mappings":";;;;;;;;AAqBA;;;;AAIW;AAsBX;;;;;;;;AAA6D;AA+B7D;;UAzDiB,YAAA;EAyDyB;EAvDxC,OAAA;EAiFc;EA/Ed,SAAS;AAAA;;AA+EkC;;;;AC9E7C;;;;;;;;;;;iBDqBgB,WAAA,CAAY,GAAA,UAAa,MAAA,EAAQ,YAAY;;;;;;;;iBA+B7C,YAAA,CAAa,KAAa;;;;;;;;;;;;;;;;;;;;;;;iBA0B1B,mBAAA,CAAoB,CAAS;;;;AAzDgB;AA+B7D;;;cCpDa,qBAAA,EAAqB,CAAA,CAAA,qBAAA,QAAA,CAAA,CAAA,SAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;KAYtB,eAAA,GAAkB,CAAA,CAAE,KAAK,QAAQ,qBAAA;;KAGjC,iBAAA,GAAoB,eAAe;;UAG9B,2BAAA;EACf,EAAA,EAAI,iBAAiB;EACrB,KAAA;;EAEA,QAAA;AAAA;;;;;;cAQW,oBAAA,WAA+B,2BAA2B;;KAY3D,SAAA;;;;;;;;iBASI,qBAAA,CACd,GAAA,UACA,UAAA,WAAqB,eAAA,IACrB,MAAA,EAAQ,YAAA,GACP,SAAA;;;;UC/Dc,WAAA;EF6B4C;EE3B3D,GAAA;EF0D0B;EExD1B,KAAA;EFwDwC;EEtDxC,QAAA;EFgFc;EE9Ed,IAAA;;;AF8E2C;;;;AC9E7C;;;;;;;;;ECgBE,OAAA;AAAA;;KAIU,SAAA,GAAY,MAAM,SAAS,SAAA;;UAGtB,eAAA;EACf,WAAA;EACA,SAAA;;;;;EAKA,MAAA,EAAQ,MAAM;AAAA;;UAIC,YAAA;;EAEf,IAAA;;EAEA,MAAA,WAAiB,WAAA;;EAEjB,QAAA,CAAS,MAAA,EAAQ,SAAA,EAAW,GAAA,EAAK,eAAA,GAAkB,SAAA,CAAU,IAAA;AAAA;;;;;;iBAY/C,UAAA,CAAW,CAAwB,EAArB,SAAS;;KAO3B,aAAA;EACN,IAAA;EAAe,KAAA;AAAA;EACf,IAAA;AAAA;EACA,IAAA;AAAA;;;;;;iBAOU,UAAA,CAAW,CAAA,EAAG,SAAA,eAAwB,aAAa;;iBASnD,QAAA,CAAS,SAAA,UAAmB,OAAA,UAAiB,KAAA;;;;;;;;;;;cChFhD,mBAAA,EAAmB,CAAA,CAAA,SAAA;EHsDU,iGA0BP;uBAAU;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;KGxEjC,aAAA,GAAgB,CAAA,CAAE,KAAK,QAAQ,mBAAA;;;;;;;;;cAU9B,kBAAA,EAAkB,CAAA,CAAA,UAAA,CAAA,CAAA,CAAA,SAAA;;;;;;;;;;;;;;;;;;;;;;cAgBlB,mBAAA,EAAmB,CAAA,CAAA,SAAA;;;;;;;;;;;;;;;;;;;IFFuC,iGAY3D;yBAAS;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;KELT,aAAA,GAAgB,CAAA,CAAE,KAAK,QAAQ,mBAAA;;;;;;;;;;;;;iBAc3B,qBAAA,OACd,MAAA,EAAQ,aAAA,EACR,MAAA,EAAQ,YAAA,CAAa,IAAA,IACpB,YAAA,CAAa,IAAA;;UA+BC,iBAAA;;EAEf,OAAA;EACA,MAAM;AAAA;;;;;;;iBASQ,cAAA,WAAyB,iBAAA,EACvC,QAAA,WAAmB,CAAA,IACnB,OAAA,kBACC,CAAA"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"index-Bm69N5DW.d.mts","names":[],"sources":["../src/matching/auto-assign.ts","../src/matching/header-snapshot.ts"],"mappings":";;AAqCA;;;;;;;;AAGS;AAiCT;;;;AAA6C;AAkC7C;;;;;;;;;AAGM;;;;ACtGN;;;;;;;;UD6BiB,eAAA;EACf,GAAA;EACA,KAAA;EACA,OAAA;AAAA;;ACd+B;AACjC;;;;AAAsC;AACtC;;;;AAA8B;AAC9B;iBD4CgB,cAAA,CAAe,MAAc;;;AC5CT;AACpC;;iBD6EgB,cAAA,CACd,OAAA,qBACA,MAAA,WAAiB,eAAA,KAChB,GAAG;;;;AAzEN;;;;;;;UC7BiB,YAAA;EDgCR;EC9BP,OAAA;ED+D4B;EC7D5B,IAAA,EAAM,KAAK,CAAC,MAAA;AAAA;AAAA,cAcD,oBAAA;AAAA,cACA,yBAAA;AAAA,cACA,iBAAA;AAAA,cACA,uBAAA;AAAA,cACA,6BAAA;;;;;;ADgFP;;;;ACtGN;iBAkCgB,gBAAA,CAAiB,OAAA,EAAS,YAAA,GAAe,YAAY"}
|
package/dist/index.d.mts.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.mts","names":[],"sources":["../src/admin/audit-empty-header.ts","../src/admin/cleanup.ts","../src/entities/import-mapping.ts","../src/entities/import-mapping-validation.ts","../src/preview-feed.ts"],"mappings":"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"}
|
package/dist/index.mjs.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"index.mjs","names":[],"sources":["../src/admin/audit-empty-header.ts","../src/preview-feed.ts"],"sourcesContent":["/**\n * Retroactive audit for the silent empty-header corruption fixed in #839\n * (issue #842).\n *\n * **What went wrong.** exceljs assigns `this.sharedStrings = []` at the top of\n * `_parseSharedStrings`, before parsing a single entry — and an empty array is\n * truthy, which is what its inline-worksheet guard tests. A shared-strings\n * entry that yields nothing therefore leaves every string cell resolving to\n * `undefined`: the header parses to zero columns, every data row maps to `{}`,\n * and the import reports SUCCESS with every value dropped. #839 made\n * `streamXlsx` refuse that file. It cannot see backwards, and a past occurrence\n * is indistinguishable from success in the `import_run` row alone — both\n * `status: 'succeeded'`, both no fatal error.\n *\n * **What this does.** Re-reads the retained source file of every past xlsx run\n * with the PATCHED reader and classifies each one. `import_run.filePath` is\n * `required: true`, so every run has a source to point at.\n *\n * **What a verdict is worth — read this before acting on a report.**\n *\n * - `corrupt` has no false positives worth worrying about: the guard fires on\n * precisely the condition that produced the empty parse, so a file that\n * throws today would have thrown then. There is no threshold to tune, unlike\n * inferring corruption from suspiciously-low row counts.\n * - `clean` is NOT proof the run was fine. The upstream defect has two causes:\n * a file that genuinely carries an empty/unparseable shared-strings part\n * (deterministic — caught here), and a truncated or starved read at import\n * time (transient — the same file re-reads perfectly today). The file re-read\n * is blind to the second. That is why every audited run also carries\n * {@link AuditedImportRun.zeroRowsIndexed}, which is derived from the\n * recorded totals and needs no file at all: a run that finished `succeeded`\n * having indexed zero rows is the observable signature of the corruption\n * whichever cause produced it. It is corroborating, not conclusive — an\n * operator who uploaded a feed with the wrong column mapping gets the same\n * shape — so it is reported as a separate flag and never folded into the\n * verdict.\n * - `source_unavailable` is the honest answer for a run whose evidence is gone,\n * and there will be some: `cleanupProcessedImports` deletes the source file\n * of any terminal run older than `processedRetentionDays` (plugin default 7),\n * on an hourly schedule. The retained-file window is therefore about a week\n * on a deployment running the queue worker with the default config — run this\n * audit sooner rather than later, and read `zeroRowsIndexed` for the runs\n * whose files have already been reaped.\n *\n * Read-only by construction: it reports, it never re-imports. Deciding what to\n * do about a corrupted run needs the operator.\n *\n * Bounded per CLAUDE.md's fan-out rule — it is `O(runs)` reads against storage,\n * so it caps both the item count AND the peak concurrency, and logs whatever it\n * drops rather than silently truncating. The per-run cost is one file: the\n * resolver downloads the object, and the reader parses to the header of the\n * target sheet plus whatever follows it. Neither cap substitutes for the other,\n * and neither substitutes for running the audit while the answer is still\n * there.\n */\n\nimport { DEFAULT_BOUNDED_CONCURRENCY, runBounded, type ToolkitApp } from '@murumets-ee/core'\nimport type { Logger } from '@murumets-ee/logging'\nimport { ImportRun, type ImportRunStatus } from '../entities/import-run.js'\nimport { type FeedReaderConfig, readPersistedFeedConfig } from '../read-feed.js'\nimport type { FilePathResolver } from '../worker.js'\n\n/**\n * Per-run outcome of the file re-read.\n *\n * - `corrupt` — the patched reader refuses this file's header. The run imported\n * empty rows and reported success.\n * - `clean` — the header re-reads with at least one column today. See the\n * false-negative caveat in the module docblock.\n * - `source_unavailable` — the source file could not be read at all (reaped by\n * the retention sweep, deleted, or storage refused). Undecidable from the\n * file; `zeroRowsIndexed` is the only signal left.\n * - `undetectable` — the run is outside what this detector can decide: a\n * `hasHeader: false` run has no header for the guard to test, a non-xlsx run\n * cannot have the defect, and a file that yields no data rows never reached\n * the guard at all.\n * - `error` — reading failed for some other reason; `detail` carries it.\n */\nexport const EMPTY_HEADER_VERDICTS = [\n 'corrupt',\n 'clean',\n 'source_unavailable',\n 'undetectable',\n 'error',\n] as const\nexport type EmptyHeaderVerdict = (typeof EMPTY_HEADER_VERDICTS)[number]\n\n/** Statuses audited when the caller doesn't name any. */\nconst DEFAULT_STATUSES: readonly ImportRunStatus[] = ['succeeded']\n\n/** Default cap on runs examined per invocation. */\nconst DEFAULT_LIMIT = 200\n\n/** Hard ceiling on the per-invocation cap. Protects storage from a fat-fingered `--limit`. */\nconst MAX_LIMIT = 5000\n\n/** Hard ceiling on peak concurrent reads. Bounds the burst the item cap alone does not. */\nconst MAX_CONCURRENCY = 16\n\nexport interface InspectImportRunSourceArgs {\n /** Local path OR storage key, verbatim from `import_run.filePath`. */\n filePath: string\n /** The run's persisted reader config (`import_run.params.feed`). */\n feed: FeedReaderConfig\n /**\n * Resolves `filePath` to a readable local path, exactly as the worker does.\n * Omit when `filePath` is already a local FS path (the on-disk deployments\n * the worker's no-resolver fallback covers).\n */\n resolveFilePath?: FilePathResolver | undefined\n}\n\nexport interface InspectImportRunSourceResult {\n verdict: EmptyHeaderVerdict\n /** Human-readable reason. Always set for everything except `clean`. */\n detail?: string\n}\n\n/** Best-effort message for an unknown thrown value. */\nfunction describeError(err: unknown): string {\n if (err instanceof Error) return err.message\n return String(err)\n}\n\n/**\n * Filesystem / object-store errors that mean \"the evidence is not readable\",\n * as distinct from \"the file is readable and its header is bad\".\n *\n * ENOENT is the retention sweep having got there first — the expected outcome\n * for anything older than `processedRetentionDays`. The rest are real\n * deployment conditions rather than theory: the imports directory has had uid\n * drift before (#661's `imports-init` chown), and this audit is run from an\n * operator shell, not under the worker's uid — so EACCES against a file the\n * worker wrote is exactly the shape to expect. All of them are \"cannot decide\",\n * never \"corrupt\".\n */\nconst UNREADABLE_SOURCE_CODES = new Set(['ENOENT', 'EACCES', 'EPERM', 'EISDIR', 'ENOTDIR'])\n\n/**\n * unzipper's error for a zip that ends mid-entry. A half-written retained\n * upload is evidence we cannot read, not a verdict about the run — checked:\n * truncating a workbook at 95% / 60% / 20% all reject cleanly with this rather\n * than crashing or hanging.\n */\nconst TRUNCATED_ARCHIVE_MESSAGE = /FILE_ENDED/\n\nfunction isUnreadableSource(err: unknown, notAFile: new (...args: never[]) => Error): boolean {\n if (typeof err !== 'object' || err === null) return false\n // A directory (or socket, or fifo) where a workbook should be. Matched by\n // TYPE — the reader exports the class precisely so this is not a regex.\n if (err instanceof notAFile) return true\n if ('code' in err) {\n const { code } = err as { code?: unknown }\n if (typeof code === 'string' && UNREADABLE_SOURCE_CODES.has(code)) return true\n }\n return err instanceof Error && TRUNCATED_ARCHIVE_MESSAGE.test(err.message)\n}\n\n/**\n * Re-read ONE run's source file with the patched reader and classify it.\n *\n * Stops pulling rows once the header has been decided — the guard fires while\n * the header is captured, before any data row is yielded, so the first row\n * settles it.\n *\n * That bounds the rows parsed OF THE TARGET SHEET, and nothing else. The\n * resolver has already downloaded the whole object, and `streamXlsx` parses the\n * worksheets after the target on its way out — it has to, or exceljs strands a\n * temp file and an fd per deferred worksheet. So the per-run cost is one file,\n * plus its trailing sheets; what bounds the SWEEP is the item cap and the\n * concurrency cap in `auditEmptyHeaderRuns`.\n */\nexport async function inspectImportRunSource(\n args: InspectImportRunSourceArgs,\n): Promise<InspectImportRunSourceResult> {\n const { filePath, feed, resolveFilePath } = args\n\n // The defect is exceljs-specific: csv/tsv go through csv-parse, which has no\n // shared-strings table to lose. A run with no persisted `format` defaulted to\n // tab-delimited in the worker, so absence is a real answer, not a gap.\n if (feed.format !== 'xlsx') {\n return {\n verdict: 'undetectable',\n detail: `not an xlsx run (format: ${feed.format ?? 'tsv (default)'}) — the defect is exceljs-specific`,\n }\n }\n // With `hasHeader: false` the reader keys rows positionally and never captures\n // a header, so the guard has nothing to fire on. Say so rather than returning\n // a `clean` that means \"not checked\".\n if (feed.hasHeader === false) {\n return {\n verdict: 'undetectable',\n detail: 'run used hasHeader:false — no header row for the guard to test',\n }\n }\n\n // Dynamic import, matching `read-feed.ts`: exceljs is heavy and Node-only,\n // and this module is reachable from the package's main entry. Bound BEFORE\n // the resolver runs, so a module-resolution failure cannot strand a tmpfile\n // the resolver just downloaded — and outside the try below, because `catch`\n // cannot see a `const` block-scoped to the `try`.\n const { streamXlsx, XlsxEmptyHeaderError, XlsxNotAFileError } = await import('../xlsx-reader.js')\n\n let resolved: { localPath: string; cleanup?: (() => Promise<void>) | undefined }\n try {\n resolved = resolveFilePath ? await resolveFilePath(filePath) : { localPath: filePath }\n } catch (err) {\n // A resolver throw is a storage miss (reaped / deleted / permissions) or a\n // transport failure. `detail` carries which — a 404 and a connection reset\n // read very differently to an operator.\n return { verdict: 'source_unavailable', detail: describeError(err) }\n }\n\n try {\n let sawRow = false\n for await (const _first of streamXlsx({\n filePath: resolved.localPath,\n ...(feed.headerRowIndex !== undefined && { headerRowIndex: feed.headerRowIndex }),\n ...(feed.sheet !== undefined && { sheet: feed.sheet }),\n })) {\n void _first\n sawRow = true\n break\n }\n if (!sawRow) {\n // The reader completed without yielding, which means the guard never ran:\n // an empty worksheet, or a `headerRowIndex` past the last row. Reporting\n // `clean` here would be the comforting-but-false signal this whole audit\n // exists to avoid — nothing was verified.\n return {\n verdict: 'undetectable',\n detail: 'file yielded no data rows — the header guard never ran',\n }\n }\n return { verdict: 'clean' }\n } catch (err) {\n if (err instanceof XlsxEmptyHeaderError) {\n return { verdict: 'corrupt', detail: err.message }\n }\n if (isUnreadableSource(err, XlsxNotAFileError)) {\n return { verdict: 'source_unavailable', detail: describeError(err) }\n }\n return { verdict: 'error', detail: describeError(err) }\n } finally {\n await resolved.cleanup?.().catch(() => {\n // Best-effort tmpfile removal, same posture as the worker: a failed\n // cleanup must not turn a read-only audit into a failure. The path is a\n // /tmp copy the OS reclaims anyway.\n })\n }\n}\n\n/**\n * The counters this audit reads out of `import_run.totals`.\n *\n * A LOSSY view, deliberately: `rowsWarned` / `batchesCompleted` /\n * `patchesApplied` / `patchesOrphaned` are dropped rather than passed through,\n * because a report is easier to read for what it needs than for everything the\n * run recorded. Read the `import_run` row itself if you want the rest.\n */\nexport interface RecordedTotals {\n rowsRead?: number | undefined\n rowsSucceeded?: number | undefined\n rowsFailed?: number | undefined\n rowsSkipped?: number | undefined\n}\n\nfunction readCount(source: Record<string, unknown>, key: string): number | undefined {\n const value = source[key]\n return typeof value === 'number' && Number.isFinite(value) ? value : undefined\n}\n\n/** Narrow the opaque `totals` JSONB to the counters this audit reports. */\nexport function readRecordedTotals(raw: unknown): RecordedTotals {\n if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return {}\n const source = raw as Record<string, unknown>\n return {\n rowsRead: readCount(source, 'rowsRead'),\n rowsSucceeded: readCount(source, 'rowsSucceeded'),\n rowsFailed: readCount(source, 'rowsFailed'),\n rowsSkipped: readCount(source, 'rowsSkipped'),\n }\n}\n\nexport interface AuditedImportRun {\n id: string\n label: string\n /** Verbatim `import_run.filePath` — the storage key, so an operator can go look. */\n filePath: string\n transformName: string\n createdAt: Date | null\n finishedAt: Date | null\n /** Row counters the run recorded. A corrupted run that recorded a plausible count is the most misleading artefact of all. */\n totals: RecordedTotals\n verdict: EmptyHeaderVerdict\n detail?: string\n /**\n * The run finished having read rows but indexed none. Corroborating evidence\n * that survives the source file being reaped, and the ONLY signal available\n * for the transient variant of the defect — but it is also what a wrong\n * column mapping looks like, so it is never folded into the verdict.\n *\n * THREE-VALUED on purpose. `'unknown'` means the run recorded no usable\n * counters (older rows; any run audited with a non-`succeeded` status, since\n * `markFailed` never writes `totals`). Collapsing that to `false` would render\n * absence of evidence as evidence of absence — in the one signal that is\n * supposed to outlive the file.\n */\n zeroRowsIndexed: boolean | 'unknown'\n}\n\nexport interface EmptyHeaderAuditReport {\n /** Total xlsx runs matching the audited statuses. */\n candidates: number\n /** How many of them this invocation actually read. */\n examined: number\n /** `candidates - examined` — dropped by the cap, newest kept. Never silent: also logged. */\n notExamined: number\n counts: Record<EmptyHeaderVerdict, number>\n /** Every examined run, verdict-annotated, newest first. */\n runs: AuditedImportRun[]\n}\n\nexport interface AuditEmptyHeaderRunsArgs {\n /** Toolkit app — provides the app-bound `AdminClient` for `import_run`. */\n app: ToolkitApp\n /**\n * Same resolver the imports plugin hands the worker (typically\n * `storageResolveFilePath`). Omit only when `filePath` is a local FS path.\n */\n resolveFilePath?: FilePathResolver | undefined\n /** Statuses to audit. Default `['succeeded']` — a failed run announced itself. */\n statuses?: readonly ImportRunStatus[] | undefined\n /** Max runs examined, newest first. Default 200, hard ceiling 5000. */\n limit?: number | undefined\n /** Peak concurrent storage reads. Default 4, hard ceiling 16. */\n concurrency?: number | undefined\n logger: Pick<Logger, 'info' | 'warn'>\n}\n\nfunction emptyCounts(): Record<EmptyHeaderVerdict, number> {\n return { corrupt: 0, clean: 0, source_unavailable: 0, undetectable: 0, error: 0 }\n}\n\n/**\n * Derive the corroborating flag from whatever counters the run recorded.\n * `rowsRead` is what the reader yielded; `rowsSucceeded` is what the cluster\n * acknowledged. Read-but-none-indexed is the shape a `{}`-row stream leaves\n * behind however the transform rejected it (error OR skip), which is why the\n * test is on succeeded rather than failed.\n */\nfunction deriveZeroRowsIndexed(totals: RecordedTotals): boolean | 'unknown' {\n if (totals.rowsRead === undefined || totals.rowsSucceeded === undefined) return 'unknown'\n return totals.rowsRead > 0 && totals.rowsSucceeded === 0\n}\n\n/** Coerce a DTO date field (Date | string | null | undefined) to `Date | null`. */\nfunction toDate(value: unknown): Date | null {\n if (value instanceof Date) return value\n if (typeof value === 'string') {\n const parsed = new Date(value)\n return Number.isNaN(parsed.getTime()) ? null : parsed\n }\n return null\n}\n\n/**\n * Audit past import runs for the silent empty-header corruption.\n *\n * Newest-first, because the answerable window is the recent one — the retention\n * sweep reaps source files ~7 days after a run reaches terminal status by\n * default, so the oldest runs are the ones whose evidence is already gone.\n */\nexport async function auditEmptyHeaderRuns(\n args: AuditEmptyHeaderRunsArgs,\n): Promise<EmptyHeaderAuditReport> {\n const { app, resolveFilePath, logger } = args\n const statuses = args.statuses ?? DEFAULT_STATUSES\n if (statuses.length === 0) {\n throw new Error('auditEmptyHeaderRuns: statuses must name at least one import_run status')\n }\n const limit = args.limit ?? DEFAULT_LIMIT\n if (!Number.isInteger(limit) || limit < 1 || limit > MAX_LIMIT) {\n throw new Error(\n `auditEmptyHeaderRuns: limit must be an integer in [1, ${MAX_LIMIT}] (got ${limit})`,\n )\n }\n // Concurrency needs its own ceiling, not just `runBounded`'s\n // positive-integer check: `{ limit: 5000, concurrency: 5000 }` type-checks and\n // is a 5000-wide burst at object storage, which is the amplification the item\n // cap alone does not prevent.\n const concurrency = args.concurrency ?? DEFAULT_BOUNDED_CONCURRENCY\n if (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > MAX_CONCURRENCY) {\n throw new Error(\n `auditEmptyHeaderRuns: concurrency must be an integer in [1, ${MAX_CONCURRENCY}] (got ${concurrency})`,\n )\n }\n\n const client = app.getClient(ImportRun)\n\n const { and, desc, getTableColumns, inArray, sql } = await import('drizzle-orm')\n type AnyPgColumn = import('drizzle-orm/pg-core').AnyPgColumn\n\n // `getTableColumns` rather than dot-access: the entity table is a generic\n // PgTable, so direct access widens every column to `T | undefined` (same\n // shape the cleanup helpers use).\n const cols = getTableColumns(client.getTable()) as Record<string, AnyPgColumn>\n const statusCol = cols.status\n const paramsCol = cols.params\n const createdAtCol = cols.createdAt\n if (!statusCol || !paramsCol || !createdAtCol) {\n throw new Error(\n 'auditEmptyHeaderRuns: import_run schema missing expected columns (status/params/createdAt)',\n )\n }\n\n // Narrow to xlsx runs IN SQL rather than paging every run and filtering in\n // JS — otherwise the `limit` cap would be spent on tsv rows and a deployment\n // with a long carmaker history would examine nothing. `params` is jsonb, and\n // a JSONB operator inside Drizzle's typed builder is the sanctioned use of\n // the `sql` template (CLAUDE.md \"Database Queries\" rule 5): the column ref is\n // a typed Drizzle object and the compared value is bound as a parameter. The\n // key path mirrors the worker's `params.feed` rehydration exactly.\n const where = and(\n inArray(statusCol, [...statuses]),\n sql`${paramsCol}->'feed'->>'format' = ${'xlsx'}`,\n )\n\n const candidates = await client.count({ where })\n const rows = await client.findMany({ where, orderBy: desc(createdAtCol), limit })\n\n const notExamined = Math.max(0, candidates - rows.length)\n if (notExamined > 0) {\n logger.warn(\n { candidates, examined: rows.length, notExamined, limit },\n 'imports audit: capped at the most recent runs — re-run with a higher limit to cover the rest',\n )\n }\n if (rows.length === 0) {\n logger.info({ statuses }, 'imports audit: no xlsx runs to audit')\n return { candidates, examined: 0, notExamined, counts: emptyCounts(), runs: [] }\n }\n\n logger.info(\n { candidates, examining: rows.length, concurrency },\n 'imports audit: re-reading source files for the empty-header defect',\n )\n\n const audited = await runBounded(\n rows,\n async (row): Promise<AuditedImportRun> => {\n // `filePath` / `label` / `transformName` are `required: true`, so the\n // inferred DTO already types them as `string`. `totals` is `field.json()`.\n const filePath = row.filePath\n const totals = readRecordedTotals(row.totals)\n const base = {\n id: row.id,\n label: row.label,\n filePath,\n transformName: row.transformName,\n createdAt: toDate(row.createdAt),\n finishedAt: toDate(row.finishedAt),\n totals,\n zeroRowsIndexed: deriveZeroRowsIndexed(totals),\n }\n\n // EVERY per-item failure degrades THIS run to `error` and no further.\n // `runBounded` rejects on the first worker rejection, so an unhandled\n // throw here would discard all N results — and the report is the entire\n // product of this function.\n try {\n // Rehydrated through the SAME helper the worker uses, so the audit\n // cannot re-read a run with options that run never used.\n const feedParsed = readPersistedFeedConfig(row.params)\n if (!feedParsed.success) {\n const reason = feedParsed.error.issues[0]?.message ?? 'invalid feed reader config'\n return { ...base, verdict: 'error', detail: `invalid params.feed: ${reason}` }\n }\n\n const result = await inspectImportRunSource({\n filePath,\n feed: feedParsed.data,\n ...(resolveFilePath !== undefined && { resolveFilePath }),\n })\n return {\n ...base,\n verdict: result.verdict,\n ...(result.detail !== undefined && { detail: result.detail }),\n }\n } catch (err) {\n return { ...base, verdict: 'error', detail: describeError(err) }\n }\n },\n concurrency,\n )\n\n const counts = emptyCounts()\n for (const run of audited) {\n counts[run.verdict] += 1\n if (run.verdict === 'corrupt') {\n logger.warn(\n {\n importRunId: run.id,\n label: run.label,\n filePath: run.filePath,\n transformName: run.transformName,\n createdAt: run.createdAt,\n totals: run.totals,\n },\n 'imports audit: SILENTLY CORRUPTED run — its source file has no readable header',\n )\n }\n }\n\n const zeroRowsIndexed = audited.filter((run) => run.zeroRowsIndexed === true).length\n const totalsUnknown = audited.filter((run) => run.zeroRowsIndexed === 'unknown').length\n\n // A run of nothing but `source_unavailable` usually means the audit was\n // pointed at storage keys with no resolver, not that a week of evidence\n // evaporated. Those read identically in the report, so say which is likelier.\n if (resolveFilePath === undefined && counts.source_unavailable === audited.length) {\n logger.warn(\n { examined: audited.length },\n 'imports audit: every source was unreadable and no resolveFilePath was given — ' +\n 'if filePath holds storage keys rather than local paths, pass storageResolveFilePath',\n )\n }\n\n logger.info(\n {\n candidates,\n examined: audited.length,\n notExamined,\n ...counts,\n zeroRowsIndexed,\n totalsUnknown,\n },\n 'imports audit: complete',\n )\n\n return { candidates, examined: audited.length, notExamined, counts, runs: audited }\n}\n","/**\n * `previewFeed` — read the first N data rows of a feed for the PR3 import wizard\n * (commerce-demo phase 02 §4). Reuses {@link readFeed} so the preview is parsed by\n * the EXACT same reader (csv-parse / exceljs), with the same delimiter / header /\n * sheet config, that the run will use — the operator maps against what they'll get,\n * not a re-implemented best-guess parser (R02: drift between preview + run is a\n * mapping foot-gun).\n *\n * Bounded: it consumes only `maxRows` rows then stops iterating, so on a delimited\n * feed it streams a tiny prefix rather than the whole file. (The caller materialises\n * the storage object to a local path first — see the commerce preview route — so for\n * a very large upload the materialise cost dominates; that's a demo-depth trade-off,\n * noted on the route.)\n */\n\nimport { type ReadFeedOptions, readFeed } from './read-feed.js'\n\n/** Hard ceiling on preview rows regardless of caller request — bounds work + payload. */\nexport const MAX_PREVIEW_ROWS = 100\n/** Default sample size when the caller doesn't specify. */\nexport const DEFAULT_PREVIEW_ROWS = 20\n\nexport interface PreviewFeedOptions extends ReadFeedOptions {\n /** Rows to sample. Defaults to {@link DEFAULT_PREVIEW_ROWS}; clamped to [1, {@link MAX_PREVIEW_ROWS}]. */\n maxRows?: number\n}\n\nexport interface FeedPreview {\n /**\n * Source column names in first-seen order. With a header these are the (trimmed)\n * header names; headerless feeds yield positional `\"0\"`, `\"1\"`, … keys. The union\n * across sampled rows covers feeds whose first row omits trailing empty cells.\n */\n columns: string[]\n /** Up to `maxRows` sampled data rows, each a `{ column → cell }` map (cells are strings). */\n rows: Array<Record<string, string>>\n}\n\n/**\n * Read up to `maxRows` data rows from a feed file and return the detected columns +\n * sample rows. Throws whatever the underlying reader throws (bad sheet index,\n * duplicate header, unreadable file) — the caller maps that to a 4xx.\n */\nexport async function previewFeed(options: PreviewFeedOptions): Promise<FeedPreview> {\n const { maxRows, ...feedOptions } = options\n // Coerce to a finite integer BEFORE clamping: `previewFeed` is public API, so a\n // caller passing `NaN`/`Infinity`/fractional must not slip through. `NaN` in\n // particular would make `rows.length >= limit` always false → read the whole file.\n const requested = maxRows ?? DEFAULT_PREVIEW_ROWS\n const safeRows = Number.isFinite(requested) ? Math.floor(requested) : DEFAULT_PREVIEW_ROWS\n const limit = Math.min(MAX_PREVIEW_ROWS, Math.max(1, safeRows))\n\n const rows: Array<Record<string, string>> = []\n // First-seen-order column set: a Map preserves insertion order and dedupes.\n const columns = new Map<string, true>()\n\n // Preview is EXPLORATORY — force lenient column counts (csv/tsv). With the wrong\n // delimiter a feed parses as a single odd column, OR a row's field count differs\n // from the header; strict parsing would abort the whole preview with a cryptic\n // \"Invalid Record Length\" instead of letting the operator SEE the bad parse and\n // fix the delimiter/format, then re-preview. The actual run keeps its own\n // (stricter) settings.\n for await (const { row } of readFeed({ ...feedOptions, relaxColumnCount: true })) {\n for (const key of Object.keys(row)) columns.set(key, true)\n rows.push(row)\n if (rows.length >= limit) break\n }\n\n return { columns: [...columns.keys()], rows }\n}\n"],"mappings":"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"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.mts","names":[],"sources":["../../src/mapping/discount-table-schema.ts","../../src/mapping/mapping-transform.ts","../../src/mapping/schema-registry.ts"],"mappings":";;;;;;UAqBiB,gBAAA;EACf,aAAA;EACA,WAAW;AAAA;AAAA,cAQA,mBAAA,EAAqB,YAAY,CAAC,gBAAA;;;;cCClC,sBAAA;;;;ACPb;;cDmCa,gBAAA,EAAkB,YAAY;;;;iBCnC3B,eAAA,CAAgB,IAAA,WAAe,YAAY;AFM3D;AAAA,iBEDgB,iBAAA;;UAKC,mBAAA;EACf,IAAA;EACA,MAAA,WAAiB,WAAW;AAAA;;ADL9B;;;;AAAmC;iBCcnB,qBAAA,IAAyB,mBAAmB"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"mapping-transform-CO4MNhMe.mjs","names":[],"sources":["../src/mapping/mapping-transform.ts"],"sourcesContent":["/**\n * `commerce:mapping` — the registered transform for runs driven by a saved\n * `import_mapping` (commerce-demo phase 02; the PR3 wizard enqueues these).\n *\n * The run's `params.mapping` carries a SNAPSHOT of the mapping config, so the run\n * is reproducible (it records the exact mapping used) and the transform stays a\n * pure function of `(row, ctx)` with no per-row DB I/O. This transform compiles\n * the snapshot once per `ctx.params` reference (memoized via a `WeakMap`, the same\n * shape as the carmaker path) and applies it.\n *\n * Output type is `unknown` because the target schema (parts vs discount_table)\n * isn't known until the snapshot is read at runtime. The parts schema additionally\n * reads brand/supplier identity from the SAME `params` object (alongside `mapping`).\n *\n * **Sink caveat.** The `runImport` runner bulk-writes whatever doc this produces\n * into the ES `parts` index. That's correct for a `parts` mapping but WRONG for a\n * `discount_table` mapping — its rows belong in Postgres (`supplier_discount_group`),\n * not ES. PR2 ships the discount_table SCHEMA (so the engine parses it) but no sink;\n * a `discount_table` run must NOT be enqueued through the parts-ES `runImport` path.\n * PR4 wires the Postgres sink + the route that enqueues discount runs.\n *\n * PR2 shipped + tested this transform; the wizard that creates the mappings +\n * sets `params.feed` for the reader landed in PR3. Registration is plugin-owned:\n * the `imports()` plugin registers this transform at its own init.\n */\n\nimport { z } from 'zod'\nimport type { RowTransform } from '../transform.js'\nimport { buildMappingTransform, mappingConfigSchema } from './engine.js'\nimport { getTargetSchema, listTargetSchemas } from './schema-registry.js'\n\n/** Stable name under which the mapping-driven transform is registered. */\nexport const MAPPING_TRANSFORM_NAME = 'commerce:mapping'\n\n/** The mapping snapshot lives under `params.mapping`; other params flow to the schema. */\nconst mappingParamsSchema = z.object({ mapping: mappingConfigSchema })\n\n/** Compiled-transform cache, keyed on the `ctx.params` object identity (per run). */\nconst compiledCache = new WeakMap<object, RowTransform<unknown>>()\n\nfunction compile(params: Record<string, unknown>): RowTransform<unknown> {\n const cached = compiledCache.get(params)\n if (cached) return cached\n const { mapping } = mappingParamsSchema.parse(params)\n const schema = getTargetSchema(mapping.targetSchema)\n if (!schema) {\n throw new Error(\n `Unknown target schema \"${mapping.targetSchema}\" (registered: ${listTargetSchemas().join(', ')})`,\n )\n }\n const transform = buildMappingTransform(mapping, schema)\n compiledCache.set(params, transform)\n return transform\n}\n\n/**\n * The registered transform. Resolves + compiles the mapping snapshot from\n * `ctx.params` (memoized), then delegates each row to it. A malformed snapshot or\n * unknown schema throws — surfaced by the runner as a row-level `transform_threw`.\n */\nexport const mappingTransform: RowTransform<unknown> = async (row, ctx) => {\n const transform = compile(ctx.params)\n return transform(row, ctx)\n}\n"],"mappings":"oHAgCA,MAAa,EAAyB,mBAGhC,EAAsB,EAAE,OAAO,CAAE,QAAS,CAAoB,CAAC,EAG/D,EAAgB,IAAI,QAE1B,SAAS,EAAQ,EAAwD,CACvE,IAAM,EAAS,EAAc,IAAI,CAAM,EACvC,GAAI,EAAQ,OAAO,EACnB,GAAM,CAAE,WAAY,EAAoB,MAAM,CAAM,EAC9C,EAAS,EAAgB,EAAQ,YAAY,EACnD,GAAI,CAAC,EACH,MAAU,MACR,0BAA0B,EAAQ,aAAa,iBAAiB,EAAkB,CAAC,CAAC,KAAK,IAAI,EAAE,EACjG,EAEF,IAAM,EAAY,EAAsB,EAAS,CAAM,EAEvD,OADA,EAAc,IAAI,EAAQ,CAAS,EAC5B,CACT,CAOA,MAAa,EAA0C,MAAO,EAAK,IAC/C,EAAQ,EAAI,MACf,CAAC,CAAC,EAAK,CAAG"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"matching-Sr0z4HvN.mjs","names":[],"sources":["../src/matching/auto-assign.ts","../src/matching/header-snapshot.ts"],"sourcesContent":["/**\n * Auto-assignment of a previewed feed's columns to target fields — the wizard's first guess\n * when an operator uploads a sample file.\n *\n * Kept pure (no React, no server imports) so the matching rules are unit-testable against real\n * supplier headers instead of only being visible by driving the UI.\n *\n * ## Why this is not a substring match\n *\n * The previous rule accepted a match when either string contained the other, which is wrong in\n * both directions at once. It fired where it should not — `Barcode` contains `code`, so the\n * barcode column was offered as the REQUIRED part-code field while the actual code column\n * (`ArticleName`, `partno`) matched nothing — and it stayed silent where it mattered, because\n * no real feed names a column after our internal key. Measured against the three ground-truth\n * supplier samples, it assigned two columns correctly, one wrongly, and missed the rest,\n * including every supersession column. A missed price column is loud; a missed supersession\n * column is silent (prices import, the run succeeds, the replacement tables stay empty), which\n * is how the supersession feature shipped switched-off and stayed that way.\n *\n * ## The rules\n *\n * 1. A field's candidate spellings are its `key`, its `label`, and its declared `aliases`, each\n * reduced to lowercase alphanumerics.\n * 2. A column matches a candidate only on a TOKEN BOUNDARY — the header is split on\n * punctuation, whitespace and camelCase humps, and a candidate must equal a contiguous run\n * of those tokens. So `Net Price EUR` and `NetPrice/Discount` still reach `net_price`, while\n * `Barcode` can no longer reach `code`, because `code` is not one of its tokens.\n * 3. Every (column, field) match competes globally and the strongest wins: a whole-header hit\n * beats a partial one, and a longer candidate beats a shorter one. Assignment is greedy over\n * that order, so `Description1` claims `description1` even when a plain `Description` column\n * sits earlier in the file.\n * 4. One target is used at most once. Two columns claiming the same field would be rejected by\n * the mapping validator at save time, so the weaker claimant is left unassigned for the\n * operator to resolve rather than silently producing an unsaveable mapping.\n */\n\n/** The subset of a target field this matcher reads. */\nexport interface AutoAssignField {\n key: string\n label: string\n aliases?: readonly string[] | undefined\n}\n\n/** Lowercase alphanumerics — the comparison form for both headers and candidates. */\nfunction normalize(s: string): string {\n return s.toLowerCase().replace(/[^a-z0-9]/g, '')\n}\n\n/**\n * Tokens considered per header. Matching enumerates every contiguous run of tokens, which is\n * quadratic in their count AND in their total length, so this cap is load-bearing rather than\n * tidy: a mis-detected delimiter collapses an entire line — sometimes an entire file — into ONE\n * \"column\", and this runs in the operator's browser the moment a preview returns. Measured\n * uncapped: 500 tokens → 207 MB of run strings and 0.6s; 1,000 → 1.7 GB and 5.4s; 2,000 → 14 GB\n * and 40s, all retained in a Set. The wizard has a dedicated banner for exactly that mistake\n * (`delimiterSuggestion`), but auto-assign runs before it can paint. No real column header is\n * a dozen words long, so capping costs nothing and bounds the work at ~78 runs per column.\n */\nconst MAX_HEADER_TOKENS = 12\n\n/**\n * Split a header into comparison tokens: on punctuation/whitespace, and on camelCase humps so\n * `ArticleName` → `article` + `name` and `CurrencyCd` → `currency` + `cd`. Truncated to\n * {@link MAX_HEADER_TOKENS}.\n *\n * **Acronym boundaries are deliberately NOT split.** Adding `([A-Z]+)([A-Z][a-z])` looks like a\n * free improvement — it makes `EANCode` read as `ean` + `code` — and it re-opens this module's\n * founding bug: the standalone `code` token then outscores `barcode`'s `ean` alias (4 vs 3), so\n * the BARCODE column gets offered as the required part-code field, silently, exactly as the old\n * substring matcher did. It generalises to any `<Acronym>Code` header, which no alias list can\n * enumerate. Compound spellings that matter are handled as whole-header aliases instead, which\n * beat any partial run.\n */\nexport function tokenizeHeader(header: string): string[] {\n return header\n .replace(/([a-z0-9])([A-Z])/g, '$1 $2')\n .toLowerCase()\n .split(/[^a-z0-9]+/)\n .filter((t) => t !== '')\n .slice(0, MAX_HEADER_TOKENS)\n}\n\n/** Every contiguous run of tokens, joined — the strings a candidate may equal. */\nfunction tokenRuns(tokens: readonly string[]): string[] {\n const runs: string[] = []\n for (let start = 0; start < tokens.length; start++) {\n let joined = ''\n for (let end = start; end < tokens.length; end++) {\n joined += tokens[end] ?? ''\n runs.push(joined)\n }\n }\n return runs\n}\n\ninterface Candidate {\n columnIndex: number\n fieldIndex: number\n /** Whole-header hits outrank partial ones; longer candidates outrank shorter ones. */\n score: number\n}\n\n/**\n * Suggest a target field for each column. Returns `column → target key`, omitting any column\n * with no confident match — an absent entry means \"the operator decides\", never \"no mapping\n * needed\".\n */\nexport function suggestTargets(\n columns: readonly string[],\n fields: readonly AutoAssignField[],\n): Map<string, string> {\n const candidatesByField = fields.map((f) => {\n const spellings = new Set<string>([normalize(f.key), normalize(f.label)])\n for (const alias of f.aliases ?? []) spellings.add(normalize(alias))\n spellings.delete('')\n return spellings\n })\n\n const matches: Candidate[] = []\n columns.forEach((column, columnIndex) => {\n const full = normalize(column)\n if (full === '') return\n const runs = new Set(tokenRuns(tokenizeHeader(column)))\n candidatesByField.forEach((spellings, fieldIndex) => {\n let best = 0\n for (const spelling of spellings) {\n // A whole-header hit is worth more than a run hit of the same length, so a column\n // named exactly `code` beats one that merely contains the token.\n if (full === spelling) best = Math.max(best, 1000 + spelling.length)\n else if (runs.has(spelling)) best = Math.max(best, spelling.length)\n }\n if (best > 0) matches.push({ columnIndex, fieldIndex, score: best })\n })\n })\n\n // Strongest first; ties resolved by declaration order then file order so the result is\n // independent of Set/array iteration incidentals.\n matches.sort(\n (a, b) => b.score - a.score || a.fieldIndex - b.fieldIndex || a.columnIndex - b.columnIndex,\n )\n\n const assigned = new Map<string, string>()\n const takenColumns = new Set<number>()\n const takenFields = new Set<number>()\n for (const m of matches) {\n if (takenColumns.has(m.columnIndex) || takenFields.has(m.fieldIndex)) continue\n const column = columns[m.columnIndex]\n const field = fields[m.fieldIndex]\n if (column === undefined || field === undefined) continue\n takenColumns.add(m.columnIndex)\n takenFields.add(m.fieldIndex)\n assigned.set(column, field.key)\n }\n return assigned\n}\n","/**\n * The parsed-sample shape this builder consumes and emits — structurally identical to\n * `FeedPreview` from `preview-feed.ts` and to the wizard's `FeedPreviewResponse`, and\n * declared HERE rather than imported from either so this module keeps zero dependencies.\n * That is what lets one implementation serve the browser wizard and the `lumi` CLI: an\n * import of `preview-feed.js` would pull `csv-parse` and the xlsx reader toward a client\n * bundle, and an import of the wizard's types would point the dependency the wrong way.\n */\nexport interface HeaderSample {\n /** Source column names, in first-seen order. */\n columns: string[]\n /** Sample data rows, each `{ column -> cell }`. */\n rows: Array<Record<string, string>>\n}\n\n// Snapshot caps — mirror `../entities/import-mapping-validation.ts`'s\n// `headerSnapshotSchema` + `HEADER_SNAPSHOT_MAX_CHARS` ceiling, so a snapshot this\n// builder emits always passes the server's save validation.\n//\n// Still deliberately duplicated rather than imported, and the reason has changed with\n// the module's address: it is no longer \"don't drag server code into the browser\" (this\n// file now lives in the server package), it is that `import-mapping-validation.ts` pulls\n// zod and the schema registry, which would end this subpath's zero-dependency property —\n// the one thing that lets the wizard and the CLI share ONE matcher. The contract is\n// pinned the way it always was, by the round-trip test in `header-snapshot.test.ts`\n// feeding this builder's output through the real `assertValidMapping`.\nexport const SNAPSHOT_MAX_COLUMNS = 256\nexport const SNAPSHOT_MAX_COLUMN_CHARS = 256\nexport const SNAPSHOT_MAX_ROWS = 5\nexport const SNAPSHOT_MAX_CELL_CHARS = 500\nexport const SNAPSHOT_MAX_SERIALIZED_CHARS = 100_000\n\n/**\n * Build the file-less-edit `headerSnapshot` (#667) from the current preview.\n * Rows are capped + cells truncated (they're display samples, not data); a\n * preview that can't fit the caps at all (pathological column count / column\n * names) returns `null` — the mapping saves fine, it just won't support\n * file-less editing.\n *\n * Pure module (no React, no 'use client') so the caps mirror is unit-testable\n * without page infrastructure — same shape as `find-saved-mapping.ts`.\n */\nexport function toHeaderSnapshot(preview: HeaderSample): HeaderSample | null {\n // Drop blank-named columns rather than rejecting the snapshot: csv-parse\n // treats '' as a valid column name, so a header with a trailing delimiter or\n // blank middle cell (`code,price,`) reaches here from perfectly ordinary\n // feeds — while the server schema (rightly) refuses empty names. A blank\n // column is unmappable anyway (columnMap sources require min-1 names).\n const columns = preview.columns.filter((c) => c.length > 0)\n if (\n columns.length === 0 ||\n columns.length > SNAPSHOT_MAX_COLUMNS ||\n columns.some((c) => c.length > SNAPSHOT_MAX_COLUMN_CHARS)\n ) {\n return null\n }\n const snapshot: HeaderSample = {\n columns,\n rows: preview.rows.slice(0, SNAPSHOT_MAX_ROWS).map((row) => {\n const out: Record<string, string> = {}\n for (const [col, cell] of Object.entries(row)) {\n if (col.length === 0) continue // cell of a dropped blank column\n out[col] = truncateCell(cell)\n }\n return out\n }),\n }\n // Mirror the server's serialized ceiling too — without this, a wide feed\n // with long sample cells (~40+ columns × 500-char cells) passes the per-axis\n // caps here but hard-fails the SAVE server-side with an error the operator\n // can't act on. Over the ceiling → no file-less support, save proceeds.\n if (JSON.stringify(snapshot).length > SNAPSHOT_MAX_SERIALIZED_CHARS) return null\n return snapshot\n}\n\n/**\n * Truncate a sample cell to the cap WITHOUT splitting a surrogate pair: a\n * `.slice()` cut through an astral character (emoji in a product name) leaves\n * a trailing lone high surrogate, which zod accepts but Postgres rejects as\n * jsonb input — the save would die at the DB layer instead of degrading.\n */\nfunction truncateCell(cell: string): string {\n if (cell.length <= SNAPSHOT_MAX_CELL_CHARS) return cell\n const cut = cell.slice(0, SNAPSHOT_MAX_CELL_CHARS)\n const last = cut.charCodeAt(cut.length - 1)\n const endsWithLoneHighSurrogate = last >= 0xd800 && last <= 0xdbff\n return endsWithLoneHighSurrogate ? cut.slice(0, -1) : cut\n}\n"],"mappings":"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"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"parts-schema-M1AtjNJt.d.mts","names":[],"sources":["../src/mapping/parts-schema.ts"],"mappings":";;;;;;;;;;;cAmDa,iBAAA,EAAiB,CAAA,CAAA,SAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;KAqBlB,WAAA,GAAc,CAAA,CAAE,KAAK,QAAQ,iBAAA;;;;;AAAzC;;;iBAsMgB,aAAA,CAAc,CAAS;;;;;AAtMmB;cA6R7C,WAAA,EAAa,YAAY,CAAC,aAAA"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"parts-schema-QqLci_Pl.mjs","names":[],"sources":["../src/mapping/number-format.ts","../src/mapping/target-schema.ts","../src/mapping/parts-schema.ts"],"sourcesContent":["/**\n * Number parsing for the configurable mapping engine (commerce-demo phase 02).\n *\n * Real supplier feeds (R03) disagree on decimal AND thousands separators:\n * `55.02` (dot decimal), `326,88` (comma decimal), `1.234,56` (EU\n * thousands+decimal), `1,234.56` (US), `1 234,56` (space-grouped). The mapping\n * stores an explicit `{ decimal, thousands }` per profile so there is no guessing\n * — `parseNumber` trusts the configured format.\n *\n * Two parsers live here:\n * - `parseNumber(raw, format)` — the format-driven parser used by the generic\n * `number` per-column transform.\n * - `parseEuropeanNumber(raw)` — the carmaker-feed legacy parser (comma decimal,\n * dot thousands, **bare-dot rejected as ambiguous**). Carries the carmaker feed's\n * documented format with two intentional broadenings vs the old inline parser:\n * it trims surrounding whitespace, and returns `null` (not `NaN`) for an empty /\n * `'NA'` cell so both number ops share absent-vs-malformed semantics. The\n * carmaker doc-shape suite (27 tests) is the regression net.\n */\n\n/** Separator config for {@link parseNumber}. Stored per `import_mapping`. */\nexport interface NumberFormat {\n /** Decimal separator. */\n decimal: '.' | ','\n /** Thousands grouping separator. `''` = none. */\n thousands: '' | '.' | ',' | ' '\n}\n\n/** Post-cleanup numeric form: optional sign, digits, optional single decimal. */\nconst STRICT_NUMBER_RE = /^-?\\d+(\\.\\d+)?$/\n\n/**\n * Parse a feed cell into a number using an EXPLICIT `{ decimal, thousands }`\n * format. No ambiguity: thousands separators are stripped, the decimal separator\n * is normalised to `.`, and the result is strict-validated.\n *\n * Returns:\n * - `null` for an empty / `'NA'` cell (a legitimately-absent value — the caller\n * decides whether that's allowed for a given target field).\n * - `NaN` for a non-empty but unparseable cell (malformed — the caller surfaces\n * a row-level error rather than coercing to 0).\n * - the parsed number otherwise.\n *\n * A character that is neither a digit, the configured decimal, the configured\n * thousands separator, nor a leading `-` makes the cell unparseable (`NaN`) —\n * e.g. a literal `.` when the format's decimal is `,` and thousands is not `.`.\n */\nexport function parseNumber(raw: string, format: NumberFormat): number | null {\n const trimmed = raw.trim()\n if (trimmed === '' || trimmed.toUpperCase() === 'NA') return null\n\n const { decimal, thousands } = format\n let s = trimmed\n // 1. Remove every thousands separator (when one is configured).\n if (thousands !== '') s = s.split(thousands).join('')\n\n // 2. Normalise the decimal separator to `.`. After thousands removal, the\n // \"other\" separator must not appear — if it does, the cell doesn't match\n // the declared format and is unparseable.\n if (decimal === ',') {\n if (s.includes('.')) return Number.NaN\n s = s.split(',').join('.')\n } else {\n // decimal === '.'\n if (s.includes(',')) return Number.NaN\n }\n\n if (!STRICT_NUMBER_RE.test(s)) return Number.NaN\n return Number.parseFloat(s)\n}\n\n/**\n * Convert a major-unit money value (e.g. `55.02` EUR) to integer MINOR units\n * (`5502`). Rounds to the nearest cent to absorb float drift. The commerce-demo\n * PartsDocument money fields (`list_price_minor`, `purchase_price_minor`,\n * `core_charge_minor`) are integer minor units so derived prices (PR4) don't\n * accrue rounding error — see F003 in progress.md.\n */\nexport function toMinorUnits(value: number): number {\n return Math.round(value * 100)\n}\n\n/**\n * Parse a European-formatted decimal: `,` is the decimal separator, `.` is the\n * (optional) thousands separator. Returns `null` for an empty / `'NA'` cell and\n * `NaN` on unparseable (malformed) input.\n *\n * This is the carmaker-feed legacy parser, preserved from the hardcoded transform.\n * Crucially it **rejects bare-dot** input (`1.98`, `1.234`) as ambiguous rather\n * than guessing — the carmaker feeds are documented as comma-decimal, and a `.`\n * without a `,` could mean a US-format decimal OR a thousands marker. The\n * format-driven {@link parseNumber} doesn't need this guard (its separators are\n * explicit); the carmaker path keeps it.\n *\n * The `null` (absent) vs `NaN` (malformed) split mirrors {@link parseNumber} so\n * the mapping engine treats both number ops uniformly: a target field's `buildRow`\n * reads `null` as \"not provided\" and `NaN` as \"invalid\". This is what lets the\n * reimplemented carmaker transform keep its `GrossPrice 'NA' → null` behaviour\n * (optional field) while still erroring on a malformed `NetPrice` (required).\n *\n * Cases: `'1,98'`→`1.98`, `'1.234,56'`→`1234.56`, `'5'`→`5`, `'-5,00'`→`-5`,\n * empty / `'NA'` → `null`. Rejected (`NaN`): bare-dot (`'1.98'`, `'1.234'`),\n * trailing junk (`'1,98EUR'`), multiple commas (`'1,2,3'`), other non-numeric.\n */\nexport function parseEuropeanNumber(s: string): number | null {\n const trimmed = s.trim()\n if (trimmed === '' || trimmed.toUpperCase() === 'NA') return null\n const hasComma = trimmed.includes(',')\n const hasDot = trimmed.includes('.')\n let cleaned: string\n if (hasComma && hasDot) {\n // European thousands+decimal: strip every `.`, then swap the (sole) `,` for\n // `.`. The strict regex below catches multi-comma input.\n cleaned = trimmed.replace(/\\./g, '').replace(',', '.')\n } else if (hasComma) {\n cleaned = trimmed.replace(',', '.')\n } else if (hasDot) {\n return Number.NaN\n } else {\n cleaned = trimmed\n }\n if (!STRICT_NUMBER_RE.test(cleaned)) return Number.NaN\n return Number.parseFloat(cleaned)\n}\n","/**\n * TargetSchema — the contract a mapping writes INTO (commerce-demo phase 02, D005).\n *\n * A mapping's `column_map` points source columns at a schema's whitelisted target\n * fields; the engine applies the per-column transforms to build a `MappedRow`, then\n * hands it to `schema.buildRow` to produce the typed output document (an ES\n * `PartsDocument` for `parts`, a discount row for `discount_table`).\n *\n * The `fields` list is the SECOND injection-boundary whitelist (the first is the\n * registered schema name): a `column_map` entry whose `target` is not a field key\n * here is rejected at validation time (R02). Two schemas ship in PR2 — `parts` and\n * `discount_table`.\n */\n\nimport type { RowResult } from '../transform.js'\nimport type { CellValue } from './column-transforms.js'\n\n/** One whitelisted target field on a schema. */\nexport interface TargetField {\n /** Stable target key referenced by `column_map[].target`. */\n key: string\n /** Human label for the wizard dropdown (PR3). */\n label: string\n /** Whether a mapping for this schema MUST include this target. */\n required: boolean\n /** Coarse type — drives the wizard's transform suggestions; `buildRow` does the real coercion. */\n type: 'string' | 'number'\n /**\n * Header spellings this field should be SUGGESTED for when the wizard auto-assigns a freshly\n * previewed file. Purely advisory: the operator can always override, and nothing here widens\n * the injection-boundary whitelist — `key` remains the only accepted `column_map[].target`.\n *\n * Real feeds do not name their columns after our keys (a supplier ships `partno`, `VARUOSA\n * KOOD`, `ArticleName`, never `code`), so without aliases the wizard suggests nothing for the\n * columns that matter and the operator has to know the schema by heart. `describeTargetSchemas`\n * ships this to the wizard verbatim, so a field's synonyms live next to the field itself\n * rather than in a lookup table on the client.\n *\n * Only add a spelling whose meaning is UNAMBIGUOUS. An alias is a silent decision applied to\n * somebody's catalogue — see the supersession fields below, where two spellings that both read\n * as \"replacement\" denote opposite directions and are therefore deliberately absent.\n */\n aliases?: readonly string[]\n}\n\n/** The per-row product of the engine: target field key → cleaned value. */\nexport type MappedRow = Record<string, CellValue>\n\n/** Per-row context handed to `buildRow`. `params` is the opaque per-run params object. */\nexport interface BuildRowContext {\n importRunId: string\n rowNumber: number\n /**\n * Opaque per-run params (the parts schema reads brand/supplier identity from\n * it; `discount_table` ignores it). The schema validates the shape it needs.\n */\n params: Record<string, unknown>\n}\n\n/** A registered target schema: a whitelist + a row builder. */\nexport interface TargetSchema<TOut> {\n /** Registered name — also the `import_mapping.target_schema` value. */\n name: string\n /** The target-field whitelist (injection boundary). */\n fields: readonly TargetField[]\n /** Build a typed output (success) / skip / error from a mapped row. */\n buildRow(mapped: MappedRow, ctx: BuildRowContext): RowResult<TOut>\n}\n\n// ---------------------------------------------------------------------------\n// Shared `buildRow` helpers (used by every concrete schema).\n// ---------------------------------------------------------------------------\n\n/**\n * A nullable text cell → trimmed string, or `null` for empty / non-string. Accepts\n * `undefined` (an UNMAPPED target — indexed access under `noUncheckedIndexedAccess`)\n * and treats it as absent.\n */\nexport function textOrNull(v: CellValue | undefined): string | null {\n if (typeof v !== 'string') return null\n const t = v.trim()\n return t === '' ? null : t\n}\n\n/** The three outcomes a `number` / `europeanNumber` op produces for a cell. */\nexport type NumberOutcome =\n | { kind: 'value'; value: number }\n | { kind: 'absent' }\n | { kind: 'invalid' }\n\n/**\n * Classify a cell that should hold a number. `undefined` (unmapped) / `null` →\n * absent; a real number → value (`NaN` → invalid); a non-empty raw string (no\n * number op declared) → invalid; a blank raw string → absent.\n */\nexport function numberCell(v: CellValue | undefined): NumberOutcome {\n if (v === undefined || v === null) return { kind: 'absent' }\n if (typeof v === 'number') {\n return Number.isNaN(v) ? { kind: 'invalid' } : { kind: 'value', value: v }\n }\n return v.trim() === '' ? { kind: 'absent' } : { kind: 'invalid' }\n}\n\n/** Build a `RowResult` error variant (assignable to any `TargetSchema<TOut>`). */\nexport function rowError(errorType: string, message: string, field: string) {\n return { kind: 'error' as const, error: { errorType, message, field } }\n}\n","/**\n * `parts` target schema — maps a cleaned feed row into the ES `PartsDocument`\n * (commerce-demo phase 02, D005). This is the SHARED assembly + validation logic\n * the reimplemented carmaker transform and every operator-built `parts` mapping\n * run through, so the row taxonomy (code normalization, currency gate, price\n * validation) is uniform across all supplier feeds.\n *\n * **Field coverage.** This schema populates the direct passthroughs (the existing\n * `PartsDocument` columns plus `list_price_minor`, `discount_group`,\n * `superseding_code`) AND the DERIVED `purchase_price_minor` (PR4 — direct net, or\n * two-file `list × (1 − groupDiscount)` resolved from `params.discountGroups`). Core\n * charge (Korpusetasu, PR7, D004) has TWO encodings → one `core_charge_minor`:\n * Encoding A is a `surcharge`/`core_charge` COLUMN on the saleable row (written here);\n * Encoding B is a separate `<code>*S` core-fee row that skips the saleable index and\n * emits a doc-patch attaching its deposit to the PARENT part in the runner's phase-2\n * pass. Replacement-pair SIDE EFFECTS (separate entity) are PR6 — emitted from\n * `superseding_code` and/or `replaces_code`, which name the same relationship from\n * opposite ends and are pairs-only (`replaces_code` has no `PartsDocument` column).\n *\n * **Money.** Every money field is integer minor units via `toMinorUnits` (F003), and\n * the two-file derivation stays in minor units end-to-end (no major↔minor\n * round-trip). The legacy `net_price_eur` stays a major-unit float for back-compat:\n * it holds the direct net price, or — for a two-file LIST feed with no direct net —\n * the list price as a display placeholder (the true cost is `purchase_price_minor`).\n *\n * **Cost-pending warning.** A list-only row whose `discount_group` doesn't resolve\n * against `params.discountGroups` is still indexed (list set, `purchase_price_minor`\n * null) and carries a `no_purchase_price` WARNING — not a row error (phase 03 §5).\n */\n\nimport type { PartsDocument } from '@murumets-ee/search-elasticsearch'\nimport { z } from 'zod'\nimport type { DirectionalPair, DocPatch, RowWarning } from '../transform.js'\nimport type { CellValue } from './column-transforms.js'\nimport { toMinorUnits } from './number-format.js'\nimport {\n type BuildRowContext,\n type MappedRow,\n numberCell,\n rowError,\n type TargetField,\n type TargetSchema,\n textOrNull,\n} from './target-schema.js'\n\n/**\n * Params the `parts` schema reads from the run — the per-(supplier, brand)\n * identity resolved at enqueue time. Identical to the carmaker transform's params\n * (the carmaker path re-exports this), so brand/supplier injection + the optional\n * supplier code-prefix strip are validated the same way everywhere.\n */\nexport const partsParamsSchema = z.object({\n brandId: z.string().uuid(),\n brandSlug: z.string().min(1).max(64),\n supplierId: z.string().uuid(),\n supplierDisplayName: z.string().min(1).max(255),\n importBatchId: z.string().uuid(),\n /** Supplier code prefix to strip before normalization (e.g. `'ME-'`). Optional. */\n supplierCodePrefix: z\n .string()\n .max(8)\n .regex(/^[A-Z0-9-]+$/, 'must be uppercase alphanumeric or \"-\"')\n .optional(),\n /**\n * Purchase-discount map for this supplier (commerce-demo PR4, phase 03 §2):\n * `{ groupCode → discountPct }`, resolved from `supplier_discount_group` at\n * enqueue time and snapshotted onto the run so `buildRow` stays a pure function\n * (no per-row DB I/O; `@murumets-ee/imports` stays a leaf). Absent for direct-net\n * suppliers + the carmaker path. Percentages are 0–100 (the entity constrains them).\n */\n discountGroups: z.record(z.string(), z.number().min(0).max(100)).optional(),\n})\nexport type PartsParams = z.infer<typeof partsParamsSchema>\n\n/** Per-run memoization of the parsed params (keyed on the `ctx.params` reference). */\nconst paramsCache = new WeakMap<object, PartsParams>()\n\nfunction resolveParams(raw: Record<string, unknown>): PartsParams {\n const cached = paramsCache.get(raw)\n if (cached) return cached\n const parsed = partsParamsSchema.parse(raw)\n paramsCache.set(raw, parsed)\n return parsed\n}\n\n/** Per-locale display-name target fields (8 locales). */\nconst NAME_LOCALES = ['de', 'en', 'es', 'fr', 'it', 'nl', 'pt', 'ro'] as const\ntype NameLocale = (typeof NAME_LOCALES)[number]\nconst nameKey = (l: NameLocale): `name_${NameLocale}` => `name_${l}`\n\n/**\n * The `parts` target-field whitelist.\n *\n * **On the two supersession fields.** They exist as a PAIR because the direction of a\n * \"replacement\" column cannot be read off its name. TecDoc's data format defines `SupersNo` /\n * `ErsatzNr` as *\"Article ArtNo supersedes Article SupersNo\"* — the column holds the OLD code\n * and the row is current. German `ersetzt durch` and English `replaced by` mean the exact\n * opposite: the column holds the NEW code and the row is obsolete. Both are \"replacement\"\n * words; only the grammatical voice distinguishes them, and a bare noun like `Ersatznummer`\n * carries no voice at all. TecDoc's own SOAP API resolved this by shipping `replacesArticles`\n * and `replacedByArticles` as separate elements rather than one ambiguous field, and that is\n * what these two keys are.\n *\n * Getting it backwards is silent — an inverted feed builds well-formed chains with valid heads\n * and no warnings, and the union-find merge is append-only, so re-importing the right way round\n * does NOT repair it. Hence: no alias in this file is ambiguous about direction, and the\n * genuinely ambiguous spellings (`superseded no`, `Ersatznummer`, `Ersatz-Nr`) are deliberately\n * aliased to NEITHER field, so an operator meeting one has to state the direction rather than\n * inherit a guess.\n */\nconst PARTS_FIELDS: readonly TargetField[] = [\n {\n key: 'code',\n label: 'Code',\n required: true,\n type: 'string',\n // `part no` already covers a `partno` header: aliases are compared as bare alphanumerics,\n // and a header matches on a run of its own tokens, so both spellings reduce to `partno`.\n aliases: [\n 'part no',\n 'part number',\n 'article name',\n 'article no',\n 'article number',\n 'sku',\n 'varuosa kood',\n 'kood',\n 'artikelnummer',\n 'artikel nr',\n ],\n },\n {\n key: 'net_price',\n label: 'Net price',\n required: false,\n type: 'number',\n aliases: ['price', 'net price discount', 'nettopreis', 'netto'],\n },\n {\n key: 'gross_price',\n label: 'Gross price',\n required: false,\n type: 'number',\n aliases: ['bruttopreis', 'brutto'],\n },\n {\n key: 'list_price',\n label: 'List price',\n required: false,\n type: 'number',\n // `JAEHIND KM-TA` is Estonian for \"retail price, VAT-excluded\" (R03 sample A).\n aliases: ['retail price', 'rrp', 'jaehind', 'jaehind km ta', 'listenpreis'],\n },\n {\n key: 'currency',\n label: 'Currency',\n required: false,\n type: 'string',\n aliases: ['currency cd', 'currency code', 'valuuta'],\n },\n {\n key: 'barcode',\n label: 'Barcode',\n required: false,\n type: 'string',\n // `ean code` / `gtin code` are whole-header aliases on purpose: without them a header\n // spelled `EAN Code` matches the bare `code` token and claims the REQUIRED part-code\n // field, because `code` (4 chars) outscores the `ean` alias (3). A whole-header hit\n // outranks any partial run, so naming the compound form settles it.\n aliases: ['ean', 'ean code', 'gtin', 'gtin code', 'triipkood'],\n },\n {\n key: 'description1',\n label: 'Description 1',\n required: false,\n type: 'string',\n aliases: ['description', 'kirjeldus', 'beschreibung'],\n },\n { key: 'description2', label: 'Description 2', required: false, type: 'string' },\n {\n key: 'discount_group',\n label: 'Discount group',\n required: false,\n type: 'string',\n // `TOOTEGRUPP` is Estonian for \"product group\" (R03 sample A), which IS the discount key.\n // A bare `group` is deliberately NOT here: on a parts feed it is as likely an assortment\n // or category group, and a wrong-but-colliding value resolves against\n // `params.discountGroups` and derives a wrong `purchase_price_minor` with no warning at all\n // (only a NON-colliding code raises `no_purchase_price`). A silent money error is worth\n // more than a saved click.\n aliases: ['product group', 'tootegrupp', 'rabattgruppe'],\n },\n {\n /**\n * The NEWER code that supersedes THIS row — the row's own code is the obsolete one.\n * Emits `(this row → the value)`.\n */\n key: 'superseding_code',\n label: 'Replaced by (newer code)',\n required: false,\n type: 'string',\n // Every entry states direction by grammar: a passive (\"replaced by\", \"ersetzt durch\",\n // \"vervangen door\") or a word that means \"the one after\" (\"successor\", \"new code\",\n // \"Nachfolgenummer\"). A bare noun phrase like `replacement code` does NOT — it reads just\n // as easily as \"the code this replaces\" — so it is excluded for the same reason\n // `Ersatznummer` is.\n aliases: [\n 'replaced by',\n 'successor',\n 'successor code',\n 'new code',\n 'ersetzt durch',\n 'wird ersetzt durch',\n 'nachfolgenummer',\n 'vervangen door',\n ],\n },\n {\n /**\n * The OLDER code THIS row supersedes — the row's own code is the current one. Emits the\n * INVERSE pair `(the value → this row)`. This is TecDoc table 204's shape.\n */\n key: 'replaces_code',\n label: 'Replaces (older code)',\n required: false,\n type: 'string',\n // Same rule from the other side: active verbs (\"replaces\", \"supersedes\") or words meaning\n // \"the one before\" (\"predecessor\", \"old code\", \"Vorgängernummer\"). `superseded article` is\n // NOT here — TecDoc reads that phrasing as the old code, but it is the same bare\n // past-participle family as `superseded no`, which this file refuses to auto-assign;\n // excluding one and accepting the other would be incoherent.\n aliases: [\n 'replaces',\n 'supersedes',\n 'old code',\n 'previous code',\n 'predecessor',\n 'vorgaengernummer',\n ],\n },\n // Core charge (Korpusetasu, commerce-demo PR7, D004). Encoding A: a supplier's\n // `surcharge`/`core_charge` COLUMN maps here → this row's deposit. (Encoding B —\n // a separate `<code>*S` row — needs no whitelist field; it's detected on the code.)\n {\n key: 'core_charge',\n label: 'Core charge',\n required: false,\n type: 'number',\n aliases: ['surcharge', 'deposit', 'core deposit', 'korpusetasu', 'pfand'],\n },\n ...NAME_LOCALES.map(\n (l): TargetField => ({\n key: nameKey(l),\n label: `Name (${l})`,\n required: false,\n type: 'string',\n // The carmaker feed ships these as `Description_<LOCALE>` (R03 sample D).\n aliases: [`description ${l}`, `name ${l}`],\n }),\n ),\n] as const\n\nconst NORMALIZE_CODE_RE = /[\\s-]/g\n/**\n * Uppercase + dash/whitespace-stripped (D2). `*`/other chars are preserved (e.g. `*S`\n * markers). EXPORTED so downstream consumers that key off `code_normalized` — the\n * replacement editor (commerce-demo PR6), which normalizes admin-typed codes before\n * matching members — use the IDENTICAL rule (R05 Q7: normalization must match across\n * every code source or merge-on-collision silently fails to collide).\n */\nexport function normalizeCode(s: string): string {\n return s.toUpperCase().replace(NORMALIZE_CODE_RE, '')\n}\n\n/**\n * A supersession cell → one normalized code, or `null` when there is no usable edge end:\n * unmapped, blank, or punctuation-only (a `'---'` placeholder normalizes to the empty string).\n *\n * **The supplier prefix must be stripped here too.** `code_normalized` removes\n * `params.supplierCodePrefix` before normalizing, so a feed that writes `FO-1120698` in its own\n * code column also writes `FO-1091888` in its supersession column. Normalizing one with the\n * prefix and the other without produces an edge between a code that exists in the catalogue and\n * one that never will — the chain builds cleanly and matches nothing, which is indistinguishable\n * from having no supersession data at all.\n */\nfunction normalizeCodeCell(\n cell: CellValue | undefined,\n supplierCodePrefix: string | undefined,\n): string | null {\n const raw = textOrNull(cell)\n if (raw === null) return null\n const stripped =\n supplierCodePrefix && raw.startsWith(supplierCodePrefix)\n ? raw.slice(supplierCodePrefix.length)\n : raw\n const normalized = normalizeCode(stripped)\n return normalized === '' ? null : normalized\n}\n\n/**\n * Two-file purchase price (phase 03 §2): `list × (1 − discountPct/100)`, computed\n * ENTIRELY in integer minor units so no major↔minor round-trip can introduce drift\n * (F003). `listMinor` is already minor units; the result is rounded to the nearest\n * minor unit. Worked (R03 A+B): `5502 × 0.74 = 4071.48 → 4071` (€40.71).\n */\nfunction deriveTwoFilePurchaseMinor(listMinor: number, discountPct: number): number {\n return Math.round(listMinor * (1 - discountPct / 100))\n}\n\n/**\n * The core-charge marker suffix on a NORMALIZED code (D004, SD002). `normalizeCode`\n * uppercases + strips dashes/spaces but PRESERVES `*`, so a `<code>*S` core-fee row\n * arrives here intact — e.g. `1120698*S`. A code ending in `*S` is a core-fee line,\n * not a saleable part (Encoding B).\n */\nconst CORE_FEE_SUFFIX = '*S'\n\n/** The five core-charge doc fields the two encodings write. */\ntype CoreChargeFields = Pick<\n PartsDocument,\n | 'core_charge_minor'\n | 'core_charge_currency'\n | 'core_charge_refundable'\n | 'core_charge_tax_class'\n | 'core_charge_tax_inclusive'\n>\n\n/**\n * The tax-NEUTRAL core-charge block (D004 / D007) for a deposit amount in minor\n * units, or all-null when there's no core charge. EUR (D9) + refundable by default;\n * the imported EU feed amounts are net so `tax_inclusive = false`; `tax_class` stays\n * null — no rate is baked in here, the future pluggable EU/US tax engine maps it (D007).\n */\nfunction coreChargeFields(minor: number | null): CoreChargeFields {\n if (minor === null) {\n return {\n core_charge_minor: null,\n core_charge_currency: null,\n core_charge_refundable: null,\n core_charge_tax_class: null,\n core_charge_tax_inclusive: null,\n }\n }\n return {\n core_charge_minor: minor,\n core_charge_currency: 'EUR',\n core_charge_refundable: true,\n core_charge_tax_class: null,\n core_charge_tax_inclusive: false,\n }\n}\n\n/**\n * The `parts` schema. `buildRow` THROWS on invalid run params (so a misconfigured\n * run surfaces as a transform error, matching the carmaker contract) and returns a\n * typed `RowResult` per row otherwise.\n */\nexport const partsSchema: TargetSchema<PartsDocument> = {\n name: 'parts',\n fields: PARTS_FIELDS,\n buildRow(mapped: MappedRow, ctx: BuildRowContext) {\n const params = resolveParams(ctx.params)\n\n // 1. Code (required) → raw `code` + normalized `code_normalized` + doc id.\n const codeRaw = typeof mapped.code === 'string' ? mapped.code.trim() : ''\n if (!codeRaw) return rowError('missing_code', 'code is empty', 'code')\n const stripped =\n params.supplierCodePrefix && codeRaw.startsWith(params.supplierCodePrefix)\n ? codeRaw.slice(params.supplierCodePrefix.length)\n : codeRaw\n const codeNormalized = normalizeCode(stripped)\n if (!codeNormalized) {\n return rowError(\n 'empty_normalized_code',\n `Code \"${codeRaw}\" is empty after normalization`,\n 'code',\n )\n }\n\n // 2. Currency gate (D9 EUR-only). Unmapped/blank defaults to EUR. Runs BEFORE the\n // `*S` detection so a non-EUR core-fee row is rejected too — Encoding A and B keep\n // the SAME currency contract (a `*S` deposit is never silently coerced to EUR).\n const currencyText = textOrNull(mapped.currency)\n const currency = currencyText ?? 'EUR'\n if (currency !== 'EUR') {\n return rowError(\n 'unsupported_currency',\n `Currency \"${currency}\" not supported (EUR-only)`,\n 'currency',\n )\n }\n\n // 2b. Core-fee line — Encoding B (D004, SD002). A `<code>*S` normalized code is a\n // core-fee MARKER row, not a saleable part: strip the `*S`, take THIS row's net\n // price as the deposit, and emit a doc-patch that attaches it onto the PARENT\n // part's core-charge fields. The row itself SKIPS the saleable index (never a\n // `*S` SKU — D004). The runner applies the patch in a phase-2 pass; a parent\n // that isn't in the import becomes an `orphan_core_charge` warning (never a\n // phantom doc, never a silent drop). A `*S` with no parent code, or no/invalid\n // amount, is a row error.\n if (codeNormalized.endsWith(CORE_FEE_SUFFIX)) {\n const parentNormalized = codeNormalized.slice(0, -CORE_FEE_SUFFIX.length)\n if (!parentNormalized) {\n return rowError(\n 'core_charge_no_parent',\n `Core-fee row \"${codeRaw}\" has no part code before the \"*S\" marker`,\n 'code',\n )\n }\n const deposit = numberCell(mapped.net_price)\n if (deposit.kind === 'invalid') {\n return rowError(\n 'invalid_core_charge',\n 'core-fee (*S) row net_price is not a number',\n 'net_price',\n )\n }\n if (deposit.kind === 'absent') {\n return rowError(\n 'missing_core_charge',\n 'core-fee (*S) row has no net_price to use as the deposit',\n 'net_price',\n )\n }\n // Gate on the MINOR-unit result (a sub-cent value like 0.004 → round to 0):\n // 0 / sub-cent / negative deposit → nothing to attach; drop it without a patch\n // rather than zeroing a parent's core charge.\n const depositMinor = toMinorUnits(deposit.value)\n if (depositMinor <= 0) {\n return { kind: 'skip', reason: 'core_fee_zero' }\n }\n const patch: DocPatch<PartsDocument> = {\n id: `${parentNormalized}__${params.supplierId}`,\n doc: coreChargeFields(depositMinor),\n orphanWarning: {\n warningType: 'orphan_core_charge',\n message: 'core-fee (*S) row references a part not present in this import',\n field: 'code',\n },\n }\n return { kind: 'skip', reason: 'core_charge_marker', patches: [patch] }\n }\n\n // 3. Prices. net/gross/list each: absent → null, invalid → error, value → number.\n const net = numberCell(mapped.net_price)\n if (net.kind === 'invalid')\n return rowError('invalid_net_price', 'net_price is not a number', 'net_price')\n const gross = numberCell(mapped.gross_price)\n if (gross.kind === 'invalid') {\n return rowError('invalid_gross_price', 'gross_price is not a number', 'gross_price')\n }\n const list = numberCell(mapped.list_price)\n if (list.kind === 'invalid')\n return rowError('invalid_list_price', 'list_price is not a number', 'list_price')\n\n const netValue = net.kind === 'value' ? net.value : null\n const listValue = list.kind === 'value' ? list.value : null\n // At least one price must resolve. `net_price_eur` is non-nullable on the doc,\n // so it takes the direct net or — for a list-only two-file feed — the list\n // price as a display placeholder (the true cost is `purchase_price_minor`).\n const displayPrice = netValue ?? listValue\n if (displayPrice === null) {\n return rowError('missing_price', 'no net_price or list_price provided', 'price')\n }\n\n // 4. Purchase-price derivation (phase 03 §2, PR4). All money stays in integer\n // minor units (F003 — never round-trip back to major).\n // - direct net column (R03 C/D) → purchase = net\n // - two-file list × (1 − groupDiscount) → purchase = round(list × (1 − d/100))\n // - neither resolvable → cost pending: index list-only + WARN\n // (a `no_purchase_price` warning, NOT a row error — the part is still indexed).\n const discountGroup = textOrNull(mapped.discount_group)\n const listMinor = listValue === null ? null : toMinorUnits(listValue)\n let purchasePriceMinor: number | null = null\n const warnings: RowWarning[] = []\n if (netValue !== null) {\n purchasePriceMinor = toMinorUnits(netValue)\n } else if (listMinor !== null) {\n // List-only row: resolve the supplier's discount group → derive cost.\n const discountPct =\n discountGroup !== null ? params.discountGroups?.[discountGroup] : undefined\n if (discountPct !== undefined) {\n purchasePriceMinor = deriveTwoFilePurchaseMinor(listMinor, discountPct)\n } else {\n warnings.push({\n warningType: 'no_purchase_price',\n message:\n discountGroup === null\n ? 'list price present but no discount group on the row — cost pending'\n : `no discount group \"${discountGroup}\" configured for this supplier — cost pending`,\n field: 'discount_group',\n })\n }\n }\n\n // 5. Replacement-pair side-output (commerce-demo PR6, SD007). Both supersession columns\n // are SPARSE on a full manufacturer catalogue, and both produce the same\n // `(from = older → to = newer)` edge the chain engine consumes — they differ only in\n // which end of the edge the row itself is:\n // `superseding_code` → the value is NEWER, so the edge runs (this row → value);\n // `replaces_code` → the value is OLDER, so the edge runs (value → this row).\n // Every code is normalized the SAME way as `code_normalized` so the downstream merge\n // collides codes across feeds (R05 Q7). A value that is empty, normalizes to empty\n // (e.g. `'---'`), or self-pairs yields no edge. A row MAY carry both columns — that is\n // a legitimate three-code chain (older → this → newer), not a conflict.\n const pairs: DirectionalPair[] = []\n const supersedingNormalized = normalizeCodeCell(\n mapped.superseding_code,\n params.supplierCodePrefix,\n )\n if (supersedingNormalized !== null && supersedingNormalized !== codeNormalized) {\n pairs.push({ from: codeNormalized, to: supersedingNormalized })\n }\n const replacesNormalized = normalizeCodeCell(mapped.replaces_code, params.supplierCodePrefix)\n if (replacesNormalized !== null && replacesNormalized !== codeNormalized) {\n pairs.push({ from: replacesNormalized, to: codeNormalized })\n }\n\n // 6. Core charge — Encoding A (D004). A `surcharge`/`core_charge` COLUMN on this\n // saleable row is the deposit for THIS part. 0 / empty / absent / sub-cent → no\n // core charge. (Encoding B — a separate `<code>*S` row — is handled at step 1b\n // and attaches to the parent in phase-2, so it never reaches here. In the unusual\n // case a part has BOTH a `core_charge` column AND a sibling `*S` row — suppliers\n // use one encoding — the `*S` phase-2 patch runs after indexing and wins.)\n const coreChargeCell = numberCell(mapped.core_charge)\n if (coreChargeCell.kind === 'invalid') {\n return rowError('invalid_core_charge', 'core_charge is not a number', 'core_charge')\n }\n // Gate on the MINOR-unit result so a sub-cent value (rounds to 0) yields no charge.\n const coreChargeMinorRaw =\n coreChargeCell.kind === 'value' ? toMinorUnits(coreChargeCell.value) : null\n const coreChargeMinor =\n coreChargeMinorRaw !== null && coreChargeMinorRaw > 0 ? coreChargeMinorRaw : null\n\n const docId = `${codeNormalized}__${params.supplierId}`\n const doc: PartsDocument = {\n doc_id: docId,\n code: codeRaw,\n code_normalized: codeNormalized,\n brand_id: params.brandId,\n brand_slug: params.brandSlug,\n supplier_id: params.supplierId,\n supplier_display_name: params.supplierDisplayName,\n net_price_eur: displayPrice,\n gross_price_eur: gross.kind === 'value' ? gross.value : null,\n currency: 'EUR',\n barcode: textOrNull(mapped.barcode),\n name_de: textOrNull(mapped.name_de),\n name_en: textOrNull(mapped.name_en),\n name_es: textOrNull(mapped.name_es),\n name_fr: textOrNull(mapped.name_fr),\n name_it: textOrNull(mapped.name_it),\n name_nl: textOrNull(mapped.name_nl),\n name_pt: textOrNull(mapped.name_pt),\n name_ro: textOrNull(mapped.name_ro),\n description1: textOrNull(mapped.description1),\n description2: textOrNull(mapped.description2),\n // commerce-demo: list price → minor units; group + code passthroughs.\n list_price_minor: listMinor,\n discount_group: discountGroup,\n superseding_code: textOrNull(mapped.superseding_code),\n // Derived purchase cost (PR4 — direct net or two-file list × (1 − d)); null = cost pending.\n purchase_price_minor: purchasePriceMinor,\n // Core charge (PR7, Encoding A column) — or all-null when this row has none.\n ...coreChargeFields(coreChargeMinor),\n import_batch_id: params.importBatchId,\n imported_at: new Date().toISOString(),\n }\n\n return {\n kind: 'success',\n id: docId,\n doc,\n ...(warnings.length > 0 && { warnings }),\n ...(pairs.length > 0 && { pairs }),\n // Value-drift sampling (F010 / #602) — the DERIVED cost, not the raw\n // net/list price, so a decimal-format flip on either the direct-net\n // or two-file source column shows up here regardless of which pricing\n // path this supplier's feed uses. `null` (cost-pending) is omitted,\n // same as `no_purchase_price` warnings already do — the collector\n // counts it toward `nullRate` instead.\n ...(purchasePriceMinor !== null && { priceSample: purchasePriceMinor }),\n }\n },\n}\n"],"mappings":"wBA6BA,MAAM,EAAmB,kBAkBzB,SAAgB,EAAY,EAAa,EAAqC,CAC5E,IAAM,EAAU,EAAI,KAAK,EACzB,GAAI,IAAY,IAAM,EAAQ,YAAY,IAAM,KAAM,OAAO,KAE7D,GAAM,CAAE,UAAS,aAAc,EAC3B,EAAI,EAOR,GALI,IAAc,KAAI,EAAI,EAAE,MAAM,CAAS,CAAC,CAAC,KAAK,EAAE,GAKhD,IAAY,IAAK,CACnB,GAAI,EAAE,SAAS,GAAG,EAAG,MAAO,KAC5B,EAAI,EAAE,MAAM,GAAG,CAAC,CAAC,KAAK,GAAG,CAC3B,MAEE,GAAI,EAAE,SAAS,GAAG,EAAG,MAAO,KAI9B,OADK,EAAiB,KAAK,CAAC,EACrB,OAAO,WAAW,CAAC,EADY,GAExC,CASA,SAAgB,EAAa,EAAuB,CAClD,OAAO,KAAK,MAAM,EAAQ,GAAG,CAC/B,CAwBA,SAAgB,EAAoB,EAA0B,CAC5D,IAAM,EAAU,EAAE,KAAK,EACvB,GAAI,IAAY,IAAM,EAAQ,YAAY,IAAM,KAAM,OAAO,KAC7D,IAAM,EAAW,EAAQ,SAAS,GAAG,EAC/B,EAAS,EAAQ,SAAS,GAAG,EAC/B,EACJ,GAAI,GAAY,EAGd,EAAU,EAAQ,QAAQ,MAAO,EAAE,CAAC,CAAC,QAAQ,IAAK,GAAG,OAChD,GAAI,EACT,EAAU,EAAQ,QAAQ,IAAK,GAAG,OAC7B,GAAI,EACT,MAAO,SAEP,GAAU,EAGZ,OADK,EAAiB,KAAK,CAAO,EAC3B,OAAO,WAAW,CAAO,EADY,GAE9C,CC7CA,SAAgB,EAAW,EAAyC,CAClE,GAAI,OAAO,GAAM,SAAU,OAAO,KAClC,IAAM,EAAI,EAAE,KAAK,EACjB,OAAO,IAAM,GAAK,KAAO,CAC3B,CAaA,SAAgB,EAAW,EAAyC,CAKlE,OAJI,GAAyB,KAAa,CAAE,KAAM,QAAS,EACvD,OAAO,GAAM,SACR,OAAO,MAAM,CAAC,EAAI,CAAE,KAAM,SAAU,EAAI,CAAE,KAAM,QAAS,MAAO,CAAE,EAEpE,EAAE,KAAK,IAAM,GAAK,CAAE,KAAM,QAAS,EAAI,CAAE,KAAM,SAAU,CAClE,CAGA,SAAgB,EAAS,EAAmB,EAAiB,EAAe,CAC1E,MAAO,CAAE,KAAM,QAAkB,MAAO,CAAE,YAAW,UAAS,OAAM,CAAE,CACxE,CCvDA,MAAa,EAAoB,EAAE,OAAO,CACxC,QAAS,EAAE,OAAO,CAAC,CAAC,KAAK,EACzB,UAAW,EAAE,OAAO,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,EAAE,EACnC,WAAY,EAAE,OAAO,CAAC,CAAC,KAAK,EAC5B,oBAAqB,EAAE,OAAO,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,GAAG,EAC9C,cAAe,EAAE,OAAO,CAAC,CAAC,KAAK,EAE/B,mBAAoB,EACjB,OAAO,CAAC,CACR,IAAI,CAAC,CAAC,CACN,MAAM,eAAgB,uCAAuC,CAAC,CAC9D,SAAS,EAQZ,eAAgB,EAAE,OAAO,EAAE,OAAO,EAAG,EAAE,OAAO,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,GAAG,CAAC,CAAC,CAAC,SAAS,CAC5E,CAAC,EAIK,EAAc,IAAI,QAExB,SAAS,EAAc,EAA2C,CAChE,IAAM,EAAS,EAAY,IAAI,CAAG,EAClC,GAAI,EAAQ,OAAO,EACnB,IAAM,EAAS,EAAkB,MAAM,CAAG,EAE1C,OADA,EAAY,IAAI,EAAK,CAAM,EACpB,CACT,CAGA,MAAM,EAAe,CAAC,KAAM,KAAM,KAAM,KAAM,KAAM,KAAM,KAAM,IAAI,EAE9D,EAAW,GAAwC,QAAQ,IAsB3D,EAAuC,CAC3C,CACE,IAAK,OACL,MAAO,OACP,SAAU,GACV,KAAM,SAGN,QAAS,CACP,UACA,cACA,eACA,aACA,iBACA,MACA,eACA,OACA,gBACA,YACF,CACF,EACA,CACE,IAAK,YACL,MAAO,YACP,SAAU,GACV,KAAM,SACN,QAAS,CAAC,QAAS,qBAAsB,aAAc,OAAO,CAChE,EACA,CACE,IAAK,cACL,MAAO,cACP,SAAU,GACV,KAAM,SACN,QAAS,CAAC,cAAe,QAAQ,CACnC,EACA,CACE,IAAK,aACL,MAAO,aACP,SAAU,GACV,KAAM,SAEN,QAAS,CAAC,eAAgB,MAAO,UAAW,gBAAiB,aAAa,CAC5E,EACA,CACE,IAAK,WACL,MAAO,WACP,SAAU,GACV,KAAM,SACN,QAAS,CAAC,cAAe,gBAAiB,SAAS,CACrD,EACA,CACE,IAAK,UACL,MAAO,UACP,SAAU,GACV,KAAM,SAKN,QAAS,CAAC,MAAO,WAAY,OAAQ,YAAa,WAAW,CAC/D,EACA,CACE,IAAK,eACL,MAAO,gBACP,SAAU,GACV,KAAM,SACN,QAAS,CAAC,cAAe,YAAa,cAAc,CACtD,EACA,CAAE,IAAK,eAAgB,MAAO,gBAAiB,SAAU,GAAO,KAAM,QAAS,EAC/E,CACE,IAAK,iBACL,MAAO,iBACP,SAAU,GACV,KAAM,SAON,QAAS,CAAC,gBAAiB,aAAc,cAAc,CACzD,EACA,CAKE,IAAK,mBACL,MAAO,2BACP,SAAU,GACV,KAAM,SAMN,QAAS,CACP,cACA,YACA,iBACA,WACA,gBACA,qBACA,kBACA,gBACF,CACF,EACA,CAKE,IAAK,gBACL,MAAO,wBACP,SAAU,GACV,KAAM,SAMN,QAAS,CACP,WACA,aACA,WACA,gBACA,cACA,kBACF,CACF,EAIA,CACE,IAAK,cACL,MAAO,cACP,SAAU,GACV,KAAM,SACN,QAAS,CAAC,YAAa,UAAW,eAAgB,cAAe,OAAO,CAC1E,EACA,GAAG,EAAa,IACb,IAAoB,CACnB,IAAK,EAAQ,CAAC,EACd,MAAO,SAAS,EAAE,GAClB,SAAU,GACV,KAAM,SAEN,QAAS,CAAC,eAAe,IAAK,QAAQ,GAAG,CAC3C,EACF,CACF,EAEM,EAAoB,SAQ1B,SAAgB,EAAc,EAAmB,CAC/C,OAAO,EAAE,YAAY,CAAC,CAAC,QAAQ,EAAmB,EAAE,CACtD,CAaA,SAAS,EACP,EACA,EACe,CACf,IAAM,EAAM,EAAW,CAAI,EAC3B,GAAI,IAAQ,KAAM,OAAO,KAKzB,IAAM,EAAa,EAHjB,GAAsB,EAAI,WAAW,CAAkB,EACnD,EAAI,MAAM,EAAmB,MAAM,EACnC,CACmC,EACzC,OAAO,IAAe,GAAK,KAAO,CACpC,CAQA,SAAS,EAA2B,EAAmB,EAA6B,CAClF,OAAO,KAAK,MAAM,GAAa,EAAI,EAAc,IAAI,CACvD,CA0BA,SAAS,EAAiB,EAAwC,CAUhE,OATI,IAAU,KACL,CACL,kBAAmB,KACnB,qBAAsB,KACtB,uBAAwB,KACxB,sBAAuB,KACvB,0BAA2B,IAC7B,EAEK,CACL,kBAAmB,EACnB,qBAAsB,MACtB,uBAAwB,GACxB,sBAAuB,KACvB,0BAA2B,EAC7B,CACF,CAOA,MAAa,EAA2C,CACtD,KAAM,QACN,OAAQ,EACR,SAAS,EAAmB,EAAsB,CAChD,IAAM,EAAS,EAAc,EAAI,MAAM,EAGjC,EAAU,OAAO,EAAO,MAAS,SAAW,EAAO,KAAK,KAAK,EAAI,GACvE,GAAI,CAAC,EAAS,OAAO,EAAS,eAAgB,gBAAiB,MAAM,EAKrE,IAAM,EAAiB,EAHrB,EAAO,oBAAsB,EAAQ,WAAW,EAAO,kBAAkB,EACrE,EAAQ,MAAM,EAAO,mBAAmB,MAAM,EAC9C,CACuC,EAC7C,GAAI,CAAC,EACH,OAAO,EACL,wBACA,SAAS,EAAQ,gCACjB,MACF,EAOF,IAAM,EADe,EAAW,EAAO,QACX,GAAK,MACjC,GAAI,IAAa,MACf,OAAO,EACL,uBACA,aAAa,EAAS,4BACtB,UACF,EAWF,GAAI,EAAe,SAAS,IAAe,EAAG,CAC5C,IAAM,EAAmB,EAAe,MAAM,EAAG,EAAuB,EACxE,GAAI,CAAC,EACH,OAAO,EACL,wBACA,iBAAiB,EAAQ,2CACzB,MACF,EAEF,IAAM,EAAU,EAAW,EAAO,SAAS,EAC3C,GAAI,EAAQ,OAAS,UACnB,OAAO,EACL,sBACA,8CACA,WACF,EAEF,GAAI,EAAQ,OAAS,SACnB,OAAO,EACL,sBACA,2DACA,WACF,EAKF,IAAM,EAAe,EAAa,EAAQ,KAAK,EAa/C,OAZI,GAAgB,EACX,CAAE,KAAM,OAAQ,OAAQ,eAAgB,EAW1C,CAAE,KAAM,OAAQ,OAAQ,qBAAsB,QAAS,CAAC,CAR7D,GAAI,GAAG,EAAiB,IAAI,EAAO,aACnC,IAAK,EAAiB,CAAY,EAClC,cAAe,CACb,YAAa,qBACb,QAAS,iEACT,MAAO,MACT,CAEiE,CAAC,CAAE,CACxE,CAGA,IAAM,EAAM,EAAW,EAAO,SAAS,EACvC,GAAI,EAAI,OAAS,UACf,OAAO,EAAS,oBAAqB,4BAA6B,WAAW,EAC/E,IAAM,EAAQ,EAAW,EAAO,WAAW,EAC3C,GAAI,EAAM,OAAS,UACjB,OAAO,EAAS,sBAAuB,8BAA+B,aAAa,EAErF,IAAM,EAAO,EAAW,EAAO,UAAU,EACzC,GAAI,EAAK,OAAS,UAChB,OAAO,EAAS,qBAAsB,6BAA8B,YAAY,EAElF,IAAM,EAAW,EAAI,OAAS,QAAU,EAAI,MAAQ,KAC9C,EAAY,EAAK,OAAS,QAAU,EAAK,MAAQ,KAIjD,EAAe,GAAY,EACjC,GAAI,IAAiB,KACnB,OAAO,EAAS,gBAAiB,sCAAuC,OAAO,EASjF,IAAM,EAAgB,EAAW,EAAO,cAAc,EAChD,EAAY,IAAc,KAAO,KAAO,EAAa,CAAS,EAChE,EAAoC,KAClC,EAAyB,CAAC,EAChC,GAAI,IAAa,KACf,EAAqB,EAAa,CAAQ,OACrC,GAAI,IAAc,KAAM,CAE7B,IAAM,EACJ,IAAkB,KAAgD,IAAA,GAAzC,EAAO,iBAAiB,GAC/C,IAAgB,IAAA,GAGlB,EAAS,KAAK,CACZ,YAAa,oBACb,QACE,IAAkB,KACd,qEACA,sBAAsB,EAAc,+CAC1C,MAAO,gBACT,CAAC,EATD,EAAqB,EAA2B,EAAW,CAAW,CAW1E,CAYA,IAAM,EAA2B,CAAC,EAC5B,EAAwB,EAC5B,EAAO,iBACP,EAAO,kBACT,EACI,IAA0B,MAAQ,IAA0B,GAC9D,EAAM,KAAK,CAAE,KAAM,EAAgB,GAAI,CAAsB,CAAC,EAEhE,IAAM,EAAqB,EAAkB,EAAO,cAAe,EAAO,kBAAkB,EACxF,IAAuB,MAAQ,IAAuB,GACxD,EAAM,KAAK,CAAE,KAAM,EAAoB,GAAI,CAAe,CAAC,EAS7D,IAAM,EAAiB,EAAW,EAAO,WAAW,EACpD,GAAI,EAAe,OAAS,UAC1B,OAAO,EAAS,sBAAuB,8BAA+B,aAAa,EAGrF,IAAM,EACJ,EAAe,OAAS,QAAU,EAAa,EAAe,KAAK,EAAI,KACnE,EACJ,IAAuB,MAAQ,EAAqB,EAAI,EAAqB,KAEzE,EAAQ,GAAG,EAAe,IAAI,EAAO,aAmC3C,MAAO,CACL,KAAM,UACN,GAAI,EACJ,IAAA,CApCA,OAAQ,EACR,KAAM,EACN,gBAAiB,EACjB,SAAU,EAAO,QACjB,WAAY,EAAO,UACnB,YAAa,EAAO,WACpB,sBAAuB,EAAO,oBAC9B,cAAe,EACf,gBAAiB,EAAM,OAAS,QAAU,EAAM,MAAQ,KACxD,SAAU,MACV,QAAS,EAAW,EAAO,OAAO,EAClC,QAAS,EAAW,EAAO,OAAO,EAClC,QAAS,EAAW,EAAO,OAAO,EAClC,QAAS,EAAW,EAAO,OAAO,EAClC,QAAS,EAAW,EAAO,OAAO,EAClC,QAAS,EAAW,EAAO,OAAO,EAClC,QAAS,EAAW,EAAO,OAAO,EAClC,QAAS,EAAW,EAAO,OAAO,EAClC,QAAS,EAAW,EAAO,OAAO,EAClC,aAAc,EAAW,EAAO,YAAY,EAC5C,aAAc,EAAW,EAAO,YAAY,EAE5C,iBAAkB,EAClB,eAAgB,EAChB,iBAAkB,EAAW,EAAO,gBAAgB,EAEpD,qBAAsB,EAEtB,GAAG,EAAiB,CAAe,EACnC,gBAAiB,EAAO,cACxB,YAAa,IAAI,KAAK,CAAA,CAAE,YAAY,CAMlC,EACF,GAAI,EAAS,OAAS,GAAK,CAAE,UAAS,EACtC,GAAI,EAAM,OAAS,GAAK,CAAE,OAAM,EAOhC,GAAI,IAAuB,MAAQ,CAAE,YAAa,CAAmB,CACvE,CACF,CACF"}
|
package/dist/plugin.d.mts.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"plugin.d.mts","names":[],"sources":["../src/plugin.ts"],"mappings":";;;;;UAwFiB,oBAAA;;;;;;EAMf,QAAA,EAAU,gBAAA;;;;;;;;;;;;;;;;;EAiBV,OAAA;;;;;;;;;;;EAWA,WAAA,GAAc,mBAAA;;;;;;;;;;;;;;;;EAgBd,WAAA,GAAc,WAAA;;;;;;;;;;;;;;;EAed,eAAA,GAAkB,gBAAA;;;;;;;;EAQlB,QAAA,GAAW,QAAA;;;;;;;;;;;;;;;;;;EAkBX,OAAA,GAAU,qBAAA;;;;;;;;;;;;;;;EAeV,aAAA;;;;;;;;;;;;;;;EAeA,oBAAA;;;;;;;;;;;;;EAaA,sBAAA;AAAA;AAAA,iBAGc,OAAA,CAAQ,OAAA,EAAS,oBAAA,GAAoB,IAAA,6BAAA,MAAA;EAAA"}
|
package/dist/plugin.mjs.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"plugin.mjs","names":[],"sources":["../src/reindex-job.ts","../src/plugin.ts"],"sourcesContent":["/**\n * Queue handler for `imports:reindex` — the OPERATOR ENTRY POINT for the\n * alias-versioned reindex (PLAN-ECOMMERCE D6).\n *\n * `@murumets-ee/search-elasticsearch` has shipped `createIndex` → `reindex` →\n * `swapAlias` since commerce-demo PR1, and until now nothing could invoke them\n * on a running box: `reindex()` was exported, unit-tested, and had zero callers\n * outside its own test file. That is not a cosmetic gap. `ensureAliasedIndex`\n * returns the moment the alias resolves and NEVER issues a `put_mapping`, so\n * editing `partsIndexConfig` has no effect whatsoever on a cluster that has\n * already been bootstrapped — which makes every future mapping change\n * unshippable until this job exists. commerce-launch PR 13 hit exactly that\n * constraint and routed around it (reading `supplier.leadTimeDaysDefault` live\n * rather than denormalising it into the doc).\n *\n * **Why a queue job and not a CLI command that does the work itself.** The ES\n * client is APP-level wiring (`apps/<app>/lib/parts-search.ts`), injected into this\n * plugin as `imports({ esClient })`. `@murumets-ee/cli` has no Elasticsearch\n * dependency and should not grow one for a parts-shaped command. So the CLI\n * enqueues by job NAME (`lumi imports:reindex`) exactly as `lumi paths:rebuild`\n * enqueues `content:paths-rebuild`, and the worker — which already holds the\n * client — does the work. The job also shows up in the queue admin page with\n * live progress, for free.\n *\n * **Safety contract**, inherited from `reindex()` and extended here:\n * - The destination is created explicitly with the app-supplied\n * {@link ImportsPluginOptions.indexConfig}, so the new mapping is what\n * actually lands. Reindexing into an auto-created index would silently\n * defeat the entire point.\n * - The alias is swapped ONLY when every document that could be copied WAS\n * copied. A bulk failure leaves the alias pointing at the source, so the live\n * read target is never half-built. Note the exact shape: `reindex()` also\n * counts `skipped` hits (a source document with no `_id` or no `_source`) and\n * those do NOT block the swap — so this job surfaces the count in its\n * progress blob and the CLI prints it, because a silent skip plus\n * `--drop-old` is how you lose documents and are told it succeeded.\n * - ⚠️ **The job cannot be interrupted once it starts.** `JobContext` carries\n * no `AbortSignal`, so the queue admin's \"cancel\" only flips the ROW to\n * `dead` — the handler runs to completion and still swaps the alias (and\n * still drops the source, if `--drop-old`). Treat the enqueue as the\n * point of no return, not the poll.\n * - A pre-existing destination is REFUSED unless `allowExisting` is set. The\n * resume case is real (`bulkUpsert` is idempotent by `_id`, so re-running\n * into a partly-filled destination is correct and cheap), but it must be an\n * explicit operator decision — silently reusing an index somebody created by\n * hand, with who-knows-what mapping, is how a mapping change gets lost.\n * - The OLD index is never dropped automatically. `dropOld` exists, defaults\n * off, and the confidence window is the operator's call.\n * - ⚠️ **Documents written to the SOURCE while the copy runs are LOST at the\n * swap.** The alias still points at the source throughout, so a concurrent\n * `imports:run` writes there, and a page this job already read is never\n * re-read.\n *\n * GUARDED, NOT SOLVED. The job refuses to start while any `import_run` is\n * `pending` or `running`, and refuses to swap if one appears during the copy\n * (`beforeSwap`) — which covers the realistic case, an operator running both\n * at once. It does NOT close the window: a write landing between the final\n * check and the swap is still lost, as is one that landed on a page already\n * copied. Closing it properly means dual-writing during the copy;\n * `django-elasticsearch-dsl` documents the identical hole in its own\n * alias-swap path rather than solving it. The guard needs `importRuns`\n * wired — without it nothing stands between an import and this race.\n *\n * That last one is a DELIBERATE deviation from prior art, so it is worth the\n * sentence: searchkick, django-elasticsearch-dsl and Elastic's own runbook all\n * delete the old index by default (retention is the opt-in flag). They can,\n * because their reindex rebuilds from the system of record — a Rails model, a\n * Django queryset — so the old index is a derived artifact that can always be\n * regenerated. Ours copies index → index. The source index IS the only copy of\n * pre-swap state, so dropping it by default would make a bad mapping\n * discovered after the swap unrecoverable, when keeping it makes recovery a\n * single `swapAlias` back. It also matches the D6 contract already written on\n * `reindex()`: \"caller decides when to dropIndex(fromIndex) after a confidence\n * window\".\n */\n\nimport type { Logger } from '@murumets-ee/core'\nimport { defineJob, type JobDefinition } from '@murumets-ee/queue/client'\nimport {\n createIndex,\n dropIndex,\n type IndexConfig,\n readAliasTargets,\n reindex,\n} from '@murumets-ee/search-elasticsearch'\nimport { inArray } from 'drizzle-orm'\nimport type { PgColumn } from 'drizzle-orm/pg-core'\nimport { z } from 'zod'\nimport type { ImportRunClient } from './entities/import-run.js'\nimport type { EsClientResolver } from './worker.js'\n\n/**\n * Payload schema. Every field is optional — the common invocation is\n * `lumi imports:reindex` with no flags, which reads the alias, derives\n * `<alias>_v<N+1>`, and copies.\n */\nexport const importsReindexJobPayloadSchema = z.object({\n /**\n * Destination physical index. Omit to derive `<alias>_v<N+1>` from whatever\n * the alias currently points at. Pass explicitly when the current physical\n * index does not follow the `_v<N>` convention, or to reindex into a name of\n * your own choosing.\n */\n toIndex: z.string().min(1).optional(),\n /**\n * Continue into a destination index that already exists. Off by default: an\n * existing destination is normally the debris of a failed run, and reusing it\n * blind would apply whatever mapping it was created with rather than the one\n * you just changed.\n */\n allowExisting: z.boolean().optional(),\n /** Drop the source index after a successful swap. Off by default. */\n dropOld: z.boolean().optional(),\n /** Documents per copy batch. Defaults to `reindex()`'s own default (1000). */\n batchSize: z.number().int().min(1).max(10_000).optional(),\n})\nexport type ImportsReindexJobPayload = z.infer<typeof importsReindexJobPayloadSchema>\n\n/**\n * Progress blob written to `toolkit_jobs.progress`. Small and fixed-shape — the\n * queue caps the blob at ~7 KB and silently DROPS anything larger, so this\n * carries counters and index names only.\n */\nexport interface ImportsReindexProgress {\n phase: 'creating' | 'resuming' | 'copying' | 'swapped'\n fromIndex: string\n toIndex: string\n processed: number\n total: number\n /**\n * Source hits `reindex()` could not copy (missing `_id` or `_source`). Carried\n * here rather than left in the worker log because it does NOT block the swap:\n * without it the CLI would report a clean success over an index that is\n * missing rows, and `--drop-old` would then delete the only copy of them.\n */\n skipped?: number\n}\n\n/**\n * Job definition. Registered by `imports()` in `plugin.ts`.\n *\n * `defaultRetries: 0` — a reindex is a deliberate operator action over a large\n * index, and an automatic retry would race the run it is retrying (both writing\n * the same destination, both racing to swap). The operator re-runs, which is\n * safe by design: `bulkUpsert` is idempotent by `_id`, so a re-run with\n * `allowExisting` resumes rather than duplicates.\n *\n * ⚠️ **This field does NOT bind a caller that enqueues by NAME.**\n * `QueueClient.enqueue` reads `defaultRetries` off this OBJECT, and the only\n * shipped caller — `lumi imports:reindex` — enqueues by string, because this\n * module sits behind the queue client that the CLI keeps off its load graph. It\n * therefore passes `{ maxRetries: 0 }` explicitly (`REINDEX_ENQUEUE_OPTIONS` in\n * `@murumets-ee/cli`), and any FUTURE by-name caller must do the same or\n * silently get 3 retries — reproducing the exact failure this setting exists to\n * prevent, with the real cause hidden behind an \"already exists\" `lastError`\n * from a bounced retry.\n */\nexport const importsReindexJob: JobDefinition<ImportsReindexJobPayload> = defineJob({\n name: 'imports:reindex',\n description:\n 'Copy the aliased parts index into a new physical index built with the current mapping, then atomically swap the alias onto it.',\n schema: importsReindexJobPayloadSchema,\n defaultRetries: 0,\n})\n\n/** Structural subset of `JobContext` this handler reads — mirrors `ImportsRunJobContext`. */\nexport interface ImportsReindexJobContext {\n id: string\n payload: ImportsReindexJobPayload\n updateProgress(data: ImportsReindexProgress): void\n}\n\nexport interface ReindexHandlerConfig {\n /** Resolver for the ES client. */\n esClient: EsClientResolver\n /** The alias to reindex. Same value the import path writes to. */\n esIndex: string\n /**\n * Settings + mappings the DESTINATION index is created with. Without it there\n * is no way to apply a mapping change, so the handler refuses to run rather\n * than copy into a default-mapped index.\n */\n indexConfig?: IndexConfig\n /**\n * AdminClient over `import_run`, used to refuse a reindex while a feed import\n * is in flight. Optional so a consumer without the entity wired still gets a\n * working reindex — but omitting it removes the ONLY guard against the\n * lost-writes race described in the safety contract.\n */\n importRuns?: ImportRunClient\n /** Optional structured logger. Defaults to silent. */\n logger?: Logger\n}\n\n/** Statuses that mean a feed import is writing, or is about to. */\nconst ACTIVE_IMPORT_STATUSES = ['pending', 'running'] as const\n\n/**\n * Count `import_run` rows that are writing or about to write.\n *\n * A feed import bulk-writes THROUGH THE ALIAS, which points at the source for\n * the whole copy — so its documents land in the index we are replacing and are\n * never copied forward. `pending` counts as active because a queued run can\n * start mid-copy.\n */\nasync function countActiveImports(importRuns: ImportRunClient): Promise<number> {\n // `getTable()` returns `PgTableWithColumns<any>`; narrow just the column we\n // touch so `inArray()` and type-coverage both see a typed value. Same shape\n // the dead-listener in `worker.ts` uses.\n const table = importRuns.getTable() as unknown as { status: PgColumn }\n return importRuns.count({ where: inArray(table.status, [...ACTIVE_IMPORT_STATUSES]) })\n}\n\n/** `<base>_v<N>` → `<base>_v<N+1>`. Returns null when the name doesn't follow the convention. */\nexport function nextVersionedIndexName(current: string): string | null {\n const match = /^(.*)_v(\\d+)$/.exec(current)\n if (!match) return null\n const [, base, version] = match\n if (base === undefined || version === undefined) return null\n const next = Number.parseInt(version, 10) + 1\n if (!Number.isSafeInteger(next)) return null\n return `${base}_v${next}`\n}\n\n/**\n * Build the `JobHandler` for {@link importsReindexJob}.\n *\n * Deliberately NOT wrapped in `runAsCli`, unlike `createRunImportHandler`. That\n * wrapper is a privilege grant (`checker: () => true`, `groups: ['admin']`) and\n * this handler performs no entity operation at all — it only talks to\n * Elasticsearch. Per CLAUDE.md's \"reach is not authority\", acquiring an admin\n * identity for a path that never uses one is exactly the habit that makes the\n * elevation allowlist meaningless. If a future change here needs an\n * `AdminClient`, add the wrapper then, with the reason.\n */\nexport function createReindexHandler(\n config: ReindexHandlerConfig,\n): (job: ImportsReindexJobContext) => Promise<void> {\n const { esClient, esIndex, indexConfig, importRuns, logger } = config\n\n return async (job: ImportsReindexJobContext): Promise<void> => {\n const log = logger?.child({ jobId: job.id, type: 'imports:reindex' })\n\n if (indexConfig === undefined) {\n throw new Error(\n `imports:reindex requires imports({ indexConfig }) — the destination index must be ` +\n `created with the settings + mappings this app intends, otherwise Elasticsearch ` +\n `auto-creates it with inferred text mappings and the reindex silently discards the ` +\n `mapping change it exists to ship. Pass your app's index config (for the parts ` +\n `catalog: partsIndexConfig from @murumets-ee/search-elasticsearch).`,\n )\n }\n\n // Validate the payload HERE, not at enqueue. The job's Zod schema is\n // applied by `QueueClient.enqueue` only for the `defineJob`-object form,\n // and the CLI enqueues by NAME (the definition sits behind the queue\n // client, which the CLI keeps off its load graph) — so without this parse\n // the schema is decorative and `batchSize` is unbounded on the worker\n // side. `content:paths-rebuild` parses in its handler for the same reason.\n //\n // Ordered AFTER the `indexConfig` check on purpose: a misconfigured app\n // should read \"pass imports({ indexConfig })\", not a multi-line ZodError\n // dump, and the CLI is the only thing that produces payloads anyway.\n const payload = importsReindexJobPayloadSchema.parse(job.payload)\n\n // Refuse to START while a feed import is in flight. An import writes\n // THROUGH the alias, which points at the source for the whole copy, so its\n // documents land in the index being replaced and are never copied forward —\n // the swap would silently drop them. Checked here so the operator is told\n // before minutes of copying, and again just before the swap for a run that\n // started in between.\n if (importRuns !== undefined) {\n const active = await countActiveImports(importRuns)\n if (active > 0) {\n throw new Error(\n `imports:reindex: ${active} feed import(s) are pending or running. An import writes ` +\n `through the alias \"${esIndex}\", which points at the SOURCE index for the whole copy, ` +\n `so anything it writes now would be lost at the swap. Wait for them to finish (or ` +\n `cancel them) and re-run.`,\n )\n }\n }\n\n const client = await esClient()\n\n // An alias may legitimately point at MORE than one physical index (that is\n // how a rolling reindex looks mid-flight). `readAliasIndex` collapses that\n // to a single name, so proceeding would copy only one of them, then swap —\n // leaving the alias on {untouched sibling + new index} and quietly serving\n // a union of stale and fresh documents, with `--drop-old` deleting only\n // the one that was read. Refuse, and name what we found.\n const aliasTargets = Object.keys(await readAliasTargets(client, esIndex)).sort()\n if (aliasTargets.length > 1) {\n throw new Error(\n `imports:reindex: alias \"${esIndex}\" points at ${aliasTargets.length} indices ` +\n `(${aliasTargets.join(', ')}). Reindexing would copy only one of them and then ` +\n `swap, leaving the alias on a mix of stale and fresh data. Resolve the alias to a ` +\n `single index first, then re-run. An interrupted reindex cannot cause this — ` +\n `swapAlias sends its remove with must_exist, so a stale swap fails instead of ` +\n `adding a second target. Look for a manual alias edit, another tool writing to this ` +\n `cluster, or a swap issued before that guard existed.`,\n )\n }\n\n // Taken from the SAME read as the guard above, not a second `getAlias`. Two\n // round trips would leave a window in which a target added between them\n // passes the guard and is then silently collapsed by `readAliasIndex`'s\n // tiebreak — the guard defeated by the very state it exists to refuse.\n const fromIndex = aliasTargets[0]\n if (fromIndex === undefined) {\n throw new Error(\n `imports:reindex: alias \"${esIndex}\" does not exist, so there is nothing to reindex. ` +\n `Run an import first — imports({ ensureIndex }) bootstraps the alias on the import path.`,\n )\n }\n\n const derived = payload.toIndex ?? nextVersionedIndexName(fromIndex)\n if (derived === null) {\n throw new Error(\n `imports:reindex: alias \"${esIndex}\" points at \"${fromIndex}\", which does not follow the ` +\n `\"<name>_v<N>\" convention, so the next version cannot be derived. Pass an explicit ` +\n `destination (\\`lumi imports:reindex --to <index>\\`).`,\n )\n }\n const toIndex = derived\n\n if (toIndex === fromIndex) {\n throw new Error(\n `imports:reindex: destination \"${toIndex}\" is the index the alias already points at. ` +\n `Reindexing an index onto itself would copy every document over itself and swap the ` +\n `alias onto where it already is. Pass a different --to.`,\n )\n }\n\n const destExists = await client.indices.exists({ index: toIndex })\n if (destExists && payload.allowExisting !== true) {\n throw new Error(\n `imports:reindex: destination \"${toIndex}\" already exists. If it is debris from a failed ` +\n `run, re-run with --resume to continue into it (bulkUpsert is idempotent by _id, so ` +\n `already-copied documents are simply overwritten); if it was created by hand with a ` +\n `different mapping, delete it or pass --to <another-index> instead. Refusing to reuse ` +\n `it silently, because doing so would apply ITS mapping rather than the one you changed.`,\n )\n }\n\n // Distinct phases, because they are materially different operations and\n // the operator can only see the progress blob. A `--resume` reuses an\n // index somebody else created, so `indexConfig` is NOT applied — and if\n // the reason for the reindex was a mapping change, that means it silently\n // does not ship. Saying so in the worker log alone would be saying it\n // where the person running the command cannot read it.\n job.updateProgress({\n phase: destExists ? 'resuming' : 'creating',\n fromIndex,\n toIndex,\n processed: 0,\n total: 0,\n })\n\n if (!destExists) {\n log?.info({ fromIndex, toIndex }, 'imports:reindex: creating destination index')\n await createIndex(client, { name: toIndex, config: indexConfig })\n } else {\n log?.warn(\n { fromIndex, toIndex },\n 'imports:reindex: resuming into an existing destination index (--resume); its EXISTING mapping applies, not indexConfig',\n )\n }\n\n log?.info({ fromIndex, toIndex }, 'imports:reindex: copying documents')\n const result = await reindex({\n client,\n alias: esIndex,\n fromIndex,\n toIndex,\n // ONE refresh before the swap. NOT `refresh: 'wait_for'`, which is the\n // obvious-looking choice and is a trap: it blocks until the next\n // SCHEDULED refresh, per batch, and the parts index sets\n // `refresh_interval: '30s'` — hours of waiting for a large catalogue,\n // to achieve what one final refresh achieves in milliseconds.\n refreshOnCompletion: true,\n ...(payload.batchSize !== undefined && { batchSize: payload.batchSize }),\n // Re-check immediately before the swap: an import that STARTED during the\n // copy wrote to the source and is not in the destination. Throwing here\n // leaves the alias untouched and the destination in place, so `--resume`\n // can finish the job once the import has.\n //\n // ⚠️ Narrows the race, does not close it. A write landing between this\n // check and the swap is still lost, and so is one that landed on a page\n // already copied. Closing it properly needs dual-writes during the copy.\n ...(importRuns !== undefined && {\n beforeSwap: async () => {\n const active = await countActiveImports(importRuns)\n if (active > 0) {\n throw new Error(\n `imports:reindex: ${active} feed import(s) started while the copy was running. ` +\n `Their documents went to the source index through the alias and are NOT in ` +\n `\"${toIndex}\", so swapping now would drop them. The alias is untouched and ` +\n `\"${toIndex}\" is left in place — wait for the import(s), then re-run with ` +\n `--resume to finish.`,\n )\n }\n },\n }),\n reportProgress: async ({ processed, total }) => {\n job.updateProgress({ phase: 'copying', fromIndex, toIndex, processed, total })\n },\n })\n\n job.updateProgress({\n phase: 'swapped',\n fromIndex,\n toIndex,\n processed: result.processed,\n total: result.processed + result.skipped,\n skipped: result.skipped,\n })\n log?.info(\n { fromIndex, toIndex, processed: result.processed, skipped: result.skipped },\n 'imports:reindex: alias swapped onto destination',\n )\n\n if (payload.dropOld === true) {\n await dropIndex(client, fromIndex)\n log?.info({ fromIndex }, 'imports:reindex: dropped source index (--drop-old)')\n }\n }\n}\n","/**\n * Imports plugin — registers the `import_run` entity and (when ES + queue\n * are wired) registers the `imports:run` and `imports:reindex` job handlers.\n *\n * Per PLAN-ECOMMERCE.md PR 7 (PoC scope): plumbing only. The bespoke\n * admin pages (upload form, parts search) live in PR 8a.\n *\n * **Built-in transforms register HERE, at plugin init.** Both transforms\n * this package ships (`commerce:mapping`, `commerce:carmaker-feed`) go\n * into the process-global registry inside `init`, so every process that\n * boots the plugin — Next.js web server, standalone queue worker — can\n * dispatch them with zero app-side wiring. Registration used to be the\n * app's job (`lib/app.ts` calling `registry.register(...)`), which is\n * exactly the drift foot-gun CLAUDE.md's \"no forgotten registrations\"\n * rule warns about: an app scaffolded before the mapping transform\n * existed keeps its old `lib/app.ts` through `pnpm up` toolkit upgrades,\n * and every mapping-driven run then dies in the worker with\n * `No transform registered for name \"commerce:mapping\"`. App-side\n * registration remains harmless even when unguarded —\n * `registerImportTransform` overwrites last-wins instead of throwing —\n * it is just no longer required.\n *\n * @example\n * ```ts\n * import { defineLumiConfig } from '@murumets-ee/core'\n * import { imports } from '@murumets-ee/imports/plugin'\n * import { Client } from '@elastic/elasticsearch'\n *\n * const es = new Client({ node: process.env.ES_URL })\n *\n * export default defineLumiConfig({\n * plugins: [\n * // queue() must be present too — it runs the imports:run jobs.\n * queue(),\n * imports({ esClient: () => es, esIndex: 'parts', indexConfig: partsIndexConfig }),\n * ],\n * })\n * ```\n */\n\nimport { definePlugin } from '@murumets-ee/core'\nimport type { IndexConfig } from '@murumets-ee/search-elasticsearch'\n// **Why no static `@murumets-ee/core/clients` import in this file.**\n//\n// `@murumets-ee/core/clients` begins with `import 'server-only'`, which\n// throws when this plugin module is loaded at `lumi.config.ts`\n// evaluation time by the CLI (`lumi migrate`/`seed`/`doctor` use jiti,\n// which doesn't apply the `react-server` export condition).\n// Static-importing would propagate that throw all the way through\n// `lumi.config.ts` → CLI startup, blocking every CLI invocation.\n//\n// **Today (after PLAN-DECLARATIVE-PLUGINS PR 2):** plugin `init`\n// reaches `AdminClient` via the framework-provided `app.getClient(\n// entity)` accessor — no `createAdminClient` call, no dynamic import\n// either. The remaining `@murumets-ee/core/clients` dependency lives\n// in `./admin/cleanup.js`, which IS still dynamic-imported inside\n// `init` and which still benefits from this rationale. The pattern\n// matches `getDb()` in `@murumets-ee/queue/admin.ts`.\nimport { ImportMapping } from './entities/import-mapping.js'\nimport { ImportRun } from './entities/import-run.js'\nimport { MAPPING_TRANSFORM_NAME, mappingTransform } from './mapping/mapping-transform.js'\nimport { createReindexHandler, importsReindexJob } from './reindex-job.js'\nimport { type ImportsStorageFactory, registerImportsStorageFactory } from './storage.js'\nimport { getTransformRegistry } from './transform.js'\nimport { CARMAKER_FEED_TRANSFORM_NAME, carmakerFeedTransform } from './transforms/carmaker-feed.js'\nimport {\n createImportsRunDeadListener,\n createRunImportHandler,\n type EnsureIndexResolver,\n type EsClientResolver,\n type FilePathResolver,\n importsRunJob,\n type PairSink,\n} from './worker.js'\n\n/**\n * `Symbol.for`-keyed slot for the active dead-listener unsubscribe\n * handle. Symbol.for so an HMR-replaced module instance still finds\n * the previous unsubscribe (`Symbol`-by-reference would skip the\n * lookup across module-evaluation boundaries). Same pattern as\n * `transform.ts`'s registry slot and `storage.ts`'s factory slot.\n */\nconst IMPORTS_DEAD_LISTENER_KEY = Symbol.for('@murumets-ee/imports:dead-listener-unsubscribe')\n\ninterface GlobalThisWithUnsubscribe {\n [IMPORTS_DEAD_LISTENER_KEY]?: () => void\n}\n\nexport interface ImportsPluginOptions {\n /**\n * Elasticsearch client resolver. Lazy so consumers can construct\n * the client inside a route initialiser instead of at plugin-init\n * time. Required — without it, the worker can't bulk-write.\n */\n esClient: EsClientResolver\n /**\n * ES index alias to write to. Per D6 always pass an ALIAS, never a physical\n * index — that is what makes {@link importsReindexJob}'s atomic swap possible.\n *\n * **Required, deliberately.** It defaulted to `'parts'` until #665: two apps\n * sharing one Elasticsearch cluster both took that default and wrote the same\n * physical index, so staging's 858k-row Ford import surfaced in production's\n * parts search. The pod workaround was to set the alias per deployment — but\n * a DEFAULT here means an app can set `PARTS_INDEX_ALIAS`, wire it into the\n * read path, forget this option, and have its reads and writes silently name\n * different indices. That is the same defect one level down, and it fails\n * SILENTLY. Making the option required turns it into a compile error instead.\n *\n * Pass the SAME value the search provider's `indexAlias` uses; the app's\n * `lib/parts-search.ts` exports one `partsIndexAlias` const for exactly this.\n */\n esIndex: string\n /**\n * Optional bootstrap for the destination index/alias, run once per import\n * job before any bulk-write (see {@link EnsureIndexResolver}). Wire your\n * app's `ensureAliasedIndex`-backed helper here so the worker can never\n * auto-create a mis-mapped physical index — WITHOUT gating app boot on ES.\n *\n * Omit only when the index is bootstrapped out-of-band; without it a\n * first-ever import against an unbootstrapped alias lets ES auto-create a\n * `text`-mapped physical index, breaking aggregations (issue #610).\n */\n ensureIndex?: EnsureIndexResolver\n /**\n * Settings + mappings the `imports:reindex` job creates its DESTINATION index\n * with. Pass the same config `ensureIndex` bootstraps the alias from (for the\n * parts catalog: `partsIndexConfig` from `@murumets-ee/search-elasticsearch`).\n *\n * Omit it and the job still registers, but refuses to run with an actionable\n * error — a reindex into an index Elasticsearch auto-created with inferred\n * `text` mappings would silently discard the mapping change the reindex\n * exists to ship, which is a worse outcome than not running.\n *\n * This is what makes a mapping change deployable at all: `ensureAliasedIndex`\n * returns as soon as the alias resolves and never issues a `put_mapping`, so\n * on an already-bootstrapped cluster, editing the mapping config has no\n * effect whatsoever without a reindex.\n */\n indexConfig?: IndexConfig\n /**\n * Resolve `import_run.filePath` to a local FS path before streaming.\n * **Required when uploads land in remote storage** (R2/S3/etc.) —\n * without it, the worker hands the storage key to `createReadStream`\n * and crashes with `ENOENT`.\n *\n * Typical wiring downloads the storage object to a tmpfile and\n * returns its path; the optional `cleanup` callback runs after the\n * run finishes (success or failure).\n *\n * Omit this option for the original on-disk PoC setup where the\n * upload route writes directly to a local directory and persists\n * the absolute path on `import_run.filePath`.\n */\n resolveFilePath?: FilePathResolver\n /**\n * Optional consumer for the directional supersession pairs the runner harvests as\n * a side-output (commerce-demo PR6, SD007). The app composes it — commerce exports\n * the replacement-merge sink and passes it here, same injection shape as\n * {@link esClient} / {@link resolveFilePath}. Omit when not ingesting replacement\n * codes; the pairs are then discarded after the run.\n */\n pairSink?: PairSink\n /**\n * Optional dedicated `StorageClient` factory for imports. When\n * provided, the upload route (in `@murumets-ee/commerce`) and the\n * worker resolver consult this client instead of the global\n * `getStorageConfig()` default — so a deployment can pin imports\n * to a local-disk adapter (single-VPS / dev) while media,\n * ticketing attachments, and the rest of the app keep using the\n * default R2 (or S3) adapter.\n *\n * Lazy / async so consumers can defer adapter construction until\n * the first import — useful when the local-disk root is\n * environment-dependent or when the factory needs `getApp()` to\n * have resolved.\n *\n * Omit to keep the legacy single-storage behaviour (everything on\n * the global default).\n */\n storage?: ImportsStorageFactory\n /**\n * Bucket name to scope the orphan-cleanup queue job. Rows in\n * `toolkit_files` with this bucket value AND no matching\n * `import_run.filePath` AND `createdAt < cutoff` get garbage-\n * collected on each cleanup tick. Defaults to `'lumi-imports'` —\n * the convention `apps/admin/lib/imports-storage.ts`\n * uses by default. Override if your deployment pins a different\n * bucket name via `IMPORTS_STORAGE_BUCKET` or equivalent.\n *\n * Set to `null` to DISABLE the cleanup job entirely (e.g. for\n * deployments that handle their own retention via S3 lifecycle\n * rules). Disabling without an alternative will leak storage\n * over time as failed uploads accumulate.\n */\n storageBucket?: string | null\n /**\n * Hours after which an unreferenced `toolkit_files` row in the\n * imports bucket is considered orphaned and deleted by the\n * cleanup job. Default 2h — long enough that a normal\n * upload-then-register round-trip never wipes a fresh upload,\n * short enough that storage waste from failed uploads doesn't\n * accumulate.\n *\n * The upload→register flow happens client-side automatically (no\n * \"operator clicks register\" step in the current UI), so the TTL\n * just needs to cover network blips + retries. Bumping the TTL\n * is safe if your operators commonly upload then wait before\n * processing.\n */\n orphanUploadTtlHours?: number\n /**\n * Days after which a terminal-status `import_run`'s source file is\n * deleted from `toolkit_files`. Default 7 days — gives operators a\n * reprocess window if a transform bug surfaces post-success, while\n * keeping storage costs bounded. Set to 0 for instant cleanup at\n * the next cleanup tick after `import_run.status` becomes terminal\n * (`succeeded` / `failed` / `cancelled`).\n *\n * The `import_run` row itself is preserved — the audit trail and\n * History panel still show the past run; only the source feed\n * file is removed.\n */\n processedRetentionDays?: number\n}\n\nexport function imports(options: ImportsPluginOptions) {\n const esIndex = options.esIndex\n // Resolve the cleanup config eagerly. `storageBucket === null` is the\n // explicit opt-out path; `undefined` falls back to the convention.\n // Two cleanup jobs share the same bucket but have independent TTLs:\n // orphans (no import_run ref) churn fast (2h); processed runs\n // (terminal status) get a longer reprocess window (7d).\n const cleanupConfig =\n options.storageBucket === null\n ? null\n : {\n bucket: options.storageBucket ?? 'lumi-imports',\n orphanTtlHours: options.orphanUploadTtlHours ?? 2,\n processedRetentionDays: options.processedRetentionDays ?? 7,\n }\n\n // Register the storage factory eagerly — runs at `imports()` factory\n // call time during `lumi.config.ts` evaluation, NOT inside the\n // `init` hook. The hook only fires at `createApp()` time (Next.js\n // request boot / worker boot), but the factory needs to be visible\n // to consumers that read it at module-load (e.g. the commerce\n // route's lazy `getStorageClient` thunk closes over a value that\n // resolves the moment a request comes in). Late registration in\n // `init` would race the first request.\n if (options.storage) {\n registerImportsStorageFactory(options.storage)\n }\n\n return definePlugin({\n name: '@murumets-ee/imports',\n server: {\n entities: [ImportRun, ImportMapping],\n init: async (app) => {\n // Built-in transforms — registered FIRST, before the queue-plugin\n // probe below, so the registry reflects this package's dispatch\n // capability even in queue-less processes (the admin Catalog tab\n // lists registry names). The `has()` guards make this idempotent\n // under dev HMR re-init AND let older scaffolds that still\n // self-register from `lib/app.ts` coexist (their registration is\n // also `has()`-guarded; whichever runs first wins — it's the same\n // function either way). See the module JSDoc for why app-side-only\n // registration was a drift foot-gun.\n //\n // Why imperative-in-init rather than a declarative `contributes`\n // slot (CLAUDE.md's default for contributions): these are this\n // package's OWN built-ins landing in this package's OWN\n // process-global registry — nothing crosses a plugin boundary, and\n // the registry is consumed at job-dispatch time, not at the\n // framework's merge step. A contributes slot buys cross-plugin\n // introspection we don't need at the cost of a core-visible slot\n // type for an imports-internal concern.\n const transformRegistry = getTransformRegistry()\n if (!transformRegistry.has(MAPPING_TRANSFORM_NAME)) {\n transformRegistry.register(MAPPING_TRANSFORM_NAME, mappingTransform)\n }\n if (!transformRegistry.has(CARMAKER_FEED_TRANSFORM_NAME)) {\n transformRegistry.register(CARMAKER_FEED_TRANSFORM_NAME, carmakerFeedTransform)\n }\n\n // Queue PACKAGE being importable doesn't mean the queue() PLUGIN\n // is in the consumer's plugins array. Without it, registerJob()\n // succeeds but no worker ever picks up the job. Probe and warn.\n const queuePluginPresent = app.plugins.all().some((p) => p.name === '@murumets-ee/queue')\n if (!queuePluginPresent) {\n app.logger.warn(\n 'imports: queue() plugin not in plugins array — imports:run jobs will not be processed',\n )\n return\n }\n\n // Queue client stays dynamic-imported: we already early-returned\n // above when the queue() plugin isn't in the consumer's plugins\n // array, so static-importing here would force-load the queue\n // client module in deployments that don't enable the worker.\n // `@murumets-ee/core/clients` is NO LONGER imported here —\n // `app.getClient(entity)` gives us a typed, app-bound\n // AdminClient without the F1+F2 foot-guns from\n // PLAN-DECLARATIVE-PLUGINS.md.\n const queueClientModule = await import('@murumets-ee/queue/client')\n\n const importRuns = app.getClient(ImportRun)\n\n queueClientModule.registerJob(\n importsRunJob,\n createRunImportHandler({\n importRuns,\n transforms: transformRegistry,\n esClient: options.esClient,\n esIndex,\n ...(options.ensureIndex !== undefined && { ensureIndex: options.ensureIndex }),\n ...(options.resolveFilePath !== undefined && {\n resolveFilePath: options.resolveFilePath,\n }),\n ...(options.pairSink !== undefined && { pairSink: options.pairSink }),\n logger: app.logger.child({ pkg: 'imports' }),\n }),\n )\n\n // The operator entry point for the alias-versioned reindex (D6).\n // Registered unconditionally: a missing `indexConfig` is reported by\n // the handler with an actionable message, which beats a job that\n // silently never exists and leaves `lumi imports:reindex` enqueuing\n // work no worker will ever claim.\n queueClientModule.registerJob(\n importsReindexJob,\n createReindexHandler({\n esClient: options.esClient,\n esIndex,\n // Lets the reindex refuse to run (and refuse to swap) while a feed\n // import is in flight — those writes go to the SOURCE through the\n // alias and would be dropped by the swap.\n importRuns,\n ...(options.indexConfig !== undefined && { indexConfig: options.indexConfig }),\n logger: app.logger.child({ pkg: 'imports' }),\n }),\n )\n\n // Subscribe to the queue's terminal-state event stream so a\n // worker that crashes mid-import — and whose `imports:run` job\n // ultimately reaches `dead` without the handler reaching its\n // own `markFailed` path — flips the mirror `import_run` row to\n // `failed`. This replaces what would otherwise be a per-domain\n // periodic sweep; the queue's terminal notifier is the generic\n // primitive (graphile-worker's `job:failed` event, BullMQ's\n // `failed` listener — same shape).\n //\n // The listener is multi-subscriber, so we coexist with the\n // notifications plugin's admin-email handler without either of\n // us clobbering the other. Filtering on `event.jobType` keeps\n // this listener cheap on jobs we don't own.\n //\n // **HMR**: each re-init of this `init` hook constructs a new\n // listener instance. The notifier registry stores listeners\n // by reference, so without explicit cleanup an HMR cycle would\n // STACK duplicate listeners — `findMany`+`update` per dead\n // event growing linearly with the number of HMR rounds. Stash\n // the unsubscribe on a globalThis Symbol-keyed slot (same\n // pattern as `transform.ts` / `storage.ts`) and call it before\n // re-subscribing so HMR is idempotent.\n const previousUnsubscribe = (globalThis as GlobalThisWithUnsubscribe)[\n IMPORTS_DEAD_LISTENER_KEY\n ]\n if (previousUnsubscribe) previousUnsubscribe()\n const unsubscribe = queueClientModule.addQueueTerminalListener(\n createImportsRunDeadListener({\n importRuns,\n logger: app.logger.child({ pkg: 'imports' }),\n }),\n )\n ;(globalThis as GlobalThisWithUnsubscribe)[IMPORTS_DEAD_LISTENER_KEY] = unsubscribe\n\n // Storage-cleanup jobs. Two passes, same bucket:\n // 1. Orphan sweep — uploads whose register handshake never\n // completed (no `import_run` reference). 2h TTL.\n // 2. Processed sweep — uploads whose `import_run` is terminal\n // (succeeded / failed / cancelled). 7-day reprocess\n // window then drop.\n // `defineJob({ schedule })` declares the cadence; the queue\n // worker reads the metadata at startup, materialises a\n // `toolkit_scheduled_jobs` row, and its cron loop fires the\n // registered handler each tick. No explicit\n // `client.schedule()` call needed — the worker owns the\n // schedule, not the handler. Same helper-signature shape as\n // ticketing's cleanup.\n if (cleanupConfig !== null) {\n const { cleanupOrphanedImports, cleanupProcessedImports } = await import(\n './admin/cleanup.js'\n )\n const { runAsCli } = await import('@murumets-ee/core')\n const cleanupLogger = app.logger.child({ pkg: 'imports', job: 'cleanup' })\n\n queueClientModule.registerJob(\n queueClientModule.defineJob({\n name: 'imports:cleanup-orphaned-uploads',\n description:\n 'Delete toolkit_files rows in the imports bucket with no matching import_run reference and createdAt older than the configured TTL.',\n schedule: '@every 1h',\n }),\n async () => {\n // `runAsCli` is load-bearing today: the queue worker's\n // handler context (see `packages/queue/src/handler-context.ts`)\n // tracks the job type but NOT a user / RequestContext.\n // Any AdminClient call reachable from the cleanup\n // helpers — currently `createAdminClient(ImportRun).getTable()`\n // for column-metadata resolution — runs without a user\n // unless we enter a CLI scope. The synthetic CLI user\n // is the right attribution for scheduled-job work\n // (matches ticketing's `cleanupOrphanedUploads` shape).\n await runAsCli(() =>\n cleanupOrphanedImports({\n app,\n bucket: cleanupConfig.bucket,\n ttlHours: cleanupConfig.orphanTtlHours,\n logger: cleanupLogger,\n }),\n )\n },\n )\n\n queueClientModule.registerJob(\n queueClientModule.defineJob({\n name: 'imports:cleanup-processed-uploads',\n description:\n 'Delete toolkit_files rows for import_runs in terminal status (succeeded/failed/cancelled) whose finishedAt is older than the configured retention window. The import_run row itself is preserved.',\n schedule: '@every 1h',\n }),\n async () => {\n await runAsCli(() =>\n cleanupProcessedImports({\n app,\n bucket: cleanupConfig.bucket,\n retentionDays: cleanupConfig.processedRetentionDays,\n logger: cleanupLogger,\n }),\n )\n },\n )\n }\n\n app.logger.info(\n {\n esIndex,\n // Stable shape regardless of enabled/disabled so structured-\n // log queries (`cleanup.enabled = false`) don't have to\n // handle a string fallback.\n cleanup:\n cleanupConfig === null ? { enabled: false } : { enabled: true, ...cleanupConfig },\n },\n 'Imports plugin initialized',\n )\n },\n },\n })\n}\n"],"mappings":"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"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"publish-DCXIPwTE.mjs","names":[],"sources":["../src/realtime/topics.ts","../src/realtime/publish.ts"],"sourcesContent":["/**\n * Imports realtime topic registry.\n *\n * Topics use the `imports.run.<event>` namespace. Subscribers listen\n * via `imports.run.**` for everything or narrow per event.\n *\n * Payload shapes are intentionally untyped here — callers pass\n * `Record<string, unknown>` and consumers (the imports admin page)\n * read fields by name. The point of this layer is \"an import_run\n * row's status changed, refetch\" rather than diffing event payloads\n * against the page's snapshot.\n *\n * Why a separate namespace from `queue.job.*`: queue events are\n * job-shaped (id, type, attempts, …) and admin-only. Imports events\n * are row-shaped (importRunId, status, brand, supplier, …) and\n * track the entity lifecycle independent of the underlying queue\n * mechanics — a future refactor could swap the queue for a different\n * driver without changing the imports event surface.\n */\n\nexport const IMPORTS_TOPICS = {\n /**\n * The upload route inserted an `import_run` row + enqueued its\n * driving queue job. Fired AFTER the outbox txn commits, so a\n * subscriber's refetch sees the row.\n */\n runCreated: 'imports.run.created',\n /**\n * The worker claimed the row and transitioned its status to\n * `running` — the streaming reader is open, the first batch is\n * about to bulk-write. Distinct from `queue.job.claimed` because\n * imports' state machine flips on the row, not the job.\n */\n runStarted: 'imports.run.started',\n /**\n * The worker finished cleanly — `import_run.status = 'succeeded'`,\n * `errorSummary` populated, totals locked in. Subscribers refetch\n * the history slice to bring the row out of the Active panel and\n * into Recent.\n */\n runSucceeded: 'imports.run.succeeded',\n /**\n * The worker (or `markFailed`) wrote `import_run.status = 'failed'`\n * with an error in `errorSummary`. Same refetch trigger; the page\n * differentiates terminal-success vs terminal-failure by reading\n * the row.\n */\n runFailed: 'imports.run.failed',\n} as const\n\nexport type ImportsTopic = (typeof IMPORTS_TOPICS)[keyof typeof IMPORTS_TOPICS]\n","/**\n * Typed publisher wrappers around `@murumets-ee/core/realtime.publishEvent`\n * for the `imports.run.*` topic surface.\n *\n * Like queue events, every imports event is admin-only — a future\n * refactor that opens these to non-admin operators (e.g. an\n * `imports:view` permission scope) would update {@link ADMIN_SCOPE}\n * here, not the call sites.\n *\n * Publish failures are caught and logged, never thrown — these\n * wrappers are called from worker state-transition paths and the\n * upload route, and a missing realtime delivery must NEVER roll back\n * the database write that triggered it.\n *\n * Why dynamic import: `@murumets-ee/core/realtime` carries\n * `import 'server-only'`, but this module is reachable from the\n * imports package's entrypoints (e.g. `worker.ts`), which are loaded\n * by jiti/tsx in `lumi doctor` / `lumi migrate` / `pnpm worker` —\n * none of which set the `react-server` export condition that turns\n * `server-only` into the empty stub. A static value-import would\n * therefore throw at boot. Same dynamic-import pattern as\n * `packages/queue/src/realtime/publish.ts` and\n * `packages/ticketing/src/realtime/publish.ts`.\n *\n * The `cachedPublish` + `publishSafe` + `_warmRealtimePublisher`\n * scaffolding here is the third copy of the same shape (queue,\n * ticketing, imports). Tracked for extraction to a shared\n * `@murumets-ee/core/realtime/safe-publisher` helper in #201 —\n * reach for that when it lands instead of copy-pasting a fourth\n * time.\n */\n\nimport type { Scope } from '@murumets-ee/core/realtime'\nimport { createLogger } from '@murumets-ee/logging'\nimport { IMPORTS_TOPICS } from './topics.js'\n\nconst logger = createLogger({ name: 'imports:realtime' })\n\n/** All imports events fan out to admin connections only. */\nconst ADMIN_SCOPE: Scope = { admin: true }\n\n// Cached publisher — once the dynamic import has resolved, every\n// subsequent publish is fully synchronous. Only the very first call\n// after process start pays the import cost.\nlet cachedPublish: typeof import('@murumets-ee/core/realtime').publishEvent | null = null\n\nfunction logPublishError(err: unknown, topic: string): void {\n if (err instanceof Error && err.name === 'RealtimeOversizeError') {\n // Oversize is a code bug (payload above the 7900-byte pg_notify\n // cap). For imports row events the payload is tiny — id, status,\n // brand/supplier label fragments — well under the cap. If this\n // ever fires the deriver smuggled too much in.\n logger.error({ err, topic }, 'realtime publish failed — payload oversize')\n } else {\n logger.warn({ err, topic }, 'realtime publish failed — event dropped')\n }\n}\n\nfunction publishSafe(topic: string, payload: Record<string, unknown>): void {\n if (cachedPublish) {\n try {\n cachedPublish({ topic, payload, scope: ADMIN_SCOPE })\n } catch (err) {\n logPublishError(err, topic)\n }\n return\n }\n // First call: resolve the import then fan out. Subsequent calls in\n // the same microtask race the import — that's fine, they queue\n // behind the same promise and use the resolved cache when it lands.\n import('@murumets-ee/core/realtime')\n .then((mod) => {\n cachedPublish = mod.publishEvent\n cachedPublish({ topic, payload, scope: ADMIN_SCOPE })\n })\n .catch((err: unknown) => {\n logPublishError(err, topic)\n })\n}\n\n/**\n * @internal — tests pre-resolve the dynamic import so the first\n * publish is synchronous, matching the post-warmup behaviour in\n * production.\n */\nexport async function _warmRealtimePublisher(): Promise<void> {\n if (cachedPublish) return\n const mod = await import('@murumets-ee/core/realtime')\n cachedPublish = mod.publishEvent\n}\n\n/**\n * Fire after the upload route's outbox txn commits, NOT inside it.\n * The realtime fanout is best-effort and a failure must not roll\n * back the row insert.\n */\nexport function publishImportRunCreated(\n importRunId: string,\n fields: { brandSlug?: string; supplierDisplayName?: string; filename: string },\n): void {\n publishSafe(IMPORTS_TOPICS.runCreated, {\n importRunId,\n ...(fields.brandSlug !== undefined && { brandSlug: fields.brandSlug }),\n ...(fields.supplierDisplayName !== undefined && {\n supplierDisplayName: fields.supplierDisplayName,\n }),\n filename: fields.filename,\n })\n}\n\n/**\n * Fire after the worker writes `status: 'running'` on the row.\n * Subscribers refetch to move the row from \"queued\" to \"running\" in\n * the Active panel and update the History snapshot.\n */\nexport function publishImportRunStarted(importRunId: string): void {\n publishSafe(IMPORTS_TOPICS.runStarted, { importRunId })\n}\n\n/**\n * Fire after the worker writes `status: 'succeeded'` + final totals.\n * Subscribers refetch to move the row from Active → Recent.\n */\nexport function publishImportRunSucceeded(\n importRunId: string,\n totals: { rowsRead: number; rowsSucceeded: number; rowsFailed: number },\n): void {\n publishSafe(IMPORTS_TOPICS.runSucceeded, {\n importRunId,\n rowsRead: totals.rowsRead,\n rowsSucceeded: totals.rowsSucceeded,\n rowsFailed: totals.rowsFailed,\n })\n}\n\n/**\n * Fire after the worker writes `status: 'failed'` with an error in\n * `errorSummary`. Subscribers refetch to surface the failure in\n * Recent.\n */\nexport function publishImportRunFailed(importRunId: string, reason: string): void {\n publishSafe(IMPORTS_TOPICS.runFailed, { importRunId, reason })\n}\n"],"mappings":"oDAoBA,MAAa,EAAiB,CAM5B,WAAY,sBAOZ,WAAY,sBAOZ,aAAc,wBAOd,UAAW,oBACb,ECZM,EAAS,EAAa,CAAE,KAAM,kBAAmB,CAAC,EAGlD,EAAqB,CAAE,MAAO,EAAK,EAKzC,IAAI,EAAiF,KAErF,SAAS,EAAgB,EAAc,EAAqB,CACtD,aAAe,OAAS,EAAI,OAAS,wBAKvC,EAAO,MAAM,CAAE,MAAK,OAAM,EAAG,4CAA4C,EAEzE,EAAO,KAAK,CAAE,MAAK,OAAM,EAAG,yCAAyC,CAEzE,CAEA,SAAS,EAAY,EAAe,EAAwC,CAC1E,GAAI,EAAe,CACjB,GAAI,CACF,EAAc,CAAE,QAAO,UAAS,MAAO,CAAY,CAAC,CACtD,OAAS,EAAK,CACZ,EAAgB,EAAK,CAAK,CAC5B,CACA,MACF,CAIA,OAAO,6BAA6B,CACjC,KAAM,GAAQ,CACb,EAAgB,EAAI,aACpB,EAAc,CAAE,QAAO,UAAS,MAAO,CAAY,CAAC,CACtD,CAAC,CAAC,CACD,MAAO,GAAiB,CACvB,EAAgB,EAAK,CAAK,CAC5B,CAAC,CACL,CAOA,eAAsB,GAAwC,CACxD,AAEJ,KAAgB,MADE,OAAO,8BAAA,CACL,YACtB,CAOA,SAAgB,EACd,EACA,EACM,CACN,EAAY,EAAe,WAAY,CACrC,cACA,GAAI,EAAO,YAAc,IAAA,IAAa,CAAE,UAAW,EAAO,SAAU,EACpE,GAAI,EAAO,sBAAwB,IAAA,IAAa,CAC9C,oBAAqB,EAAO,mBAC9B,EACA,SAAU,EAAO,QACnB,CAAC,CACH,CAOA,SAAgB,EAAwB,EAA2B,CACjE,EAAY,EAAe,WAAY,CAAE,aAAY,CAAC,CACxD,CAMA,SAAgB,EACd,EACA,EACM,CACN,EAAY,EAAe,aAAc,CACvC,cACA,SAAU,EAAO,SACjB,cAAe,EAAO,cACtB,WAAY,EAAO,UACrB,CAAC,CACH,CAOA,SAAgB,EAAuB,EAAqB,EAAsB,CAChF,EAAY,EAAe,UAAW,CAAE,cAAa,QAAO,CAAC,CAC/D"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"read-feed-DsLgRDNR.mjs","names":[],"sources":["../src/streaming.ts","../src/read-feed.ts"],"sourcesContent":["/**\n * Tab-delimited / CSV streaming reader. Generic over delimiter so the\n * same path handles `.txt` (tab), `.csv` (comma), and the rare `;`\n * European export dialect.\n *\n * Built on `csv-parse` per giga-test precedent — node-stream-based, low\n * memory, handles UTF-8 BOM (the carmaker feeds are Windows-exported\n * and ship with a BOM that breaks naive split-on-tab parsers).\n *\n * The reader yields `{ rowNumber, row }` pairs where:\n * - `rowNumber` is 1-based and counts the header as row 0.\n * - `row` is `Record<string, string>` keyed by header name. Empty cells\n * are the empty string, NOT `undefined` — feed transforms test with\n * `value === ''` consistently.\n *\n * Why this lives in a streaming reader and not inside the transform\n * itself: the transform sees one already-parsed row at a time, never\n * the file. That keeps transform implementations free of I/O concerns\n * and makes them trivially unit-testable with a fixture row map.\n */\n\nimport { createReadStream } from 'node:fs'\nimport { parse } from 'csv-parse'\n\n/**\n * Throw if two (non-empty) header names collide. Headers are the keys the mapping\n * engine matches a `source` against; after trimming, two columns differing only in\n * whitespace would collapse to one key and silently overwrite each other's data.\n * Fail fast instead — the operator fixes the feed or the mapping. Shared by the\n * csv/tsv reader (below) and the xlsx reader. Blank columns are ignored (unmapped).\n */\nexport function assertUniqueHeaders(header: readonly string[]): void {\n const seen = new Set<string>()\n for (const h of header) {\n if (h === '') continue\n if (seen.has(h)) {\n throw new Error(`Duplicate header \"${h}\" after trimming — column names must be unique`)\n }\n seen.add(h)\n }\n}\n\n/**\n * Pre-pass: count data rows in a delimited file (excluding the header\n * row when `hasHeader` is true; default `true` to match `streamFeed`).\n *\n * Implementation: stream the file as raw bytes and tally `\\n`. NOT a\n * full parse — escaped newlines inside quoted fields would over-count.\n * That was unconditionally fine while `quote: false` was the only\n * behaviour available; a mapping can now declare RFC 4180 (`quote`),\n * and an embedded newline in a quoted field then makes this an UPPER\n * BOUND rather than a count. It is used only as the progress\n * denominator (`totalRows`), so the visible consequence is a bar that\n * stops short of 100%, never a wrong import. The parsed row count from\n * `streamFeed` is authoritative and is what the run records.\n *\n * Why bytes-only and not csv-parse: a 16 MB feed counts in ~50 ms with\n * `\\n` tally vs ~500 ms with csv-parse parsing. For 500 MB the gap\n * matters — pre-pass needs to be a small fraction of the actual import\n * or the operator perceives the run as \"stuck on pre-pass\" with no UI.\n *\n * Returns the number of DATA rows (header excluded when present).\n * Empty trailing lines (no `\\n` after the final row, or a single\n * trailing `\\n`) are handled identically to how csv-parse treats them\n * with `skip_empty_lines: true`.\n */\nexport async function countDataRows(\n filePath: string,\n options: { hasHeader?: boolean } = {},\n): Promise<number> {\n const { hasHeader = true } = options\n const stream = createReadStream(filePath)\n let count = 0\n let lastByte = 0\n for await (const chunk of stream as AsyncIterable<Buffer>) {\n for (let i = 0; i < chunk.length; i += 1) {\n if (chunk[i] === 0x0a /* \\n */) count += 1\n }\n if (chunk.length > 0) lastByte = chunk[chunk.length - 1] ?? 0\n }\n // If the file doesn't end with a newline, the final row didn't get\n // counted by the `\\n` tally — add one. Skipping when the file is\n // entirely empty (count=0 and lastByte=0).\n if (lastByte !== 0 && lastByte !== 0x0a) count += 1\n return hasHeader ? Math.max(0, count - 1) : count\n}\n\nexport interface StreamFeedOptions {\n /** Path to the file on disk. The PoC uploads land on local disk; S3-keyed reads come later. */\n filePath: string\n /**\n * Single-character field delimiter. Default `\\t` (the carmaker feed\n * format). Pass `,` for CSV, `;` for some European dialects.\n */\n delimiter?: string\n /**\n * `true` (default): the first row is the header and column names come\n * from it. `false`: rows are emitted as positional `{ \"0\": ..., \"1\": ... }`\n * and the transform reads by index — useful for headerless feeds that\n * commit to a documented column order.\n */\n hasHeader?: boolean\n /**\n * Optional explicit column-name list. When provided, takes precedence\n * over `hasHeader` (header row, if present, is skipped but its values\n * are ignored). Useful when the upstream header is unstable but the\n * positional shape isn't.\n */\n columns?: ReadonlyArray<string>\n /**\n * Forward to `csv-parse` `relax_column_count`. Default `false` —\n * a row whose column count doesn't match the header surfaces as a\n * parser error so the transform isn't silently fed truncated data.\n */\n relaxColumnCount?: boolean\n /**\n * Forward to `csv-parse` `quote`. The character (or `false`) that\n * encloses fields containing the delimiter. Default `false` — most\n * supplier feeds in this package's target use case are tab-delimited\n * exports that contain literal `\"` characters in description fields\n * (e.g. `LIVRE \"INVERSION D'IMAGE\"` from the Mercedes carmaker feed)\n * with NO quote-as-field-wrapper convention. csv-parse's library\n * default `'\"'` then misreads the literal `\"` as an opening quote\n * and rejects the row with `Invalid Opening Quote`.\n *\n * Pass `'\"'` (or any character) to opt back into RFC-4180-style\n * CSV parsing for genuine quoted-CSV inputs. Pass `'\\''` for\n * single-quoted dialects.\n */\n quote?: string | false\n}\n\nexport interface StreamFeedRow {\n /** 1-based row number. Header (when present) is row 0; first data row is row 1. */\n rowNumber: number\n /**\n * Cell values keyed by column name (or string-position when\n * `hasHeader: false` AND no `columns`).\n *\n * **Cell-value invariants:**\n * - Empty cells (`A\\t\\tC`) → `''` (empty string).\n * - Missing TRAILING cells in `relaxColumnCount: true` mode → the\n * key is **absent** from the object, not present-with-`''`. csv-parse\n * does not emit keys for short rows. Transforms reading those\n * columns get `undefined` from `row['col']` and must handle it\n * (`row['col'] ?? ''` is the canonical idiom).\n * - With the default `relaxColumnCount: false`, short rows reject at\n * the parser, so this case never reaches the transform.\n */\n row: Record<string, string>\n}\n\n/**\n * Async-iterable over the parsed rows of a delimited file. Use with\n * `for await (const { rowNumber, row } of streamFeed({ filePath, ... }))`.\n *\n * The iterator owns its file descriptor — the `for await` loop closes\n * the underlying stream when it returns or breaks. Aborting mid-stream\n * (`break`, `throw`, signal) is safe; csv-parse propagates the close.\n */\nexport async function* streamFeed(options: StreamFeedOptions): AsyncIterable<StreamFeedRow> {\n const {\n filePath,\n delimiter = '\\t',\n hasHeader = true,\n columns,\n relaxColumnCount = false,\n quote = false,\n } = options\n\n // When the caller provides explicit `columns`, prefer them. When the file\n // has a header but no explicit `columns`, derive the column names from the\n // first row via a function so we can TRIM them — real supplier feeds ship\n // leading/trailing-space headers (R03 Samples A/C: `\" JAEHIND KM-TA\"`), and\n // an untrimmed key wouldn't match a mapping's `source`. The xlsx reader trims\n // headers the same way. When neither is true, rows get string-position keys.\n //\n // csv-parse's `columns` accepts `ColumnOption[] | boolean | ((record) =>\n // ColumnOption[])`; `string[]` satisfies `ColumnOption[]`.\n const columnConfig: string[] | boolean | ((header: string[]) => string[]) = columns\n ? Array.from(columns)\n : hasHeader\n ? (header: string[]) => {\n const trimmed = header.map((h) => h.trim())\n assertUniqueHeaders(trimmed)\n return trimmed\n }\n : false\n\n const stream = createReadStream(filePath)\n const parser = stream.pipe(\n parse({\n delimiter,\n columns: columnConfig,\n bom: true,\n skip_empty_lines: true,\n // csv-parse defaults to strict column count; opt-in relaxation only.\n relax_column_count: relaxColumnCount,\n // Default `quote: false` — see StreamFeedOptions.quote JSDoc.\n // Carmaker / parts-feed exports contain literal `\"` characters\n // in description fields and would otherwise reject as\n // `Invalid Opening Quote`. Genuine RFC-4180 CSV consumers opt\n // back in by passing `quote: '\"'`.\n quote,\n }),\n )\n\n let rowNumber = 0\n try {\n for await (const rawRow of parser as AsyncIterable<\n ReadonlyArray<string> | Record<string, string | undefined>\n >) {\n rowNumber += 1\n // csv-parse emits records with string keys when `columns` is\n // truthy, otherwise an Array. Normalise both shapes to\n // `Record<string, string>` so transforms can rely on `value === ''`\n // for missing cells (csv-parse leaves trailing missing cells as\n // `undefined` when `relax_column_count: true`; this collapses\n // them to `''` to keep the contract uniform).\n const row: Record<string, string> = {}\n if (Array.isArray(rawRow)) {\n const arr = rawRow as ReadonlyArray<string | undefined>\n for (let i = 0; i < arr.length; i += 1) {\n row[String(i)] = arr[i] ?? ''\n }\n } else {\n for (const [k, v] of Object.entries(rawRow)) {\n row[k] = v ?? ''\n }\n }\n yield { rowNumber, row }\n }\n } finally {\n // Safety: ensure the underlying file descriptor closes even if the\n // consumer breaks mid-iteration. Node closes streams on\n // garbage-collection but this makes it deterministic under tests.\n stream.destroy()\n }\n}\n","/**\n * Feed reader dispatcher (commerce-demo phase 02, D005). Selects the reader by\n * `format` and yields the uniform `AsyncIterable<{ rowNumber, row }>` the runner\n * consumes — csv/tsv via the existing `streamFeed` (csv-parse), xlsx via the\n * exceljs-backed `streamXlsx`.\n *\n * **Back-compat**: with no `format` (the carmaker worker passes only `filePath`),\n * this defaults to tab-delimited — the original `streamFeed` behaviour — so the\n * existing import path is unchanged.\n *\n * **Bundle safety**: the xlsx reader (and its heavy, Node-only exceljs dep) is\n * `await import()`-ed ONLY for `format: 'xlsx'`, so the csv/tsv path — and anything\n * that statically imports the runner — never pulls exceljs into its module graph.\n */\n\nimport { z } from 'zod'\nimport { type StreamFeedOptions, type StreamFeedRow, streamFeed } from './streaming.js'\n\n/**\n * The reader-config subset safe to persist in `import_run.params.feed` and rehydrate\n * in the worker (commerce-demo PR3). It is exactly the {@link ReadFeedOptions} fields\n * that describe HOW to parse a feed — `filePath` is supplied by the worker after it\n * materialises the upload, never carried in params. Validated at BOTH ends: the\n * commerce run route builds it from a saved `import_mapping`, the worker re-parses it\n * before handing it to {@link readFeed} (defense-in-depth against a tampered row).\n */\nexport const feedReaderConfigSchema = z\n .object({\n format: z.enum(['csv', 'tsv', 'xlsx']).optional(),\n delimiter: z.string().min(1).max(4).optional(),\n hasHeader: z.boolean().optional(),\n headerRowIndex: z.number().int().min(0).optional(),\n sheet: z.string().min(1).max(128).optional(),\n /**\n * csv-parse's `quote`. Absent (or `false`) = quotes are ordinary data, which is this\n * package's long-standing default and what the tab-delimited supplier dumps require. A\n * single character opts into RFC 4180 for feeds that genuinely quote their fields.\n *\n * Persisted deliberately: the two shapes are mutually exclusive on real data (R027 —\n * each of the three ground-truth feeds hard-fails under the other's setting), so this\n * is a per-file fact a mapping has to carry. Deriving it from `format` would be the\n * same class of mistake as a global decimal separator.\n */\n quote: z.union([z.string().length(1), z.literal(false)]).optional(),\n })\n .strict()\nexport type FeedReaderConfig = z.infer<typeof feedReaderConfigSchema>\n\n/**\n * Rehydrate the reader config a run persisted in `import_run.params.feed`.\n *\n * ONE implementation, because two would drift and drift here is silent: the\n * worker uses it to decide how to READ a feed, and the #842 audit uses it to\n * re-read the same file the same way. An audit that rehydrates differently from\n * the run it is auditing invents corruption, or misses it.\n *\n * ABSENT means \"the carmaker default\", i.e. tab-delimited, which is what an\n * empty config parses to — so a non-object `params`, or `params` with no\n * `feed`, is a real answer rather than a gap. A PRESENT `feed` is validated\n * whatever its shape: a scalar or an array there is a malformed persisted blob,\n * and reading it as \"no config\" would silently import an xlsx run as tsv.\n */\nexport function readPersistedFeedConfig(\n params: unknown,\n): ReturnType<typeof feedReaderConfigSchema.safeParse> {\n const record =\n params && typeof params === 'object' && !Array.isArray(params)\n ? (params as Record<string, unknown>)\n : {}\n // `null` is how \"absent\" round-trips through JSONB, so it defaults with\n // `undefined`. Everything else present goes to the schema and is rejected on\n // its merits rather than being flattened into the default.\n const feed = record.feed === undefined || record.feed === null ? {} : record.feed\n return feedReaderConfigSchema.safeParse(feed)\n}\n\nexport interface ReadFeedOptions {\n /** Path to the file on local disk. */\n filePath: string\n /** Feed format. Omitted → tab-delimited (tsv), matching the legacy carmaker reader. */\n format?: 'csv' | 'tsv' | 'xlsx'\n /** Explicit delimiter override (csv/tsv). Defaults from `format` (`csv` → `,`, else `\\t`). */\n delimiter?: string\n /** First row is the header. Default `true`. */\n hasHeader?: boolean\n /** 0-based header row index (xlsx). */\n headerRowIndex?: number\n /** xlsx worksheet as a 1-based index string. Omit → first sheet. */\n sheet?: string\n /** Explicit column-name list (csv/tsv) — see `streamFeed`. */\n columns?: ReadonlyArray<string>\n /** Forward to csv-parse `relax_column_count` (csv/tsv). */\n relaxColumnCount?: boolean\n /** Forward to csv-parse `quote` (csv/tsv). */\n quote?: string | false\n}\n\nfunction toStreamFeedOptions(o: ReadFeedOptions): StreamFeedOptions {\n // No format → tab (the carmaker feed default). `csv` → comma; everything else → tab.\n const delimiter = o.delimiter ?? (o.format === 'csv' ? ',' : '\\t')\n return {\n filePath: o.filePath,\n delimiter,\n ...(o.hasHeader !== undefined && { hasHeader: o.hasHeader }),\n ...(o.columns !== undefined && { columns: o.columns }),\n ...(o.relaxColumnCount !== undefined && { relaxColumnCount: o.relaxColumnCount }),\n ...(o.quote !== undefined && { quote: o.quote }),\n }\n}\n\n/** Stream a feed file's data rows, dispatching on `format`. */\nexport async function* readFeed(options: ReadFeedOptions): AsyncIterable<StreamFeedRow> {\n if (options.format === 'xlsx') {\n const { streamXlsx } = await import('./xlsx-reader.js')\n yield* streamXlsx({\n filePath: options.filePath,\n ...(options.hasHeader !== undefined && { hasHeader: options.hasHeader }),\n ...(options.headerRowIndex !== undefined && { headerRowIndex: options.headerRowIndex }),\n ...(options.sheet !== undefined && { sheet: options.sheet }),\n })\n return\n }\n yield* streamFeed(toStreamFeedOptions(options))\n}\n"],"mappings":"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"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.mts","names":[],"sources":["../../src/realtime/publish.ts","../../src/realtime/topics.ts"],"mappings":";;AAqFA;;;;AAAuD;AAWvD;;;;;;;;;;AAEgF;AAiBhF;;;;AAA2D;AAQ3D;;;;;;;;;;AAEyE;AAezE;;;iBAvDsB,sBAAA,IAA0B,OAAO;AAuDmB;;;;ACxH1E;ADwH0E,iBA5C1D,uBAAA,CACd,WAAA,UACA,MAAA;EAAU,SAAA;EAAoB,mBAAA;EAA8B,QAAA;AAAA;;;;;AChD9D;iBDiEgB,uBAAA,CAAwB,WAAmB;;;ACjEmB;;iBDyE9D,yBAAA,CACd,WAAA,UACA,MAAA;EAAU,QAAA;EAAkB,aAAA;EAAuB,UAAA;AAAA;;;;;;iBAerC,sBAAA,CAAuB,WAAA,UAAqB,MAAc;;;;AAvD1E;;;;AAAuD;AAWvD;;;;;;;;;;AAEgF;AAiBhF;;cC/Fa,cAAA;ED+F8C;AAAA;AAQ3D;;;EAR2D;EASzD;;;;;;EAAA;EAgBc;;;;AAA0D;;EAA1D;;ACxHhB;;;;;;;KA8BY,YAAA,WAAuB,cAAA,eAA6B,cAAc"}
|