@murumets-ee/imports 0.30.0 → 0.32.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.
Files changed (38) hide show
  1. package/dist/{cleanup-DJHXcbpZ.mjs → cleanup-CFo7Pw0t.mjs} +2 -2
  2. package/dist/{cleanup-DJHXcbpZ.mjs.map → cleanup-CFo7Pw0t.mjs.map} +1 -1
  3. package/dist/index.d.mts +94 -2
  4. package/dist/index.d.mts.map +1 -1
  5. package/dist/index.mjs +1 -1
  6. package/dist/mapping/index.d.mts +367 -0
  7. package/dist/mapping/index.d.mts.map +1 -0
  8. package/dist/mapping/index.mjs +2 -0
  9. package/dist/mapping/index.mjs.map +1 -0
  10. package/dist/parts-schema-DQeMGPBC.mjs +2 -0
  11. package/dist/parts-schema-DQeMGPBC.mjs.map +1 -0
  12. package/dist/parts-schema-RI_ltubl.d.mts +260 -0
  13. package/dist/parts-schema-RI_ltubl.d.mts.map +1 -0
  14. package/dist/plugin.d.mts +1 -1
  15. package/dist/plugin.d.mts.map +1 -1
  16. package/dist/plugin.mjs +1 -1
  17. package/dist/plugin.mjs.map +1 -1
  18. package/dist/runner-DvKNfQJO.mjs +2 -0
  19. package/dist/runner-DvKNfQJO.mjs.map +1 -0
  20. package/dist/storage-resolver.d.mts +1 -1
  21. package/dist/transform-DloItB80.mjs +2 -0
  22. package/dist/transform-DloItB80.mjs.map +1 -0
  23. package/dist/transforms.d.mts +19 -33
  24. package/dist/transforms.d.mts.map +1 -1
  25. package/dist/transforms.mjs +1 -1
  26. package/dist/transforms.mjs.map +1 -1
  27. package/dist/{worker-BY0JQw39.d.mts → worker-ByPOP6Qe.d.mts} +29 -25
  28. package/dist/worker-ByPOP6Qe.d.mts.map +1 -0
  29. package/dist/worker.d.mts +1 -1
  30. package/dist/worker.mjs +1 -1
  31. package/dist/xlsx-reader-B2_F37jA.mjs +2 -0
  32. package/dist/xlsx-reader-B2_F37jA.mjs.map +1 -0
  33. package/package.json +13 -8
  34. package/dist/runner-CSlZ0JFO.mjs +0 -2
  35. package/dist/runner-CSlZ0JFO.mjs.map +0 -1
  36. package/dist/transform-BzqTvH7B.mjs +0 -2
  37. package/dist/transform-BzqTvH7B.mjs.map +0 -1
  38. package/dist/worker-BY0JQw39.d.mts.map +0 -1
@@ -0,0 +1,2 @@
1
+ import{t as e}from"./rolldown-runtime-DK3Fl9T5.mjs";import{behavior as t,defineEntity as n,field as r,searchable as i}from"@murumets-ee/entity";import{sql as a}from"drizzle-orm";var o=e({IMPORT_RUN_STATUSES:()=>s,ImportRun:()=>c});const s=[`pending`,`running`,`succeeded`,`failed`,`cancelled`],c=n({name:`import_run`,fields:{label:r.text({required:!0,maxLength:255}),status:r.select({options:s,default:`pending`,indexed:!0}),filePath:r.text({required:!0,maxLength:2048}),transformName:r.text({required:!0,maxLength:128,indexed:!0}),params:r.json(),totals:r.json(),errorSummary:r.json(),startedAt:r.date(),finishedAt:r.date(),queueJobId:r.text({maxLength:64,indexed:!0})},behaviors:[t.auditable(),i({fields:[`label`,`transformName`,`queueJobId`,`status`,`filePath`],projection:e=>({id:e.id,label:e.label,description:`${e.transformName} · ${e.status}`})})],scope:`global`,admin:{group:`imports`,label:`Import runs`,labelSingular:`Import run`,icon:`upload`,hideFromMenu:!0}}),l=[`parts`,`discount_table`],u=[`csv`,`tsv`,`xlsx`],d=n({name:`import_mapping`,scope:`global`,fields:{supplierId:r.reference({entity:`supplier`,required:!0,indexed:!0}),brandId:r.reference({entity:`brand`,indexed:!0}),targetSchema:r.select({options:l,required:!0,indexed:!0}),name:r.text({required:!0,maxLength:255}),format:r.select({options:u,required:!0}),delimiter:r.text({maxLength:4}),hasHeader:r.boolean({default:!0}),headerRowIndex:r.number({integer:!0,min:0,default:0}),sheet:r.text({maxLength:128}),numberFormat:r.json({default:{decimal:`.`,thousands:``}}),columnMap:r.json({default:[]}),active:r.boolean({default:!0,indexed:!0})},behaviors:[t.auditable(),i({fields:[`name`,`targetSchema`,`format`],projection:e=>({id:e.id,label:e.name,description:`${e.targetSchema} · ${e.format}`})}),{name:`imports:import-mapping-unique`,constraints:(e,t)=>{if(t!==`main`)return[];let n=e.supplierId,r=e.brandId,i=e.targetSchema;return!n||!r||!i?[]:[{kind:`uniqueIndex`,name:`uq_import_mapping_supplier_brand_schema`,on:[n,r,i],where:a`${r} IS NOT NULL`},{kind:`uniqueIndex`,name:`uq_import_mapping_supplier_schema_default`,on:[n,i],where:a`${r} IS NULL`}]}}],admin:{group:`imports`,label:`Import mappings`,labelSingular:`Import mapping`,icon:`table`,hideFromMenu:!0}});var f=class{entries=new Map;register(e,t){if(this.entries.has(e))throw Error(`Transform "${e}" is already registered — two plugins cannot register the same transform name.`);this.entries.set(e,t)}get(e){return this.entries.get(e)}has(e){return this.entries.has(e)}list(){return Array.from(this.entries.keys()).sort()}clear(){this.entries.clear()}};const p=Symbol.for(`@murumets-ee/imports:transforms`);function m(){let e=globalThis,t=e[p];return t||(t=new f,e[p]=t),t}function h(e,t){m().register(e,t)}export{l as a,c,u as i,o as l,m as n,d as o,h as r,s,f as t};
2
+ //# sourceMappingURL=transform-DloItB80.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"transform-DloItB80.mjs","names":[],"sources":["../src/entities/import-run.ts","../src/entities/import-mapping.ts","../src/transform.ts"],"sourcesContent":["/**\n * ImportRun — one feed-import attempt. Tracks file, transform, status,\n * row totals, and the top error patterns surfaced by `ErrorTracker`.\n *\n * Per PLAN-ECOMMERCE.md PR 7 (PoC scope): generic plumbing entity. The\n * commerce-specific transform parameters (brandId, supplierId, supplier\n * code prefix) live in the opaque `params` JSONB so the imports package\n * stays free of commerce-domain dependencies. PR 8 wires a concrete\n * carmaker-feed transform that reads those params; PR 8a renders a\n * bespoke `/admin/commerce/imports` page over this entity's history.\n *\n * `hideFromMenu: true` because PR 7 ships only the entity + plumbing —\n * the operator-facing surface is PR 8a. The auto-EntityListPage at\n * `/admin/import_run` still resolves for direct lookups, but no sidebar\n * entry points to it.\n */\n\nimport { behavior, defineEntity, field, searchable } from '@murumets-ee/entity'\nimport type { AdminClient } from '@murumets-ee/entity/admin'\n\n/**\n * Lifecycle stages.\n *\n * - `pending` — row exists, queue job has been enqueued, worker hasn't picked it up yet.\n * - `running` — worker is streaming + bulk-writing.\n * - `succeeded` — worker finished cleanly (note: per-row failures still possible — see `totals.failed`).\n * - `failed` — worker threw and the queue marked the job dead. `errorSummary.fatal` carries the cause.\n * - `cancelled` — manual operator action via PR 8a (out of scope for PR 7).\n */\nexport const IMPORT_RUN_STATUSES = ['pending', 'running', 'succeeded', 'failed', 'cancelled'] as const\nexport type ImportRunStatus = (typeof IMPORT_RUN_STATUSES)[number]\n\nexport const ImportRun = defineEntity({\n name: 'import_run',\n fields: {\n /** Operator-visible label, e.g. `\"MERCEDES — ME_20251027075918.txt\"`. Free-form. */\n label: field.text({ required: true, maxLength: 255 }),\n /** See {@link IMPORT_RUN_STATUSES}. */\n status: field.select({\n options: IMPORT_RUN_STATUSES,\n default: 'pending',\n indexed: true,\n }),\n /**\n * Path or storage key of the uploaded feed file. Generic string —\n * could be `/var/lumi/uploads/<id>.txt` for the local-disk PoC or an\n * S3 object key once storage adapter integration lands. The worker\n * reads this with the configured `readFeed` resolver.\n */\n filePath: field.text({ required: true, maxLength: 2048 }),\n /**\n * Name of the registered transform applied to each row. Resolved at\n * worker-dispatch time against the transform registry contributed by\n * the consumer. PoC: `'commerce:carmaker-feed'`.\n */\n transformName: field.text({ required: true, maxLength: 128, indexed: true }),\n /**\n * Opaque per-transform parameters. The carmaker transform expects\n * `{ brandId, supplierId, codePrefix? }`. Validation happens inside\n * the transform — the imports package never inspects this shape so\n * a new transform with different params doesn't require a schema\n * migration.\n */\n params: field.json(),\n /**\n * Row counters: `{ submitted, succeeded, failed, skipped, batches }`.\n * Updated by the worker as each batch completes. Final values are\n * what the operator reads; intermediate progress comes from\n * `toolkit_jobs.progress` via the queue UI.\n */\n totals: field.json(),\n /**\n * Output of `ErrorTracker.getTopPatterns(totalRows)` — the top-50\n * error signatures with up to 5 sample rows each. Empty `[]` until\n * the worker writes it on completion.\n */\n errorSummary: field.json(),\n /** Set when the worker picks up the job. */\n startedAt: field.date(),\n /** Set when the worker finishes (success OR fatal failure). */\n finishedAt: field.date(),\n /**\n * `toolkit_jobs.id` of the queue job processing this run — link\n * back so PR 8a can show live progress without a second lookup.\n */\n queueJobId: field.text({ maxLength: 64, indexed: true }),\n },\n behaviors: [\n behavior.auditable(),\n // Operator-facing search: by label (e.g. \"MERCEDES — ME_*.txt\"),\n // transform name, or queue job id. No FTS — labels are short\n // alphanumeric tags, not prose.\n searchable({\n fields: ['label', 'transformName', 'queueJobId', 'status', 'filePath'],\n projection: (row) => ({\n id: row.id as string,\n label: row.label as string,\n description: `${row.transformName} · ${row.status}`,\n }),\n }),\n ],\n scope: 'global',\n admin: {\n group: 'imports',\n label: 'Import runs',\n labelSingular: 'Import run',\n icon: 'upload',\n hideFromMenu: true,\n },\n})\n\nexport type ImportRunClient = AdminClient<typeof ImportRun.allFields>\n","/**\n * ImportMapping — a reusable, per-(supplier, brand) column-mapping profile that\n * drives the configurable feed-import engine (commerce-demo D005, phase 02).\n *\n * Replaces the hardcoded 14/15-column carmaker transform with operator-editable\n * mappings: which source column → which whitelisted target field, per-column\n * cleaning transforms, the file format (csv/tsv/xlsx), and the number format\n * (decimal + thousands separators — real supplier feeds vary, see R03).\n *\n * **Why this lives in `@murumets-ee/imports`, not `@murumets-ee/commerce`**\n * (D005 cycle constraint): the engine + the concrete target schemas need the\n * commerce `PartsDocument` shape, and `imports` already depends on\n * `@murumets-ee/search-elasticsearch` for it. Putting the mapping in commerce\n * would force `commerce → imports` and a forbidden import cycle. The entity\n * references the commerce `supplier` / `brand` entities **by name string**\n * (`field.reference({ entity: 'supplier' })`) — no Entity-object import, so the\n * leaf-package boundary holds.\n *\n * **Resolution** (most-specific, D002 ladder): for an upload tagged\n * `(supplier, brand)`, pick the `(supplier, brand)` mapping if present, else the\n * `(supplier, NULL)` supplier-default. See `resolveMapping()` in the engine.\n *\n * `hideFromMenu: true` — the mapping CRUD wizard lands in commerce-demo PR3. The\n * auto-EntityListPage at `/admin/import_mapping` still resolves for direct lookups\n * and for the admin-only AdminClient writes this engine relies on.\n */\n\nimport type { Behavior, SchemaFragment, SchemaTarget } from '@murumets-ee/entity'\nimport { behavior, defineEntity, field, searchable } from '@murumets-ee/entity'\nimport type { AdminClient } from '@murumets-ee/entity/admin'\nimport { sql } from 'drizzle-orm'\nimport type { PgColumn } from 'drizzle-orm/pg-core'\n\n/**\n * Registered target schemas a mapping may write into. This is the FIRST of two\n * whitelists (the second is each schema's target-field set) — a mapping whose\n * `targetSchema` is outside this list is rejected (deny-by-default, R02).\n */\nexport const IMPORT_MAPPING_TARGET_SCHEMAS = ['parts', 'discount_table'] as const\nexport type ImportMappingTargetSchema = (typeof IMPORT_MAPPING_TARGET_SCHEMAS)[number]\n\n/** File formats the reader dispatches on. `csv`/`tsv` → csv-parse; `xlsx` → exceljs. */\nexport const IMPORT_MAPPING_FORMATS = ['csv', 'tsv', 'xlsx'] as const\nexport type ImportMappingFormat = (typeof IMPORT_MAPPING_FORMATS)[number]\n\n/**\n * Declarative DDL: one mapping per `(supplier, brand, target_schema)`, enforced by\n * TWO PARTIAL unique indexes rather than one plain composite unique.\n *\n * Why partial: a plain `UNIQUE (supplierId, brandId, targetSchema)` treats NULL\n * `brandId` as distinct (Postgres NULL-distinct semantics), so it would allow MANY\n * supplier-default rows (`brandId IS NULL`) for the same `(supplier, schema)` —\n * ambiguous, since the engine resolves `brandId === null` as THE supplier default\n * (`resolveMapping`). Splitting it:\n * - brand-specific rows (`brandId IS NOT NULL`): unique on `(supplier, brand, schema)`;\n * - supplier-default rows (`brandId IS NULL`): unique on `(supplier, schema)`.\n *\n * The `where` predicates use raw `sql` with the live Drizzle column reference (so the\n * emitted identifier is correct regardless of casing) — `IS NULL` / `IS NOT NULL`,\n * NOT `eq(col, …)` which would serialize as an invalid bound `$1` in DDL.\n *\n * Typed `Behavior<Record<never, never>, …>` (NOT plain `Behavior`, whose wide\n * `fields?` would collapse the entity's inferred field types to `any` — the\n * documented no-field-behavior gotcha). Contributes constraints, no fields.\n */\nconst importMappingUnique: Behavior<Record<never, never>, 'imports:import-mapping-unique'> = {\n name: 'imports:import-mapping-unique',\n constraints: (table: Record<string, PgColumn>, target: SchemaTarget): SchemaFragment[] => {\n if (target !== 'main') return []\n const supplierId = table.supplierId\n const brandId = table.brandId\n const targetSchema = table.targetSchema\n // Defensive: only emit when all three columns exist.\n if (!supplierId || !brandId || !targetSchema) return []\n return [\n {\n kind: 'uniqueIndex',\n name: 'uq_import_mapping_supplier_brand_schema',\n on: [supplierId, brandId, targetSchema],\n where: sql`${brandId} IS NOT NULL`,\n },\n {\n kind: 'uniqueIndex',\n name: 'uq_import_mapping_supplier_schema_default',\n on: [supplierId, targetSchema],\n where: sql`${brandId} IS NULL`,\n },\n ]\n },\n}\n\nexport const ImportMapping = defineEntity({\n name: 'import_mapping',\n scope: 'global',\n fields: {\n /** FK to commerce `supplier` (by name string — no cross-package import). Required. */\n supplierId: field.reference({ entity: 'supplier', required: true, indexed: true }),\n /** FK to commerce `brand`. NULL = supplier default (applies to all that supplier's brands). */\n brandId: field.reference({ entity: 'brand', indexed: true }),\n /** Which registered target schema this mapping feeds — `parts` or `discount_table`. */\n targetSchema: field.select({\n options: IMPORT_MAPPING_TARGET_SCHEMAS,\n required: true,\n indexed: true,\n }),\n /** Operator-facing profile name (a supplier can have e.g. \"monthly full\" vs \"weekly delta\"). */\n name: field.text({ required: true, maxLength: 255 }),\n /** File format — drives reader dispatch + the default delimiter. */\n format: field.select({ options: IMPORT_MAPPING_FORMATS, required: true }),\n /**\n * Explicit single-char field delimiter override. When omitted, the reader\n * defaults from `format` (`csv` → `,`, `tsv` → `\\t`). Ignored for `xlsx`.\n */\n delimiter: field.text({ maxLength: 4 }),\n /** First row is the header. `false` → positional `{ \"0\": …, \"1\": … }` source keys. */\n hasHeader: field.boolean({ default: true }),\n /** 0-based header row index (header hygiene — R03 feeds have leading/trailing-space headers). */\n headerRowIndex: field.number({ integer: true, min: 0, default: 0 }),\n /** xlsx sheet name (or 1-based index as a string). Ignored for csv/tsv. */\n sheet: field.text({ maxLength: 128 }),\n /**\n * Number format for the `number` per-column transform:\n * `{ decimal: '.'|',', thousands: ''|'.'|','|' ' }`. Opaque json; validated\n * by the engine's Zod schema at consume time (matches `import_run.params`).\n */\n numberFormat: field.json({ default: { decimal: '.', thousands: '' } }),\n /**\n * Ordered column map: `[{ source, target, transforms? }]`. `source` is a\n * header name (or positional index string); `target` MUST be a field in the\n * chosen schema's whitelist (validated by the engine's Zod schema, R02\n * injection boundary); `transforms` is a bounded, enum-keyed list. Opaque\n * json on the entity; typed + validated by the engine.\n */\n columnMap: field.json({ default: [] }),\n /** Inactive mappings are skipped by resolution. */\n active: field.boolean({ default: true, indexed: true }),\n },\n behaviors: [\n behavior.auditable(),\n // Operator-facing search (PR3 wizard): by profile name, schema, or format.\n // No `fts` → IlikeProvider, so this adds NO DDL (the 0036 migration is\n // unchanged). Names are short tags, not prose.\n searchable({\n fields: ['name', 'targetSchema', 'format'],\n projection: (row) => ({\n id: row.id as string,\n label: row.name as string,\n description: `${row.targetSchema} · ${row.format}`,\n }),\n }),\n importMappingUnique,\n ],\n admin: {\n group: 'imports',\n label: 'Import mappings',\n labelSingular: 'Import mapping',\n icon: 'table',\n hideFromMenu: true,\n },\n})\n\nexport type ImportMappingClient = AdminClient<typeof ImportMapping.allFields>\n","/**\n * Per-feed transform plugin interface (PLAN-ECOMMERCE.md PR 7 / D14).\n *\n * The streaming reader reads raw rows out of the file (column key →\n * string). The transform turns one row into a typed `OutputDoc` ready\n * for the bulk-write surface (PoC: `PartsDocument` for the parts ES\n * index). The transform is what makes a generic feed-importer\n * commerce-aware — without it, the importer has no opinion on what a\n * row \"means\".\n *\n * Why an interface, not a function:\n * - The transform may need to reject a row (`RowSkip`) without that\n * counting as an error in `ErrorTracker` — e.g. blank lines, header\n * rows mistakenly retained, or \"this is a replacement-code marker\n * row, handled out-of-band\" decisions.\n * - The transform may need to fail a row (`RowError`) with a typed\n * error class so `ErrorTracker` collapses them by `errorType`.\n * - The transform may need access to the run-level params (brand /\n * supplier IDs, code prefix, batch ID) without those being\n * re-derived per row.\n *\n * PR 7 ships only the interface + a registry. PR 8 ships the first\n * concrete carmaker-feed transform.\n */\n\n/** Identifies which transform a registered handler implements. Stable across deploys. */\nexport type TransformName = string\n\n/** Per-run context provided to every row call. Kept narrow on purpose. */\nexport interface TransformContext {\n /** UUID of the `import_run` row. Forward to the output doc as `import_batch_id` for source attribution (D20). */\n importRunId: string\n /** Opaque per-run params copied from `import_run.params`. The transform validates the shape it expects. */\n params: Record<string, unknown>\n /** Operator-supplied label, useful for logging / debugging. */\n runLabel: string\n /** 1-based row number across the whole feed (header counts as row 0). Forward to `RowError.rowNumber`. */\n rowNumber: number\n}\n\n/**\n * One of: a successful `OutputDoc` keyed by a stable `id`, an\n * intentional `skip` (counted in `totals.skipped`), or a row-level\n * error (counted in `totals.failed` AND aggregated by `ErrorTracker`).\n *\n * The `id` field on `success` is used as the bulk-upsert primary key\n * (`_id` in ES). Per D4 the parts index uses\n * `<code_normalized>__<supplier_id>` so a re-run of the same feed\n * idempotently overwrites instead of duplicating.\n */\nexport type RowResult<TDoc> =\n | { kind: 'success'; id: string; doc: TDoc }\n | { kind: 'skip'; reason: string }\n | { kind: 'error'; error: RowError }\n\n/** Row-level error. The aggregator uses `(errorType, field, message)` as the dedup signature. */\nexport interface RowError {\n errorType: string\n message: string\n field?: string | undefined\n}\n\n/**\n * The contract every per-feed transform implements. Pure function of\n * `(row, ctx) → RowResult` — no I/O. Async only because the future\n * carmaker variant might consult an in-memory taxonomy lookup.\n */\nexport interface RowTransform<TDoc> {\n (rawRow: Record<string, string>, ctx: TransformContext): Promise<RowResult<TDoc>>\n}\n\n/**\n * Holds the registered transforms for the current process. Each plugin\n * that ships a transform calls {@link TransformRegistry.register} from\n * its `init` hook.\n *\n * Per CLAUDE.md \"Whitelist, don't blacklist\": the worker dispatches\n * by `import_run.transformName` against this registry. An unregistered\n * name fails the run rather than running with a default transform.\n *\n * **Production callers MUST go through {@link getTransformRegistry}**, not\n * `new TransformRegistry()`. The constructor is exposed only so tests\n * (and rare custom-worker embeds) can build an isolated instance and\n * inject it into {@link createRunImportHandler} directly. Two `new`\n * instances do NOT share state — registering a transform on one will\n * NOT make it visible to a worker resolving against the other. The\n * package's queue handler (registered by `imports()` plugin) always\n * resolves against the singleton.\n */\nexport class TransformRegistry {\n private readonly entries = new Map<TransformName, RowTransform<unknown>>()\n\n /**\n * Register a transform under a stable name. Throws on duplicate\n * names so two plugins can't silently overwrite each other (same\n * fail-loud collision protection that `combineAdminRoutes` enforces\n * for duplicate `(prefix, method, path)` route registrations).\n */\n register<TDoc>(name: TransformName, transform: RowTransform<TDoc>): void {\n if (this.entries.has(name)) {\n throw new Error(\n `Transform \"${name}\" is already registered — two plugins cannot register the same transform name.`,\n )\n }\n this.entries.set(name, transform as RowTransform<unknown>)\n }\n\n /** Look up a transform by name. Returns `undefined` if no plugin has registered one. */\n get(name: TransformName): RowTransform<unknown> | undefined {\n return this.entries.get(name)\n }\n\n /** True iff a transform is registered for the given name. */\n has(name: TransformName): boolean {\n return this.entries.has(name)\n }\n\n /** All registered names. Useful for the admin Catalog tab and for tests. */\n list(): TransformName[] {\n return Array.from(this.entries.keys()).sort()\n }\n\n /** Drop all registrations. Tests only. */\n clear(): void {\n this.entries.clear()\n }\n}\n\n// ---------------------------------------------------------------------------\n// Process-global singleton (mirrors `@murumets-ee/queue`'s handler registry).\n// ---------------------------------------------------------------------------\n//\n// Why a singleton: PR 8's carmaker-feed transform lives in a different\n// plugin from `@murumets-ee/imports`. Under Next.js HMR, ONE of those\n// plugins might re-evaluate while the other does not. Holding the\n// registry on `globalThis` via `Symbol.for` means the same `Map`\n// instance is shared across module evaluations — the carmaker plugin's\n// `register` and the imports worker's `get` see each other regardless\n// of evaluation order. Same fix shape as the queue's handler-registry\n// HMR bug (#186).\n\nconst REGISTRY_KEY = Symbol.for('@murumets-ee/imports:transforms')\n\ninterface GlobalThisWithTransforms {\n [REGISTRY_KEY]?: TransformRegistry\n}\n\n/** Returns the singleton registry, creating it on first access. */\nexport function getTransformRegistry(): TransformRegistry {\n const g = globalThis as GlobalThisWithTransforms\n let reg = g[REGISTRY_KEY]\n if (!reg) {\n reg = new TransformRegistry()\n g[REGISTRY_KEY] = reg\n }\n return reg\n}\n\n/**\n * Convenience wrapper around `getTransformRegistry().register(name, fn)`.\n * Plugins that ship a transform call this from their `init` hook — same\n * shape as `@murumets-ee/queue/client`'s `registerJob`.\n */\nexport function registerImportTransform<TDoc>(\n name: TransformName,\n transform: RowTransform<TDoc>,\n): void {\n getTransformRegistry().register(name, transform)\n}\n"],"mappings":"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"}
@@ -1,56 +1,42 @@
1
1
  import { r as RowTransform } from "./transform-dO4z0Ze6.mjs";
2
+ import { t as PartsParams } from "./parts-schema-RI_ltubl.mjs";
2
3
  import { PartsDocument } from "@murumets-ee/search-elasticsearch";
3
- import { z } from "zod";
4
4
 
5
5
  //#region src/transforms/carmaker-feed.d.ts
6
6
  /** Stable name under which this transform is registered. */
7
7
  declare const CARMAKER_FEED_TRANSFORM_NAME = "commerce:carmaker-feed";
8
8
  /**
9
- * Per-job configuration. PR 8a's upload route validates the
10
- * brandId/supplierId UUIDs, looks up the brand slug + supplier
11
- * display_name from the commerce entities, and writes the resolved
12
- * shape into `import_run.params` before enqueuing — that keeps
13
- * this transform free of DB I/O.
9
+ * Per-job params — the per-(supplier, brand) identity resolved at enqueue time.
10
+ * This IS the parts schema's params (re-exported), so brand/supplier injection +
11
+ * the optional `supplierCodePrefix` strip validate identically everywhere.
14
12
  */
15
- declare const carmakerFeedParamsSchema: z.ZodObject<{
16
- /** UUID of the brand. Forwarded to ES doc per D24 (typed FK). */brandId: z.ZodString; /** Brand slug for facet labels — pre-resolved by the route. */
17
- brandSlug: z.ZodString; /** UUID of the supplier. */
18
- supplierId: z.ZodString;
19
- /**
20
- * Customer-facing supplier alias (D24 anti-disintermediation) —
21
- * pre-resolved. NEVER `supplier.name` (the legal name).
22
- */
23
- supplierDisplayName: z.ZodString;
24
- /**
25
- * Optional supplier-specific code prefix to strip from
26
- * `ArticleName` before normalization. e.g. `'ME-'` for Mercedes,
27
- * `'TO-'` for Toyota. When absent, the full ArticleName is
28
- * normalized as-is.
29
- */
30
- supplierCodePrefix: z.ZodOptional<z.ZodString>;
31
- /**
32
- * Stable identifier for this run's batch — written verbatim to
33
- * `PartsDocument.import_batch_id` so D20 source-attribution holds
34
- * for the rare cases an operator needs to roll back a single feed
35
- * import in ES.
36
- */
37
- importBatchId: z.ZodString;
38
- }, "strip", z.ZodTypeAny, {
13
+ declare const carmakerFeedParamsSchema: import("zod").ZodObject<{
14
+ brandId: import("zod").ZodString;
15
+ brandSlug: import("zod").ZodString;
16
+ supplierId: import("zod").ZodString;
17
+ supplierDisplayName: import("zod").ZodString;
18
+ importBatchId: import("zod").ZodString;
19
+ supplierCodePrefix: import("zod").ZodOptional<import("zod").ZodString>;
20
+ }, "strip", import("zod").ZodTypeAny, {
21
+ supplierId: string;
39
22
  brandId: string;
40
23
  brandSlug: string;
41
- supplierId: string;
42
24
  supplierDisplayName: string;
43
25
  importBatchId: string;
44
26
  supplierCodePrefix?: string | undefined;
45
27
  }, {
28
+ supplierId: string;
46
29
  brandId: string;
47
30
  brandSlug: string;
48
- supplierId: string;
49
31
  supplierDisplayName: string;
50
32
  importBatchId: string;
51
33
  supplierCodePrefix?: string | undefined;
52
34
  }>;
53
- type CarmakerFeedParams = z.infer<typeof carmakerFeedParamsSchema>;
35
+ type CarmakerFeedParams = PartsParams;
36
+ /**
37
+ * The carmaker transform — built once at module load from the static mapping. The
38
+ * build validates every target against the `parts` whitelist (throws on a typo).
39
+ */
54
40
  declare const carmakerFeedTransform: RowTransform<PartsDocument>;
55
41
  //#endregion
56
42
  export { CARMAKER_FEED_TRANSFORM_NAME, type CarmakerFeedParams, carmakerFeedParamsSchema, carmakerFeedTransform };
@@ -1 +1 @@
1
- {"version":3,"file":"transforms.d.mts","names":[],"sources":["../src/transforms/carmaker-feed.ts"],"mappings":";;;;;;cAqCa,4BAAA;;;;;;;;cASA,wBAAA,EAAwB,CAAA,CAAA,SAAA;;;;;;;;;;;;;;;;EA+BzB;;;;;;;;;;;;;;;;;;;;;;KAAA,kBAAA,GAAqB,CAAA,CAAE,KAAK,QAAQ,wBAAA;AAAA,cAoFnC,qBAAA,EAAuB,YAAY,CAAC,aAAA"}
1
+ {"version":3,"file":"transforms.d.mts","names":[],"sources":["../src/transforms/carmaker-feed.ts"],"mappings":";;;;;;cA6Ca,4BAAA;AAQb;;;;AAA4C;AAA5C,cADa,wBAAA,gBAAwB,SAAA;;;;;;;;;;;;;;;;;;;;;;KACzB,kBAAA,GAAqB,WAAW;;;;;cAkC/B,qBAAA,EAAuB,YAAY,CAAC,aAAA"}
@@ -1,2 +1,2 @@
1
- import{z as e}from"zod";const t=`commerce:carmaker-feed`,n=e.object({brandId:e.string().uuid(),brandSlug:e.string().min(1).max(64),supplierId:e.string().uuid(),supplierDisplayName:e.string().min(1).max(255),supplierCodePrefix:e.string().max(8).regex(/^[A-Z0-9-]+$/,`must be uppercase alphanumeric or "-"`).optional(),importBatchId:e.string().uuid()}),r=new WeakMap;function i(e){let t=r.get(e);if(t)return t;let i=n.parse(e);return r.set(e,i),i}const a=/[\s-]/g;function o(e){return e.toUpperCase().replace(a,``)}const s=/^-?\d+(\.\d+)?$/;function c(e){let t=e.includes(`,`),n=e.includes(`.`),r;if(t&&n)r=e.replace(/\./g,``).replace(`,`,`.`);else if(t)r=e.replace(`,`,`.`);else if(n)return NaN;else r=e;return s.test(r)?Number.parseFloat(r):NaN}function l(e){if(e===void 0)return null;let t=e.trim();return t===``?null:t}const u=async(e,t)=>{let n=i(t.params),r=e.ArticleName?.trim();if(!r)return{kind:`error`,error:{errorType:`missing_article_name`,message:`ArticleName is empty`,field:`ArticleName`}};let a=(e.CurrencyCd??``).trim();if(a!==`EUR`)return{kind:`error`,error:{errorType:`unsupported_currency`,message:`Currency "${a}" not supported (PoC is EUR-only)`,field:`CurrencyCd`}};let s=o(n.supplierCodePrefix&&r.startsWith(n.supplierCodePrefix)?r.slice(n.supplierCodePrefix.length):r);if(!s)return{kind:`error`,error:{errorType:`empty_normalized_code`,message:`Code "${r}" is empty after normalization`,field:`ArticleName`}};let u=e[`NetPrice/Discount`]??``,d=c(u);if(Number.isNaN(d))return{kind:`error`,error:{errorType:`invalid_net_price`,message:`Could not parse "${u}" as a number`,field:`NetPrice/Discount`}};let f=(e.GrossPrice??``).trim(),p=null;if(f!==``&&f!==`NA`){let e=c(f);if(Number.isNaN(e))return{kind:`error`,error:{errorType:`invalid_gross_price`,message:`Could not parse "${f}" as a number`,field:`GrossPrice`}};p=e}let m=`${s}__${n.supplierId}`;return{kind:`success`,id:m,doc:{doc_id:m,code:r,code_normalized:s,brand_id:n.brandId,brand_slug:n.brandSlug,supplier_id:n.supplierId,supplier_display_name:n.supplierDisplayName,net_price_eur:d,gross_price_eur:p,currency:`EUR`,barcode:l(e.Barcode),name_de:l(e.Description_DE),name_en:l(e.Description_EN),name_es:l(e.Description_ES),name_fr:l(e.Description_FR),name_it:l(e.Description_IT),name_nl:l(e.Description_NL),name_pt:l(e.Description_PT),description1:l(e.Description1),description2:l(e.Description2),name_ro:null,list_price_minor:null,purchase_price_minor:null,discount_group:null,superseding_code:null,core_charge_minor:null,core_charge_currency:null,core_charge_refundable:null,core_charge_tax_class:null,core_charge_tax_inclusive:null,import_batch_id:n.importBatchId,imported_at:new Date().toISOString()}}};export{t as CARMAKER_FEED_TRANSFORM_NAME,n as carmakerFeedParamsSchema,u as carmakerFeedTransform};
1
+ import{n as e,o as t,t as n}from"./parts-schema-DQeMGPBC.mjs";const r=`commerce:carmaker-feed`,i=n,a=t({targetSchema:`parts`,numberFormat:{decimal:`,`,thousands:`.`},columnMap:[{source:`ArticleName`,target:`code`,transforms:[{op:`trim`}]},{source:`NetPrice/Discount`,target:`net_price`,transforms:[{op:`europeanNumber`}]},{source:`GrossPrice`,target:`gross_price`,transforms:[{op:`europeanNumber`}]},{source:`CurrencyCd`,target:`currency`,transforms:[{op:`trim`}]},{source:`Barcode`,target:`barcode`,transforms:[{op:`trim`}]},{source:`Description1`,target:`description1`,transforms:[{op:`trim`}]},{source:`Description2`,target:`description2`,transforms:[{op:`trim`}]},{source:`Description_DE`,target:`name_de`,transforms:[{op:`trim`}]},{source:`Description_EN`,target:`name_en`,transforms:[{op:`trim`}]},{source:`Description_ES`,target:`name_es`,transforms:[{op:`trim`}]},{source:`Description_FR`,target:`name_fr`,transforms:[{op:`trim`}]},{source:`Description_IT`,target:`name_it`,transforms:[{op:`trim`}]},{source:`Description_NL`,target:`name_nl`,transforms:[{op:`trim`}]},{source:`Description_PT`,target:`name_pt`,transforms:[{op:`trim`}]},{source:`Description_RO`,target:`name_ro`,transforms:[{op:`trim`}]}]},e);export{r as CARMAKER_FEED_TRANSFORM_NAME,i as carmakerFeedParamsSchema,a as carmakerFeedTransform};
2
2
  //# sourceMappingURL=transforms.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"transforms.mjs","names":[],"sources":["../src/transforms/carmaker-feed.ts"],"sourcesContent":["/**\n * Carmaker-feed transform — converts the giga-test 14-column tab-\n * delimited row into a `PartsDocument` for the parts ES alias.\n *\n * Per PLAN-ECOMMERCE.md PR 8 (PoC scope): ONE generic transform\n * covering every supplier whose feed matches this header. Per-job\n * `(brandId, brandSlug, supplierId, supplierDisplayName, supplierCodePrefix?)`\n * config is fully resolved at enqueue time (PR 8a's route looks up\n * the slug + display_name from the UUIDs before enqueuing) so the\n * transform is a pure function with no DB lookups per row.\n *\n * Decisions enforced:\n * - **D2** Code = bare manufacturer code, normalized. Strip the\n * supplier prefix (e.g. `ME-` for Mercedes, `TO-` for Toyota) if\n * present, then uppercase + dash/space-stripped.\n * - **D4** ES `_id` = `<code_normalized>__<supplierId>` so re-running\n * the same feed idempotently overwrites instead of duplicating.\n * - **D9** Single currency (EUR). Non-EUR rows are rejected as\n * row-level errors — at the PoC stage, only EUR data is loaded.\n * - **D21** Bulk-write path bypasses entity hooks. Per-batch audit\n * lives on the surrounding `import_run`, NOT per-row.\n *\n * **`*S` rows pass through unchanged** in this legacy transform — the\n * row goes into ES with its `*S` suffix preserved, no side effect.\n * Per commerce-demo SD002 (resolving PLAN-ECOMMERCE D11), `*S` marks a\n * **core charge** (NOT a replacement code): a `<code>*S` row's price is\n * the parent part's core-charge deposit. That routing lands when the\n * configurable mapping engine (commerce-demo PR2) replaces this transform\n * and the core-charge ingestion (PR7) consumes it; replacement codes come\n * from the dealer superseding-code column (D003), not `*S`.\n */\n\nimport type { PartsDocument } from '@murumets-ee/search-elasticsearch'\nimport { z } from 'zod'\nimport type { RowTransform } from '../transform.js'\n\n/** Stable name under which this transform is registered. */\nexport const CARMAKER_FEED_TRANSFORM_NAME = 'commerce:carmaker-feed'\n\n/**\n * Per-job configuration. PR 8a's upload route validates the\n * brandId/supplierId UUIDs, looks up the brand slug + supplier\n * display_name from the commerce entities, and writes the resolved\n * shape into `import_run.params` before enqueuing — that keeps\n * this transform free of DB I/O.\n */\nexport const carmakerFeedParamsSchema = z.object({\n /** UUID of the brand. Forwarded to ES doc per D24 (typed FK). */\n brandId: z.string().uuid(),\n /** Brand slug for facet labels — pre-resolved by the route. */\n brandSlug: z.string().min(1).max(64),\n /** UUID of the supplier. */\n supplierId: z.string().uuid(),\n /**\n * Customer-facing supplier alias (D24 anti-disintermediation) —\n * pre-resolved. NEVER `supplier.name` (the legal name).\n */\n supplierDisplayName: z.string().min(1).max(255),\n /**\n * Optional supplier-specific code prefix to strip from\n * `ArticleName` before normalization. e.g. `'ME-'` for Mercedes,\n * `'TO-'` for Toyota. When absent, the full ArticleName is\n * normalized as-is.\n */\n supplierCodePrefix: z\n .string()\n .max(8)\n .regex(/^[A-Z0-9-]+$/, 'must be uppercase alphanumeric or \"-\"')\n .optional(),\n /**\n * Stable identifier for this run's batch — written verbatim to\n * `PartsDocument.import_batch_id` so D20 source-attribution holds\n * for the rare cases an operator needs to roll back a single feed\n * import in ES.\n */\n importBatchId: z.string().uuid(),\n})\nexport type CarmakerFeedParams = z.infer<typeof carmakerFeedParamsSchema>\n\n/**\n * Run-level memoization of the parsed params. The runner passes the\n * same `ctx.params` reference for every row of a run, so this caches\n * the Zod parse result by reference identity. Garbage collection\n * clears the entry naturally once the run finishes and the params\n * object goes out of scope.\n *\n * Failed parses are NOT cached — a malformed-params run throws on\n * every row, but the ErrorTracker collapses identical Zod errors\n * into one bucket so the noise is bounded.\n */\nconst paramsCache = new WeakMap<object, CarmakerFeedParams>()\n\nfunction resolveParams(raw: Record<string, unknown>): CarmakerFeedParams {\n const cached = paramsCache.get(raw)\n if (cached) return cached\n const parsed = carmakerFeedParamsSchema.parse(raw)\n paramsCache.set(raw, parsed)\n return parsed\n}\n\nconst NORMALIZE_CODE_RE = /[\\s-]/g\n\n/** Uppercase + dash/whitespace-stripped (per D2). */\nfunction normalizeCode(s: string): string {\n return s.toUpperCase().replace(NORMALIZE_CODE_RE, '')\n}\n\n/** Validates the post-cleanup numeric form before accepting it. */\nconst STRICT_NUMBER_RE = /^-?\\d+(\\.\\d+)?$/\n\n/**\n * Parse a European-formatted decimal: `,` is the decimal separator, `.`\n * is the (optional) thousands separator. Returns `NaN` on unparseable\n * input — the caller surfaces that as a row-level error rather than\n * silently coercing to `0` or to a wrong value.\n *\n * Cases handled:\n * - `'1,98'` → `1.98` (decimal only)\n * - `'1.234,56'` → `1234.56` (thousands + decimal)\n * - `'5'` → `5` (integer)\n * - `'-5,00'` → `-5` (signed)\n *\n * Cases REJECTED (return `NaN`):\n * - `'1.98'` — bare-dot decimal is ambiguous in this format. The\n * carmaker feeds are documented as comma-decimal; a `.` without a\n * `,` could mean US-format decimal (`1.98`) OR a thousands marker\n * without decimal part. Rather than guess, reject. If a future\n * supplier sends US-format prices, that's a per-supplier transform\n * decision, not a quiet promotion of every dot.\n * - `'1,98EUR'`, `'1,98 '`, `'1,98abc'` — trailing junk. `parseFloat`\n * would silently accept the leading numeric prefix; the strict regex\n * post-check ensures the entire string is a clean number.\n * - `'1,2,3'` — multiple commas.\n * - `''`, `'NA'`, anything else non-numeric.\n */\nfunction parseEuropeanNumber(s: string): number {\n const hasComma = s.includes(',')\n const hasDot = s.includes('.')\n let cleaned: string\n if (hasComma && hasDot) {\n // European thousands+decimal: strip every `.`, then swap the (sole)\n // `,` for `.`. The strict regex below catches multi-comma input.\n cleaned = s.replace(/\\./g, '').replace(',', '.')\n } else if (hasComma) {\n cleaned = s.replace(',', '.')\n } else if (hasDot) {\n return Number.NaN\n } else {\n cleaned = s\n }\n if (!STRICT_NUMBER_RE.test(cleaned)) return Number.NaN\n return Number.parseFloat(cleaned)\n}\n\n/** Trim + collapse `''` and `undefined` to `null` (matches the `PartsDocument.* | null` shape). */\nfunction emptyToNull(s: string | undefined): string | null {\n if (s === undefined) return null\n const trimmed = s.trim()\n return trimmed === '' ? null : trimmed\n}\n\nexport const carmakerFeedTransform: RowTransform<PartsDocument> = async (row, ctx) => {\n const params = resolveParams(ctx.params)\n\n // 1. ArticleName is the only required cell that drives both `code`\n // and the ES `_id`. An empty value cannot be normalized; reject\n // rather than emit a doc with `_id = '__<supplierId>'`.\n const articleName = row['ArticleName']?.trim()\n if (!articleName) {\n return {\n kind: 'error',\n error: {\n errorType: 'missing_article_name',\n message: 'ArticleName is empty',\n field: 'ArticleName',\n },\n }\n }\n\n // 2. Currency gate (D9). The PoC is EUR-only — non-EUR data is not\n // converted, it's rejected. When PR 5c lands the price-application\n // layer with FxRate, this gate widens (or moves to the indexer).\n const currency = (row['CurrencyCd'] ?? '').trim()\n if (currency !== 'EUR') {\n return {\n kind: 'error',\n error: {\n errorType: 'unsupported_currency',\n message: `Currency \"${currency}\" not supported (PoC is EUR-only)`,\n field: 'CurrencyCd',\n },\n }\n }\n\n // 3. Strip supplier prefix if configured + present, then normalize.\n // A row whose ArticleName doesn't start with the configured prefix\n // is normalized as-is — this matches feeds that mix prefixed and\n // bare codes (rare but observed).\n const stripped =\n params.supplierCodePrefix && articleName.startsWith(params.supplierCodePrefix)\n ? articleName.slice(params.supplierCodePrefix.length)\n : articleName\n const codeNormalized = normalizeCode(stripped)\n if (!codeNormalized) {\n return {\n kind: 'error',\n error: {\n errorType: 'empty_normalized_code',\n message: `Code \"${articleName}\" is empty after normalization`,\n field: 'ArticleName',\n },\n }\n }\n\n // 4. Parse prices. Asymmetric: NetPrice is required (empty / unparseable\n // fails as `invalid_net_price`); GrossPrice is optional (`'NA'` or\n // empty → null, only a non-empty unparseable value fails). Required\n // because every parts row carries a wholesale price; gross is a\n // derived retail figure the supplier doesn't always populate.\n const netPriceRaw = row['NetPrice/Discount'] ?? ''\n const netPrice = parseEuropeanNumber(netPriceRaw)\n if (Number.isNaN(netPrice)) {\n return {\n kind: 'error',\n error: {\n errorType: 'invalid_net_price',\n message: `Could not parse \"${netPriceRaw}\" as a number`,\n field: 'NetPrice/Discount',\n },\n }\n }\n\n const grossRaw = (row['GrossPrice'] ?? '').trim()\n let grossPrice: number | null = null\n if (grossRaw !== '' && grossRaw !== 'NA') {\n const g = parseEuropeanNumber(grossRaw)\n if (Number.isNaN(g)) {\n return {\n kind: 'error',\n error: {\n errorType: 'invalid_gross_price',\n message: `Could not parse \"${grossRaw}\" as a number`,\n field: 'GrossPrice',\n },\n }\n }\n grossPrice = g\n }\n\n // 5. Build the ES doc. `imported_at` is generated per-row rather\n // than per-batch so the document carries a usable timestamp even\n // when the batch boundary is invisible to the reader.\n const docId = `${codeNormalized}__${params.supplierId}`\n const doc: PartsDocument = {\n doc_id: docId,\n code: articleName,\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: netPrice,\n gross_price_eur: grossPrice,\n currency: 'EUR',\n barcode: emptyToNull(row['Barcode']),\n name_de: emptyToNull(row['Description_DE']),\n name_en: emptyToNull(row['Description_EN']),\n name_es: emptyToNull(row['Description_ES']),\n name_fr: emptyToNull(row['Description_FR']),\n name_it: emptyToNull(row['Description_IT']),\n name_nl: emptyToNull(row['Description_NL']),\n name_pt: emptyToNull(row['Description_PT']),\n description1: emptyToNull(row['Description1']),\n description2: emptyToNull(row['Description2']),\n // commerce-demo fields (PartsDocument PR1 foundation). This legacy\n // hardcoded transform does NOT populate them — the configurable\n // mapping engine (commerce-demo PR2) supersedes it and fills these from\n // the supplier feed (purchase price PR4, superseding code PR6, core\n // charge PR7, `Description_RO` 15-col drift PR2). Null until then.\n name_ro: null,\n list_price_minor: null,\n purchase_price_minor: null,\n discount_group: null,\n superseding_code: null,\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 import_batch_id: params.importBatchId,\n imported_at: new Date().toISOString(),\n }\n\n return { kind: 'success', id: docId, doc }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"transforms.mjs","names":[],"sources":["../src/transforms/carmaker-feed.ts"],"sourcesContent":["/**\n * Carmaker-feed transform — now a THIN WRAPPER over the configurable mapping\n * engine (commerce-demo PR2). The hardcoded 14/15-column parsing logic is RETIRED:\n * this declares the carmaker layout as a built-in `parts` `MappingConfig` and\n * delegates to `buildMappingTransform`. The shared `partsSchema` does code\n * normalization (stripping the per-run `supplierCodePrefix`), the EUR currency\n * gate, price validation, and `PartsDocument` assembly — so the carmaker feed and\n * every operator-built `parts` mapping run through one row taxonomy.\n *\n * **Behaviour**: the emitted `PartsDocument` is equivalent to the old hardcoded\n * transform for in-spec rows, with deliberate differences worth knowing:\n * - It now populates `name_ro` from the 15-column feed's `Description_RO` — the\n * old 14-column transform always left it null (PR1 added the field).\n * - Number/currency cells are more lenient: surrounding whitespace is trimmed,\n * empty / lowercase `na` count as absent, and a blank/unmapped currency\n * defaults to EUR (the old transform rejected a blank `CurrencyCd`).\n * - The error TAXONOMY is uniform target-field naming (`missing_code`/`code`,\n * `unsupported_currency`/`currency`, empty required price → `missing_price`)\n * rather than source-column-specific (`missing_article_name`/`ArticleName`),\n * since the engine is source-column-agnostic.\n *\n * The carmaker doc-shape suite (27 tests) plus the leniency tests below it are the\n * regression net.\n *\n * **Decisions still enforced (now via the shared parts schema):**\n * - **D2** Code normalized (supplier prefix stripped, uppercased, dash/space-stripped).\n * - **D4** ES `_id` = `<code_normalized>__<supplierId>`.\n * - **D9** EUR-only (non-EUR rows rejected).\n * - **D21** Bulk-write path bypasses entity hooks (audit on the `import_run`).\n *\n * **`*S` rows** still pass through to ES with the `*S` suffix preserved (no side\n * effect) — per commerce-demo SD002 the core-charge routing lands in PR7, and\n * replacement codes come from the dealer superseding-code column (D003), not `*S`.\n *\n * Numbers use the `europeanNumber` op (comma decimal, dot thousands, bare-dot\n * rejected) to match the carmaker feed's documented format exactly; the\n * config's `numberFormat` is unused by that op but required by the schema.\n */\n\nimport type { PartsDocument } from '@murumets-ee/search-elasticsearch'\nimport { buildMappingTransform, type MappingConfig } from '../mapping/engine.js'\nimport { type PartsParams, partsParamsSchema, partsSchema } from '../mapping/parts-schema.js'\nimport type { RowTransform } from '../transform.js'\n\n/** Stable name under which this transform is registered. */\nexport const CARMAKER_FEED_TRANSFORM_NAME = 'commerce:carmaker-feed'\n\n/**\n * Per-job params — the per-(supplier, brand) identity resolved at enqueue time.\n * This IS the parts schema's params (re-exported), so brand/supplier injection +\n * the optional `supplierCodePrefix` strip validate identically everywhere.\n */\nexport const carmakerFeedParamsSchema = partsParamsSchema\nexport type CarmakerFeedParams = PartsParams\n\n/**\n * The built-in carmaker `parts` mapping (15 columns incl. `Description_RO`). Every\n * column carries a `trim` op; the two price columns use `europeanNumber`. The code\n * column is mapped RAW — the parts schema strips the supplier prefix (from params)\n * and normalizes, so both `doc.code` (raw) and `doc.code_normalized` are produced.\n */\nconst CARMAKER_MAPPING: MappingConfig = {\n targetSchema: 'parts',\n numberFormat: { decimal: ',', thousands: '.' },\n columnMap: [\n { source: 'ArticleName', target: 'code', transforms: [{ op: 'trim' }] },\n { source: 'NetPrice/Discount', target: 'net_price', transforms: [{ op: 'europeanNumber' }] },\n { source: 'GrossPrice', target: 'gross_price', transforms: [{ op: 'europeanNumber' }] },\n { source: 'CurrencyCd', target: 'currency', transforms: [{ op: 'trim' }] },\n { source: 'Barcode', target: 'barcode', transforms: [{ op: 'trim' }] },\n { source: 'Description1', target: 'description1', transforms: [{ op: 'trim' }] },\n { source: 'Description2', target: 'description2', transforms: [{ op: 'trim' }] },\n { source: 'Description_DE', target: 'name_de', transforms: [{ op: 'trim' }] },\n { source: 'Description_EN', target: 'name_en', transforms: [{ op: 'trim' }] },\n { source: 'Description_ES', target: 'name_es', transforms: [{ op: 'trim' }] },\n { source: 'Description_FR', target: 'name_fr', transforms: [{ op: 'trim' }] },\n { source: 'Description_IT', target: 'name_it', transforms: [{ op: 'trim' }] },\n { source: 'Description_NL', target: 'name_nl', transforms: [{ op: 'trim' }] },\n { source: 'Description_PT', target: 'name_pt', transforms: [{ op: 'trim' }] },\n { source: 'Description_RO', target: 'name_ro', transforms: [{ op: 'trim' }] },\n ],\n}\n\n/**\n * The carmaker transform — built once at module load from the static mapping. The\n * build validates every target against the `parts` whitelist (throws on a typo).\n */\nexport const carmakerFeedTransform: RowTransform<PartsDocument> = buildMappingTransform(\n CARMAKER_MAPPING,\n partsSchema,\n)\n"],"mappings":"8DA6CA,MAAa,EAA+B,yBAO/B,EAA2B,EAmC3B,EAAqD,EAChE,CA1BA,aAAc,QACd,aAAc,CAAE,QAAS,IAAK,UAAW,GAAI,EAC7C,UAAW,CACT,CAAE,OAAQ,cAAe,OAAQ,OAAQ,WAAY,CAAC,CAAE,GAAI,MAAO,CAAC,CAAE,EACtE,CAAE,OAAQ,oBAAqB,OAAQ,YAAa,WAAY,CAAC,CAAE,GAAI,gBAAiB,CAAC,CAAE,EAC3F,CAAE,OAAQ,aAAc,OAAQ,cAAe,WAAY,CAAC,CAAE,GAAI,gBAAiB,CAAC,CAAE,EACtF,CAAE,OAAQ,aAAc,OAAQ,WAAY,WAAY,CAAC,CAAE,GAAI,MAAO,CAAC,CAAE,EACzE,CAAE,OAAQ,UAAW,OAAQ,UAAW,WAAY,CAAC,CAAE,GAAI,MAAO,CAAC,CAAE,EACrE,CAAE,OAAQ,eAAgB,OAAQ,eAAgB,WAAY,CAAC,CAAE,GAAI,MAAO,CAAC,CAAE,EAC/E,CAAE,OAAQ,eAAgB,OAAQ,eAAgB,WAAY,CAAC,CAAE,GAAI,MAAO,CAAC,CAAE,EAC/E,CAAE,OAAQ,iBAAkB,OAAQ,UAAW,WAAY,CAAC,CAAE,GAAI,MAAO,CAAC,CAAE,EAC5E,CAAE,OAAQ,iBAAkB,OAAQ,UAAW,WAAY,CAAC,CAAE,GAAI,MAAO,CAAC,CAAE,EAC5E,CAAE,OAAQ,iBAAkB,OAAQ,UAAW,WAAY,CAAC,CAAE,GAAI,MAAO,CAAC,CAAE,EAC5E,CAAE,OAAQ,iBAAkB,OAAQ,UAAW,WAAY,CAAC,CAAE,GAAI,MAAO,CAAC,CAAE,EAC5E,CAAE,OAAQ,iBAAkB,OAAQ,UAAW,WAAY,CAAC,CAAE,GAAI,MAAO,CAAC,CAAE,EAC5E,CAAE,OAAQ,iBAAkB,OAAQ,UAAW,WAAY,CAAC,CAAE,GAAI,MAAO,CAAC,CAAE,EAC5E,CAAE,OAAQ,iBAAkB,OAAQ,UAAW,WAAY,CAAC,CAAE,GAAI,MAAO,CAAC,CAAE,EAC5E,CAAE,OAAQ,iBAAkB,OAAQ,UAAW,WAAY,CAAC,CAAE,GAAI,MAAO,CAAC,CAAE,CAC9E,CAQA,EACA,CACF"}
@@ -1,8 +1,8 @@
1
1
  import { o as TransformRegistry, r as RowTransform } from "./transform-dO4z0Ze6.mjs";
2
2
  import { EsClientLike } from "@murumets-ee/search-elasticsearch";
3
+ import { z } from "zod";
3
4
  import { Logger } from "@murumets-ee/core";
4
5
  import { JobDefinition, QueueTerminalNotifier } from "@murumets-ee/queue/client";
5
- import { z } from "zod";
6
6
  import { AdminClient } from "@murumets-ee/entity/admin";
7
7
 
8
8
  //#region src/entities/import-run.d.ts
@@ -193,26 +193,6 @@ interface ErrorTrackerSnapshot {
193
193
  }
194
194
  //#endregion
195
195
  //#region src/streaming.d.ts
196
- /**
197
- * Tab-delimited / CSV streaming reader. Generic over delimiter so the
198
- * same path handles `.txt` (tab), `.csv` (comma), and the rare `;`
199
- * European export dialect.
200
- *
201
- * Built on `csv-parse` per giga-test precedent — node-stream-based, low
202
- * memory, handles UTF-8 BOM (the carmaker feeds are Windows-exported
203
- * and ship with a BOM that breaks naive split-on-tab parsers).
204
- *
205
- * The reader yields `{ rowNumber, row }` pairs where:
206
- * - `rowNumber` is 1-based and counts the header as row 0.
207
- * - `row` is `Record<string, string>` keyed by header name. Empty cells
208
- * are the empty string, NOT `undefined` — feed transforms test with
209
- * `value === ''` consistently.
210
- *
211
- * Why this lives in a streaming reader and not inside the transform
212
- * itself: the transform sees one already-parsed row at a time, never
213
- * the file. That keeps transform implementations free of I/O concerns
214
- * and makes them trivially unit-testable with a fixture row map.
215
- */
216
196
  /**
217
197
  * Pre-pass: count data rows in a delimited file (excluding the header
218
198
  * row when `hasHeader` is true; default `true` to match `streamFeed`).
@@ -310,6 +290,30 @@ interface StreamFeedRow {
310
290
  */
311
291
  declare function streamFeed(options: StreamFeedOptions): AsyncIterable<StreamFeedRow>;
312
292
  //#endregion
293
+ //#region src/read-feed.d.ts
294
+ interface ReadFeedOptions {
295
+ /** Path to the file on local disk. */
296
+ filePath: string;
297
+ /** Feed format. Omitted → tab-delimited (tsv), matching the legacy carmaker reader. */
298
+ format?: 'csv' | 'tsv' | 'xlsx';
299
+ /** Explicit delimiter override (csv/tsv). Defaults from `format` (`csv` → `,`, else `\t`). */
300
+ delimiter?: string;
301
+ /** First row is the header. Default `true`. */
302
+ hasHeader?: boolean;
303
+ /** 0-based header row index (xlsx). */
304
+ headerRowIndex?: number;
305
+ /** xlsx worksheet as a 1-based index string. Omit → first sheet. */
306
+ sheet?: string;
307
+ /** Explicit column-name list (csv/tsv) — see `streamFeed`. */
308
+ columns?: ReadonlyArray<string>;
309
+ /** Forward to csv-parse `relax_column_count` (csv/tsv). */
310
+ relaxColumnCount?: boolean;
311
+ /** Forward to csv-parse `quote` (csv/tsv). */
312
+ quote?: string | false;
313
+ }
314
+ /** Stream a feed file's data rows, dispatching on `format`. */
315
+ declare function readFeed(options: ReadFeedOptions): AsyncIterable<StreamFeedRow>;
316
+ //#endregion
313
317
  //#region src/runner.d.ts
314
318
  /** Soft default; chosen to match giga-test. ES bulk requests over ~5MB get split server-side anyway. */
315
319
  declare const DEFAULT_BATCH_SIZE = 1000;
@@ -322,8 +326,8 @@ interface RunImportOptions<TDoc> {
322
326
  params: Record<string, unknown>;
323
327
  /** Transform applied to every parsed row. */
324
328
  transform: RowTransform<TDoc>;
325
- /** Streaming reader options — file path, delimiter, header config. */
326
- feed: StreamFeedOptions;
329
+ /** Feed reader options — file path + format (csv/tsv/xlsx) + delimiter/header config. */
330
+ feed: ReadFeedOptions;
327
331
  /** ES client (low-level shape from `@murumets-ee/search-elasticsearch`). */
328
332
  esClient: EsClientLike;
329
333
  /** Index alias to write to. Per D6, callers always pass an alias, never a physical index. */
@@ -570,5 +574,5 @@ interface ImportsRunDeadListenerConfig {
570
574
  */
571
575
  declare function createImportsRunDeadListener(config: ImportsRunDeadListenerConfig): QueueTerminalNotifier;
572
576
  //#endregion
573
- export { ErrorSample as C, ImportRun as D, IMPORT_RUN_STATUSES as E, ImportRunClient as O, ErrorPattern as S, ErrorTrackerConfig as T, StreamFeedRow as _, ImportsRunJobPayload as a, DEFAULT_MAX_PATTERNS as b, createRunImportHandler as c, DEFAULT_BATCH_SIZE as d, ImportRunProgress as f, StreamFeedOptions as g, runImport as h, ImportsRunJobContext as i, ImportRunStatus as k, importsRunJob as l, RunImportResult as m, FilePathResolver as n, RunImportHandlerConfig as o, RunImportOptions as p, ImportsRunDeadListenerConfig as r, createImportsRunDeadListener as s, EsClientResolver as t, importsRunJobPayloadSchema as u, countDataRows as v, ErrorTracker as w, DEFAULT_MAX_SAMPLES_PER_PATTERN as x, streamFeed as y };
574
- //# sourceMappingURL=worker-BY0JQw39.d.mts.map
577
+ export { ImportRunClient as A, DEFAULT_MAX_SAMPLES_PER_PATTERN as C, ErrorTrackerConfig as D, ErrorTracker as E, IMPORT_RUN_STATUSES as O, DEFAULT_MAX_PATTERNS as S, ErrorSample as T, readFeed as _, ImportsRunJobPayload as a, countDataRows as b, createRunImportHandler as c, DEFAULT_BATCH_SIZE as d, ImportRunProgress as f, ReadFeedOptions as g, runImport as h, ImportsRunJobContext as i, ImportRunStatus as j, ImportRun as k, importsRunJob as l, RunImportResult as m, FilePathResolver as n, RunImportHandlerConfig as o, RunImportOptions as p, ImportsRunDeadListenerConfig as r, createImportsRunDeadListener as s, EsClientResolver as t, importsRunJobPayloadSchema as u, StreamFeedOptions as v, ErrorPattern as w, streamFeed as x, StreamFeedRow as y };
578
+ //# sourceMappingURL=worker-ByPOP6Qe.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"worker-ByPOP6Qe.d.mts","names":[],"sources":["../src/entities/import-run.ts","../src/error-tracker.ts","../src/streaming.ts","../src/read-feed.ts","../src/runner.ts","../src/worker.ts"],"mappings":"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"}
package/dist/worker.d.mts CHANGED
@@ -1,2 +1,2 @@
1
- import { a as ImportsRunJobPayload, c as createRunImportHandler, i as ImportsRunJobContext, l as importsRunJob, n as FilePathResolver, o as RunImportHandlerConfig, r as ImportsRunDeadListenerConfig, s as createImportsRunDeadListener, t as EsClientResolver, u as importsRunJobPayloadSchema } from "./worker-BY0JQw39.mjs";
1
+ import { a as ImportsRunJobPayload, c as createRunImportHandler, i as ImportsRunJobContext, l as importsRunJob, n as FilePathResolver, o as RunImportHandlerConfig, r as ImportsRunDeadListenerConfig, s as createImportsRunDeadListener, t as EsClientResolver, u as importsRunJobPayloadSchema } from "./worker-ByPOP6Qe.mjs";
2
2
  export { EsClientResolver, FilePathResolver, ImportsRunDeadListenerConfig, ImportsRunJobContext, ImportsRunJobPayload, RunImportHandlerConfig, createImportsRunDeadListener, createRunImportHandler, importsRunJob, importsRunJobPayloadSchema };
package/dist/worker.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import{n as e,r as t}from"./runner-CSlZ0JFO.mjs";import{a as n,i as r,r as i}from"./publish-DCXIPwTE.mjs";import{runAsCli as a}from"@murumets-ee/core";import{defineJob as o}from"@murumets-ee/queue/client";import{eq as s}from"drizzle-orm";import{z as c}from"zod";const l=c.object({importRunId:c.string().uuid()}),u=o({name:`imports:run`,description:`Stream a feed file, transform rows, bulk-write to Elasticsearch.`,schema:l,defaultRetries:0});function d(i){let{importRuns:o,transforms:s,esClient:c,esIndex:l,resolveFilePath:u,logger:d}=i;return async i=>a(async()=>{let{importRunId:a}=i.payload,p=d?.child({jobId:i.id,importRunId:a,type:`imports:run`}),m=await o.findById(a);if(!m){p?.warn(`import_run row not found — skipping`);return}m.status===`running`&&p?.warn({previousQueueJobId:m.queueJobId,previousStartedAt:m.startedAt},`imports: re-claiming a row left in running by a prior worker — likely a crash recovered by queue lock-recovery`);let h=s.get(m.transformName);if(!h)throw await f(o,a,`Unknown transform "${m.transformName}"`),Error(`No transform registered for name "${m.transformName}"`);let g=m.params&&typeof m.params==`object`&&!Array.isArray(m.params)?m.params:{};await o.update(a,{status:`running`,queueJobId:i.id,startedAt:new Date}),r(a);let _;try{_=await c()}catch(e){throw await f(o,a,`Could not resolve ES client: ${e instanceof Error?e.message:String(e)}`),e}let v,y;if(u)try{let e=await u(m.filePath);v=e.localPath,y=e.cleanup}catch(e){throw await f(o,a,`Could not resolve filePath: ${e instanceof Error?e.message:String(e)}`),e}else v=m.filePath;let b;try{b=await t(v),p?.info({importRunId:a,totalRows:b},`imports: pre-pass row count`)}catch(e){p?.warn({err:e},`imports: pre-pass row count failed — continuing without total`)}let x;try{x=await e({importRunId:a,runLabel:m.label,params:g,transform:h,feed:{filePath:v},esClient:_,esIndex:l,onProgress:e=>i.updateProgress(b===void 0?e:{...e,totalRows:b}),...p!==void 0&&{logger:p}})}catch(e){throw await f(o,a,e instanceof Error?e.message:String(e)),y&&await y().catch(e=>{p?.warn({err:e},`filePath cleanup after failed run threw — ignoring`)}),e}y&&await y().catch(e=>{p?.warn({err:e},`filePath cleanup after successful run threw — ignoring`)}),await o.update(a,{status:`succeeded`,finishedAt:new Date,totals:{rowsRead:x.rowsRead,rowsSucceeded:x.rowsSucceeded,rowsFailed:x.rowsFailed,rowsSkipped:x.rowsSkipped,batchesCompleted:x.batchesCompleted},errorSummary:JSON.parse(JSON.stringify(x.errors))}),n(a,{rowsRead:x.rowsRead,rowsSucceeded:x.rowsSucceeded,rowsFailed:x.rowsFailed}),p?.info({rowsRead:x.rowsRead,rowsSucceeded:x.rowsSucceeded,rowsFailed:x.rowsFailed,rowsSkipped:x.rowsSkipped,batches:x.batchesCompleted},`import_run completed`)})}async function f(e,t,n){try{await e.update(t,{status:`failed`,finishedAt:new Date,errorSummary:{fatal:n}}),i(t,n)}catch{}}function p(e){let{importRuns:t,logger:n}=e;return{onJobDead:async e=>{e.jobType===`imports:run`&&await a(async()=>{let r=n?.child({event:`imports.run.dead`,jobId:e.jobId,type:`imports:run`}),i=t.getTable(),a=(await t.findMany({where:s(i.queueJobId,e.jobId),limit:1}))[0];if(!a){r?.warn(`queue.job.dead with no matching import_run — skipping`);return}if(a.status!==`running`&&a.status!==`pending`){r?.info({importRunId:a.id,status:a.status},`queue.job.dead arrived for a row already in a terminal state — leaving as-is`);return}a.status===`pending`&&r?.warn({importRunId:a.id,attempts:e.attempts},`queue.job.dead arrived for an import_run still in pending — worker crashed BEFORE claim/lookup`),await f(t,a.id,e.error),r?.info({importRunId:a.id,attempts:e.attempts,priorStatus:a.status},`queue.job.dead → import_run marked failed`)})}}}export{p as createImportsRunDeadListener,d as createRunImportHandler,u as importsRunJob,l as importsRunJobPayloadSchema};
1
+ import{a as e,n as t}from"./runner-DvKNfQJO.mjs";import{a as n,i as r,r as i}from"./publish-DCXIPwTE.mjs";import{eq as a}from"drizzle-orm";import{z as o}from"zod";import{runAsCli as s}from"@murumets-ee/core";import{defineJob as c}from"@murumets-ee/queue/client";const l=o.object({importRunId:o.string().uuid()}),u=c({name:`imports:run`,description:`Stream a feed file, transform rows, bulk-write to Elasticsearch.`,schema:l,defaultRetries:0});function d(i){let{importRuns:a,transforms:o,esClient:c,esIndex:l,resolveFilePath:u,logger:d}=i;return async i=>s(async()=>{let{importRunId:s}=i.payload,p=d?.child({jobId:i.id,importRunId:s,type:`imports:run`}),m=await a.findById(s);if(!m){p?.warn(`import_run row not found — skipping`);return}m.status===`running`&&p?.warn({previousQueueJobId:m.queueJobId,previousStartedAt:m.startedAt},`imports: re-claiming a row left in running by a prior worker — likely a crash recovered by queue lock-recovery`);let h=o.get(m.transformName);if(!h)throw await f(a,s,`Unknown transform "${m.transformName}"`),Error(`No transform registered for name "${m.transformName}"`);let g=m.params&&typeof m.params==`object`&&!Array.isArray(m.params)?m.params:{};await a.update(s,{status:`running`,queueJobId:i.id,startedAt:new Date}),r(s);let _;try{_=await c()}catch(e){throw await f(a,s,`Could not resolve ES client: ${e instanceof Error?e.message:String(e)}`),e}let v,y;if(u)try{let e=await u(m.filePath);v=e.localPath,y=e.cleanup}catch(e){throw await f(a,s,`Could not resolve filePath: ${e instanceof Error?e.message:String(e)}`),e}else v=m.filePath;let b;try{b=await e(v),p?.info({importRunId:s,totalRows:b},`imports: pre-pass row count`)}catch(e){p?.warn({err:e},`imports: pre-pass row count failed — continuing without total`)}let x;try{x=await t({importRunId:s,runLabel:m.label,params:g,transform:h,feed:{filePath:v},esClient:_,esIndex:l,onProgress:e=>i.updateProgress(b===void 0?e:{...e,totalRows:b}),...p!==void 0&&{logger:p}})}catch(e){throw await f(a,s,e instanceof Error?e.message:String(e)),y&&await y().catch(e=>{p?.warn({err:e},`filePath cleanup after failed run threw — ignoring`)}),e}y&&await y().catch(e=>{p?.warn({err:e},`filePath cleanup after successful run threw — ignoring`)}),await a.update(s,{status:`succeeded`,finishedAt:new Date,totals:{rowsRead:x.rowsRead,rowsSucceeded:x.rowsSucceeded,rowsFailed:x.rowsFailed,rowsSkipped:x.rowsSkipped,batchesCompleted:x.batchesCompleted},errorSummary:JSON.parse(JSON.stringify(x.errors))}),n(s,{rowsRead:x.rowsRead,rowsSucceeded:x.rowsSucceeded,rowsFailed:x.rowsFailed}),p?.info({rowsRead:x.rowsRead,rowsSucceeded:x.rowsSucceeded,rowsFailed:x.rowsFailed,rowsSkipped:x.rowsSkipped,batches:x.batchesCompleted},`import_run completed`)})}async function f(e,t,n){try{await e.update(t,{status:`failed`,finishedAt:new Date,errorSummary:{fatal:n}}),i(t,n)}catch{}}function p(e){let{importRuns:t,logger:n}=e;return{onJobDead:async e=>{e.jobType===`imports:run`&&await s(async()=>{let r=n?.child({event:`imports.run.dead`,jobId:e.jobId,type:`imports:run`}),i=t.getTable(),o=(await t.findMany({where:a(i.queueJobId,e.jobId),limit:1}))[0];if(!o){r?.warn(`queue.job.dead with no matching import_run — skipping`);return}if(o.status!==`running`&&o.status!==`pending`){r?.info({importRunId:o.id,status:o.status},`queue.job.dead arrived for a row already in a terminal state — leaving as-is`);return}o.status===`pending`&&r?.warn({importRunId:o.id,attempts:e.attempts},`queue.job.dead arrived for an import_run still in pending — worker crashed BEFORE claim/lookup`),await f(t,o.id,e.error),r?.info({importRunId:o.id,attempts:e.attempts,priorStatus:o.status},`queue.job.dead → import_run marked failed`)})}}}export{p as createImportsRunDeadListener,d as createRunImportHandler,u as importsRunJob,l as importsRunJobPayloadSchema};
2
2
  //# sourceMappingURL=worker.mjs.map
@@ -0,0 +1,2 @@
1
+ import{i as e}from"./runner-DvKNfQJO.mjs";function t(e){if(e==null)return``;if(typeof e==`string`)return e;if(typeof e==`number`||typeof e==`boolean`)return String(e);if(e instanceof Date)return e.toISOString();if(`richText`in e&&Array.isArray(e.richText))return e.richText.map(e=>e.text).join(``);if(`text`in e&&typeof e.text==`string`)return e.text;if(`result`in e&&e.result!==void 0&&e.result!==null){let t=e.result;return t instanceof Date?t.toISOString():typeof t==`object`?``:String(t)}return``}function n(e){let n=[];if(Array.isArray(e))for(let r=1;r<e.length;r+=1)n[r-1]=t(e[r]);else for(let[r,i]of Object.entries(e)){let e=Number.parseInt(r,10);!Number.isNaN(e)&&e>=1&&(n[e-1]=t(i))}for(let e=0;e<n.length;e+=1)n[e]===void 0&&(n[e]=``);return n}async function*r(t){let{filePath:r,hasHeader:i=!0,headerRowIndex:a=0,sheet:o}=t,s=new(await(import(`exceljs`))).stream.xlsx.WorkbookReader(r,{worksheets:`emit`,sharedStrings:`cache`,styles:`ignore`,hyperlinks:`ignore`,entries:`ignore`}),c;if(o!==void 0&&(c=Number.parseInt(o,10),Number.isNaN(c)||c<1))throw Error(`Invalid xlsx sheet "${o}" — expected a 1-based worksheet index`);let l=0,u=0;for await(let t of s){if(u+=1,!(c===void 0?u===1:u===c)){for await(let e of t);continue}let r=0,o=null;for await(let s of t){r+=1;let t=n(s.values);if(i&&o===null){if(r<a+1)continue;o=t.map(e=>e.trim()),e(o);continue}l+=1;let c={};if(o)for(let e=0;e<o.length;e+=1){let n=o[e];n&&(c[n]=t[e]??``)}else for(let e=0;e<t.length;e+=1)c[String(e)]=t[e]??``;yield{rowNumber:l,row:c}}return}if(c!==void 0)throw Error(`xlsx worksheet ${c} not found`)}export{r as streamXlsx};
2
+ //# sourceMappingURL=xlsx-reader-B2_F37jA.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"xlsx-reader-B2_F37jA.mjs","names":[],"sources":["../src/xlsx-reader.ts"],"sourcesContent":["/**\n * Streaming XLSX reader (commerce-demo phase 02, D005) — the exceljs branch of\n * the feed reader. Yields the SAME `AsyncIterable<{ rowNumber, row }>` contract as\n * the csv-parse `streamFeed`, so the runner / transform layer downstream is\n * format-agnostic.\n *\n * **exceljs over SheetJS** (R02): we parse operator-uploaded spreadsheets — the\n * exact threat model of the unfixed npm `xlsx` prototype-pollution CVE\n * (CVE-2023-30533). exceljs is MIT, ships its own types, has a real streaming\n * `WorkbookReader`, and carries no equivalent advisory.\n *\n * **exceljs is `await import()`-ed**, never statically imported, so it stays out\n * of this package's static module graph — heavy + Node-only, it must not be\n * inlinable into any `*-ui` server-pages bundle (CLAUDE.md bundle budgets). The\n * `read-feed` dispatcher additionally only loads THIS module for `format: 'xlsx'`.\n */\n\nimport type { CellValue } from 'exceljs'\nimport { assertUniqueHeaders, type StreamFeedRow } from './streaming.js'\n\nexport interface XlsxReadOptions {\n /** Path to the .xlsx file on local disk. */\n filePath: string\n /** First row is the header (column names). `false` → positional `{ \"0\": … }` keys. Default `true`. */\n hasHeader?: boolean\n /** 0-based index of the header row (rows before it are skipped). Default `0`. */\n headerRowIndex?: number\n /** Worksheet to read, as a 1-based index string. Omit → the first worksheet. */\n sheet?: string\n}\n\n/** Convert one exceljs cell value to a plain string (feeds are text/number cells). */\nfunction cellToString(v: CellValue): string {\n if (v === null || v === undefined) return ''\n if (typeof v === 'string') return v\n if (typeof v === 'number' || typeof v === 'boolean') return String(v)\n if (v instanceof Date) return v.toISOString()\n // Object cell values: rich text, hyperlink, formula result, error.\n if ('richText' in v && Array.isArray(v.richText)) {\n return v.richText.map((r) => r.text).join('')\n }\n if ('text' in v && typeof v.text === 'string') return v.text\n if ('result' in v && v.result !== undefined && v.result !== null) {\n const r = v.result\n if (r instanceof Date) return r.toISOString()\n // A nested error object (formula error) surfaces as empty; primitives stringify.\n return typeof r === 'object' ? '' : String(r)\n }\n return ''\n}\n\n/** Normalise an exceljs `row.values` (1-indexed array OR object form) to a 0-indexed string[]. */\nfunction rowCells(values: CellValue[] | { [key: string]: CellValue }): string[] {\n const out: string[] = []\n if (Array.isArray(values)) {\n // exceljs row arrays are 1-indexed (`values[0]` is empty); shift to 0-based.\n for (let i = 1; i < values.length; i += 1) out[i - 1] = cellToString(values[i])\n } else {\n for (const [k, v] of Object.entries(values)) {\n const idx = Number.parseInt(k, 10)\n if (!Number.isNaN(idx) && idx >= 1) out[idx - 1] = cellToString(v)\n }\n }\n // Fill any holes (sparse rows) with empty strings — the contract is \"missing\n // cell = ''\", matching streamFeed.\n for (let i = 0; i < out.length; i += 1) if (out[i] === undefined) out[i] = ''\n return out\n}\n\n/**\n * Async-iterable over the data rows of an .xlsx file. `rowNumber` is the 1-based\n * DATA row index (header excluded). Cell values are coerced to strings so the\n * mapping engine's per-column transforms (which expect raw string cells) apply\n * uniformly across csv/tsv/xlsx.\n */\nexport async function* streamXlsx(options: XlsxReadOptions): AsyncIterable<StreamFeedRow> {\n const { filePath, hasHeader = true, headerRowIndex = 0, sheet } = options\n const ExcelJS = await import('exceljs')\n const reader = new ExcelJS.stream.xlsx.WorkbookReader(filePath, {\n worksheets: 'emit',\n sharedStrings: 'cache',\n styles: 'ignore',\n hyperlinks: 'ignore',\n entries: 'ignore',\n })\n\n // `WorksheetReader` doesn't expose its sheet id, so count emitted worksheets\n // (1-based) to match the configured `sheet`. Default: the first worksheet.\n // A provided-but-invalid sheet FAILS FAST rather than silently reading sheet 1.\n let targetSheetIndex: number | undefined\n if (sheet !== undefined) {\n targetSheetIndex = Number.parseInt(sheet, 10)\n if (Number.isNaN(targetSheetIndex) || targetSheetIndex < 1) {\n throw new Error(`Invalid xlsx sheet \"${sheet}\" — expected a 1-based worksheet index`)\n }\n }\n let dataRowNumber = 0\n let sheetIndex = 0\n\n for await (const worksheet of reader) {\n sheetIndex += 1\n const isTarget =\n targetSheetIndex === undefined ? sheetIndex === 1 : sheetIndex === targetSheetIndex\n\n if (!isTarget) {\n // Drain a non-target worksheet so the underlying zip stream advances to the\n // next; exceljs's streaming reader stalls if a worksheet is left unconsumed.\n for await (const _skip of worksheet) {\n void _skip\n }\n continue\n }\n\n let excelRowNum = 0\n let header: string[] | null = null\n for await (const row of worksheet) {\n excelRowNum += 1\n const cells = rowCells(row.values)\n\n if (hasHeader && header === null) {\n // Skip rows before the configured header row, then capture it (trimmed).\n if (excelRowNum < headerRowIndex + 1) continue\n header = cells.map((c) => c.trim())\n assertUniqueHeaders(header)\n continue\n }\n\n dataRowNumber += 1\n const rec: Record<string, string> = {}\n if (header) {\n for (let i = 0; i < header.length; i += 1) {\n const key = header[i]\n if (key) rec[key] = cells[i] ?? ''\n }\n } else {\n for (let i = 0; i < cells.length; i += 1) rec[String(i)] = cells[i] ?? ''\n }\n yield { rowNumber: dataRowNumber, row: rec }\n }\n\n // Target worksheet fully read — done (remaining sheets are not consumed).\n return\n }\n\n // Reached only when a specific sheet was requested but never matched — the\n // first-sheet default always returns inside the loop on a non-empty workbook.\n if (targetSheetIndex !== undefined) {\n throw new Error(`xlsx worksheet ${targetSheetIndex} not found`)\n }\n}\n"],"mappings":"0CAgCA,SAAS,EAAa,EAAsB,CAC1C,GAAI,GAAM,KAAyB,MAAO,GAC1C,GAAI,OAAO,GAAM,SAAU,OAAO,EAClC,GAAI,OAAO,GAAM,UAAY,OAAO,GAAM,UAAW,OAAO,OAAO,CAAC,EACpE,GAAI,aAAa,KAAM,OAAO,EAAE,YAAY,EAE5C,GAAI,aAAc,GAAK,MAAM,QAAQ,EAAE,QAAQ,EAC7C,OAAO,EAAE,SAAS,IAAK,GAAM,EAAE,IAAI,CAAC,CAAC,KAAK,EAAE,EAE9C,GAAI,SAAU,GAAK,OAAO,EAAE,MAAS,SAAU,OAAO,EAAE,KACxD,GAAI,WAAY,GAAK,EAAE,SAAW,IAAA,IAAa,EAAE,SAAW,KAAM,CAChE,IAAM,EAAI,EAAE,OAGZ,OAFI,aAAa,KAAa,EAAE,YAAY,EAErC,OAAO,GAAM,SAAW,GAAK,OAAO,CAAC,CAC9C,CACA,MAAO,EACT,CAGA,SAAS,EAAS,EAA8D,CAC9E,IAAM,EAAgB,CAAC,EACvB,GAAI,MAAM,QAAQ,CAAM,EAEtB,IAAK,IAAI,EAAI,EAAG,EAAI,EAAO,OAAQ,GAAK,EAAG,EAAI,EAAI,GAAK,EAAa,EAAO,EAAE,OAE9E,IAAK,GAAM,CAAC,EAAG,KAAM,OAAO,QAAQ,CAAM,EAAG,CAC3C,IAAM,EAAM,OAAO,SAAS,EAAG,EAAE,EAC7B,CAAC,OAAO,MAAM,CAAG,GAAK,GAAO,IAAG,EAAI,EAAM,GAAK,EAAa,CAAC,EACnE,CAIF,IAAK,IAAI,EAAI,EAAG,EAAI,EAAI,OAAQ,GAAK,EAAO,EAAI,KAAO,IAAA,KAAW,EAAI,GAAK,IAC3E,OAAO,CACT,CAQA,eAAuB,EAAW,EAAwD,CACxF,GAAM,CAAE,WAAU,YAAY,GAAM,iBAAiB,EAAG,SAAU,EAE5D,EAAS,IAAI,MADG,OAAO,YAAA,CACF,OAAO,KAAK,eAAe,EAAU,CAC9D,WAAY,OACZ,cAAe,QACf,OAAQ,SACR,WAAY,SACZ,QAAS,QACX,CAAC,EAKG,EACJ,GAAI,IAAU,IAAA,KACZ,EAAmB,OAAO,SAAS,EAAO,EAAE,EACxC,OAAO,MAAM,CAAgB,GAAK,EAAmB,GACvD,MAAU,MAAM,uBAAuB,EAAM,uCAAuC,EAGxF,IAAI,EAAgB,EAChB,EAAa,EAEjB,UAAW,IAAM,KAAa,EAAQ,CAKpC,GAJA,GAAc,EAIV,EAFF,IAAqB,IAAA,GAAY,IAAe,EAAI,IAAe,GAEtD,CAGb,UAAW,IAAM,KAAS,GAG1B,QACF,CAEA,IAAI,EAAc,EACd,EAA0B,KAC9B,UAAW,IAAM,KAAO,EAAW,CACjC,GAAe,EACf,IAAM,EAAQ,EAAS,EAAI,MAAM,EAEjC,GAAI,GAAa,IAAW,KAAM,CAEhC,GAAI,EAAc,EAAiB,EAAG,SACtC,EAAS,EAAM,IAAK,GAAM,EAAE,KAAK,CAAC,EAClC,EAAoB,CAAM,EAC1B,QACF,CAEA,GAAiB,EACjB,IAAM,EAA8B,CAAC,EACrC,GAAI,EACF,IAAK,IAAI,EAAI,EAAG,EAAI,EAAO,OAAQ,GAAK,EAAG,CACzC,IAAM,EAAM,EAAO,GACf,IAAK,EAAI,GAAO,EAAM,IAAM,GAClC,MAEA,IAAK,IAAI,EAAI,EAAG,EAAI,EAAM,OAAQ,GAAK,EAAG,EAAI,OAAO,CAAC,GAAK,EAAM,IAAM,GAEzE,KAAM,CAAE,UAAW,EAAe,IAAK,CAAI,CAC7C,CAGA,MACF,CAIA,GAAI,IAAqB,IAAA,GACvB,MAAU,MAAM,kBAAkB,EAAiB,WAAW,CAElE"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@murumets-ee/imports",
3
- "version": "0.30.0",
3
+ "version": "0.32.0",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "exports": {
@@ -8,6 +8,10 @@
8
8
  "types": "./dist/index.d.mts",
9
9
  "import": "./dist/index.mjs"
10
10
  },
11
+ "./mapping": {
12
+ "types": "./dist/mapping/index.d.mts",
13
+ "import": "./dist/mapping/index.mjs"
14
+ },
11
15
  "./plugin": {
12
16
  "types": "./dist/plugin.d.mts",
13
17
  "import": "./dist/plugin.mjs"
@@ -39,14 +43,15 @@
39
43
  "dependencies": {
40
44
  "csv-parse": "^5.5.3",
41
45
  "drizzle-orm": "^0.45.2",
46
+ "exceljs": "^4.4.0",
42
47
  "zod": "^3.24.1",
43
- "@murumets-ee/core": "0.30.0",
44
- "@murumets-ee/db": "0.30.0",
45
- "@murumets-ee/entity": "0.30.0",
46
- "@murumets-ee/logging": "0.30.0",
47
- "@murumets-ee/queue": "0.30.0",
48
- "@murumets-ee/search-elasticsearch": "0.30.0",
49
- "@murumets-ee/storage": "0.30.0"
48
+ "@murumets-ee/core": "0.32.0",
49
+ "@murumets-ee/db": "0.32.0",
50
+ "@murumets-ee/entity": "0.32.0",
51
+ "@murumets-ee/logging": "0.32.0",
52
+ "@murumets-ee/queue": "0.32.0",
53
+ "@murumets-ee/search-elasticsearch": "0.32.0",
54
+ "@murumets-ee/storage": "0.32.0"
50
55
  },
51
56
  "devDependencies": {
52
57
  "@types/node": "^20.19.42",
@@ -1,2 +0,0 @@
1
- import{bulkUpsert as e}from"@murumets-ee/search-elasticsearch";import{createReadStream as t}from"node:fs";import{parse as n}from"csv-parse";const r=50,i=5;var a=class{patterns=new Map;maxPatterns;maxSamplesPerPattern;droppedSignatures=0;constructor(e={}){this.maxPatterns=e.maxPatterns??50,this.maxSamplesPerPattern=e.maxSamplesPerPattern??5}addError(e,t,n,r,i){let a=`${t}:${r??`GENERAL`}:${n}`,o=this.patterns.get(a);if(!o){if(this.patterns.size>=this.maxPatterns){this.droppedSignatures+=1;return}o={errorType:t,field:r??null,message:n,count:0,firstOccurrence:e,lastOccurrence:e,samples:[]},this.patterns.set(a,o)}o.count+=1,o.lastOccurrence=e,o.samples.length<this.maxSamplesPerPattern&&o.samples.push({rowNumber:e,rowData:i})}getTotalErrorCount(){let e=0;for(let t of this.patterns.values())e+=t.count;return e}getDistinctPatternCount(){return this.patterns.size}getDroppedSignatureCount(){return this.droppedSignatures}getTopPatterns(){let e=Array.from(this.patterns.values()).sort((e,t)=>t.count-e.count),t=e.reduce((e,t)=>e+t.count,0);return e.map(e=>({errorType:e.errorType,field:e.field,message:e.message,count:e.count,firstOccurrence:e.firstOccurrence,lastOccurrence:e.lastOccurrence,samples:e.samples.slice(),percentage:t>0?e.count/t*100:0}))}snapshot(){return{totalErrors:this.getTotalErrorCount(),distinctPatterns:this.getDistinctPatternCount(),droppedSignatures:this.droppedSignatures,patterns:this.getTopPatterns()}}};async function o(e,n={}){let{hasHeader:r=!0}=n,i=t(e),a=0,o=0;for await(let e of i){for(let t=0;t<e.length;t+=1)e[t]===10&&(a+=1);e.length>0&&(o=e[e.length-1]??0)}return o!==0&&o!==10&&(a+=1),r?Math.max(0,a-1):a}async function*s(e){let{filePath:r,delimiter:i=` `,hasHeader:a=!0,columns:o,relaxColumnCount:s=!1,quote:c=!1}=e,l=o?Array.from(o):a,u=t(r),d=u.pipe(n({delimiter:i,columns:l,bom:!0,skip_empty_lines:!0,relax_column_count:s,quote:c})),f=0;try{for await(let e of d){f+=1;let t={};if(Array.isArray(e)){let n=e;for(let e=0;e<n.length;e+=1)t[String(e)]=n[e]??``}else for(let[n,r]of Object.entries(e))t[n]=r??``;yield{rowNumber:f,row:t}}}finally{u.destroy()}}const c=1e3;async function l(t){let{importRunId:n,runLabel:r,params:i,transform:o,feed:l,esClient:u,esIndex:d,batchSize:f=c,onProgress:p,rowLimit:m,signal:h,errorTracker:g=new a,logger:_}=t;if(f<1)throw Error(`batchSize must be >= 1 (got ${f})`);let v=Date.now();_?.info({importRunId:n,runLabel:r,batchSize:f,esIndex:d},`imports.runner: started`);let y=0,b=0,x=0,S=0,C=0,w=[],T=async()=>{if(w.length===0)return;let t=w;w=[];let r=Date.now(),i;try{i=await e(u,{index:d,docs:t.map(({id:e,doc:t})=>({id:e,doc:t})),...h!==void 0&&{signal:h}})}catch(e){let n=h?.aborted??(e instanceof Error&&(e.name===`AbortError`||/abort/i.test(e.message)))?`aborted`:`bulk_request_failed`,r=e instanceof Error?e.message:String(e);for(let{rowNumber:e}of t)g.addError(e,n,r,void 0,null);throw x+=t.length,e}b+=i.succeeded,x+=i.failures.length,C+=1;let a=Date.now()-r,o=(Date.now()-v)/1e3;if(_?.info({importRunId:n,batch:C,flushMs:a,submitted:t.length,succeeded:i.succeeded,failed:i.failures.length,rowsRead:y,rowsPerSecond:o>0?Math.round(y/o):0},`imports.runner: batch flushed`),i.failures.length>0){let e=new Map(t.map(e=>[e.id,e.rowNumber]));for(let t of i.failures){let n=e.get(t.id)??-1;g.addError(n,t.type,t.reason,void 0,{id:t.id})}}if(p){let e=(Date.now()-v)/1e3;p({rowsRead:y,rowsSucceeded:b,rowsFailed:x,rowsSkipped:S,batchesCompleted:C,elapsedSeconds:e,rowsPerSecond:e>0?y/e:0,distinctErrorPatterns:g.getDistinctPatternCount()})}},E=f,D=new Map;for await(let{rowNumber:e,row:t}of s(l)){if(h?.aborted||m!==void 0&&y>=m)break;if(y+=1,y===1&&_?.info({importRunId:n,rowNumber:e,sampleKeys:Object.keys(t)},`imports.runner: STREAM_FIRST_ROW`),y%E===0){let e=(Date.now()-v)/1e3;_?.info({importRunId:n,rowsRead:y,rowsSucceeded:b,rowsFailed:x,rowsSkipped:S,pending:w.length,elapsedSec:Math.round(e*10)/10,rowsPerSecond:e>0?Math.round(y/e):0,distinctFirstErrorTypes:D.size},`imports.runner: STREAM_TICK`)}let a={importRunId:n,params:i,runLabel:r,rowNumber:e},s;try{s=await o(t,a)}catch(r){let i=r instanceof Error?r.message:String(r);g.addError(e,`transform_threw`,i,void 0,t),D.has(`transform_threw`)||(D.set(`transform_threw`,{rowNumber:e,message:i}),_?.warn({importRunId:n,rowNumber:e,errorType:`transform_threw`,message:i},`imports.runner: FIRST_ERROR transform_threw`)),x+=1;continue}if(s.kind===`skip`){S+=1;continue}if(s.kind===`error`){g.addError(e,s.error.errorType,s.error.message,s.error.field,t),D.has(s.error.errorType)||(D.set(s.error.errorType,{rowNumber:e,message:s.error.message}),_?.warn({importRunId:n,rowNumber:e,errorType:s.error.errorType,field:s.error.field,message:s.error.message},`imports.runner: FIRST_ERROR ${s.error.errorType}`)),x+=1;continue}w.push({id:s.id,doc:s.doc,rowNumber:e}),w.length>=f&&await T()}await T();let O=(Date.now()-v)/1e3;return _?.info({importRunId:n,rowsRead:y,rowsSucceeded:b,rowsFailed:x,rowsSkipped:S,batchesCompleted:C,totalSec:Math.round(O*10)/10,rowsPerSecond:O>0?Math.round(y/O):0},`imports.runner: finished`),{rowsRead:y,rowsSucceeded:b,rowsFailed:x,rowsSkipped:S,batchesCompleted:C,errors:g.snapshot()}}export{r as a,s as i,l as n,i as o,o as r,a as s,c as t};
2
- //# sourceMappingURL=runner-CSlZ0JFO.mjs.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"runner-CSlZ0JFO.mjs","names":[],"sources":["../src/error-tracker.ts","../src/streaming.ts","../src/runner.ts"],"sourcesContent":["/**\n * Aggregates per-row errors into top-N pattern buckets so a feed of 1M\n * malformed rows surfaces as a handful of actionable signatures rather\n * than a million identical strings.\n *\n * Lifted from giga-test (`backend/src/workers/csv-importer.ts`) and\n * generalized:\n * - Configurable caps so the importer can tune memory bounds per run.\n * - Pure data — no logging side effects, no I/O.\n * - JSON-serialisable output via {@link ErrorTracker.snapshot} for the\n * `import_run.errorSummary` column.\n *\n * Pattern signature shape: `${errorType}:${field || 'GENERAL'}:${message}`.\n * Same `errorType + field + message` collapses to one bucket; differing\n * messages stay separate. This is intentional: a parser error on column\n * `NetPrice/Discount` (\"invalid number 'NA,5'\") and the same on column\n * `GrossPrice` are operationally distinct even if the parser is the same.\n *\n * Memory bounds: the patterns map is capped at `maxPatterns`. Once full,\n * additional NEW signatures are dropped — known patterns keep accumulating\n * counts. This is the \"top-N most common\" model: rare-but-novel errors\n * past the cap are invisible, but the cap protects against a runaway\n * adversarial feed exploding the map. Sample arrays are independently\n * capped at `maxSamplesPerPattern`.\n */\n\n/**\n * Recursively-defined JSON-serialisable value. Mirrors the shape of\n * `JsonValue` in `@murumets-ee/entity` without taking a dependency on\n * that package — error-tracker is otherwise standalone, and the\n * `ImportRun.errorSummary` JSONB column accepts anything in this shape.\n */\nexport type ImportJsonValue =\n | string\n | number\n | boolean\n | null\n | ImportJsonValue[]\n | { [key: string]: ImportJsonValue }\n\n/** One sample row attached to a pattern. `rowData` is the originally-parsed row. */\nexport interface ErrorSample {\n rowNumber: number\n rowData: ImportJsonValue\n}\n\n/** Public shape of an aggregated pattern as returned by {@link ErrorTracker.getTopPatterns}. */\nexport interface ErrorPattern {\n errorType: string\n field: string | null\n message: string\n count: number\n firstOccurrence: number\n lastOccurrence: number\n samples: ReadonlyArray<ErrorSample>\n /** `count / totalErrors`, scaled 0..100. `0` when there are zero errors total. */\n percentage: number\n}\n\nexport interface ErrorTrackerConfig {\n /** Hard cap on distinct signatures. Defaults to 50. New signatures past the cap are dropped. */\n maxPatterns?: number\n /** Hard cap on samples retained per pattern. Defaults to 5. Excess samples are dropped. */\n maxSamplesPerPattern?: number\n}\n\n/** Default caps — match giga-test for compatibility. */\nexport const DEFAULT_MAX_PATTERNS = 50\nexport const DEFAULT_MAX_SAMPLES_PER_PATTERN = 5\n\ninterface InternalPattern {\n errorType: string\n field: string | null\n message: string\n count: number\n firstOccurrence: number\n lastOccurrence: number\n samples: ErrorSample[]\n}\n\nexport class ErrorTracker {\n private readonly patterns = new Map<string, InternalPattern>()\n private readonly maxPatterns: number\n private readonly maxSamplesPerPattern: number\n private droppedSignatures = 0\n\n constructor(config: ErrorTrackerConfig = {}) {\n this.maxPatterns = config.maxPatterns ?? DEFAULT_MAX_PATTERNS\n this.maxSamplesPerPattern = config.maxSamplesPerPattern ?? DEFAULT_MAX_SAMPLES_PER_PATTERN\n }\n\n /**\n * Record one error. Same `(errorType, field, message)` triple bumps the\n * existing bucket; a new triple opens a new one (subject to {@link maxPatterns}).\n *\n * `field` is optional — pass `undefined` for errors not tied to a single\n * column (e.g. parse errors at row level). Internally normalised to the\n * literal string `'GENERAL'` so it shares a bucket with other genericised\n * errors of the same type+message.\n */\n addError(\n rowNumber: number,\n errorType: string,\n message: string,\n field: string | undefined,\n rowData: ImportJsonValue,\n ): void {\n const fieldKey = field ?? 'GENERAL'\n const signature = `${errorType}:${fieldKey}:${message}`\n\n let pattern = this.patterns.get(signature)\n if (!pattern) {\n if (this.patterns.size >= this.maxPatterns) {\n this.droppedSignatures += 1\n return\n }\n pattern = {\n errorType,\n field: field ?? null,\n message,\n count: 0,\n firstOccurrence: rowNumber,\n lastOccurrence: rowNumber,\n samples: [],\n }\n this.patterns.set(signature, pattern)\n }\n\n pattern.count += 1\n pattern.lastOccurrence = rowNumber\n if (pattern.samples.length < this.maxSamplesPerPattern) {\n pattern.samples.push({ rowNumber, rowData })\n }\n }\n\n /** Total count across every pattern. Counts errors, not patterns. */\n getTotalErrorCount(): number {\n let sum = 0\n for (const p of this.patterns.values()) sum += p.count\n return sum\n }\n\n /** Number of distinct signatures retained in the map (≤ `maxPatterns`). */\n getDistinctPatternCount(): number {\n return this.patterns.size\n }\n\n /**\n * Number of NEW signatures dropped because the map was already at\n * capacity. Surfacing this in the import_run summary tells the operator\n * \"the top-N was saturated — there's a long tail you're not seeing\".\n */\n getDroppedSignatureCount(): number {\n return this.droppedSignatures\n }\n\n /**\n * Top patterns sorted by descending count, capped at `maxPatterns`.\n * Stable secondary order is insertion order (Map iteration order is\n * insertion order; Array.sort is stable in V8).\n */\n getTopPatterns(): ErrorPattern[] {\n const all = Array.from(this.patterns.values()).sort((a, b) => b.count - a.count)\n const totalErrors = all.reduce((sum, p) => sum + p.count, 0)\n return all.map((p) => ({\n errorType: p.errorType,\n field: p.field,\n message: p.message,\n count: p.count,\n firstOccurrence: p.firstOccurrence,\n lastOccurrence: p.lastOccurrence,\n samples: p.samples.slice(),\n percentage: totalErrors > 0 ? (p.count / totalErrors) * 100 : 0,\n }))\n }\n\n /**\n * Compact JSON-serialisable snapshot for `import_run.errorSummary`.\n * Aside from the patterns array, includes the totals so a reader of\n * just this column doesn't have to re-derive them.\n */\n snapshot(): ErrorTrackerSnapshot {\n return {\n totalErrors: this.getTotalErrorCount(),\n distinctPatterns: this.getDistinctPatternCount(),\n droppedSignatures: this.droppedSignatures,\n patterns: this.getTopPatterns(),\n }\n }\n}\n\nexport interface ErrorTrackerSnapshot {\n totalErrors: number\n distinctPatterns: number\n droppedSignatures: number\n patterns: ErrorPattern[]\n}\n","/**\n * 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 * 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's acceptable for the supplier-feed use case because we default\n * `quote: false` (every `\\n` IS a row boundary). For genuine RFC-4180\n * CSV inputs with embedded newlines, treat the result as an upper\n * bound; the actual parsed row count from `streamFeed` is authoritative.\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\n // file has a header but no explicit `columns`, csv-parse takes the\n // first row as the column source. When neither is true, rows are\n // emitted with string-position keys.\n //\n // Typed against csv-parse's actual `columns?: ColumnOption[] | boolean`\n // signature — `string[]` satisfies `ColumnOption[]` since\n // `ColumnOption = string | undefined | null | false | { name: string }`.\n const columnConfig: string[] | boolean = columns ? Array.from(columns) : hasHeader\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 * One-shot importer: stream rows → transform → batched bulk-write to ES,\n * accumulating per-row errors into `ErrorTracker` and reporting progress\n * to the queue every batch.\n *\n * Per PLAN-ECOMMERCE.md PR 7 (PoC scope):\n * - **Batch size 1000.** Matches giga-test precedent. Configurable for\n * integration tests that don't want a 1k row floor.\n * - **No resumability and no automatic retries.** The `imports:run` queue\n * job is registered with `defaultRetries: 0` (see `worker.ts`) so a\n * failed handler does NOT re-enqueue itself — re-running a multi-batch\n * import against the same `import_run.id` while the previous attempt\n * may still be writing is a footgun (duplicate batches, double-counted\n * progress). Operator retries by creating a NEW `import_run` row.\n * - **No per-supplier transform plugin.** The runner takes a single\n * `RowTransform<TDoc>` from the registry and applies it to every row;\n * PR 8 may diverge but only by registering a different transform name.\n * - **Direct `bulkUpsert` into the live aliased index** (D6 alias is\n * set up by PR 4's `ensureAliasedIndex`; the importer doesn't reindex).\n *\n * Per D21 (sanctioned bulk path): this runner intentionally bypasses\n * AdminClient and entity hooks. Per-batch audit / observability lives\n * on the surrounding `import_run` row + queue progress, NOT per-row.\n */\n\nimport { bulkUpsert, type BulkIndexResult, type EsClientLike } from '@murumets-ee/search-elasticsearch'\nimport { ErrorTracker } from './error-tracker.js'\nimport { streamFeed, type StreamFeedOptions } from './streaming.js'\nimport type { RowTransform, TransformContext } from './transform.js'\n\n/** Soft default; chosen to match giga-test. ES bulk requests over ~5MB get split server-side anyway. */\nexport const DEFAULT_BATCH_SIZE = 1000\n\nexport interface RunImportOptions<TDoc> {\n /** UUID of the `import_run` row driving this run. Forwarded to every transform invocation. */\n importRunId: string\n /** Operator-supplied label for the run. Forwarded to the transform context. */\n runLabel: string\n /** Opaque per-run params copied from `import_run.params`. */\n params: Record<string, unknown>\n /** Transform applied to every parsed row. */\n transform: RowTransform<TDoc>\n /** Streaming reader options — file path, delimiter, header config. */\n feed: StreamFeedOptions\n /** ES client (low-level shape from `@murumets-ee/search-elasticsearch`). */\n esClient: EsClientLike\n /** Index alias to write to. Per D6, callers always pass an alias, never a physical index. */\n esIndex: string\n /** Rows per `bulkUpsert` call. Default {@link DEFAULT_BATCH_SIZE}. */\n batchSize?: number\n /**\n * Callback invoked after every batch. The handler in `worker.ts`\n * forwards this to `ctx.updateProgress` for the queue UI; tests\n * inspect it directly. Synchronous + cheap so a slow callback can't\n * back-pressure the importer.\n */\n onProgress?: (progress: ImportRunProgress) => void\n /** Optional: stop processing after this many rows. Tests use it; production passes `undefined`. */\n rowLimit?: number\n /** Abort signal threaded into the underlying ES client request — cooperative cancel. */\n signal?: AbortSignal\n /** Optional ErrorTracker config (caps). Default: top-50 patterns × 5 samples. */\n errorTracker?: ErrorTracker\n /**\n * Optional structural logger for per-batch telemetry. Receives\n * `{ batch, ms, succeeded, failed, rowsRead, rowsPerSecond }` after\n * every flush, plus a one-shot `started` and `finished` event. The\n * worker forwards `app.logger.child({ pkg: 'imports' })`; tests\n * leave it undefined.\n *\n * Why: without per-batch visibility, a stalled run looks identical\n * to \"first batch in progress\" forever — the only feedback is the\n * queue's `progress` jsonb (which writes after each batch) and the\n * eventual `succeeded`/`failed` job event. Adding this lets the\n * operator tail the worker log and immediately see whether ES is\n * rate-limiting, the transform is rejecting every row, or the\n * stream is actually progressing.\n */\n logger?: {\n info: (data: Record<string, unknown>, msg: string) => void\n warn: (data: Record<string, unknown>, msg: string) => void\n }\n}\n\n/**\n * Progress payload written to `toolkit_jobs.progress` after every batch.\n * Caps + flush rules live on the queue's `updateProgress` debounce —\n * callers don't need to throttle.\n */\nexport interface ImportRunProgress {\n rowsRead: number\n rowsSucceeded: number\n rowsFailed: number\n rowsSkipped: number\n batchesCompleted: number\n /** Wall-clock seconds since the runner started. */\n elapsedSeconds: number\n /** Rows / second, computed at every batch. */\n rowsPerSecond: number\n /** Distinct error patterns currently held by the tracker. Saturates at the cap. */\n distinctErrorPatterns: number\n /**\n * Total data-row count from a pre-pass scan, when the worker computed one.\n * Lets the UI render `processed / total` percent. Absent when the worker\n * skipped the count (file too large for the count budget, count failed,\n * etc.) — UI must treat it as optional.\n */\n totalRows?: number\n}\n\n/**\n * Final result returned by {@link runImport}. The handler writes these\n * onto the `import_run` row alongside the ErrorTracker snapshot.\n */\nexport interface RunImportResult {\n /** Total rows read from the file (excludes skipped empty lines). */\n rowsRead: number\n /** Rows the transform turned into a successful doc AND the ES cluster acknowledged. */\n rowsSucceeded: number\n /**\n * Rows that the transform rejected (`{ kind: 'error' }`) OR that ES\n * rejected on bulk-write (per-doc failure). Both are aggregated by\n * `errorTracker` for the import_run summary.\n */\n rowsFailed: number\n /** Rows that the transform skipped (`{ kind: 'skip' }`) — header noise, blank lines, intentional drop. */\n rowsSkipped: number\n /** Number of `bulkUpsert` calls made. */\n batchesCompleted: number\n /** Final value of {@link ErrorTracker.snapshot}. */\n errors: ReturnType<ErrorTracker['snapshot']>\n}\n\n/**\n * Apply the runner against a feed file. Stops on rowLimit OR end-of-file\n * OR if `signal` aborts. Throws if the streaming reader / ES client\n * throws — caller (the queue handler) catches that and writes\n * `import_run.status = 'failed'` with the error message in\n * `errorSummary.fatal`.\n */\nexport async function runImport<TDoc>(options: RunImportOptions<TDoc>): Promise<RunImportResult> {\n const {\n importRunId,\n runLabel,\n params,\n transform,\n feed,\n esClient,\n esIndex,\n batchSize = DEFAULT_BATCH_SIZE,\n onProgress,\n rowLimit,\n signal,\n errorTracker = new ErrorTracker(),\n logger,\n } = options\n\n if (batchSize < 1) {\n throw new Error(`batchSize must be >= 1 (got ${batchSize})`)\n }\n\n const startedAt = Date.now()\n logger?.info(\n { importRunId, runLabel, batchSize, esIndex },\n 'imports.runner: started',\n )\n let rowsRead = 0\n let rowsSucceeded = 0\n let rowsFailed = 0\n let rowsSkipped = 0\n let batchesCompleted = 0\n\n let pending: Array<{ id: string; doc: TDoc; rowNumber: number }> = []\n\n const flush = async (): Promise<void> => {\n if (pending.length === 0) return\n const batch = pending\n pending = []\n const flushStartedAt = Date.now()\n let result: BulkIndexResult\n try {\n result = await bulkUpsert<TDoc>(esClient, {\n index: esIndex,\n docs: batch.map(({ id, doc }) => ({ id, doc })),\n ...(signal !== undefined && { signal }),\n })\n } catch (err) {\n // Cluster- or transport-level failure — the whole batch is\n // unaccounted for. Distinguish abort (operator-driven cancel) from\n // a real cluster failure so the errorSummary doesn't mislabel a\n // cancelled run as broken cluster connectivity.\n const isAbort =\n signal?.aborted ??\n (err instanceof Error && (err.name === 'AbortError' || /abort/i.test(err.message)))\n const errorType = isAbort ? 'aborted' : 'bulk_request_failed'\n const reason = err instanceof Error ? err.message : String(err)\n for (const { rowNumber } of batch) {\n errorTracker.addError(rowNumber, errorType, reason, undefined, null)\n }\n rowsFailed += batch.length\n throw err\n }\n\n rowsSucceeded += result.succeeded\n rowsFailed += result.failures.length\n batchesCompleted += 1\n\n const flushMs = Date.now() - flushStartedAt\n const elapsedSec = (Date.now() - startedAt) / 1000\n logger?.info(\n {\n importRunId,\n batch: batchesCompleted,\n flushMs,\n submitted: batch.length,\n succeeded: result.succeeded,\n failed: result.failures.length,\n rowsRead,\n rowsPerSecond: elapsedSec > 0 ? Math.round(rowsRead / elapsedSec) : 0,\n },\n 'imports.runner: batch flushed',\n )\n\n if (result.failures.length > 0) {\n // Map each ES failure back to its source row via `id`. The bulk\n // response order matches the request order, but ES doesn't promise\n // that; matching by `id` is the safe path. PoC volume is small\n // enough that the O(failures × batch) cost is irrelevant.\n const byId = new Map(batch.map((b) => [b.id, b.rowNumber]))\n for (const fail of result.failures) {\n const rowNumber = byId.get(fail.id) ?? -1\n errorTracker.addError(rowNumber, fail.type, fail.reason, undefined, { id: fail.id })\n }\n }\n\n if (onProgress) {\n const elapsedSeconds = (Date.now() - startedAt) / 1000\n onProgress({\n rowsRead,\n rowsSucceeded,\n rowsFailed,\n rowsSkipped,\n batchesCompleted,\n elapsedSeconds,\n rowsPerSecond: elapsedSeconds > 0 ? rowsRead / elapsedSeconds : 0,\n distinctErrorPatterns: errorTracker.getDistinctPatternCount(),\n })\n }\n }\n\n // Streaming heartbeat — sample every Nth row so the operator sees the\n // streamer is alive even when a transform-rejected file would prevent\n // any flush from firing. Without this, a feed that's 100% rejected\n // looks identical to \"stuck\" until end-of-file. Sample frequency is\n // batchSize so the cadence matches the natural batch rhythm.\n const streamSampleEvery = batchSize\n\n // First-error capture per error type — surface what's actually rejecting\n // the rows when 100% of a feed is rejected. Without this, the only signal\n // is `errorTracker.snapshot()` which only writes to import_run.errorSummary\n // at the END of the run. Mid-run we want to know NOW.\n const firstErrorByType = new Map<string, { rowNumber: number; message: string }>()\n\n for await (const { rowNumber, row } of streamFeed(feed)) {\n if (signal?.aborted) break\n if (rowLimit !== undefined && rowsRead >= rowLimit) break\n rowsRead += 1\n\n if (rowsRead === 1) {\n logger?.info({ importRunId, rowNumber, sampleKeys: Object.keys(row) }, 'imports.runner: STREAM_FIRST_ROW')\n }\n if (rowsRead % streamSampleEvery === 0) {\n const elapsedSec = (Date.now() - startedAt) / 1000\n // Telemetry only — no row content, no error sample text.\n // PR #262 review (M-5): the prior `firstErrors:\n // Object.fromEntries(firstErrorByType)` payload included the\n // raw error message captured from the transform, which for the\n // carmaker feed embeds supplier codes + price fragments. The\n // detailed first-error breakdown is already persisted on\n // `import_run.errorSummary` via `errorTracker.snapshot()` —\n // operators inspect it there, not in the worker log.\n logger?.info(\n {\n importRunId,\n rowsRead,\n rowsSucceeded,\n rowsFailed,\n rowsSkipped,\n pending: pending.length,\n elapsedSec: Math.round(elapsedSec * 10) / 10,\n rowsPerSecond: elapsedSec > 0 ? Math.round(rowsRead / elapsedSec) : 0,\n distinctFirstErrorTypes: firstErrorByType.size,\n },\n 'imports.runner: STREAM_TICK',\n )\n }\n\n const ctx: TransformContext = { importRunId, params, runLabel, rowNumber }\n let result: Awaited<ReturnType<typeof transform>>\n try {\n result = await transform(row, ctx)\n } catch (err) {\n // A throw from the transform is a programmer error — surface it as\n // a row-level error so the run can continue. (If the bug is\n // catastrophic, the operator sees the same message repeated and\n // can stop the run.)\n const reason = err instanceof Error ? err.message : String(err)\n errorTracker.addError(rowNumber, 'transform_threw', reason, undefined, row)\n if (!firstErrorByType.has('transform_threw')) {\n firstErrorByType.set('transform_threw', { rowNumber, message: reason })\n // PR #262 review (M-5): drop the `row` payload from the\n // log line — supplier feeds carry pricing + internal codes.\n // The full row is on `import_run.errorSummary.samples` for\n // post-mortem review by operators with view permission.\n logger?.warn(\n { importRunId, rowNumber, errorType: 'transform_threw', message: reason },\n 'imports.runner: FIRST_ERROR transform_threw',\n )\n }\n rowsFailed += 1\n continue\n }\n\n if (result.kind === 'skip') {\n rowsSkipped += 1\n continue\n }\n if (result.kind === 'error') {\n errorTracker.addError(\n rowNumber,\n result.error.errorType,\n result.error.message,\n result.error.field,\n row,\n )\n if (!firstErrorByType.has(result.error.errorType)) {\n firstErrorByType.set(result.error.errorType, {\n rowNumber,\n message: result.error.message,\n })\n logger?.warn(\n {\n importRunId,\n rowNumber,\n errorType: result.error.errorType,\n field: result.error.field,\n message: result.error.message,\n },\n `imports.runner: FIRST_ERROR ${result.error.errorType}`,\n )\n }\n rowsFailed += 1\n continue\n }\n\n pending.push({ id: result.id, doc: result.doc, rowNumber })\n if (pending.length >= batchSize) {\n await flush()\n }\n }\n\n await flush()\n\n const totalSec = (Date.now() - startedAt) / 1000\n logger?.info(\n {\n importRunId,\n rowsRead,\n rowsSucceeded,\n rowsFailed,\n rowsSkipped,\n batchesCompleted,\n totalSec: Math.round(totalSec * 10) / 10,\n rowsPerSecond: totalSec > 0 ? Math.round(rowsRead / totalSec) : 0,\n },\n 'imports.runner: finished',\n )\n\n return {\n rowsRead,\n rowsSucceeded,\n rowsFailed,\n rowsSkipped,\n batchesCompleted,\n errors: errorTracker.snapshot(),\n }\n}\n"],"mappings":"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"}
@@ -1,2 +0,0 @@
1
- import{t as e}from"./rolldown-runtime-DK3Fl9T5.mjs";import{behavior as t,defineEntity as n,field as r,searchable as i}from"@murumets-ee/entity";var a=e({IMPORT_RUN_STATUSES:()=>o,ImportRun:()=>s});const o=[`pending`,`running`,`succeeded`,`failed`,`cancelled`],s=n({name:`import_run`,fields:{label:r.text({required:!0,maxLength:255}),status:r.select({options:o,default:`pending`,indexed:!0}),filePath:r.text({required:!0,maxLength:2048}),transformName:r.text({required:!0,maxLength:128,indexed:!0}),params:r.json(),totals:r.json(),errorSummary:r.json(),startedAt:r.date(),finishedAt:r.date(),queueJobId:r.text({maxLength:64,indexed:!0})},behaviors:[t.auditable(),i({fields:[`label`,`transformName`,`queueJobId`,`status`,`filePath`],projection:e=>({id:e.id,label:e.label,description:`${e.transformName} · ${e.status}`})})],scope:`global`,admin:{group:`imports`,label:`Import runs`,labelSingular:`Import run`,icon:`upload`,hideFromMenu:!0}});var c=class{entries=new Map;register(e,t){if(this.entries.has(e))throw Error(`Transform "${e}" is already registered — two plugins cannot register the same transform name.`);this.entries.set(e,t)}get(e){return this.entries.get(e)}has(e){return this.entries.has(e)}list(){return Array.from(this.entries.keys()).sort()}clear(){this.entries.clear()}};const l=Symbol.for(`@murumets-ee/imports:transforms`);function u(){let e=globalThis,t=e[l];return t||(t=new c,e[l]=t),t}function d(e,t){u().register(e,t)}export{s as a,o as i,u as n,a as o,d as r,c as t};
2
- //# sourceMappingURL=transform-BzqTvH7B.mjs.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"transform-BzqTvH7B.mjs","names":[],"sources":["../src/entities/import-run.ts","../src/transform.ts"],"sourcesContent":["/**\n * ImportRun — one feed-import attempt. Tracks file, transform, status,\n * row totals, and the top error patterns surfaced by `ErrorTracker`.\n *\n * Per PLAN-ECOMMERCE.md PR 7 (PoC scope): generic plumbing entity. The\n * commerce-specific transform parameters (brandId, supplierId, supplier\n * code prefix) live in the opaque `params` JSONB so the imports package\n * stays free of commerce-domain dependencies. PR 8 wires a concrete\n * carmaker-feed transform that reads those params; PR 8a renders a\n * bespoke `/admin/commerce/imports` page over this entity's history.\n *\n * `hideFromMenu: true` because PR 7 ships only the entity + plumbing —\n * the operator-facing surface is PR 8a. The auto-EntityListPage at\n * `/admin/import_run` still resolves for direct lookups, but no sidebar\n * entry points to it.\n */\n\nimport { behavior, defineEntity, field, searchable } from '@murumets-ee/entity'\nimport type { AdminClient } from '@murumets-ee/entity/admin'\n\n/**\n * Lifecycle stages.\n *\n * - `pending` — row exists, queue job has been enqueued, worker hasn't picked it up yet.\n * - `running` — worker is streaming + bulk-writing.\n * - `succeeded` — worker finished cleanly (note: per-row failures still possible — see `totals.failed`).\n * - `failed` — worker threw and the queue marked the job dead. `errorSummary.fatal` carries the cause.\n * - `cancelled` — manual operator action via PR 8a (out of scope for PR 7).\n */\nexport const IMPORT_RUN_STATUSES = ['pending', 'running', 'succeeded', 'failed', 'cancelled'] as const\nexport type ImportRunStatus = (typeof IMPORT_RUN_STATUSES)[number]\n\nexport const ImportRun = defineEntity({\n name: 'import_run',\n fields: {\n /** Operator-visible label, e.g. `\"MERCEDES — ME_20251027075918.txt\"`. Free-form. */\n label: field.text({ required: true, maxLength: 255 }),\n /** See {@link IMPORT_RUN_STATUSES}. */\n status: field.select({\n options: IMPORT_RUN_STATUSES,\n default: 'pending',\n indexed: true,\n }),\n /**\n * Path or storage key of the uploaded feed file. Generic string —\n * could be `/var/lumi/uploads/<id>.txt` for the local-disk PoC or an\n * S3 object key once storage adapter integration lands. The worker\n * reads this with the configured `readFeed` resolver.\n */\n filePath: field.text({ required: true, maxLength: 2048 }),\n /**\n * Name of the registered transform applied to each row. Resolved at\n * worker-dispatch time against the transform registry contributed by\n * the consumer. PoC: `'commerce:carmaker-feed'`.\n */\n transformName: field.text({ required: true, maxLength: 128, indexed: true }),\n /**\n * Opaque per-transform parameters. The carmaker transform expects\n * `{ brandId, supplierId, codePrefix? }`. Validation happens inside\n * the transform — the imports package never inspects this shape so\n * a new transform with different params doesn't require a schema\n * migration.\n */\n params: field.json(),\n /**\n * Row counters: `{ submitted, succeeded, failed, skipped, batches }`.\n * Updated by the worker as each batch completes. Final values are\n * what the operator reads; intermediate progress comes from\n * `toolkit_jobs.progress` via the queue UI.\n */\n totals: field.json(),\n /**\n * Output of `ErrorTracker.getTopPatterns(totalRows)` — the top-50\n * error signatures with up to 5 sample rows each. Empty `[]` until\n * the worker writes it on completion.\n */\n errorSummary: field.json(),\n /** Set when the worker picks up the job. */\n startedAt: field.date(),\n /** Set when the worker finishes (success OR fatal failure). */\n finishedAt: field.date(),\n /**\n * `toolkit_jobs.id` of the queue job processing this run — link\n * back so PR 8a can show live progress without a second lookup.\n */\n queueJobId: field.text({ maxLength: 64, indexed: true }),\n },\n behaviors: [\n behavior.auditable(),\n // Operator-facing search: by label (e.g. \"MERCEDES — ME_*.txt\"),\n // transform name, or queue job id. No FTS — labels are short\n // alphanumeric tags, not prose.\n searchable({\n fields: ['label', 'transformName', 'queueJobId', 'status', 'filePath'],\n projection: (row) => ({\n id: row.id as string,\n label: row.label as string,\n description: `${row.transformName} · ${row.status}`,\n }),\n }),\n ],\n scope: 'global',\n admin: {\n group: 'imports',\n label: 'Import runs',\n labelSingular: 'Import run',\n icon: 'upload',\n hideFromMenu: true,\n },\n})\n\nexport type ImportRunClient = AdminClient<typeof ImportRun.allFields>\n","/**\n * Per-feed transform plugin interface (PLAN-ECOMMERCE.md PR 7 / D14).\n *\n * The streaming reader reads raw rows out of the file (column key →\n * string). The transform turns one row into a typed `OutputDoc` ready\n * for the bulk-write surface (PoC: `PartsDocument` for the parts ES\n * index). The transform is what makes a generic feed-importer\n * commerce-aware — without it, the importer has no opinion on what a\n * row \"means\".\n *\n * Why an interface, not a function:\n * - The transform may need to reject a row (`RowSkip`) without that\n * counting as an error in `ErrorTracker` — e.g. blank lines, header\n * rows mistakenly retained, or \"this is a replacement-code marker\n * row, handled out-of-band\" decisions.\n * - The transform may need to fail a row (`RowError`) with a typed\n * error class so `ErrorTracker` collapses them by `errorType`.\n * - The transform may need access to the run-level params (brand /\n * supplier IDs, code prefix, batch ID) without those being\n * re-derived per row.\n *\n * PR 7 ships only the interface + a registry. PR 8 ships the first\n * concrete carmaker-feed transform.\n */\n\n/** Identifies which transform a registered handler implements. Stable across deploys. */\nexport type TransformName = string\n\n/** Per-run context provided to every row call. Kept narrow on purpose. */\nexport interface TransformContext {\n /** UUID of the `import_run` row. Forward to the output doc as `import_batch_id` for source attribution (D20). */\n importRunId: string\n /** Opaque per-run params copied from `import_run.params`. The transform validates the shape it expects. */\n params: Record<string, unknown>\n /** Operator-supplied label, useful for logging / debugging. */\n runLabel: string\n /** 1-based row number across the whole feed (header counts as row 0). Forward to `RowError.rowNumber`. */\n rowNumber: number\n}\n\n/**\n * One of: a successful `OutputDoc` keyed by a stable `id`, an\n * intentional `skip` (counted in `totals.skipped`), or a row-level\n * error (counted in `totals.failed` AND aggregated by `ErrorTracker`).\n *\n * The `id` field on `success` is used as the bulk-upsert primary key\n * (`_id` in ES). Per D4 the parts index uses\n * `<code_normalized>__<supplier_id>` so a re-run of the same feed\n * idempotently overwrites instead of duplicating.\n */\nexport type RowResult<TDoc> =\n | { kind: 'success'; id: string; doc: TDoc }\n | { kind: 'skip'; reason: string }\n | { kind: 'error'; error: RowError }\n\n/** Row-level error. The aggregator uses `(errorType, field, message)` as the dedup signature. */\nexport interface RowError {\n errorType: string\n message: string\n field?: string | undefined\n}\n\n/**\n * The contract every per-feed transform implements. Pure function of\n * `(row, ctx) → RowResult` — no I/O. Async only because the future\n * carmaker variant might consult an in-memory taxonomy lookup.\n */\nexport interface RowTransform<TDoc> {\n (rawRow: Record<string, string>, ctx: TransformContext): Promise<RowResult<TDoc>>\n}\n\n/**\n * Holds the registered transforms for the current process. Each plugin\n * that ships a transform calls {@link TransformRegistry.register} from\n * its `init` hook.\n *\n * Per CLAUDE.md \"Whitelist, don't blacklist\": the worker dispatches\n * by `import_run.transformName` against this registry. An unregistered\n * name fails the run rather than running with a default transform.\n *\n * **Production callers MUST go through {@link getTransformRegistry}**, not\n * `new TransformRegistry()`. The constructor is exposed only so tests\n * (and rare custom-worker embeds) can build an isolated instance and\n * inject it into {@link createRunImportHandler} directly. Two `new`\n * instances do NOT share state — registering a transform on one will\n * NOT make it visible to a worker resolving against the other. The\n * package's queue handler (registered by `imports()` plugin) always\n * resolves against the singleton.\n */\nexport class TransformRegistry {\n private readonly entries = new Map<TransformName, RowTransform<unknown>>()\n\n /**\n * Register a transform under a stable name. Throws on duplicate\n * names so two plugins can't silently overwrite each other (same\n * fail-loud collision protection that `combineAdminRoutes` enforces\n * for duplicate `(prefix, method, path)` route registrations).\n */\n register<TDoc>(name: TransformName, transform: RowTransform<TDoc>): void {\n if (this.entries.has(name)) {\n throw new Error(\n `Transform \"${name}\" is already registered — two plugins cannot register the same transform name.`,\n )\n }\n this.entries.set(name, transform as RowTransform<unknown>)\n }\n\n /** Look up a transform by name. Returns `undefined` if no plugin has registered one. */\n get(name: TransformName): RowTransform<unknown> | undefined {\n return this.entries.get(name)\n }\n\n /** True iff a transform is registered for the given name. */\n has(name: TransformName): boolean {\n return this.entries.has(name)\n }\n\n /** All registered names. Useful for the admin Catalog tab and for tests. */\n list(): TransformName[] {\n return Array.from(this.entries.keys()).sort()\n }\n\n /** Drop all registrations. Tests only. */\n clear(): void {\n this.entries.clear()\n }\n}\n\n// ---------------------------------------------------------------------------\n// Process-global singleton (mirrors `@murumets-ee/queue`'s handler registry).\n// ---------------------------------------------------------------------------\n//\n// Why a singleton: PR 8's carmaker-feed transform lives in a different\n// plugin from `@murumets-ee/imports`. Under Next.js HMR, ONE of those\n// plugins might re-evaluate while the other does not. Holding the\n// registry on `globalThis` via `Symbol.for` means the same `Map`\n// instance is shared across module evaluations — the carmaker plugin's\n// `register` and the imports worker's `get` see each other regardless\n// of evaluation order. Same fix shape as the queue's handler-registry\n// HMR bug (#186).\n\nconst REGISTRY_KEY = Symbol.for('@murumets-ee/imports:transforms')\n\ninterface GlobalThisWithTransforms {\n [REGISTRY_KEY]?: TransformRegistry\n}\n\n/** Returns the singleton registry, creating it on first access. */\nexport function getTransformRegistry(): TransformRegistry {\n const g = globalThis as GlobalThisWithTransforms\n let reg = g[REGISTRY_KEY]\n if (!reg) {\n reg = new TransformRegistry()\n g[REGISTRY_KEY] = reg\n }\n return reg\n}\n\n/**\n * Convenience wrapper around `getTransformRegistry().register(name, fn)`.\n * Plugins that ship a transform call this from their `init` hook — same\n * shape as `@murumets-ee/queue/client`'s `registerJob`.\n */\nexport function registerImportTransform<TDoc>(\n name: TransformName,\n transform: RowTransform<TDoc>,\n): void {\n getTransformRegistry().register(name, transform)\n}\n"],"mappings":"qMA6BA,MAAa,EAAsB,CAAC,UAAW,UAAW,YAAa,SAAU,WAAW,EAG/E,EAAY,EAAa,CACpC,KAAM,aACN,OAAQ,CAEN,MAAO,EAAM,KAAK,CAAE,SAAU,GAAM,UAAW,GAAI,CAAC,EAEpD,OAAQ,EAAM,OAAO,CACnB,QAAS,EACT,QAAS,UACT,QAAS,EACX,CAAC,EAOD,SAAU,EAAM,KAAK,CAAE,SAAU,GAAM,UAAW,IAAK,CAAC,EAMxD,cAAe,EAAM,KAAK,CAAE,SAAU,GAAM,UAAW,IAAK,QAAS,EAAK,CAAC,EAQ3E,OAAQ,EAAM,KAAK,EAOnB,OAAQ,EAAM,KAAK,EAMnB,aAAc,EAAM,KAAK,EAEzB,UAAW,EAAM,KAAK,EAEtB,WAAY,EAAM,KAAK,EAKvB,WAAY,EAAM,KAAK,CAAE,UAAW,GAAI,QAAS,EAAK,CAAC,CACzD,EACA,UAAW,CACT,EAAS,UAAU,EAInB,EAAW,CACT,OAAQ,CAAC,QAAS,gBAAiB,aAAc,SAAU,UAAU,EACrE,WAAa,IAAS,CACpB,GAAI,EAAI,GACR,MAAO,EAAI,MACX,YAAa,GAAG,EAAI,cAAc,KAAK,EAAI,QAC7C,EACF,CAAC,CACH,EACA,MAAO,SACP,MAAO,CACL,MAAO,UACP,MAAO,cACP,cAAe,aACf,KAAM,SACN,aAAc,EAChB,CACF,CAAC,ECpBD,IAAa,EAAb,KAA+B,CAC7B,QAA2B,IAAI,IAQ/B,SAAe,EAAqB,EAAqC,CACvE,GAAI,KAAK,QAAQ,IAAI,CAAI,EACvB,MAAU,MACR,cAAc,EAAK,+EACrB,EAEF,KAAK,QAAQ,IAAI,EAAM,CAAkC,CAC3D,CAGA,IAAI,EAAwD,CAC1D,OAAO,KAAK,QAAQ,IAAI,CAAI,CAC9B,CAGA,IAAI,EAA8B,CAChC,OAAO,KAAK,QAAQ,IAAI,CAAI,CAC9B,CAGA,MAAwB,CACtB,OAAO,MAAM,KAAK,KAAK,QAAQ,KAAK,CAAC,CAAC,CAAC,KAAK,CAC9C,CAGA,OAAc,CACZ,KAAK,QAAQ,MAAM,CACrB,CACF,EAeA,MAAM,EAAe,OAAO,IAAI,iCAAiC,EAOjE,SAAgB,GAA0C,CACxD,IAAM,EAAI,WACN,EAAM,EAAE,GAKZ,OAJK,IACH,EAAM,IAAI,EACV,EAAE,GAAgB,GAEb,CACT,CAOA,SAAgB,EACd,EACA,EACM,CACN,EAAqB,CAAC,CAAC,SAAS,EAAM,CAAS,CACjD"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"worker-BY0JQw39.d.mts","names":[],"sources":["../src/entities/import-run.ts","../src/error-tracker.ts","../src/streaming.ts","../src/runner.ts","../src/worker.ts"],"mappings":"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"}