@murumets-ee/imports 0.48.0 → 0.51.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 (51) hide show
  1. package/dist/carmaker-feed-C_MnDaOC.mjs +2 -0
  2. package/dist/{carmaker-feed-CxhZBrkO.mjs.map → carmaker-feed-C_MnDaOC.mjs.map} +1 -1
  3. package/dist/carmaker-feed-DlD2xPs0.d.mts +58 -0
  4. package/dist/carmaker-feed-DlD2xPs0.d.mts.map +1 -0
  5. package/dist/{cleanup-CHjDgvKT.mjs → cleanup-DdILFoAP.mjs} +2 -2
  6. package/dist/{cleanup-CHjDgvKT.mjs.map → cleanup-DdILFoAP.mjs.map} +1 -1
  7. package/dist/index.d.mts +5 -3
  8. package/dist/index.d.mts.map +1 -1
  9. package/dist/index.mjs +1 -1
  10. package/dist/index.mjs.map +1 -1
  11. package/dist/mapping/index.d.mts +2 -359
  12. package/dist/mapping/index.d.mts.map +1 -1
  13. package/dist/mapping/index.mjs +1 -1
  14. package/dist/{mapping-transform-D76b-P-p.mjs → mapping-transform-AMFEg1rS.mjs} +2 -2
  15. package/dist/{mapping-transform-D76b-P-p.mjs.map → mapping-transform-AMFEg1rS.mjs.map} +1 -1
  16. package/dist/{parts-schema-D0gMhNWj.mjs → parts-schema-BBUINSdN.mjs} +2 -2
  17. package/dist/{parts-schema-D0gMhNWj.mjs.map → parts-schema-BBUINSdN.mjs.map} +1 -1
  18. package/dist/{parts-schema-x8_GRYWc.d.mts → parts-schema-u5kr5zFg.d.mts} +359 -3
  19. package/dist/parts-schema-u5kr5zFg.d.mts.map +1 -0
  20. package/dist/plugin.d.mts +33 -6
  21. package/dist/plugin.d.mts.map +1 -1
  22. package/dist/plugin.mjs +1 -1
  23. package/dist/plugin.mjs.map +1 -1
  24. package/dist/runner-wPnCZlB4.mjs +2 -0
  25. package/dist/runner-wPnCZlB4.mjs.map +1 -0
  26. package/dist/{schema-registry-C_lQje-K.mjs → schema-registry-B_7AweTa.mjs} +2 -2
  27. package/dist/{schema-registry-C_lQje-K.mjs.map → schema-registry-B_7AweTa.mjs.map} +1 -1
  28. package/dist/storage-resolver.d.mts +1 -1
  29. package/dist/transform-DwAQ4b-j.mjs +2 -0
  30. package/dist/transform-DwAQ4b-j.mjs.map +1 -0
  31. package/dist/{transform-CABBaYmF.d.mts → transform-WeiiLdYP.d.mts} +12 -1
  32. package/dist/transform-WeiiLdYP.d.mts.map +1 -0
  33. package/dist/transforms.d.mts +2 -46
  34. package/dist/transforms.mjs +1 -1
  35. package/dist/{worker-DRTgbtT7.d.mts → worker-DH3SN-91.d.mts} +189 -4
  36. package/dist/worker-DH3SN-91.d.mts.map +1 -0
  37. package/dist/worker.d.mts +1 -1
  38. package/dist/worker.mjs +1 -1
  39. package/dist/worker.mjs.map +1 -1
  40. package/dist/{xlsx-reader-Ccesszbc.mjs → xlsx-reader-Dsq_ERiB.mjs} +2 -2
  41. package/dist/{xlsx-reader-Ccesszbc.mjs.map → xlsx-reader-Dsq_ERiB.mjs.map} +1 -1
  42. package/package.json +8 -8
  43. package/dist/carmaker-feed-CxhZBrkO.mjs +0 -2
  44. package/dist/parts-schema-x8_GRYWc.d.mts.map +0 -1
  45. package/dist/runner-C-s6SD7t.mjs +0 -2
  46. package/dist/runner-C-s6SD7t.mjs.map +0 -1
  47. package/dist/transform-CABBaYmF.d.mts.map +0 -1
  48. package/dist/transform-DIzWppgJ.mjs +0 -2
  49. package/dist/transform-DIzWppgJ.mjs.map +0 -1
  50. package/dist/transforms.d.mts.map +0 -1
  51. package/dist/worker-DRTgbtT7.d.mts.map +0 -1
@@ -1 +1 @@
1
- {"version":3,"file":"plugin.mjs","names":[],"sources":["../src/plugin.ts"],"sourcesContent":["/**\n * Imports plugin — registers the `import_run` entity and (when ES + queue\n * are wired) registers the `imports:run` job handler.\n *\n * Per PLAN-ECOMMERCE.md PR 7 (PoC scope): plumbing only. The bespoke\n * admin pages (upload form, parts search) live in PR 8a.\n *\n * **Built-in transforms register HERE, at plugin init.** Both transforms\n * this package ships (`commerce:mapping`, `commerce:carmaker-feed`) go\n * into the process-global registry inside `init`, so every process that\n * boots the plugin — Next.js web server, standalone queue worker — can\n * dispatch them with zero app-side wiring. Registration used to be the\n * app's job (`lib/app.ts` calling `registry.register(...)`), which is\n * exactly the drift foot-gun CLAUDE.md's \"no forgotten registrations\"\n * rule warns about: an app scaffolded before the mapping transform\n * existed keeps its old `lib/app.ts` through `pnpm up` toolkit upgrades,\n * and every mapping-driven run then dies in the worker with\n * `No transform registered for name \"commerce:mapping\"`. App-side\n * registration remains harmless even when unguarded —\n * `registerImportTransform` overwrites last-wins instead of throwing —\n * it is just no longer required.\n *\n * @example\n * ```ts\n * import { defineLumiConfig } from '@murumets-ee/core'\n * import { imports } from '@murumets-ee/imports/plugin'\n * import { Client } from '@elastic/elasticsearch'\n *\n * const es = new Client({ node: process.env.ES_URL })\n *\n * export default defineLumiConfig({\n * plugins: [\n * // queue() must be present too — it runs the imports:run jobs.\n * queue(),\n * imports({ esClient: () => es, esIndex: 'parts' }),\n * ],\n * })\n * ```\n */\n\nimport { definePlugin } from '@murumets-ee/core'\n// **Why no static `@murumets-ee/core/clients` import in this file.**\n//\n// `@murumets-ee/core/clients` begins with `import 'server-only'`, which\n// throws when this plugin module is loaded at `lumi.config.ts`\n// evaluation time by the CLI (`lumi migrate`/`seed`/`doctor` use jiti,\n// which doesn't apply the `react-server` export condition).\n// Static-importing would propagate that throw all the way through\n// `lumi.config.ts` → CLI startup, blocking every CLI invocation.\n//\n// **Today (after PLAN-DECLARATIVE-PLUGINS PR 2):** plugin `init`\n// reaches `AdminClient` via the framework-provided `app.getClient(\n// entity)` accessor — no `createAdminClient` call, no dynamic import\n// either. The remaining `@murumets-ee/core/clients` dependency lives\n// in `./admin/cleanup.js`, which IS still dynamic-imported inside\n// `init` and which still benefits from this rationale. The pattern\n// matches `getDb()` in `@murumets-ee/queue/admin.ts`.\nimport { ImportMapping } from './entities/import-mapping.js'\nimport { ImportRun } from './entities/import-run.js'\nimport { MAPPING_TRANSFORM_NAME, mappingTransform } from './mapping/mapping-transform.js'\nimport { type ImportsStorageFactory, registerImportsStorageFactory } from './storage.js'\nimport { getTransformRegistry } from './transform.js'\nimport { CARMAKER_FEED_TRANSFORM_NAME, carmakerFeedTransform } from './transforms/carmaker-feed.js'\nimport {\n createImportsRunDeadListener,\n createRunImportHandler,\n type EnsureIndexResolver,\n type EsClientResolver,\n type FilePathResolver,\n importsRunJob,\n type PairSink,\n} from './worker.js'\n\n/**\n * `Symbol.for`-keyed slot for the active dead-listener unsubscribe\n * handle. Symbol.for so an HMR-replaced module instance still finds\n * the previous unsubscribe (`Symbol`-by-reference would skip the\n * lookup across module-evaluation boundaries). Same pattern as\n * `transform.ts`'s registry slot and `storage.ts`'s factory slot.\n */\nconst IMPORTS_DEAD_LISTENER_KEY = Symbol.for('@murumets-ee/imports:dead-listener-unsubscribe')\n\ninterface GlobalThisWithUnsubscribe {\n [IMPORTS_DEAD_LISTENER_KEY]?: () => void\n}\n\nexport interface ImportsPluginOptions {\n /**\n * Elasticsearch client resolver. Lazy so consumers can construct\n * the client inside a route initialiser instead of at plugin-init\n * time. Required — without it, the worker can't bulk-write.\n */\n esClient: EsClientResolver\n /**\n * ES index alias to write to. Defaults to `'parts'` (the\n * `PARTS_INDEX_ALIAS` re-exported from `@murumets-ee/search-elasticsearch`,\n * inlined here to avoid a runtime import for a one-line constant).\n * Per D6 always pass an alias, never a physical index.\n */\n esIndex?: string\n /**\n * Optional bootstrap for the destination index/alias, run once per import\n * job before any bulk-write (see {@link EnsureIndexResolver}). Wire your\n * app's `ensureAliasedIndex`-backed helper here so the worker can never\n * auto-create a mis-mapped physical index — WITHOUT gating app boot on ES.\n *\n * Omit only when the index is bootstrapped out-of-band; without it a\n * first-ever import against an unbootstrapped alias lets ES auto-create a\n * `text`-mapped physical index, breaking aggregations (issue #610).\n */\n ensureIndex?: EnsureIndexResolver\n /**\n * Resolve `import_run.filePath` to a local FS path before streaming.\n * **Required when uploads land in remote storage** (R2/S3/etc.) —\n * without it, the worker hands the storage key to `createReadStream`\n * and crashes with `ENOENT`.\n *\n * Typical wiring downloads the storage object to a tmpfile and\n * returns its path; the optional `cleanup` callback runs after the\n * run finishes (success or failure).\n *\n * Omit this option for the original on-disk PoC setup where the\n * upload route writes directly to a local directory and persists\n * the absolute path on `import_run.filePath`.\n */\n resolveFilePath?: FilePathResolver\n /**\n * Optional consumer for the directional supersession pairs the runner harvests as\n * a side-output (commerce-demo PR6, SD007). The app composes it — commerce exports\n * the replacement-merge sink and passes it here, same injection shape as\n * {@link esClient} / {@link resolveFilePath}. Omit when not ingesting replacement\n * codes; the pairs are then discarded after the run.\n */\n pairSink?: PairSink\n /**\n * Optional dedicated `StorageClient` factory for imports. When\n * provided, the upload route (in `@murumets-ee/commerce`) and the\n * worker resolver consult this client instead of the global\n * `getStorageConfig()` default — so a deployment can pin imports\n * to a local-disk adapter (single-VPS / dev) while media,\n * ticketing attachments, and the rest of the app keep using the\n * default R2 (or S3) adapter.\n *\n * Lazy / async so consumers can defer adapter construction until\n * the first import — useful when the local-disk root is\n * environment-dependent or when the factory needs `getApp()` to\n * have resolved.\n *\n * Omit to keep the legacy single-storage behaviour (everything on\n * the global default).\n */\n storage?: ImportsStorageFactory\n /**\n * Bucket name to scope the orphan-cleanup queue job. Rows in\n * `toolkit_files` with this bucket value AND no matching\n * `import_run.filePath` AND `createdAt < cutoff` get garbage-\n * collected on each cleanup tick. Defaults to `'lumi-imports'` —\n * the convention `apps/admin/lib/imports-storage.ts`\n * uses by default. Override if your deployment pins a different\n * bucket name via `IMPORTS_STORAGE_BUCKET` or equivalent.\n *\n * Set to `null` to DISABLE the cleanup job entirely (e.g. for\n * deployments that handle their own retention via S3 lifecycle\n * rules). Disabling without an alternative will leak storage\n * over time as failed uploads accumulate.\n */\n storageBucket?: string | null\n /**\n * Hours after which an unreferenced `toolkit_files` row in the\n * imports bucket is considered orphaned and deleted by the\n * cleanup job. Default 2h — long enough that a normal\n * upload-then-register round-trip never wipes a fresh upload,\n * short enough that storage waste from failed uploads doesn't\n * accumulate.\n *\n * The upload→register flow happens client-side automatically (no\n * \"operator clicks register\" step in the current UI), so the TTL\n * just needs to cover network blips + retries. Bumping the TTL\n * is safe if your operators commonly upload then wait before\n * processing.\n */\n orphanUploadTtlHours?: number\n /**\n * Days after which a terminal-status `import_run`'s source file is\n * deleted from `toolkit_files`. Default 7 days — gives operators a\n * reprocess window if a transform bug surfaces post-success, while\n * keeping storage costs bounded. Set to 0 for instant cleanup at\n * the next cleanup tick after `import_run.status` becomes terminal\n * (`succeeded` / `failed` / `cancelled`).\n *\n * The `import_run` row itself is preserved — the audit trail and\n * History panel still show the past run; only the source feed\n * file is removed.\n */\n processedRetentionDays?: number\n}\n\nexport function imports(options: ImportsPluginOptions) {\n const esIndex = options.esIndex ?? 'parts'\n // Resolve the cleanup config eagerly. `storageBucket === null` is the\n // explicit opt-out path; `undefined` falls back to the convention.\n // Two cleanup jobs share the same bucket but have independent TTLs:\n // orphans (no import_run ref) churn fast (2h); processed runs\n // (terminal status) get a longer reprocess window (7d).\n const cleanupConfig =\n options.storageBucket === null\n ? null\n : {\n bucket: options.storageBucket ?? 'lumi-imports',\n orphanTtlHours: options.orphanUploadTtlHours ?? 2,\n processedRetentionDays: options.processedRetentionDays ?? 7,\n }\n\n // Register the storage factory eagerly — runs at `imports()` factory\n // call time during `lumi.config.ts` evaluation, NOT inside the\n // `init` hook. The hook only fires at `createApp()` time (Next.js\n // request boot / worker boot), but the factory needs to be visible\n // to consumers that read it at module-load (e.g. the commerce\n // route's lazy `getStorageClient` thunk closes over a value that\n // resolves the moment a request comes in). Late registration in\n // `init` would race the first request.\n if (options.storage) {\n registerImportsStorageFactory(options.storage)\n }\n\n return definePlugin({\n name: '@murumets-ee/imports',\n server: {\n entities: [ImportRun, ImportMapping],\n init: async (app) => {\n // Built-in transforms — registered FIRST, before the queue-plugin\n // probe below, so the registry reflects this package's dispatch\n // capability even in queue-less processes (the admin Catalog tab\n // lists registry names). The `has()` guards make this idempotent\n // under dev HMR re-init AND let older scaffolds that still\n // self-register from `lib/app.ts` coexist (their registration is\n // also `has()`-guarded; whichever runs first wins — it's the same\n // function either way). See the module JSDoc for why app-side-only\n // registration was a drift foot-gun.\n //\n // Why imperative-in-init rather than a declarative `contributes`\n // slot (CLAUDE.md's default for contributions): these are this\n // package's OWN built-ins landing in this package's OWN\n // process-global registry — nothing crosses a plugin boundary, and\n // the registry is consumed at job-dispatch time, not at the\n // framework's merge step. A contributes slot buys cross-plugin\n // introspection we don't need at the cost of a core-visible slot\n // type for an imports-internal concern.\n const transformRegistry = getTransformRegistry()\n if (!transformRegistry.has(MAPPING_TRANSFORM_NAME)) {\n transformRegistry.register(MAPPING_TRANSFORM_NAME, mappingTransform)\n }\n if (!transformRegistry.has(CARMAKER_FEED_TRANSFORM_NAME)) {\n transformRegistry.register(CARMAKER_FEED_TRANSFORM_NAME, carmakerFeedTransform)\n }\n\n // Queue PACKAGE being importable doesn't mean the queue() PLUGIN\n // is in the consumer's plugins array. Without it, registerJob()\n // succeeds but no worker ever picks up the job. Probe and warn.\n const queuePluginPresent = app.plugins.all().some((p) => p.name === '@murumets-ee/queue')\n if (!queuePluginPresent) {\n app.logger.warn(\n 'imports: queue() plugin not in plugins array — imports:run jobs will not be processed',\n )\n return\n }\n\n // Queue client stays dynamic-imported: we already early-returned\n // above when the queue() plugin isn't in the consumer's plugins\n // array, so static-importing here would force-load the queue\n // client module in deployments that don't enable the worker.\n // `@murumets-ee/core/clients` is NO LONGER imported here —\n // `app.getClient(entity)` gives us a typed, app-bound\n // AdminClient without the F1+F2 foot-guns from\n // PLAN-DECLARATIVE-PLUGINS.md.\n const queueClientModule = await import('@murumets-ee/queue/client')\n\n const importRuns = app.getClient(ImportRun)\n\n queueClientModule.registerJob(\n importsRunJob,\n createRunImportHandler({\n importRuns,\n transforms: transformRegistry,\n esClient: options.esClient,\n esIndex,\n ...(options.ensureIndex !== undefined && { ensureIndex: options.ensureIndex }),\n ...(options.resolveFilePath !== undefined && {\n resolveFilePath: options.resolveFilePath,\n }),\n ...(options.pairSink !== undefined && { pairSink: options.pairSink }),\n logger: app.logger.child({ pkg: 'imports' }),\n }),\n )\n\n // Subscribe to the queue's terminal-state event stream so a\n // worker that crashes mid-import — and whose `imports:run` job\n // ultimately reaches `dead` without the handler reaching its\n // own `markFailed` path — flips the mirror `import_run` row to\n // `failed`. This replaces what would otherwise be a per-domain\n // periodic sweep; the queue's terminal notifier is the generic\n // primitive (graphile-worker's `job:failed` event, BullMQ's\n // `failed` listener — same shape).\n //\n // The listener is multi-subscriber, so we coexist with the\n // notifications plugin's admin-email handler without either of\n // us clobbering the other. Filtering on `event.jobType` keeps\n // this listener cheap on jobs we don't own.\n //\n // **HMR**: each re-init of this `init` hook constructs a new\n // listener instance. The notifier registry stores listeners\n // by reference, so without explicit cleanup an HMR cycle would\n // STACK duplicate listeners — `findMany`+`update` per dead\n // event growing linearly with the number of HMR rounds. Stash\n // the unsubscribe on a globalThis Symbol-keyed slot (same\n // pattern as `transform.ts` / `storage.ts`) and call it before\n // re-subscribing so HMR is idempotent.\n const previousUnsubscribe = (globalThis as GlobalThisWithUnsubscribe)[\n IMPORTS_DEAD_LISTENER_KEY\n ]\n if (previousUnsubscribe) previousUnsubscribe()\n const unsubscribe = queueClientModule.addQueueTerminalListener(\n createImportsRunDeadListener({\n importRuns,\n logger: app.logger.child({ pkg: 'imports' }),\n }),\n )\n ;(globalThis as GlobalThisWithUnsubscribe)[IMPORTS_DEAD_LISTENER_KEY] = unsubscribe\n\n // Storage-cleanup jobs. Two passes, same bucket:\n // 1. Orphan sweep — uploads whose register handshake never\n // completed (no `import_run` reference). 2h TTL.\n // 2. Processed sweep — uploads whose `import_run` is terminal\n // (succeeded / failed / cancelled). 7-day reprocess\n // window then drop.\n // `defineJob({ schedule })` declares the cadence; the queue\n // worker reads the metadata at startup, materialises a\n // `toolkit_scheduled_jobs` row, and its cron loop fires the\n // registered handler each tick. No explicit\n // `client.schedule()` call needed — the worker owns the\n // schedule, not the handler. Same helper-signature shape as\n // ticketing's cleanup.\n if (cleanupConfig !== null) {\n const { cleanupOrphanedImports, cleanupProcessedImports } = await import(\n './admin/cleanup.js'\n )\n const { runAsCli } = await import('@murumets-ee/core')\n const cleanupLogger = app.logger.child({ pkg: 'imports', job: 'cleanup' })\n\n queueClientModule.registerJob(\n queueClientModule.defineJob({\n name: 'imports:cleanup-orphaned-uploads',\n description:\n 'Delete toolkit_files rows in the imports bucket with no matching import_run reference and createdAt older than the configured TTL.',\n schedule: '@every 1h',\n }),\n async () => {\n // `runAsCli` is load-bearing today: the queue worker's\n // handler context (see `packages/queue/src/handler-context.ts`)\n // tracks the job type but NOT a user / RequestContext.\n // Any AdminClient call reachable from the cleanup\n // helpers — currently `createAdminClient(ImportRun).getTable()`\n // for column-metadata resolution — runs without a user\n // unless we enter a CLI scope. The synthetic CLI user\n // is the right attribution for scheduled-job work\n // (matches ticketing's `cleanupOrphanedUploads` shape).\n await runAsCli(() =>\n cleanupOrphanedImports({\n app,\n bucket: cleanupConfig.bucket,\n ttlHours: cleanupConfig.orphanTtlHours,\n logger: cleanupLogger,\n }),\n )\n },\n )\n\n queueClientModule.registerJob(\n queueClientModule.defineJob({\n name: 'imports:cleanup-processed-uploads',\n description:\n 'Delete toolkit_files rows for import_runs in terminal status (succeeded/failed/cancelled) whose finishedAt is older than the configured retention window. The import_run row itself is preserved.',\n schedule: '@every 1h',\n }),\n async () => {\n await runAsCli(() =>\n cleanupProcessedImports({\n app,\n bucket: cleanupConfig.bucket,\n retentionDays: cleanupConfig.processedRetentionDays,\n logger: cleanupLogger,\n }),\n )\n },\n )\n }\n\n app.logger.info(\n {\n esIndex,\n // Stable shape regardless of enabled/disabled so structured-\n // log queries (`cleanup.enabled = false`) don't have to\n // handle a string fallback.\n cleanup:\n cleanupConfig === null ? { enabled: false } : { enabled: true, ...cleanupConfig },\n },\n 'Imports plugin initialized',\n )\n },\n },\n })\n}\n"],"mappings":"yYAgFA,MAAM,EAA4B,OAAO,IAAI,gDAAgD,EAqH7F,SAAgB,EAAQ,EAA+B,CACrD,IAAM,EAAU,EAAQ,SAAW,QAM7B,EACJ,EAAQ,gBAAkB,KACtB,KACA,CACE,OAAQ,EAAQ,eAAiB,eACjC,eAAgB,EAAQ,sBAAwB,EAChD,uBAAwB,EAAQ,wBAA0B,CAC5D,EAcN,OAJI,EAAQ,SACV,EAA8B,EAAQ,OAAO,EAGxC,EAAa,CAClB,KAAM,uBACN,OAAQ,CACN,SAAU,CAAC,EAAW,CAAa,EACnC,KAAM,KAAO,IAAQ,CAmBnB,IAAM,EAAoB,EAAqB,EAY/C,GAXK,EAAkB,IAAA,kBAA0B,GAC/C,EAAkB,SAAS,EAAwB,CAAgB,EAEhE,EAAkB,IAAA,wBAAgC,GACrD,EAAkB,SAAS,EAA8B,CAAqB,EAO5E,CADuB,EAAI,QAAQ,IAAI,CAAC,CAAC,KAAM,GAAM,EAAE,OAAS,oBAC9C,EAAG,CACvB,EAAI,OAAO,KACT,uFACF,EACA,MACF,CAUA,IAAM,EAAoB,MAAM,OAAO,6BAEjC,EAAa,EAAI,UAAU,CAAS,EAE1C,EAAkB,YAChB,EACA,EAAuB,CACrB,aACA,WAAY,EACZ,SAAU,EAAQ,SAClB,UACA,GAAI,EAAQ,cAAgB,IAAA,IAAa,CAAE,YAAa,EAAQ,WAAY,EAC5E,GAAI,EAAQ,kBAAoB,IAAA,IAAa,CAC3C,gBAAiB,EAAQ,eAC3B,EACA,GAAI,EAAQ,WAAa,IAAA,IAAa,CAAE,SAAU,EAAQ,QAAS,EACnE,OAAQ,EAAI,OAAO,MAAM,CAAE,IAAK,SAAU,CAAC,CAC7C,CAAC,CACH,EAwBA,IAAM,EAAuB,WAC3B,GAEE,GAAqB,EAAoB,EAC7C,IAAM,EAAc,EAAkB,yBACpC,EAA6B,CAC3B,aACA,OAAQ,EAAI,OAAO,MAAM,CAAE,IAAK,SAAU,CAAC,CAC7C,CAAC,CACH,EAgBA,GAfC,WAA0C,GAA6B,EAepE,IAAkB,KAAM,CAC1B,GAAM,CAAE,yBAAwB,2BAA4B,MAAM,OAChE,yBACD,CAAA,KAAA,GAAA,EAAA,CAAA,EACK,CAAE,YAAa,MAAM,OAAO,qBAC5B,EAAgB,EAAI,OAAO,MAAM,CAAE,IAAK,UAAW,IAAK,SAAU,CAAC,EAEzE,EAAkB,YAChB,EAAkB,UAAU,CAC1B,KAAM,mCACN,YACE,qIACF,SAAU,WACZ,CAAC,EACD,SAAY,CAUV,MAAM,MACJ,EAAuB,CACrB,MACA,OAAQ,EAAc,OACtB,SAAU,EAAc,eACxB,OAAQ,CACV,CAAC,CACH,CACF,CACF,EAEA,EAAkB,YAChB,EAAkB,UAAU,CAC1B,KAAM,oCACN,YACE,oMACF,SAAU,WACZ,CAAC,EACD,SAAY,CACV,MAAM,MACJ,EAAwB,CACtB,MACA,OAAQ,EAAc,OACtB,cAAe,EAAc,uBAC7B,OAAQ,CACV,CAAC,CACH,CACF,CACF,CACF,CAEA,EAAI,OAAO,KACT,CACE,UAIA,QACE,IAAkB,KAAO,CAAE,QAAS,EAAM,EAAI,CAAE,QAAS,GAAM,GAAG,CAAc,CACpF,EACA,4BACF,CACF,CACF,CACF,CAAC,CACH"}
1
+ {"version":3,"file":"plugin.mjs","names":[],"sources":["../src/reindex-job.ts","../src/plugin.ts"],"sourcesContent":["/**\n * Queue handler for `imports:reindex` — the OPERATOR ENTRY POINT for the\n * alias-versioned reindex (PLAN-ECOMMERCE D6).\n *\n * `@murumets-ee/search-elasticsearch` has shipped `createIndex` → `reindex` →\n * `swapAlias` since commerce-demo PR1, and until now nothing could invoke them\n * on a running box: `reindex()` was exported, unit-tested, and had zero callers\n * outside its own test file. That is not a cosmetic gap. `ensureAliasedIndex`\n * returns the moment the alias resolves and NEVER issues a `put_mapping`, so\n * editing `partsIndexConfig` has no effect whatsoever on a cluster that has\n * already been bootstrapped — which makes every future mapping change\n * unshippable until this job exists. commerce-launch PR 13 hit exactly that\n * constraint and routed around it (reading `supplier.leadTimeDaysDefault` live\n * rather than denormalising it into the doc).\n *\n * **Why a queue job and not a CLI command that does the work itself.** The ES\n * client is APP-level wiring (`apps/<app>/lib/parts-search.ts`), injected into this\n * plugin as `imports({ esClient })`. `@murumets-ee/cli` has no Elasticsearch\n * dependency and should not grow one for a parts-shaped command. So the CLI\n * enqueues by job NAME (`lumi imports:reindex`) exactly as `lumi paths:rebuild`\n * enqueues `content:paths-rebuild`, and the worker — which already holds the\n * client — does the work. The job also shows up in the queue admin page with\n * live progress, for free.\n *\n * **Safety contract**, inherited from `reindex()` and extended here:\n * - The destination is created explicitly with the app-supplied\n * {@link ImportsPluginOptions.indexConfig}, so the new mapping is what\n * actually lands. Reindexing into an auto-created index would silently\n * defeat the entire point.\n * - The alias is swapped ONLY when every document that could be copied WAS\n * copied. A bulk failure leaves the alias pointing at the source, so the live\n * read target is never half-built. Note the exact shape: `reindex()` also\n * counts `skipped` hits (a source document with no `_id` or no `_source`) and\n * those do NOT block the swap — so this job surfaces the count in its\n * progress blob and the CLI prints it, because a silent skip plus\n * `--drop-old` is how you lose documents and are told it succeeded.\n * - ⚠️ **The job cannot be interrupted once it starts.** `JobContext` carries\n * no `AbortSignal`, so the queue admin's \"cancel\" only flips the ROW to\n * `dead` — the handler runs to completion and still swaps the alias (and\n * still drops the source, if `--drop-old`). Treat the enqueue as the\n * point of no return, not the poll.\n * - A pre-existing destination is REFUSED unless `allowExisting` is set. The\n * resume case is real (`bulkUpsert` is idempotent by `_id`, so re-running\n * into a partly-filled destination is correct and cheap), but it must be an\n * explicit operator decision — silently reusing an index somebody created by\n * hand, with who-knows-what mapping, is how a mapping change gets lost.\n * - The OLD index is never dropped automatically. `dropOld` exists, defaults\n * off, and the confidence window is the operator's call.\n * - ⚠️ **Documents written to the SOURCE while the copy runs are LOST at the\n * swap.** The alias still points at the source throughout, so a concurrent\n * `imports:run` writes there, and a page this job already read is never\n * re-read.\n *\n * GUARDED, NOT SOLVED. The job refuses to start while any `import_run` is\n * `pending` or `running`, and refuses to swap if one appears during the copy\n * (`beforeSwap`) — which covers the realistic case, an operator running both\n * at once. It does NOT close the window: a write landing between the final\n * check and the swap is still lost, as is one that landed on a page already\n * copied. Closing it properly means dual-writing during the copy;\n * `django-elasticsearch-dsl` documents the identical hole in its own\n * alias-swap path rather than solving it. The guard needs `importRuns`\n * wired — without it nothing stands between an import and this race.\n *\n * That last one is a DELIBERATE deviation from prior art, so it is worth the\n * sentence: searchkick, django-elasticsearch-dsl and Elastic's own runbook all\n * delete the old index by default (retention is the opt-in flag). They can,\n * because their reindex rebuilds from the system of record — a Rails model, a\n * Django queryset — so the old index is a derived artifact that can always be\n * regenerated. Ours copies index → index. The source index IS the only copy of\n * pre-swap state, so dropping it by default would make a bad mapping\n * discovered after the swap unrecoverable, when keeping it makes recovery a\n * single `swapAlias` back. It also matches the D6 contract already written on\n * `reindex()`: \"caller decides when to dropIndex(fromIndex) after a confidence\n * window\".\n */\n\nimport type { Logger } from '@murumets-ee/core'\nimport { defineJob, type JobDefinition } from '@murumets-ee/queue/client'\nimport {\n createIndex,\n dropIndex,\n type IndexConfig,\n readAliasTargets,\n reindex,\n} from '@murumets-ee/search-elasticsearch'\nimport { inArray } from 'drizzle-orm'\nimport type { PgColumn } from 'drizzle-orm/pg-core'\nimport { z } from 'zod'\nimport type { ImportRunClient } from './entities/import-run.js'\nimport type { EsClientResolver } from './worker.js'\n\n/**\n * Payload schema. Every field is optional — the common invocation is\n * `lumi imports:reindex` with no flags, which reads the alias, derives\n * `<alias>_v<N+1>`, and copies.\n */\nexport const importsReindexJobPayloadSchema = z.object({\n /**\n * Destination physical index. Omit to derive `<alias>_v<N+1>` from whatever\n * the alias currently points at. Pass explicitly when the current physical\n * index does not follow the `_v<N>` convention, or to reindex into a name of\n * your own choosing.\n */\n toIndex: z.string().min(1).optional(),\n /**\n * Continue into a destination index that already exists. Off by default: an\n * existing destination is normally the debris of a failed run, and reusing it\n * blind would apply whatever mapping it was created with rather than the one\n * you just changed.\n */\n allowExisting: z.boolean().optional(),\n /** Drop the source index after a successful swap. Off by default. */\n dropOld: z.boolean().optional(),\n /** Documents per copy batch. Defaults to `reindex()`'s own default (1000). */\n batchSize: z.number().int().min(1).max(10_000).optional(),\n})\nexport type ImportsReindexJobPayload = z.infer<typeof importsReindexJobPayloadSchema>\n\n/**\n * Progress blob written to `toolkit_jobs.progress`. Small and fixed-shape — the\n * queue caps the blob at ~7 KB and silently DROPS anything larger, so this\n * carries counters and index names only.\n */\nexport interface ImportsReindexProgress {\n phase: 'creating' | 'resuming' | 'copying' | 'swapped'\n fromIndex: string\n toIndex: string\n processed: number\n total: number\n /**\n * Source hits `reindex()` could not copy (missing `_id` or `_source`). Carried\n * here rather than left in the worker log because it does NOT block the swap:\n * without it the CLI would report a clean success over an index that is\n * missing rows, and `--drop-old` would then delete the only copy of them.\n */\n skipped?: number\n}\n\n/**\n * Job definition. Registered by `imports()` in `plugin.ts`.\n *\n * `defaultRetries: 0` — a reindex is a deliberate operator action over a large\n * index, and an automatic retry would race the run it is retrying (both writing\n * the same destination, both racing to swap). The operator re-runs, which is\n * safe by design: `bulkUpsert` is idempotent by `_id`, so a re-run with\n * `allowExisting` resumes rather than duplicates.\n *\n * ⚠️ **This field does NOT bind a caller that enqueues by NAME.**\n * `QueueClient.enqueue` reads `defaultRetries` off this OBJECT, and the only\n * shipped caller — `lumi imports:reindex` — enqueues by string, because this\n * module sits behind the queue client that the CLI keeps off its load graph. It\n * therefore passes `{ maxRetries: 0 }` explicitly (`REINDEX_ENQUEUE_OPTIONS` in\n * `@murumets-ee/cli`), and any FUTURE by-name caller must do the same or\n * silently get 3 retries — reproducing the exact failure this setting exists to\n * prevent, with the real cause hidden behind an \"already exists\" `lastError`\n * from a bounced retry.\n */\nexport const importsReindexJob: JobDefinition<ImportsReindexJobPayload> = defineJob({\n name: 'imports:reindex',\n description:\n 'Copy the aliased parts index into a new physical index built with the current mapping, then atomically swap the alias onto it.',\n schema: importsReindexJobPayloadSchema,\n defaultRetries: 0,\n})\n\n/** Structural subset of `JobContext` this handler reads — mirrors `ImportsRunJobContext`. */\nexport interface ImportsReindexJobContext {\n id: string\n payload: ImportsReindexJobPayload\n updateProgress(data: ImportsReindexProgress): void\n}\n\nexport interface ReindexHandlerConfig {\n /** Resolver for the ES client. */\n esClient: EsClientResolver\n /** The alias to reindex. Same value the import path writes to. */\n esIndex: string\n /**\n * Settings + mappings the DESTINATION index is created with. Without it there\n * is no way to apply a mapping change, so the handler refuses to run rather\n * than copy into a default-mapped index.\n */\n indexConfig?: IndexConfig\n /**\n * AdminClient over `import_run`, used to refuse a reindex while a feed import\n * is in flight. Optional so a consumer without the entity wired still gets a\n * working reindex — but omitting it removes the ONLY guard against the\n * lost-writes race described in the safety contract.\n */\n importRuns?: ImportRunClient\n /** Optional structured logger. Defaults to silent. */\n logger?: Logger\n}\n\n/** Statuses that mean a feed import is writing, or is about to. */\nconst ACTIVE_IMPORT_STATUSES = ['pending', 'running'] as const\n\n/**\n * Count `import_run` rows that are writing or about to write.\n *\n * A feed import bulk-writes THROUGH THE ALIAS, which points at the source for\n * the whole copy — so its documents land in the index we are replacing and are\n * never copied forward. `pending` counts as active because a queued run can\n * start mid-copy.\n */\nasync function countActiveImports(importRuns: ImportRunClient): Promise<number> {\n // `getTable()` returns `PgTableWithColumns<any>`; narrow just the column we\n // touch so `inArray()` and type-coverage both see a typed value. Same shape\n // the dead-listener in `worker.ts` uses.\n const table = importRuns.getTable() as unknown as { status: PgColumn }\n return importRuns.count({ where: inArray(table.status, [...ACTIVE_IMPORT_STATUSES]) })\n}\n\n/** `<base>_v<N>` → `<base>_v<N+1>`. Returns null when the name doesn't follow the convention. */\nexport function nextVersionedIndexName(current: string): string | null {\n const match = /^(.*)_v(\\d+)$/.exec(current)\n if (!match) return null\n const [, base, version] = match\n if (base === undefined || version === undefined) return null\n const next = Number.parseInt(version, 10) + 1\n if (!Number.isSafeInteger(next)) return null\n return `${base}_v${next}`\n}\n\n/**\n * Build the `JobHandler` for {@link importsReindexJob}.\n *\n * Deliberately NOT wrapped in `runAsCli`, unlike `createRunImportHandler`. That\n * wrapper is a privilege grant (`checker: () => true`, `groups: ['admin']`) and\n * this handler performs no entity operation at all — it only talks to\n * Elasticsearch. Per CLAUDE.md's \"reach is not authority\", acquiring an admin\n * identity for a path that never uses one is exactly the habit that makes the\n * elevation allowlist meaningless. If a future change here needs an\n * `AdminClient`, add the wrapper then, with the reason.\n */\nexport function createReindexHandler(\n config: ReindexHandlerConfig,\n): (job: ImportsReindexJobContext) => Promise<void> {\n const { esClient, esIndex, indexConfig, importRuns, logger } = config\n\n return async (job: ImportsReindexJobContext): Promise<void> => {\n const log = logger?.child({ jobId: job.id, type: 'imports:reindex' })\n\n if (indexConfig === undefined) {\n throw new Error(\n `imports:reindex requires imports({ indexConfig }) — the destination index must be ` +\n `created with the settings + mappings this app intends, otherwise Elasticsearch ` +\n `auto-creates it with inferred text mappings and the reindex silently discards the ` +\n `mapping change it exists to ship. Pass your app's index config (for the parts ` +\n `catalog: partsIndexConfig from @murumets-ee/search-elasticsearch).`,\n )\n }\n\n // Validate the payload HERE, not at enqueue. The job's Zod schema is\n // applied by `QueueClient.enqueue` only for the `defineJob`-object form,\n // and the CLI enqueues by NAME (the definition sits behind the queue\n // client, which the CLI keeps off its load graph) — so without this parse\n // the schema is decorative and `batchSize` is unbounded on the worker\n // side. `content:paths-rebuild` parses in its handler for the same reason.\n //\n // Ordered AFTER the `indexConfig` check on purpose: a misconfigured app\n // should read \"pass imports({ indexConfig })\", not a multi-line ZodError\n // dump, and the CLI is the only thing that produces payloads anyway.\n const payload = importsReindexJobPayloadSchema.parse(job.payload)\n\n // Refuse to START while a feed import is in flight. An import writes\n // THROUGH the alias, which points at the source for the whole copy, so its\n // documents land in the index being replaced and are never copied forward —\n // the swap would silently drop them. Checked here so the operator is told\n // before minutes of copying, and again just before the swap for a run that\n // started in between.\n if (importRuns !== undefined) {\n const active = await countActiveImports(importRuns)\n if (active > 0) {\n throw new Error(\n `imports:reindex: ${active} feed import(s) are pending or running. An import writes ` +\n `through the alias \"${esIndex}\", which points at the SOURCE index for the whole copy, ` +\n `so anything it writes now would be lost at the swap. Wait for them to finish (or ` +\n `cancel them) and re-run.`,\n )\n }\n }\n\n const client = await esClient()\n\n // An alias may legitimately point at MORE than one physical index (that is\n // how a rolling reindex looks mid-flight). `readAliasIndex` collapses that\n // to a single name, so proceeding would copy only one of them, then swap —\n // leaving the alias on {untouched sibling + new index} and quietly serving\n // a union of stale and fresh documents, with `--drop-old` deleting only\n // the one that was read. Refuse, and name what we found.\n const aliasTargets = Object.keys(await readAliasTargets(client, esIndex)).sort()\n if (aliasTargets.length > 1) {\n throw new Error(\n `imports:reindex: alias \"${esIndex}\" points at ${aliasTargets.length} indices ` +\n `(${aliasTargets.join(', ')}). Reindexing would copy only one of them and then ` +\n `swap, leaving the alias on a mix of stale and fresh data. Resolve the alias to a ` +\n `single index first, then re-run. An interrupted reindex cannot cause this — ` +\n `swapAlias sends its remove with must_exist, so a stale swap fails instead of ` +\n `adding a second target. Look for a manual alias edit, another tool writing to this ` +\n `cluster, or a swap issued before that guard existed.`,\n )\n }\n\n // Taken from the SAME read as the guard above, not a second `getAlias`. Two\n // round trips would leave a window in which a target added between them\n // passes the guard and is then silently collapsed by `readAliasIndex`'s\n // tiebreak — the guard defeated by the very state it exists to refuse.\n const fromIndex = aliasTargets[0]\n if (fromIndex === undefined) {\n throw new Error(\n `imports:reindex: alias \"${esIndex}\" does not exist, so there is nothing to reindex. ` +\n `Run an import first — imports({ ensureIndex }) bootstraps the alias on the import path.`,\n )\n }\n\n const derived = payload.toIndex ?? nextVersionedIndexName(fromIndex)\n if (derived === null) {\n throw new Error(\n `imports:reindex: alias \"${esIndex}\" points at \"${fromIndex}\", which does not follow the ` +\n `\"<name>_v<N>\" convention, so the next version cannot be derived. Pass an explicit ` +\n `destination (\\`lumi imports:reindex --to <index>\\`).`,\n )\n }\n const toIndex = derived\n\n if (toIndex === fromIndex) {\n throw new Error(\n `imports:reindex: destination \"${toIndex}\" is the index the alias already points at. ` +\n `Reindexing an index onto itself would copy every document over itself and swap the ` +\n `alias onto where it already is. Pass a different --to.`,\n )\n }\n\n const destExists = await client.indices.exists({ index: toIndex })\n if (destExists && payload.allowExisting !== true) {\n throw new Error(\n `imports:reindex: destination \"${toIndex}\" already exists. If it is debris from a failed ` +\n `run, re-run with --resume to continue into it (bulkUpsert is idempotent by _id, so ` +\n `already-copied documents are simply overwritten); if it was created by hand with a ` +\n `different mapping, delete it or pass --to <another-index> instead. Refusing to reuse ` +\n `it silently, because doing so would apply ITS mapping rather than the one you changed.`,\n )\n }\n\n // Distinct phases, because they are materially different operations and\n // the operator can only see the progress blob. A `--resume` reuses an\n // index somebody else created, so `indexConfig` is NOT applied — and if\n // the reason for the reindex was a mapping change, that means it silently\n // does not ship. Saying so in the worker log alone would be saying it\n // where the person running the command cannot read it.\n job.updateProgress({\n phase: destExists ? 'resuming' : 'creating',\n fromIndex,\n toIndex,\n processed: 0,\n total: 0,\n })\n\n if (!destExists) {\n log?.info({ fromIndex, toIndex }, 'imports:reindex: creating destination index')\n await createIndex(client, { name: toIndex, config: indexConfig })\n } else {\n log?.warn(\n { fromIndex, toIndex },\n 'imports:reindex: resuming into an existing destination index (--resume); its EXISTING mapping applies, not indexConfig',\n )\n }\n\n log?.info({ fromIndex, toIndex }, 'imports:reindex: copying documents')\n const result = await reindex({\n client,\n alias: esIndex,\n fromIndex,\n toIndex,\n // ONE refresh before the swap. NOT `refresh: 'wait_for'`, which is the\n // obvious-looking choice and is a trap: it blocks until the next\n // SCHEDULED refresh, per batch, and the parts index sets\n // `refresh_interval: '30s'` — hours of waiting for a large catalogue,\n // to achieve what one final refresh achieves in milliseconds.\n refreshOnCompletion: true,\n ...(payload.batchSize !== undefined && { batchSize: payload.batchSize }),\n // Re-check immediately before the swap: an import that STARTED during the\n // copy wrote to the source and is not in the destination. Throwing here\n // leaves the alias untouched and the destination in place, so `--resume`\n // can finish the job once the import has.\n //\n // ⚠️ Narrows the race, does not close it. A write landing between this\n // check and the swap is still lost, and so is one that landed on a page\n // already copied. Closing it properly needs dual-writes during the copy.\n ...(importRuns !== undefined && {\n beforeSwap: async () => {\n const active = await countActiveImports(importRuns)\n if (active > 0) {\n throw new Error(\n `imports:reindex: ${active} feed import(s) started while the copy was running. ` +\n `Their documents went to the source index through the alias and are NOT in ` +\n `\"${toIndex}\", so swapping now would drop them. The alias is untouched and ` +\n `\"${toIndex}\" is left in place — wait for the import(s), then re-run with ` +\n `--resume to finish.`,\n )\n }\n },\n }),\n reportProgress: async ({ processed, total }) => {\n job.updateProgress({ phase: 'copying', fromIndex, toIndex, processed, total })\n },\n })\n\n job.updateProgress({\n phase: 'swapped',\n fromIndex,\n toIndex,\n processed: result.processed,\n total: result.processed + result.skipped,\n skipped: result.skipped,\n })\n log?.info(\n { fromIndex, toIndex, processed: result.processed, skipped: result.skipped },\n 'imports:reindex: alias swapped onto destination',\n )\n\n if (payload.dropOld === true) {\n await dropIndex(client, fromIndex)\n log?.info({ fromIndex }, 'imports:reindex: dropped source index (--drop-old)')\n }\n }\n}\n","/**\n * Imports plugin — registers the `import_run` entity and (when ES + queue\n * are wired) registers the `imports:run` and `imports:reindex` job handlers.\n *\n * Per PLAN-ECOMMERCE.md PR 7 (PoC scope): plumbing only. The bespoke\n * admin pages (upload form, parts search) live in PR 8a.\n *\n * **Built-in transforms register HERE, at plugin init.** Both transforms\n * this package ships (`commerce:mapping`, `commerce:carmaker-feed`) go\n * into the process-global registry inside `init`, so every process that\n * boots the plugin — Next.js web server, standalone queue worker — can\n * dispatch them with zero app-side wiring. Registration used to be the\n * app's job (`lib/app.ts` calling `registry.register(...)`), which is\n * exactly the drift foot-gun CLAUDE.md's \"no forgotten registrations\"\n * rule warns about: an app scaffolded before the mapping transform\n * existed keeps its old `lib/app.ts` through `pnpm up` toolkit upgrades,\n * and every mapping-driven run then dies in the worker with\n * `No transform registered for name \"commerce:mapping\"`. App-side\n * registration remains harmless even when unguarded —\n * `registerImportTransform` overwrites last-wins instead of throwing —\n * it is just no longer required.\n *\n * @example\n * ```ts\n * import { defineLumiConfig } from '@murumets-ee/core'\n * import { imports } from '@murumets-ee/imports/plugin'\n * import { Client } from '@elastic/elasticsearch'\n *\n * const es = new Client({ node: process.env.ES_URL })\n *\n * export default defineLumiConfig({\n * plugins: [\n * // queue() must be present too — it runs the imports:run jobs.\n * queue(),\n * imports({ esClient: () => es, esIndex: 'parts', indexConfig: partsIndexConfig }),\n * ],\n * })\n * ```\n */\n\nimport { definePlugin } from '@murumets-ee/core'\nimport type { IndexConfig } from '@murumets-ee/search-elasticsearch'\n// **Why no static `@murumets-ee/core/clients` import in this file.**\n//\n// `@murumets-ee/core/clients` begins with `import 'server-only'`, which\n// throws when this plugin module is loaded at `lumi.config.ts`\n// evaluation time by the CLI (`lumi migrate`/`seed`/`doctor` use jiti,\n// which doesn't apply the `react-server` export condition).\n// Static-importing would propagate that throw all the way through\n// `lumi.config.ts` → CLI startup, blocking every CLI invocation.\n//\n// **Today (after PLAN-DECLARATIVE-PLUGINS PR 2):** plugin `init`\n// reaches `AdminClient` via the framework-provided `app.getClient(\n// entity)` accessor — no `createAdminClient` call, no dynamic import\n// either. The remaining `@murumets-ee/core/clients` dependency lives\n// in `./admin/cleanup.js`, which IS still dynamic-imported inside\n// `init` and which still benefits from this rationale. The pattern\n// matches `getDb()` in `@murumets-ee/queue/admin.ts`.\nimport { ImportMapping } from './entities/import-mapping.js'\nimport { ImportRun } from './entities/import-run.js'\nimport { MAPPING_TRANSFORM_NAME, mappingTransform } from './mapping/mapping-transform.js'\nimport { createReindexHandler, importsReindexJob } from './reindex-job.js'\nimport { type ImportsStorageFactory, registerImportsStorageFactory } from './storage.js'\nimport { getTransformRegistry } from './transform.js'\nimport { CARMAKER_FEED_TRANSFORM_NAME, carmakerFeedTransform } from './transforms/carmaker-feed.js'\nimport {\n createImportsRunDeadListener,\n createRunImportHandler,\n type EnsureIndexResolver,\n type EsClientResolver,\n type FilePathResolver,\n importsRunJob,\n type PairSink,\n} from './worker.js'\n\n/**\n * `Symbol.for`-keyed slot for the active dead-listener unsubscribe\n * handle. Symbol.for so an HMR-replaced module instance still finds\n * the previous unsubscribe (`Symbol`-by-reference would skip the\n * lookup across module-evaluation boundaries). Same pattern as\n * `transform.ts`'s registry slot and `storage.ts`'s factory slot.\n */\nconst IMPORTS_DEAD_LISTENER_KEY = Symbol.for('@murumets-ee/imports:dead-listener-unsubscribe')\n\ninterface GlobalThisWithUnsubscribe {\n [IMPORTS_DEAD_LISTENER_KEY]?: () => void\n}\n\nexport interface ImportsPluginOptions {\n /**\n * Elasticsearch client resolver. Lazy so consumers can construct\n * the client inside a route initialiser instead of at plugin-init\n * time. Required — without it, the worker can't bulk-write.\n */\n esClient: EsClientResolver\n /**\n * ES index alias to write to. Per D6 always pass an ALIAS, never a physical\n * index — that is what makes {@link importsReindexJob}'s atomic swap possible.\n *\n * **Required, deliberately.** It defaulted to `'parts'` until #665: two apps\n * sharing one Elasticsearch cluster both took that default and wrote the same\n * physical index, so staging's 858k-row Ford import surfaced in production's\n * parts search. The pod workaround was to set the alias per deployment — but\n * a DEFAULT here means an app can set `PARTS_INDEX_ALIAS`, wire it into the\n * read path, forget this option, and have its reads and writes silently name\n * different indices. That is the same defect one level down, and it fails\n * SILENTLY. Making the option required turns it into a compile error instead.\n *\n * Pass the SAME value the search provider's `indexAlias` uses; the app's\n * `lib/parts-search.ts` exports one `partsIndexAlias` const for exactly this.\n */\n esIndex: string\n /**\n * Optional bootstrap for the destination index/alias, run once per import\n * job before any bulk-write (see {@link EnsureIndexResolver}). Wire your\n * app's `ensureAliasedIndex`-backed helper here so the worker can never\n * auto-create a mis-mapped physical index — WITHOUT gating app boot on ES.\n *\n * Omit only when the index is bootstrapped out-of-band; without it a\n * first-ever import against an unbootstrapped alias lets ES auto-create a\n * `text`-mapped physical index, breaking aggregations (issue #610).\n */\n ensureIndex?: EnsureIndexResolver\n /**\n * Settings + mappings the `imports:reindex` job creates its DESTINATION index\n * with. Pass the same config `ensureIndex` bootstraps the alias from (for the\n * parts catalog: `partsIndexConfig` from `@murumets-ee/search-elasticsearch`).\n *\n * Omit it and the job still registers, but refuses to run with an actionable\n * error — a reindex into an index Elasticsearch auto-created with inferred\n * `text` mappings would silently discard the mapping change the reindex\n * exists to ship, which is a worse outcome than not running.\n *\n * This is what makes a mapping change deployable at all: `ensureAliasedIndex`\n * returns as soon as the alias resolves and never issues a `put_mapping`, so\n * on an already-bootstrapped cluster, editing the mapping config has no\n * effect whatsoever without a reindex.\n */\n indexConfig?: IndexConfig\n /**\n * Resolve `import_run.filePath` to a local FS path before streaming.\n * **Required when uploads land in remote storage** (R2/S3/etc.) —\n * without it, the worker hands the storage key to `createReadStream`\n * and crashes with `ENOENT`.\n *\n * Typical wiring downloads the storage object to a tmpfile and\n * returns its path; the optional `cleanup` callback runs after the\n * run finishes (success or failure).\n *\n * Omit this option for the original on-disk PoC setup where the\n * upload route writes directly to a local directory and persists\n * the absolute path on `import_run.filePath`.\n */\n resolveFilePath?: FilePathResolver\n /**\n * Optional consumer for the directional supersession pairs the runner harvests as\n * a side-output (commerce-demo PR6, SD007). The app composes it — commerce exports\n * the replacement-merge sink and passes it here, same injection shape as\n * {@link esClient} / {@link resolveFilePath}. Omit when not ingesting replacement\n * codes; the pairs are then discarded after the run.\n */\n pairSink?: PairSink\n /**\n * Optional dedicated `StorageClient` factory for imports. When\n * provided, the upload route (in `@murumets-ee/commerce`) and the\n * worker resolver consult this client instead of the global\n * `getStorageConfig()` default — so a deployment can pin imports\n * to a local-disk adapter (single-VPS / dev) while media,\n * ticketing attachments, and the rest of the app keep using the\n * default R2 (or S3) adapter.\n *\n * Lazy / async so consumers can defer adapter construction until\n * the first import — useful when the local-disk root is\n * environment-dependent or when the factory needs `getApp()` to\n * have resolved.\n *\n * Omit to keep the legacy single-storage behaviour (everything on\n * the global default).\n */\n storage?: ImportsStorageFactory\n /**\n * Bucket name to scope the orphan-cleanup queue job. Rows in\n * `toolkit_files` with this bucket value AND no matching\n * `import_run.filePath` AND `createdAt < cutoff` get garbage-\n * collected on each cleanup tick. Defaults to `'lumi-imports'` —\n * the convention `apps/admin/lib/imports-storage.ts`\n * uses by default. Override if your deployment pins a different\n * bucket name via `IMPORTS_STORAGE_BUCKET` or equivalent.\n *\n * Set to `null` to DISABLE the cleanup job entirely (e.g. for\n * deployments that handle their own retention via S3 lifecycle\n * rules). Disabling without an alternative will leak storage\n * over time as failed uploads accumulate.\n */\n storageBucket?: string | null\n /**\n * Hours after which an unreferenced `toolkit_files` row in the\n * imports bucket is considered orphaned and deleted by the\n * cleanup job. Default 2h — long enough that a normal\n * upload-then-register round-trip never wipes a fresh upload,\n * short enough that storage waste from failed uploads doesn't\n * accumulate.\n *\n * The upload→register flow happens client-side automatically (no\n * \"operator clicks register\" step in the current UI), so the TTL\n * just needs to cover network blips + retries. Bumping the TTL\n * is safe if your operators commonly upload then wait before\n * processing.\n */\n orphanUploadTtlHours?: number\n /**\n * Days after which a terminal-status `import_run`'s source file is\n * deleted from `toolkit_files`. Default 7 days — gives operators a\n * reprocess window if a transform bug surfaces post-success, while\n * keeping storage costs bounded. Set to 0 for instant cleanup at\n * the next cleanup tick after `import_run.status` becomes terminal\n * (`succeeded` / `failed` / `cancelled`).\n *\n * The `import_run` row itself is preserved — the audit trail and\n * History panel still show the past run; only the source feed\n * file is removed.\n */\n processedRetentionDays?: number\n}\n\nexport function imports(options: ImportsPluginOptions) {\n const esIndex = options.esIndex\n // Resolve the cleanup config eagerly. `storageBucket === null` is the\n // explicit opt-out path; `undefined` falls back to the convention.\n // Two cleanup jobs share the same bucket but have independent TTLs:\n // orphans (no import_run ref) churn fast (2h); processed runs\n // (terminal status) get a longer reprocess window (7d).\n const cleanupConfig =\n options.storageBucket === null\n ? null\n : {\n bucket: options.storageBucket ?? 'lumi-imports',\n orphanTtlHours: options.orphanUploadTtlHours ?? 2,\n processedRetentionDays: options.processedRetentionDays ?? 7,\n }\n\n // Register the storage factory eagerly — runs at `imports()` factory\n // call time during `lumi.config.ts` evaluation, NOT inside the\n // `init` hook. The hook only fires at `createApp()` time (Next.js\n // request boot / worker boot), but the factory needs to be visible\n // to consumers that read it at module-load (e.g. the commerce\n // route's lazy `getStorageClient` thunk closes over a value that\n // resolves the moment a request comes in). Late registration in\n // `init` would race the first request.\n if (options.storage) {\n registerImportsStorageFactory(options.storage)\n }\n\n return definePlugin({\n name: '@murumets-ee/imports',\n server: {\n entities: [ImportRun, ImportMapping],\n init: async (app) => {\n // Built-in transforms — registered FIRST, before the queue-plugin\n // probe below, so the registry reflects this package's dispatch\n // capability even in queue-less processes (the admin Catalog tab\n // lists registry names). The `has()` guards make this idempotent\n // under dev HMR re-init AND let older scaffolds that still\n // self-register from `lib/app.ts` coexist (their registration is\n // also `has()`-guarded; whichever runs first wins — it's the same\n // function either way). See the module JSDoc for why app-side-only\n // registration was a drift foot-gun.\n //\n // Why imperative-in-init rather than a declarative `contributes`\n // slot (CLAUDE.md's default for contributions): these are this\n // package's OWN built-ins landing in this package's OWN\n // process-global registry — nothing crosses a plugin boundary, and\n // the registry is consumed at job-dispatch time, not at the\n // framework's merge step. A contributes slot buys cross-plugin\n // introspection we don't need at the cost of a core-visible slot\n // type for an imports-internal concern.\n const transformRegistry = getTransformRegistry()\n if (!transformRegistry.has(MAPPING_TRANSFORM_NAME)) {\n transformRegistry.register(MAPPING_TRANSFORM_NAME, mappingTransform)\n }\n if (!transformRegistry.has(CARMAKER_FEED_TRANSFORM_NAME)) {\n transformRegistry.register(CARMAKER_FEED_TRANSFORM_NAME, carmakerFeedTransform)\n }\n\n // Queue PACKAGE being importable doesn't mean the queue() PLUGIN\n // is in the consumer's plugins array. Without it, registerJob()\n // succeeds but no worker ever picks up the job. Probe and warn.\n const queuePluginPresent = app.plugins.all().some((p) => p.name === '@murumets-ee/queue')\n if (!queuePluginPresent) {\n app.logger.warn(\n 'imports: queue() plugin not in plugins array — imports:run jobs will not be processed',\n )\n return\n }\n\n // Queue client stays dynamic-imported: we already early-returned\n // above when the queue() plugin isn't in the consumer's plugins\n // array, so static-importing here would force-load the queue\n // client module in deployments that don't enable the worker.\n // `@murumets-ee/core/clients` is NO LONGER imported here —\n // `app.getClient(entity)` gives us a typed, app-bound\n // AdminClient without the F1+F2 foot-guns from\n // PLAN-DECLARATIVE-PLUGINS.md.\n const queueClientModule = await import('@murumets-ee/queue/client')\n\n const importRuns = app.getClient(ImportRun)\n\n queueClientModule.registerJob(\n importsRunJob,\n createRunImportHandler({\n importRuns,\n transforms: transformRegistry,\n esClient: options.esClient,\n esIndex,\n ...(options.ensureIndex !== undefined && { ensureIndex: options.ensureIndex }),\n ...(options.resolveFilePath !== undefined && {\n resolveFilePath: options.resolveFilePath,\n }),\n ...(options.pairSink !== undefined && { pairSink: options.pairSink }),\n logger: app.logger.child({ pkg: 'imports' }),\n }),\n )\n\n // The operator entry point for the alias-versioned reindex (D6).\n // Registered unconditionally: a missing `indexConfig` is reported by\n // the handler with an actionable message, which beats a job that\n // silently never exists and leaves `lumi imports:reindex` enqueuing\n // work no worker will ever claim.\n queueClientModule.registerJob(\n importsReindexJob,\n createReindexHandler({\n esClient: options.esClient,\n esIndex,\n // Lets the reindex refuse to run (and refuse to swap) while a feed\n // import is in flight — those writes go to the SOURCE through the\n // alias and would be dropped by the swap.\n importRuns,\n ...(options.indexConfig !== undefined && { indexConfig: options.indexConfig }),\n logger: app.logger.child({ pkg: 'imports' }),\n }),\n )\n\n // Subscribe to the queue's terminal-state event stream so a\n // worker that crashes mid-import — and whose `imports:run` job\n // ultimately reaches `dead` without the handler reaching its\n // own `markFailed` path — flips the mirror `import_run` row to\n // `failed`. This replaces what would otherwise be a per-domain\n // periodic sweep; the queue's terminal notifier is the generic\n // primitive (graphile-worker's `job:failed` event, BullMQ's\n // `failed` listener — same shape).\n //\n // The listener is multi-subscriber, so we coexist with the\n // notifications plugin's admin-email handler without either of\n // us clobbering the other. Filtering on `event.jobType` keeps\n // this listener cheap on jobs we don't own.\n //\n // **HMR**: each re-init of this `init` hook constructs a new\n // listener instance. The notifier registry stores listeners\n // by reference, so without explicit cleanup an HMR cycle would\n // STACK duplicate listeners — `findMany`+`update` per dead\n // event growing linearly with the number of HMR rounds. Stash\n // the unsubscribe on a globalThis Symbol-keyed slot (same\n // pattern as `transform.ts` / `storage.ts`) and call it before\n // re-subscribing so HMR is idempotent.\n const previousUnsubscribe = (globalThis as GlobalThisWithUnsubscribe)[\n IMPORTS_DEAD_LISTENER_KEY\n ]\n if (previousUnsubscribe) previousUnsubscribe()\n const unsubscribe = queueClientModule.addQueueTerminalListener(\n createImportsRunDeadListener({\n importRuns,\n logger: app.logger.child({ pkg: 'imports' }),\n }),\n )\n ;(globalThis as GlobalThisWithUnsubscribe)[IMPORTS_DEAD_LISTENER_KEY] = unsubscribe\n\n // Storage-cleanup jobs. Two passes, same bucket:\n // 1. Orphan sweep — uploads whose register handshake never\n // completed (no `import_run` reference). 2h TTL.\n // 2. Processed sweep — uploads whose `import_run` is terminal\n // (succeeded / failed / cancelled). 7-day reprocess\n // window then drop.\n // `defineJob({ schedule })` declares the cadence; the queue\n // worker reads the metadata at startup, materialises a\n // `toolkit_scheduled_jobs` row, and its cron loop fires the\n // registered handler each tick. No explicit\n // `client.schedule()` call needed — the worker owns the\n // schedule, not the handler. Same helper-signature shape as\n // ticketing's cleanup.\n if (cleanupConfig !== null) {\n const { cleanupOrphanedImports, cleanupProcessedImports } = await import(\n './admin/cleanup.js'\n )\n const { runAsCli } = await import('@murumets-ee/core')\n const cleanupLogger = app.logger.child({ pkg: 'imports', job: 'cleanup' })\n\n queueClientModule.registerJob(\n queueClientModule.defineJob({\n name: 'imports:cleanup-orphaned-uploads',\n description:\n 'Delete toolkit_files rows in the imports bucket with no matching import_run reference and createdAt older than the configured TTL.',\n schedule: '@every 1h',\n }),\n async () => {\n // `runAsCli` is load-bearing today: the queue worker's\n // handler context (see `packages/queue/src/handler-context.ts`)\n // tracks the job type but NOT a user / RequestContext.\n // Any AdminClient call reachable from the cleanup\n // helpers — currently `createAdminClient(ImportRun).getTable()`\n // for column-metadata resolution — runs without a user\n // unless we enter a CLI scope. The synthetic CLI user\n // is the right attribution for scheduled-job work\n // (matches ticketing's `cleanupOrphanedUploads` shape).\n await runAsCli(() =>\n cleanupOrphanedImports({\n app,\n bucket: cleanupConfig.bucket,\n ttlHours: cleanupConfig.orphanTtlHours,\n logger: cleanupLogger,\n }),\n )\n },\n )\n\n queueClientModule.registerJob(\n queueClientModule.defineJob({\n name: 'imports:cleanup-processed-uploads',\n description:\n 'Delete toolkit_files rows for import_runs in terminal status (succeeded/failed/cancelled) whose finishedAt is older than the configured retention window. The import_run row itself is preserved.',\n schedule: '@every 1h',\n }),\n async () => {\n await runAsCli(() =>\n cleanupProcessedImports({\n app,\n bucket: cleanupConfig.bucket,\n retentionDays: cleanupConfig.processedRetentionDays,\n logger: cleanupLogger,\n }),\n )\n },\n )\n }\n\n app.logger.info(\n {\n esIndex,\n // Stable shape regardless of enabled/disabled so structured-\n // log queries (`cleanup.enabled = false`) don't have to\n // handle a string fallback.\n cleanup:\n cleanupConfig === null ? { enabled: false } : { enabled: true, ...cleanupConfig },\n },\n 'Imports plugin initialized',\n )\n },\n },\n })\n}\n"],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ import{z as e}from"zod";import{createReadStream as t}from"node:fs";import{parse as n}from"csv-parse";import{bulkPartialUpdate as r,bulkUpsert as i}from"@murumets-ee/search-elasticsearch";function a(e){let t=new Set;for(let n of e)if(n!==``){if(t.has(n))throw Error(`Duplicate header "${n}" after trimming — column names must be unique`);t.add(n)}}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:o=!0,columns:s,relaxColumnCount:c=!1,quote:l=!1}=e,u=s?Array.from(s):o?e=>{let t=e.map(e=>e.trim());return a(t),t}:!1,d=t(r),f=d.pipe(n({delimiter:i,columns:u,bom:!0,skip_empty_lines:!0,relax_column_count:c,quote:l})),p=0;try{for await(let e of f){p+=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:p,row:t}}}finally{d.destroy()}}const c=e.object({format:e.enum([`csv`,`tsv`,`xlsx`]).optional(),delimiter:e.string().min(1).max(4).optional(),hasHeader:e.boolean().optional(),headerRowIndex:e.number().int().min(0).optional(),sheet:e.string().min(1).max(128).optional()}).strict();function l(e){let t=e&&typeof e==`object`&&!Array.isArray(e)?e:{},n=t.feed===void 0||t.feed===null?{}:t.feed;return c.safeParse(n)}function u(e){let t=e.delimiter??(e.format===`csv`?`,`:` `);return{filePath:e.filePath,delimiter:t,...e.hasHeader!==void 0&&{hasHeader:e.hasHeader},...e.columns!==void 0&&{columns:e.columns},...e.relaxColumnCount!==void 0&&{relaxColumnCount:e.relaxColumnCount},...e.quote!==void 0&&{quote:e.quote}}}async function*d(e){if(e.format===`xlsx`){let{streamXlsx:t}=await import(`./xlsx-reader-Dsq_ERiB.mjs`);yield*t({filePath:e.filePath,...e.hasHeader!==void 0&&{hasHeader:e.hasHeader},...e.headerRowIndex!==void 0&&{headerRowIndex:e.headerRowIndex},...e.sheet!==void 0&&{sheet:e.sheet}});return}yield*s(u(e))}const f=50,p=5;var m=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()}}};function h(e,t){let n=e.totalErrors+t.totalErrors,r=e=>({...e,percentage:n>0?e.count/n*100:0});return{totalErrors:n,distinctPatterns:e.distinctPatterns+t.distinctPatterns,droppedSignatures:e.droppedSignatures+t.droppedSignatures,patterns:[...e.patterns.map(r),...t.patterns.map(r)]}}const g={count:0,nullRate:0,min:null,median:null,max:null},_=5e4;var v=class{count=0;nullCount=0;nonNullSeen=0;samples=[];record(e){if(this.count+=1,e===void 0){this.nullCount+=1;return}if(this.nonNullSeen+=1,this.samples.length<5e4){this.samples.push(e);return}let t=Math.floor(Math.random()*this.nonNullSeen);t<5e4&&(this.samples[t]=e)}snapshot(){if(this.count===0)return{...g};let e=this.nullCount/this.count;if(this.samples.length===0)return{count:this.count,nullRate:e,min:null,median:null,max:null};let t=[...this.samples].sort((e,t)=>e-t),n=t[0]??null,r=t[t.length-1]??null,i=Math.floor(t.length/2),a;if(t.length%2==0){let e=t[i-1],n=t[i];a=e!==void 0&&n!==void 0?(e+n)/2:null}else a=t[i]??null;return{count:this.count,nullRate:e,min:n,median:a,max:r}}};const y=10;function b(e,t){if(e.median===null||t.median===null||e.median<=0||t.median<=0)return null;let n=t.median/e.median;return n>=10||n<=1/10?{ratio:n,prevMedian:e.median,currMedian:t.median}:null}function x(e){if(typeof e!=`object`||!e)return null;let t=e;return typeof t.count!=`number`||typeof t.nullRate!=`number`?null:{count:t.count,nullRate:t.nullRate,min:typeof t.min==`number`?t.min:null,median:typeof t.median==`number`?t.median:null,max:typeof t.max==`number`?t.max:null}}const S=1e3;async function C(e){let{importRunId:t,runLabel:n,params:a,transform:o,feed:s,esClient:c,esIndex:l,batchSize:u=S,onProgress:f,rowLimit:p,signal:h,errorTracker:g=new m,warningTracker:_=new m,priceStats:y=new v,logger:b}=e;if(u<1)throw Error(`batchSize must be >= 1 (got ${u})`);let x=Date.now();b?.info({importRunId:t,runLabel:n,batchSize:u,esIndex:l},`imports.runner: started`);let C=0,w=0,T=0,E=0,D=0,O=0,k=[],A=[],j=[],M=async()=>{if(j.length===0)return;let e=j;j=[];let n=Date.now(),r;try{r=await i(c,{index:l,docs:e.map(({id:e,doc:t})=>({id:e,doc:t})),...h!==void 0&&{signal:h}})}catch(t){let n=h?.aborted??(t instanceof Error&&(t.name===`AbortError`||/abort/i.test(t.message)))?`aborted`:`bulk_request_failed`,r=t instanceof Error?t.message:String(t);for(let{rowNumber:t}of e)g.addError(t,n,r,void 0,null);throw T+=e.length,t}w+=r.succeeded,T+=r.failures.length,O+=1;let a=Date.now()-n,o=(Date.now()-x)/1e3;if(b?.info({importRunId:t,batch:O,flushMs:a,submitted:e.length,succeeded:r.succeeded,failed:r.failures.length,rowsRead:C,rowsPerSecond:o>0?Math.round(C/o):0},`imports.runner: batch flushed`),r.failures.length>0){let t=new Map(e.map(e=>[e.id,e.rowNumber]));for(let e of r.failures){let n=t.get(e.id)??-1;g.addError(n,e.type,e.reason,void 0,{id:e.id})}}let s=new Set(r.failures.map(e=>e.id));for(let t of e)if(!(!t.warnings||t.warnings.length===0)&&!s.has(t.id)){D+=1;for(let e of t.warnings)_.addError(t.rowNumber,e.warningType,e.message,e.field,t.rowData)}for(let t of e)!t.pairs||t.pairs.length===0||s.has(t.id)||k.push(...t.pairs);for(let t of e)s.has(t.id)||y.record(t.priceSample);if(f){let e=(Date.now()-x)/1e3;f({rowsRead:C,rowsSucceeded:w,rowsFailed:T,rowsSkipped:E,rowsWarned:D,batchesCompleted:O,elapsedSeconds:e,rowsPerSecond:e>0?C/e:0,distinctErrorPatterns:g.getDistinctPatternCount()})}},N=u,P=new Map;for await(let{rowNumber:e,row:r}of d(s)){if(h?.aborted||p!==void 0&&C>=p)break;if(C+=1,C===1&&b?.info({importRunId:t,rowNumber:e,sampleKeys:Object.keys(r)},`imports.runner: STREAM_FIRST_ROW`),C%N===0){let e=(Date.now()-x)/1e3;b?.info({importRunId:t,rowsRead:C,rowsSucceeded:w,rowsFailed:T,rowsSkipped:E,pending:j.length,elapsedSec:Math.round(e*10)/10,rowsPerSecond:e>0?Math.round(C/e):0,distinctFirstErrorTypes:P.size},`imports.runner: STREAM_TICK`)}let i={importRunId:t,params:a,runLabel:n,rowNumber:e},s;try{s=await o(r,i)}catch(n){let i=n instanceof Error?n.message:String(n);g.addError(e,`transform_threw`,i,void 0,r),P.has(`transform_threw`)||(P.set(`transform_threw`,{rowNumber:e,message:i}),b?.warn({importRunId:t,rowNumber:e,errorType:`transform_threw`,message:i},`imports.runner: FIRST_ERROR transform_threw`)),T+=1;continue}if(s.kind===`skip`){E+=1,s.patches&&s.patches.length>0&&A.push(...s.patches);continue}if(s.kind===`error`){g.addError(e,s.error.errorType,s.error.message,s.error.field,r),P.has(s.error.errorType)||(P.set(s.error.errorType,{rowNumber:e,message:s.error.message}),b?.warn({importRunId:t,rowNumber:e,errorType:s.error.errorType,field:s.error.field,message:s.error.message},`imports.runner: FIRST_ERROR ${s.error.errorType}`)),T+=1;continue}j.push({id:s.id,doc:s.doc,rowNumber:e,rowData:r,...s.warnings&&s.warnings.length>0&&{warnings:s.warnings},...s.pairs&&s.pairs.length>0&&{pairs:s.pairs},...s.priceSample!==void 0&&{priceSample:s.priceSample}}),j.length>=u&&await M()}await M();let F=0,I=0;for(let e=0;e<A.length;e+=u){if(h?.aborted)throw Error(`imports: run aborted during core-charge attach (phase 2)`);let t=A.slice(e,e+u),n;try{n=await r(c,{index:l,updates:t.map(({id:e,doc:t})=>({id:e,doc:t})),...h!==void 0&&{signal:h}})}catch(e){if(h?.aborted??(e instanceof Error&&(e.name===`AbortError`||/abort/i.test(e.message))))throw e;let n=e instanceof Error?e.message:String(e);for(let e of t)_.addError(-1,`patch_attach_failed`,n,e.orphanWarning?.field,{target:e.id});continue}if(F+=n.succeeded,n.failures.length>0){let e=new Map(t.map(e=>[e.id,e]));for(let t of n.failures){let n=e.get(t.id)?.orphanWarning;t.status===404||t.type===`document_missing_exception`?(I+=1,_.addError(-1,n?.warningType??`orphan_patch`,n?.message??t.reason,n?.field,{target:t.id})):_.addError(-1,`patch_attach_failed`,t.reason,n?.field,{target:t.id})}}}let L=(Date.now()-x)/1e3;return b?.info({importRunId:t,rowsRead:C,rowsSucceeded:w,rowsFailed:T,rowsSkipped:E,batchesCompleted:O,patchesApplied:F,patchesOrphaned:I,totalSec:Math.round(L*10)/10,rowsPerSecond:L>0?Math.round(C/L):0},`imports.runner: finished`),{rowsRead:C,rowsSucceeded:w,rowsFailed:T,rowsSkipped:E,rowsWarned:D,batchesCompleted:O,errors:g.snapshot(),warnings:_.snapshot(),pairs:k,patchesApplied:F,patchesOrphaned:I,priceStats:y.snapshot()}}export{o as _,_ as a,x as c,m as d,h as f,a as g,l as h,y as i,f as l,d as m,C as n,v as o,c as p,g as r,b as s,S as t,p as u,s as v};
2
+ //# sourceMappingURL=runner-wPnCZlB4.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"runner-wPnCZlB4.mjs","names":[],"sources":["../src/streaming.ts","../src/read-feed.ts","../src/error-tracker.ts","../src/price-stats.ts","../src/runner.ts"],"sourcesContent":["/**\n * Tab-delimited / CSV streaming reader. Generic over delimiter so the\n * same path handles `.txt` (tab), `.csv` (comma), and the rare `;`\n * European export dialect.\n *\n * Built on `csv-parse` per giga-test precedent — node-stream-based, low\n * memory, handles UTF-8 BOM (the carmaker feeds are Windows-exported\n * and ship with a BOM that breaks naive split-on-tab parsers).\n *\n * The reader yields `{ rowNumber, row }` pairs where:\n * - `rowNumber` is 1-based and counts the header as row 0.\n * - `row` is `Record<string, string>` keyed by header name. Empty cells\n * are the empty string, NOT `undefined` — feed transforms test with\n * `value === ''` consistently.\n *\n * Why this lives in a streaming reader and not inside the transform\n * itself: the transform sees one already-parsed row at a time, never\n * the file. That keeps transform implementations free of I/O concerns\n * and makes them trivially unit-testable with a fixture row map.\n */\n\nimport { createReadStream } from 'node:fs'\nimport { parse } from 'csv-parse'\n\n/**\n * Throw if two (non-empty) header names collide. Headers are the keys the mapping\n * engine matches a `source` against; after trimming, two columns differing only in\n * whitespace would collapse to one key and silently overwrite each other's data.\n * Fail fast instead — the operator fixes the feed or the mapping. Shared by the\n * csv/tsv reader (below) and the xlsx reader. Blank columns are ignored (unmapped).\n */\nexport function assertUniqueHeaders(header: readonly string[]): void {\n const seen = new Set<string>()\n for (const h of header) {\n if (h === '') continue\n if (seen.has(h)) {\n throw new Error(`Duplicate header \"${h}\" after trimming — column names must be unique`)\n }\n seen.add(h)\n }\n}\n\n/**\n * Pre-pass: count data rows in a delimited file (excluding the header\n * row when `hasHeader` is true; default `true` to match `streamFeed`).\n *\n * Implementation: stream the file as raw bytes and tally `\\n`. NOT a\n * full parse — escaped newlines inside quoted fields would over-count.\n * That'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 file\n // has a header but no explicit `columns`, derive the column names from the\n // first row via a function so we can TRIM them — real supplier feeds ship\n // leading/trailing-space headers (R03 Samples A/C: `\" JAEHIND KM-TA\"`), and\n // an untrimmed key wouldn't match a mapping's `source`. The xlsx reader trims\n // headers the same way. When neither is true, rows get string-position keys.\n //\n // csv-parse's `columns` accepts `ColumnOption[] | boolean | ((record) =>\n // ColumnOption[])`; `string[]` satisfies `ColumnOption[]`.\n const columnConfig: string[] | boolean | ((header: string[]) => string[]) = columns\n ? Array.from(columns)\n : hasHeader\n ? (header: string[]) => {\n const trimmed = header.map((h) => h.trim())\n assertUniqueHeaders(trimmed)\n return trimmed\n }\n : false\n\n const stream = createReadStream(filePath)\n const parser = stream.pipe(\n parse({\n delimiter,\n columns: columnConfig,\n bom: true,\n skip_empty_lines: true,\n // csv-parse defaults to strict column count; opt-in relaxation only.\n relax_column_count: relaxColumnCount,\n // Default `quote: false` — see StreamFeedOptions.quote JSDoc.\n // Carmaker / parts-feed exports contain literal `\"` characters\n // in description fields and would otherwise reject as\n // `Invalid Opening Quote`. Genuine RFC-4180 CSV consumers opt\n // back in by passing `quote: '\"'`.\n quote,\n }),\n )\n\n let rowNumber = 0\n try {\n for await (const rawRow of parser as AsyncIterable<\n ReadonlyArray<string> | Record<string, string | undefined>\n >) {\n rowNumber += 1\n // csv-parse emits records with string keys when `columns` is\n // truthy, otherwise an Array. Normalise both shapes to\n // `Record<string, string>` so transforms can rely on `value === ''`\n // for missing cells (csv-parse leaves trailing missing cells as\n // `undefined` when `relax_column_count: true`; this collapses\n // them to `''` to keep the contract uniform).\n const row: Record<string, string> = {}\n if (Array.isArray(rawRow)) {\n const arr = rawRow as ReadonlyArray<string | undefined>\n for (let i = 0; i < arr.length; i += 1) {\n row[String(i)] = arr[i] ?? ''\n }\n } else {\n for (const [k, v] of Object.entries(rawRow)) {\n row[k] = v ?? ''\n }\n }\n yield { rowNumber, row }\n }\n } finally {\n // Safety: ensure the underlying file descriptor closes even if the\n // consumer breaks mid-iteration. Node closes streams on\n // garbage-collection but this makes it deterministic under tests.\n stream.destroy()\n }\n}\n","/**\n * Feed reader dispatcher (commerce-demo phase 02, D005). Selects the reader by\n * `format` and yields the uniform `AsyncIterable<{ rowNumber, row }>` the runner\n * consumes — csv/tsv via the existing `streamFeed` (csv-parse), xlsx via the\n * exceljs-backed `streamXlsx`.\n *\n * **Back-compat**: with no `format` (the carmaker worker passes only `filePath`),\n * this defaults to tab-delimited — the original `streamFeed` behaviour — so the\n * existing import path is unchanged.\n *\n * **Bundle safety**: the xlsx reader (and its heavy, Node-only exceljs dep) is\n * `await import()`-ed ONLY for `format: 'xlsx'`, so the csv/tsv path — and anything\n * that statically imports the runner — never pulls exceljs into its module graph.\n */\n\nimport { z } from 'zod'\nimport { type StreamFeedOptions, type StreamFeedRow, streamFeed } from './streaming.js'\n\n/**\n * The reader-config subset safe to persist in `import_run.params.feed` and rehydrate\n * in the worker (commerce-demo PR3). It is exactly the {@link ReadFeedOptions} fields\n * that describe HOW to parse a feed — `filePath` is supplied by the worker after it\n * materialises the upload, never carried in params. Validated at BOTH ends: the\n * commerce run route builds it from a saved `import_mapping`, the worker re-parses it\n * before handing it to {@link readFeed} (defense-in-depth against a tampered row).\n */\nexport const feedReaderConfigSchema = z\n .object({\n format: z.enum(['csv', 'tsv', 'xlsx']).optional(),\n delimiter: z.string().min(1).max(4).optional(),\n hasHeader: z.boolean().optional(),\n headerRowIndex: z.number().int().min(0).optional(),\n sheet: z.string().min(1).max(128).optional(),\n })\n .strict()\nexport type FeedReaderConfig = z.infer<typeof feedReaderConfigSchema>\n\n/**\n * Rehydrate the reader config a run persisted in `import_run.params.feed`.\n *\n * ONE implementation, because two would drift and drift here is silent: the\n * worker uses it to decide how to READ a feed, and the #842 audit uses it to\n * re-read the same file the same way. An audit that rehydrates differently from\n * the run it is auditing invents corruption, or misses it.\n *\n * ABSENT means \"the carmaker default\", i.e. tab-delimited, which is what an\n * empty config parses to — so a non-object `params`, or `params` with no\n * `feed`, is a real answer rather than a gap. A PRESENT `feed` is validated\n * whatever its shape: a scalar or an array there is a malformed persisted blob,\n * and reading it as \"no config\" would silently import an xlsx run as tsv.\n */\nexport function readPersistedFeedConfig(\n params: unknown,\n): ReturnType<typeof feedReaderConfigSchema.safeParse> {\n const record =\n params && typeof params === 'object' && !Array.isArray(params)\n ? (params as Record<string, unknown>)\n : {}\n // `null` is how \"absent\" round-trips through JSONB, so it defaults with\n // `undefined`. Everything else present goes to the schema and is rejected on\n // its merits rather than being flattened into the default.\n const feed = record.feed === undefined || record.feed === null ? {} : record.feed\n return feedReaderConfigSchema.safeParse(feed)\n}\n\nexport interface ReadFeedOptions {\n /** Path to the file on local disk. */\n filePath: string\n /** Feed format. Omitted → tab-delimited (tsv), matching the legacy carmaker reader. */\n format?: 'csv' | 'tsv' | 'xlsx'\n /** Explicit delimiter override (csv/tsv). Defaults from `format` (`csv` → `,`, else `\\t`). */\n delimiter?: string\n /** First row is the header. Default `true`. */\n hasHeader?: boolean\n /** 0-based header row index (xlsx). */\n headerRowIndex?: number\n /** xlsx worksheet as a 1-based index string. Omit → first sheet. */\n sheet?: string\n /** Explicit column-name list (csv/tsv) — see `streamFeed`. */\n columns?: ReadonlyArray<string>\n /** Forward to csv-parse `relax_column_count` (csv/tsv). */\n relaxColumnCount?: boolean\n /** Forward to csv-parse `quote` (csv/tsv). */\n quote?: string | false\n}\n\nfunction toStreamFeedOptions(o: ReadFeedOptions): StreamFeedOptions {\n // No format → tab (the carmaker feed default). `csv` → comma; everything else → tab.\n const delimiter = o.delimiter ?? (o.format === 'csv' ? ',' : '\\t')\n return {\n filePath: o.filePath,\n delimiter,\n ...(o.hasHeader !== undefined && { hasHeader: o.hasHeader }),\n ...(o.columns !== undefined && { columns: o.columns }),\n ...(o.relaxColumnCount !== undefined && { relaxColumnCount: o.relaxColumnCount }),\n ...(o.quote !== undefined && { quote: o.quote }),\n }\n}\n\n/** Stream a feed file's data rows, dispatching on `format`. */\nexport async function* readFeed(options: ReadFeedOptions): AsyncIterable<StreamFeedRow> {\n if (options.format === 'xlsx') {\n const { streamXlsx } = await import('./xlsx-reader.js')\n yield* streamXlsx({\n filePath: options.filePath,\n ...(options.hasHeader !== undefined && { hasHeader: options.hasHeader }),\n ...(options.headerRowIndex !== undefined && { headerRowIndex: options.headerRowIndex }),\n ...(options.sheet !== undefined && { sheet: options.sheet }),\n })\n return\n }\n yield* streamFeed(toStreamFeedOptions(options))\n}\n","/**\n * Aggregates per-row errors into top-N pattern buckets so a feed of 1M\n * malformed rows surfaces as a handful of actionable signatures rather\n * than a million identical strings.\n *\n * Lifted from giga-test (`backend/src/workers/csv-importer.ts`) and\n * generalized:\n * - Configurable caps so the importer can tune memory bounds per run.\n * - Pure data — no logging side effects, no I/O.\n * - JSON-serialisable output via {@link ErrorTracker.snapshot} for the\n * `import_run.errorSummary` column.\n *\n * Pattern signature shape: `${errorType}:${field || 'GENERAL'}:${message}`.\n * Same `errorType + field + message` collapses to one bucket; differing\n * messages stay separate. This is intentional: a parser error on column\n * `NetPrice/Discount` (\"invalid number 'NA,5'\") and the same on column\n * `GrossPrice` are operationally distinct even if the parser is the same.\n *\n * Memory bounds: the patterns map is capped at `maxPatterns`. Once full,\n * additional NEW signatures are dropped — known patterns keep accumulating\n * counts. This is the \"top-N most common\" model: rare-but-novel errors\n * past the cap are invisible, but the cap protects against a runaway\n * adversarial feed exploding the map. Sample arrays are independently\n * capped at `maxSamplesPerPattern`.\n */\n\n/**\n * Recursively-defined JSON-serialisable value. Mirrors the shape of\n * `JsonValue` in `@murumets-ee/entity` without taking a dependency on\n * that package — error-tracker is otherwise standalone, and the\n * `ImportRun.errorSummary` JSONB column accepts anything in this shape.\n */\nexport type ImportJsonValue =\n | string\n | number\n | boolean\n | null\n | ImportJsonValue[]\n | { [key: string]: ImportJsonValue }\n\n/** One sample row attached to a pattern. `rowData` is the originally-parsed row. */\nexport interface ErrorSample {\n rowNumber: number\n rowData: ImportJsonValue\n}\n\n/** Public shape of an aggregated pattern as returned by {@link ErrorTracker.getTopPatterns}. */\nexport interface ErrorPattern {\n errorType: string\n field: string | null\n message: string\n count: number\n firstOccurrence: number\n lastOccurrence: number\n samples: ReadonlyArray<ErrorSample>\n /** `count / totalErrors`, scaled 0..100. `0` when there are zero errors total. */\n percentage: number\n}\n\nexport interface ErrorTrackerConfig {\n /** Hard cap on distinct signatures. Defaults to 50. New signatures past the cap are dropped. */\n maxPatterns?: number\n /** Hard cap on samples retained per pattern. Defaults to 5. Excess samples are dropped. */\n maxSamplesPerPattern?: number\n}\n\n/** Default caps — match giga-test for compatibility. */\nexport const DEFAULT_MAX_PATTERNS = 50\nexport const DEFAULT_MAX_SAMPLES_PER_PATTERN = 5\n\ninterface InternalPattern {\n errorType: string\n field: string | null\n message: string\n count: number\n firstOccurrence: number\n lastOccurrence: number\n samples: ErrorSample[]\n}\n\nexport class ErrorTracker {\n private readonly patterns = new Map<string, InternalPattern>()\n private readonly maxPatterns: number\n private readonly maxSamplesPerPattern: number\n private droppedSignatures = 0\n\n constructor(config: ErrorTrackerConfig = {}) {\n this.maxPatterns = config.maxPatterns ?? DEFAULT_MAX_PATTERNS\n this.maxSamplesPerPattern = config.maxSamplesPerPattern ?? DEFAULT_MAX_SAMPLES_PER_PATTERN\n }\n\n /**\n * Record one error. Same `(errorType, field, message)` triple bumps the\n * existing bucket; a new triple opens a new one (subject to {@link maxPatterns}).\n *\n * `field` is optional — pass `undefined` for errors not tied to a single\n * column (e.g. parse errors at row level). Internally normalised to the\n * literal string `'GENERAL'` so it shares a bucket with other genericised\n * errors of the same type+message.\n */\n addError(\n rowNumber: number,\n errorType: string,\n message: string,\n field: string | undefined,\n rowData: ImportJsonValue,\n ): void {\n const fieldKey = field ?? 'GENERAL'\n const signature = `${errorType}:${fieldKey}:${message}`\n\n let pattern = this.patterns.get(signature)\n if (!pattern) {\n if (this.patterns.size >= this.maxPatterns) {\n this.droppedSignatures += 1\n return\n }\n pattern = {\n errorType,\n field: field ?? null,\n message,\n count: 0,\n firstOccurrence: rowNumber,\n lastOccurrence: rowNumber,\n samples: [],\n }\n this.patterns.set(signature, pattern)\n }\n\n pattern.count += 1\n pattern.lastOccurrence = rowNumber\n if (pattern.samples.length < this.maxSamplesPerPattern) {\n pattern.samples.push({ rowNumber, rowData })\n }\n }\n\n /** Total count across every pattern. Counts errors, not patterns. */\n getTotalErrorCount(): number {\n let sum = 0\n for (const p of this.patterns.values()) sum += p.count\n return sum\n }\n\n /** Number of distinct signatures retained in the map (≤ `maxPatterns`). */\n getDistinctPatternCount(): number {\n return this.patterns.size\n }\n\n /**\n * Number of NEW signatures dropped because the map was already at\n * capacity. Surfacing this in the import_run summary tells the operator\n * \"the top-N was saturated — there's a long tail you're not seeing\".\n */\n getDroppedSignatureCount(): number {\n return this.droppedSignatures\n }\n\n /**\n * Top patterns sorted by descending count, capped at `maxPatterns`.\n * Stable secondary order is insertion order (Map iteration order is\n * insertion order; Array.sort is stable in V8).\n */\n getTopPatterns(): ErrorPattern[] {\n const all = Array.from(this.patterns.values()).sort((a, b) => b.count - a.count)\n const totalErrors = all.reduce((sum, p) => sum + p.count, 0)\n return all.map((p) => ({\n errorType: p.errorType,\n field: p.field,\n message: p.message,\n count: p.count,\n firstOccurrence: p.firstOccurrence,\n lastOccurrence: p.lastOccurrence,\n samples: p.samples.slice(),\n percentage: totalErrors > 0 ? (p.count / totalErrors) * 100 : 0,\n }))\n }\n\n /**\n * Compact JSON-serialisable snapshot for `import_run.errorSummary`.\n * Aside from the patterns array, includes the totals so a reader of\n * just this column doesn't have to re-derive them.\n */\n snapshot(): ErrorTrackerSnapshot {\n return {\n totalErrors: this.getTotalErrorCount(),\n distinctPatterns: this.getDistinctPatternCount(),\n droppedSignatures: this.droppedSignatures,\n patterns: this.getTopPatterns(),\n }\n }\n}\n\nexport interface ErrorTrackerSnapshot {\n totalErrors: number\n distinctPatterns: number\n droppedSignatures: number\n patterns: ErrorPattern[]\n}\n\n/**\n * Merge two independently-produced `ErrorTrackerSnapshot`s into one — e.g.\n * the runner's per-row `warningSummary` plus a worker-level signal computed\n * AFTER the run completes (F010 / #602: the price-drift anomaly check runs\n * in `worker.ts`, after `runImport` returns, using a fresh single-pattern\n * tracker of its own). Patterns concatenate rather than re-dedup: the two\n * snapshots are assumed to use disjoint `errorType` signature spaces (a\n * worker-level tracker uses a type — `price_anomaly` — the runner's own\n * warning aggregation never emits), so counts sum directly with no risk of\n * double-counting one bucket as two.\n */\nexport function mergeErrorTrackerSnapshots(\n a: ErrorTrackerSnapshot,\n b: ErrorTrackerSnapshot,\n): ErrorTrackerSnapshot {\n const totalErrors = a.totalErrors + b.totalErrors\n // Each source snapshot computed `percentage` against its OWN `totalErrors`\n // (see `getTopPatterns` above); the merged total is different, so every\n // pattern's `percentage` must be recomputed against it — a straight\n // concat would leave every value stale (e.g. `20/100=20%` staying `20`\n // once the true merged denominator is `101`). `0` when the merged total\n // is `0`, matching `getTopPatterns`' own zero-total convention.\n const rescale = (p: ErrorPattern): ErrorPattern => ({\n ...p,\n percentage: totalErrors > 0 ? (p.count / totalErrors) * 100 : 0,\n })\n return {\n totalErrors,\n distinctPatterns: a.distinctPatterns + b.distinctPatterns,\n droppedSignatures: a.droppedSignatures + b.droppedSignatures,\n patterns: [...a.patterns.map(rescale), ...b.patterns.map(rescale)],\n }\n}\n","/**\n * Price-distribution stats + prior-run anomaly detection (F010 / issue #602).\n *\n * The import pipeline's existing anomaly detection — register-handler.ts's\n * \"6b. Header-presence drift check\" — only catches STRUCTURAL drift: a\n * mapped column vanishing (or getting renamed) from the header row. It does\n * NOT catch VALUE drift: a supplier silently flipping their decimal\n * separator (e.g. `1.234,56` → `1234.56`) or thousands separator WITHOUT\n * touching a single column header. Since the saved\n * `import_mapping.numberFormat` (`{decimal, thousands}`) is unchanged,\n * every price in that run parses as a NUMERICALLY VALID but WRONG value —\n * silently corrupting the catalog's prices. Nothing upstream of this module\n * flags it.\n *\n * This module computes a per-run price-distribution snapshot (`PriceStats`)\n * over `PartsDocument.purchase_price_minor` — the DERIVED cost field,\n * rather than the raw net/list price columns, because both the direct-net\n * AND two-file derivation paths in `mapping/parts-schema.ts` (step 4) funnel\n * through it, so a decimal-format flip on EITHER source column surfaces\n * here regardless of which pricing path a supplier's feed uses.\n *\n * Collection (`PriceStatsCollector`) happens in the SAME streaming pass\n * `runner.ts` already uses to build `totals` — one `.record()` call per\n * ES-accepted row, no second pass over the file, no extra DB round-trip.\n * The prior-run comparison (`detectPriceAnomaly`) happens in `worker.ts`\n * AFTER the run completes, because it needs `AdminClient` to look the\n * prior run up — this module stays DB-agnostic, pure data + pure functions.\n */\n\n/** A run's price-distribution snapshot. Values are in MINOR units (cents), matching `PartsDocument.purchase_price_minor`'s own scale. */\nexport interface PriceStats {\n /** ES-accepted rows considered — same accounting as `RunImportResult.rowsSucceeded`. */\n count: number\n /** Fraction (0..1) of `count` whose price sample was absent (e.g. a list-only row with no resolvable purchase cost — the same rows that carry a `no_purchase_price` warning). `0` when `count` is `0`. */\n nullRate: number\n /** Minimum of the non-null samples, in minor units. `null` when there are zero non-null samples. */\n min: number | null\n /** Median of the non-null samples, in minor units. `null` when there are zero non-null samples. */\n median: number | null\n /** Maximum of the non-null samples, in minor units. `null` when there are zero non-null samples. */\n max: number | null\n}\n\n/** The all-zero/empty snapshot — a run that produced zero ES-accepted rows. */\nexport const EMPTY_PRICE_STATS: PriceStats = {\n count: 0,\n nullRate: 0,\n min: null,\n median: null,\n max: null,\n}\n\n/**\n * Bounded sample cap for the min/median/max computation — the same\n * \"bounded, uniform sample\" philosophy `ErrorTracker` uses top-N patterns\n * for. 50,000 floats is ~400KB, trivial next to the ES bulk-write buffers\n * already in flight for a run this size, and far more than needed for a\n * stable median estimate. A feed with more ES-accepted rows than this cap\n * still gets an EXACT `count` / `nullRate` (those are running counters, not\n * sample-bounded) — only min/median/max become an approximation over a\n * UNIFORM RANDOM sample of `PRICE_SAMPLE_CAP` non-null values, not \"the\n * first N encountered\" (see `record`'s reservoir-sampling note for why\n * order-independence matters specifically for this module's job).\n */\nexport const PRICE_SAMPLE_CAP = 50_000\n\n/**\n * Accumulates price samples during a run's streaming pass. `record` is O(1)\n * (amortized array push below the cap, one RNG draw above it); `snapshot`\n * sorts the retained samples once at the end — cheap relative to the ES\n * bulk-write the run already performs every batch.\n */\nexport class PriceStatsCollector {\n private count = 0\n private nullCount = 0\n private nonNullSeen = 0\n private readonly samples: number[] = []\n\n /**\n * Record one ES-accepted row's price sample. Call exactly once per\n * accepted row (mirrors the `rowsWarned` / `pairs` accounting in\n * `runner.ts`'s flush loop) — `undefined` means \"this row had no price to\n * sample\" and counts toward `nullRate` rather than being skipped.\n *\n * Reservoir sampling (Algorithm R) past the cap, not \"first N wins\": each\n * new non-null value replaces a uniformly-random existing slot with\n * probability `PRICE_SAMPLE_CAP / nonNullSeen`, giving every non-null\n * sample seen so far an equal chance of surviving into the final\n * reservoir. Order-independence matters here specifically — the anomaly\n * this module exists to catch (a supplier's decimal-format flip) could\n * start partway through a feed larger than the cap (e.g. a corrupted\n * re-export appended to an otherwise-normal catalogue), and a first-N\n * sample would never see a single row past the cutover point, silently\n * missing exactly the drift F010 was built to detect.\n */\n record(value: number | undefined): void {\n this.count += 1\n if (value === undefined) {\n this.nullCount += 1\n return\n }\n this.nonNullSeen += 1\n if (this.samples.length < PRICE_SAMPLE_CAP) {\n this.samples.push(value)\n return\n }\n const j = Math.floor(Math.random() * this.nonNullSeen)\n if (j < PRICE_SAMPLE_CAP) this.samples[j] = value\n }\n\n snapshot(): PriceStats {\n if (this.count === 0) return { ...EMPTY_PRICE_STATS }\n const nullRate = this.nullCount / this.count\n if (this.samples.length === 0) {\n return { count: this.count, nullRate, min: null, median: null, max: null }\n }\n const sorted = [...this.samples].sort((a, b) => a - b)\n const min = sorted[0] ?? null\n const max = sorted[sorted.length - 1] ?? null\n const mid = Math.floor(sorted.length / 2)\n let median: number | null\n if (sorted.length % 2 === 0) {\n const lo = sorted[mid - 1]\n const hi = sorted[mid]\n median = lo !== undefined && hi !== undefined ? (lo + hi) / 2 : null\n } else {\n median = sorted[mid] ?? null\n }\n return { count: this.count, nullRate, min, median, max }\n }\n}\n\n/**\n * Anomaly threshold: a run whose median purchase price is >= 10x higher OR\n * <= 1/10 (0.1x) of the prior successful run's median (for the same\n * transform + supplier + brand) is flagged.\n *\n * Chosen generously above the range a real repricing plausibly covers\n * (even an aggressive markup/discount renegotiation is a low-single-digit\n * multiplier — 2x-3x would already be an extreme outlier for a legitimate\n * price change) but comfortably BELOW the ~100x-1000x magnitude a\n * decimal/thousands-separator flip produces for typical EUR part prices:\n * `\"1.234,56\"` (a European-format €1234.56) misparsed with dot-decimal\n * conventions reads as `1.234,56` → `1234.56`, a ~1000x inflation if the\n * true value was e.g. €1.23; a thousands-separator flip on `\"1234,56\"`\n * (true €1234.56) misparsed as `123456` (dropping the decimal entirely) is\n * a ~100x inflation. 10x leaves a wide safety margin against false\n * positives on genuine price changes while still comfortably catching\n * every realistic decimal/thousands-separator flip.\n */\nexport const PRICE_ANOMALY_THRESHOLD_MULTIPLIER = 10\n\nexport interface PriceAnomaly {\n /** `curr.median / prev.median`. */\n ratio: number\n /** The prior run's median (minor units). */\n prevMedian: number\n /** This run's median (minor units). */\n currMedian: number\n}\n\n/**\n * Compare two runs' price stats for a magnitude-scale anomaly. Returns\n * `null` when there's nothing to compare (either side has no median — zero\n * non-null price samples, e.g. a schema/transform that never emits a price\n * sample, or a run with zero ES-accepted rows) or the ratio falls within\n * the safe band.\n */\nexport function detectPriceAnomaly(prev: PriceStats, curr: PriceStats): PriceAnomaly | null {\n if (prev.median === null || curr.median === null) return null\n // A zero/negative median can't produce a meaningful ratio. Shouldn't occur\n // for real minor-unit prices (`toMinorUnits` on a positive price); this is\n // defensive-only so a pathological value can't produce Infinity/NaN below.\n if (prev.median <= 0 || curr.median <= 0) return null\n const ratio = curr.median / prev.median\n if (\n ratio >= PRICE_ANOMALY_THRESHOLD_MULTIPLIER ||\n ratio <= 1 / PRICE_ANOMALY_THRESHOLD_MULTIPLIER\n ) {\n return { ratio, prevMedian: prev.median, currMedian: curr.median }\n }\n return null\n}\n\n/**\n * Narrow an `unknown` value (a `field.json()` column read back through\n * `AdminClient`, typed `JsonValue | undefined` on the DTO) to `PriceStats`,\n * or `null` when it doesn't structurally match — e.g. a run written before\n * this field existed, or a run whose transform never emitted a price\n * sample so `min`/`median`/`max` serialised as `null`. Defensive, matching\n * the `params` narrowing pattern already used in `worker.ts`.\n */\nexport function parsePriceStats(value: unknown): PriceStats | null {\n if (typeof value !== 'object' || value === null) return null\n const v = value as Record<string, unknown>\n if (typeof v.count !== 'number' || typeof v.nullRate !== 'number') return null\n return {\n count: v.count,\n nullRate: v.nullRate,\n min: typeof v.min === 'number' ? v.min : null,\n median: typeof v.median === 'number' ? v.median : null,\n max: typeof v.max === 'number' ? v.max : null,\n }\n}\n","/**\n * One-shot importer: stream rows → transform → batched bulk-write to ES,\n * accumulating per-row errors into `ErrorTracker` and reporting progress\n * to the queue every batch.\n *\n * Per PLAN-ECOMMERCE.md PR 7 (PoC scope):\n * - **Batch size 1000.** Matches giga-test precedent. Configurable for\n * integration tests that don't want a 1k row floor.\n * - **No resumability and no automatic retries.** The `imports:run` queue\n * job is registered with `defaultRetries: 0` (see `worker.ts`) so a\n * failed handler does NOT re-enqueue itself — re-running a multi-batch\n * import against the same `import_run.id` while the previous attempt\n * may still be writing is a footgun (duplicate batches, double-counted\n * progress). Operator retries by creating a NEW `import_run` row.\n * - **No per-supplier transform plugin.** The runner takes a single\n * `RowTransform<TDoc>` from the registry and applies it to every row;\n * PR 8 may diverge but only by registering a different transform name.\n * - **Direct `bulkUpsert` into the live aliased index** (D6 alias is\n * set up by PR 4's `ensureAliasedIndex`; the importer doesn't reindex).\n *\n * Per D21 (sanctioned bulk path): this runner intentionally bypasses\n * AdminClient and entity hooks. Per-batch audit / observability lives\n * on the surrounding `import_run` row + queue progress, NOT per-row.\n */\n\nimport {\n type BulkIndexResult,\n bulkPartialUpdate,\n bulkUpsert,\n type EsClientLike,\n} from '@murumets-ee/search-elasticsearch'\nimport { ErrorTracker } from './error-tracker.js'\nimport { type PriceStats, PriceStatsCollector } from './price-stats.js'\nimport { type ReadFeedOptions, readFeed } from './read-feed.js'\nimport type {\n DirectionalPair,\n DocPatch,\n RowTransform,\n RowWarning,\n TransformContext,\n} from './transform.js'\n\n/** Soft default; chosen to match giga-test. ES bulk requests over ~5MB get split server-side anyway. */\nexport const DEFAULT_BATCH_SIZE = 1000\n\nexport interface RunImportOptions<TDoc> {\n /** UUID of the `import_run` row driving this run. Forwarded to every transform invocation. */\n importRunId: string\n /** Operator-supplied label for the run. Forwarded to the transform context. */\n runLabel: string\n /** Opaque per-run params copied from `import_run.params`. */\n params: Record<string, unknown>\n /** Transform applied to every parsed row. */\n transform: RowTransform<TDoc>\n /** Feed reader options — file path + format (csv/tsv/xlsx) + delimiter/header config. */\n feed: ReadFeedOptions\n /** ES client (low-level shape from `@murumets-ee/search-elasticsearch`). */\n esClient: EsClientLike\n /** Index alias to write to. Per D6, callers always pass an alias, never a physical index. */\n esIndex: string\n /** Rows per `bulkUpsert` call. Default {@link DEFAULT_BATCH_SIZE}. */\n batchSize?: number\n /**\n * Callback invoked after every batch. The handler in `worker.ts`\n * forwards this to `ctx.updateProgress` for the queue UI; tests\n * inspect it directly. Synchronous + cheap so a slow callback can't\n * back-pressure the importer.\n */\n onProgress?: (progress: ImportRunProgress) => void\n /** Optional: stop processing after this many rows. Tests use it; production passes `undefined`. */\n rowLimit?: number\n /** Abort signal threaded into the underlying ES client request — cooperative cancel. */\n signal?: AbortSignal\n /** Optional ErrorTracker config (caps). Default: top-50 patterns × 5 samples. */\n errorTracker?: ErrorTracker\n /**\n * Optional separate tracker for NON-FATAL row warnings (commerce-demo PR4 —\n * `no_purchase_price`). Distinct from {@link errorTracker} so warnings never\n * inflate `totals.failed` / the error summary: a warned row is a SUCCESS whose\n * doc is indexed. Default: a fresh tracker with the same caps.\n */\n warningTracker?: ErrorTracker\n /**\n * Optional price-stats collector (F010 / #602) — accumulates a\n * min/median/max/nullRate distribution over ES-accepted rows' optional\n * `priceSample`s in the SAME streaming pass that builds `totals`. Default:\n * a fresh collector. Tests can inject their own to inspect it directly —\n * mirrors the {@link errorTracker} / {@link warningTracker} DI shape.\n */\n priceStats?: PriceStatsCollector\n /**\n * Optional structural logger for per-batch telemetry. Receives\n * `{ batch, ms, succeeded, failed, rowsRead, rowsPerSecond }` after\n * every flush, plus a one-shot `started` and `finished` event. The\n * worker forwards `app.logger.child({ pkg: 'imports' })`; tests\n * leave it undefined.\n *\n * Why: without per-batch visibility, a stalled run looks identical\n * to \"first batch in progress\" forever — the only feedback is the\n * queue's `progress` jsonb (which writes after each batch) and the\n * eventual `succeeded`/`failed` job event. Adding this lets the\n * operator tail the worker log and immediately see whether ES is\n * rate-limiting, the transform is rejecting every row, or the\n * stream is actually progressing.\n */\n logger?: {\n info: (data: Record<string, unknown>, msg: string) => void\n warn: (data: Record<string, unknown>, msg: string) => void\n }\n}\n\n/**\n * Progress payload written to `toolkit_jobs.progress` after every batch.\n * Caps + flush rules live on the queue's `updateProgress` debounce —\n * callers don't need to throttle.\n */\nexport interface ImportRunProgress {\n rowsRead: number\n rowsSucceeded: number\n rowsFailed: number\n rowsSkipped: number\n /** Rows indexed WITH a non-fatal warning (commerce-demo PR4 — `no_purchase_price`). */\n rowsWarned: number\n batchesCompleted: number\n /** Wall-clock seconds since the runner started. */\n elapsedSeconds: number\n /** Rows / second, computed at every batch. */\n rowsPerSecond: number\n /** Distinct error patterns currently held by the tracker. Saturates at the cap. */\n distinctErrorPatterns: number\n /**\n * Total data-row count from a pre-pass scan, when the worker computed one.\n * Lets the UI render `processed / total` percent. Absent when the worker\n * skipped the count (file too large for the count budget, count failed,\n * etc.) — UI must treat it as optional.\n */\n totalRows?: number\n}\n\n/**\n * Final result returned by {@link runImport}. The handler writes these\n * onto the `import_run` row alongside the ErrorTracker snapshot.\n */\nexport interface RunImportResult {\n /** Total rows read from the file (excludes skipped empty lines). */\n rowsRead: number\n /** Rows the transform turned into a successful doc AND the ES cluster acknowledged. */\n rowsSucceeded: number\n /**\n * Rows that the transform rejected (`{ kind: 'error' }`) OR that ES\n * rejected on bulk-write (per-doc failure). Both are aggregated by\n * `errorTracker` for the import_run summary.\n */\n rowsFailed: number\n /** Rows that the transform skipped (`{ kind: 'skip' }`) — header noise, blank lines, intentional drop. */\n rowsSkipped: number\n /**\n * Rows that succeeded (doc indexed) but carried ≥1 non-fatal warning —\n * counted SEPARATELY from `rowsSucceeded`/`rowsFailed` (a warned row is also\n * counted in `rowsSucceeded`). commerce-demo PR4: `no_purchase_price`.\n */\n rowsWarned: number\n /** Number of `bulkUpsert` calls made. */\n batchesCompleted: number\n /** Final value of {@link ErrorTracker.snapshot}. */\n errors: ReturnType<ErrorTracker['snapshot']>\n /** Aggregated NON-FATAL warnings (commerce-demo PR4). Empty when no row warned. */\n warnings: ReturnType<ErrorTracker['snapshot']>\n /**\n * Directional supersession pairs harvested as a side-output (commerce-demo PR6,\n * SD007). Buffered in memory from ES-ACCEPTED rows only (same accounting as\n * warnings — a row whose doc was rejected by ES contributes no pair). Empty unless\n * a schema emits them (the `parts` schema, when a row's `superseding_code` is set).\n * The worker hands these to an injected `pairSink` AFTER the run for the commerce\n * replacement-merge engine; the runner itself never writes the consumer's tables.\n */\n pairs: readonly DirectionalPair[]\n /**\n * Doc-patches applied by the phase-2 pass (commerce-demo PR7): a `<code>*S`\n * core-fee row is not a saleable doc — it emits a {@link DocPatch} that merges its\n * deposit onto the PARENT part's `core_charge_minor`. The runner buffers these\n * during the stream (sparse) and applies them via `bulkPartialUpdate` AFTER the ES\n * pass. `patchesApplied` = targets the cluster merged; `patchesOrphaned` = patches\n * whose parent genuinely wasn't in the index (a `document_missing`/404 → an\n * `orphan_core_charge` warning). An INFRA failure (whole-batch transport error, or a\n * non-404 per-doc failure) is NOT counted as orphaned — it lands in a distinct\n * `patch_attach_failed` warning bucket so it can't masquerade as a data orphan. Zero\n * unless a schema emits patches.\n */\n patchesApplied: number\n patchesOrphaned: number\n /**\n * Price-distribution snapshot (F010 / #602) accumulated from ES-accepted\n * rows' `priceSample`s during this same streaming pass. See `price-stats.ts`.\n * `count` is `0` (all fields `null`/`0`) for a run whose schema never emits\n * a price sample.\n */\n priceStats: PriceStats\n}\n\n/**\n * Apply the runner against a feed file. Stops on rowLimit OR end-of-file\n * OR if `signal` aborts. Throws if the streaming reader / ES client\n * throws — caller (the queue handler) catches that and writes\n * `import_run.status = 'failed'` with the error message in\n * `errorSummary.fatal`.\n */\nexport async function runImport<TDoc>(options: RunImportOptions<TDoc>): Promise<RunImportResult> {\n const {\n importRunId,\n runLabel,\n params,\n transform,\n feed,\n esClient,\n esIndex,\n batchSize = DEFAULT_BATCH_SIZE,\n onProgress,\n rowLimit,\n signal,\n errorTracker = new ErrorTracker(),\n warningTracker = new ErrorTracker(),\n priceStats: priceStatsCollector = new PriceStatsCollector(),\n logger,\n } = options\n\n if (batchSize < 1) {\n throw new Error(`batchSize must be >= 1 (got ${batchSize})`)\n }\n\n const startedAt = Date.now()\n logger?.info({ importRunId, runLabel, batchSize, esIndex }, 'imports.runner: started')\n let rowsRead = 0\n let rowsSucceeded = 0\n let rowsFailed = 0\n let rowsSkipped = 0\n let rowsWarned = 0\n let batchesCompleted = 0\n\n // Side-output buffer (commerce-demo PR6, SD007): directional supersession pairs\n // accumulated from ES-ACCEPTED rows across the whole run. An array append per\n // sparse superseded row — it never blocks the `bulkUpsert` loop. Handed to the\n // injected `pairSink` by the worker AFTER the run (read-once / merge-after).\n const collectedPairs: DirectionalPair[] = []\n\n // Side-output buffer (commerce-demo PR7): doc-patches from `*S` core-fee rows —\n // each attaches a core charge onto a DIFFERENT (parent) doc. Collected off the\n // `skip` result (a `*S` row is never a saleable doc), sparse, applied in a phase-2\n // `bulkPartialUpdate` after the ES pass (below) — order-independent because ES\n // `update` is realtime by `_id`.\n const collectedPatches: DocPatch<TDoc>[] = []\n\n // Warnings + pairs ride on the pending item (with the raw row for the warning\n // sample) so they're accounted at FLUSH time — only for rows ES actually accepted.\n // A warned row is by contract a SUCCESSFUL row, so counting it at push time (before\n // the bulk response) would wrongly keep a later-ES-rejected row in rowsWarned /\n // warnings; pairs follow the same accepted-rows-only rule.\n let pending: Array<{\n id: string\n doc: TDoc\n rowNumber: number\n rowData: Record<string, string>\n warnings?: readonly RowWarning[]\n pairs?: readonly DirectionalPair[]\n priceSample?: number\n }> = []\n\n const flush = async (): Promise<void> => {\n if (pending.length === 0) return\n const batch = pending\n pending = []\n const flushStartedAt = Date.now()\n let result: BulkIndexResult\n try {\n result = await bulkUpsert<TDoc>(esClient, {\n index: esIndex,\n docs: batch.map(({ id, doc }) => ({ id, doc })),\n ...(signal !== undefined && { signal }),\n })\n } catch (err) {\n // Cluster- or transport-level failure — the whole batch is\n // unaccounted for. Distinguish abort (operator-driven cancel) from\n // a real cluster failure so the errorSummary doesn't mislabel a\n // cancelled run as broken cluster connectivity.\n const isAbort =\n signal?.aborted ??\n (err instanceof Error && (err.name === 'AbortError' || /abort/i.test(err.message)))\n const errorType = isAbort ? 'aborted' : 'bulk_request_failed'\n const reason = err instanceof Error ? err.message : String(err)\n for (const { rowNumber } of batch) {\n errorTracker.addError(rowNumber, errorType, reason, undefined, null)\n }\n rowsFailed += batch.length\n throw err\n }\n\n rowsSucceeded += result.succeeded\n rowsFailed += result.failures.length\n batchesCompleted += 1\n\n const flushMs = Date.now() - flushStartedAt\n const elapsedSec = (Date.now() - startedAt) / 1000\n logger?.info(\n {\n importRunId,\n batch: batchesCompleted,\n flushMs,\n submitted: batch.length,\n succeeded: result.succeeded,\n failed: result.failures.length,\n rowsRead,\n rowsPerSecond: elapsedSec > 0 ? Math.round(rowsRead / elapsedSec) : 0,\n },\n 'imports.runner: batch flushed',\n )\n\n if (result.failures.length > 0) {\n // Map each ES failure back to its source row via `id`. The bulk\n // response order matches the request order, but ES doesn't promise\n // that; matching by `id` is the safe path. PoC volume is small\n // enough that the O(failures × batch) cost is irrelevant.\n const byId = new Map(batch.map((b) => [b.id, b.rowNumber]))\n for (const fail of result.failures) {\n const rowNumber = byId.get(fail.id) ?? -1\n errorTracker.addError(rowNumber, fail.type, fail.reason, undefined, { id: fail.id })\n }\n }\n\n // Account non-fatal warnings ONLY for rows ES accepted (commerce-demo PR4) — a\n // warned row is by contract a SUCCESSFUL row, so a row that warned AND then failed\n // the bulk write must NOT land in rowsWarned/warnings (it's already in rowsFailed).\n const failedIds = new Set(result.failures.map((f) => f.id))\n for (const item of batch) {\n if (!item.warnings || item.warnings.length === 0) continue\n if (failedIds.has(item.id)) continue\n rowsWarned += 1\n for (const w of item.warnings) {\n warningTracker.addError(item.rowNumber, w.warningType, w.message, w.field, item.rowData)\n }\n }\n\n // Buffer directional pairs for rows ES accepted (commerce-demo PR6, SD007) — same\n // accepted-rows-only rule as warnings, so a pair from a doc ES rejected is dropped.\n for (const item of batch) {\n if (!item.pairs || item.pairs.length === 0) continue\n if (failedIds.has(item.id)) continue\n collectedPairs.push(...item.pairs)\n }\n\n // Price-distribution sampling (F010 / #602) — same accepted-rows-only rule as\n // warnings/pairs: a row ES rejected contributes no sample. Every ES-accepted row\n // records exactly once (`undefined` counts toward `nullRate` rather than being\n // skipped), so `priceStats.count` tracks `rowsSucceeded` exactly.\n for (const item of batch) {\n if (failedIds.has(item.id)) continue\n priceStatsCollector.record(item.priceSample)\n }\n\n if (onProgress) {\n const elapsedSeconds = (Date.now() - startedAt) / 1000\n onProgress({\n rowsRead,\n rowsSucceeded,\n rowsFailed,\n rowsSkipped,\n rowsWarned,\n batchesCompleted,\n elapsedSeconds,\n rowsPerSecond: elapsedSeconds > 0 ? rowsRead / elapsedSeconds : 0,\n distinctErrorPatterns: errorTracker.getDistinctPatternCount(),\n })\n }\n }\n\n // Streaming heartbeat — sample every Nth row so the operator sees the\n // streamer is alive even when a transform-rejected file would prevent\n // any flush from firing. Without this, a feed that's 100% rejected\n // looks identical to \"stuck\" until end-of-file. Sample frequency is\n // batchSize so the cadence matches the natural batch rhythm.\n const streamSampleEvery = batchSize\n\n // First-error capture per error type — surface what's actually rejecting\n // the rows when 100% of a feed is rejected. Without this, the only signal\n // is `errorTracker.snapshot()` which only writes to import_run.errorSummary\n // at the END of the run. Mid-run we want to know NOW.\n const firstErrorByType = new Map<string, { rowNumber: number; message: string }>()\n\n for await (const { rowNumber, row } of readFeed(feed)) {\n if (signal?.aborted) break\n if (rowLimit !== undefined && rowsRead >= rowLimit) break\n rowsRead += 1\n\n if (rowsRead === 1) {\n logger?.info(\n { importRunId, rowNumber, sampleKeys: Object.keys(row) },\n 'imports.runner: STREAM_FIRST_ROW',\n )\n }\n if (rowsRead % streamSampleEvery === 0) {\n const elapsedSec = (Date.now() - startedAt) / 1000\n // Telemetry only — no row content, no error sample text.\n // PR #262 review (M-5): the prior `firstErrors:\n // Object.fromEntries(firstErrorByType)` payload included the\n // raw error message captured from the transform, which for the\n // carmaker feed embeds supplier codes + price fragments. The\n // detailed first-error breakdown is already persisted on\n // `import_run.errorSummary` via `errorTracker.snapshot()` —\n // operators inspect it there, not in the worker log.\n logger?.info(\n {\n importRunId,\n rowsRead,\n rowsSucceeded,\n rowsFailed,\n rowsSkipped,\n pending: pending.length,\n elapsedSec: Math.round(elapsedSec * 10) / 10,\n rowsPerSecond: elapsedSec > 0 ? Math.round(rowsRead / elapsedSec) : 0,\n distinctFirstErrorTypes: firstErrorByType.size,\n },\n 'imports.runner: STREAM_TICK',\n )\n }\n\n const ctx: TransformContext = { importRunId, params, runLabel, rowNumber }\n let result: Awaited<ReturnType<typeof transform>>\n try {\n result = await transform(row, ctx)\n } catch (err) {\n // A throw from the transform is a programmer error — surface it as\n // a row-level error so the run can continue. (If the bug is\n // catastrophic, the operator sees the same message repeated and\n // can stop the run.)\n const reason = err instanceof Error ? err.message : String(err)\n errorTracker.addError(rowNumber, 'transform_threw', reason, undefined, row)\n if (!firstErrorByType.has('transform_threw')) {\n firstErrorByType.set('transform_threw', { rowNumber, message: reason })\n // PR #262 review (M-5): drop the `row` payload from the\n // log line — supplier feeds carry pricing + internal codes.\n // The full row is on `import_run.errorSummary.samples` for\n // post-mortem review by operators with view permission.\n logger?.warn(\n { importRunId, rowNumber, errorType: 'transform_threw', message: reason },\n 'imports.runner: FIRST_ERROR transform_threw',\n )\n }\n rowsFailed += 1\n continue\n }\n\n if (result.kind === 'skip') {\n rowsSkipped += 1\n // A `*S` core-fee row skips the saleable index but may carry a doc-patch\n // (commerce-demo PR7) — buffer it for the phase-2 attach. Unlike pairs, a\n // patch has no ES write of its own to gate on, so it's collected here\n // unconditionally; its success is decided by the phase-2 update.\n if (result.patches && result.patches.length > 0) {\n collectedPatches.push(...result.patches)\n }\n continue\n }\n if (result.kind === 'error') {\n errorTracker.addError(\n rowNumber,\n result.error.errorType,\n result.error.message,\n result.error.field,\n row,\n )\n if (!firstErrorByType.has(result.error.errorType)) {\n firstErrorByType.set(result.error.errorType, {\n rowNumber,\n message: result.error.message,\n })\n logger?.warn(\n {\n importRunId,\n rowNumber,\n errorType: result.error.errorType,\n field: result.error.field,\n message: result.error.message,\n },\n `imports.runner: FIRST_ERROR ${result.error.errorType}`,\n )\n }\n rowsFailed += 1\n continue\n }\n\n // Non-fatal warnings ride on a SUCCESS — the doc is still indexed\n // (commerce-demo PR4: `no_purchase_price`). Carry them (+ the raw row for the\n // sample) on the pending item; the flush aggregates them into the separate\n // `warningTracker` AFTER the bulk response confirms the row was accepted, so a\n // warned-then-ES-rejected row never inflates rowsWarned / the warning summary.\n pending.push({\n id: result.id,\n doc: result.doc,\n rowNumber,\n rowData: row,\n ...(result.warnings && result.warnings.length > 0 && { warnings: result.warnings }),\n ...(result.pairs && result.pairs.length > 0 && { pairs: result.pairs }),\n ...(result.priceSample !== undefined && { priceSample: result.priceSample }),\n })\n if (pending.length >= batchSize) {\n await flush()\n }\n }\n\n await flush()\n\n // Phase 2 (commerce-demo PR7): attach the buffered `*S` core-charge patches onto\n // their PARENT docs. Runs AFTER the whole stream so every parent that exists in\n // this run is already indexed; ES `update` is realtime by `_id`, so the merge\n // lands regardless of whether the parent streamed before or after its `*S` row\n // (order-independent). Patches are sparse (only reman parts carry `*S`), but batch\n // anyway for a giant catalogue. A patch whose parent isn't in the index comes back\n // as a per-doc failure (`document_missing_exception`) → an `orphan_core_charge`\n // warning, never a phantom doc, never a silent drop (D004). A cluster failure of\n // the whole batch is logged as a warning too — the saleable docs already indexed;\n // only the deferred core-charge attach didn't happen — so we never fail the run\n // over it.\n let patchesApplied = 0\n let patchesOrphaned = 0\n for (let i = 0; i < collectedPatches.length; i += batchSize) {\n // Operator-driven cancel BETWEEN batches: THROW rather than break, so a run\n // cancelled after phase 1 is not marked `succeeded` with core charges left\n // unattached. Mirrors phase-1's flush, which also re-throws on abort. The queue\n // handler catches this and marks the run failed.\n if (signal?.aborted) {\n throw new Error('imports: run aborted during core-charge attach (phase 2)')\n }\n const slice = collectedPatches.slice(i, i + batchSize)\n let updateResult: BulkIndexResult\n try {\n updateResult = await bulkPartialUpdate<TDoc>(esClient, {\n index: esIndex,\n updates: slice.map(({ id, doc }) => ({ id, doc })),\n ...(signal !== undefined && { signal }),\n })\n } catch (err) {\n // An abort surfacing as an AbortError from the ES client is a cancel, NOT an\n // attach failure — re-throw so the run fails (same as phase 1), never a silent\n // \"succeeded with patches unapplied\".\n const isAbort =\n signal?.aborted ??\n (err instanceof Error && (err.name === 'AbortError' || /abort/i.test(err.message)))\n if (isAbort) throw err\n // Whole-batch transport/cluster failure (ES down, timeout). The parents may\n // well exist — the attach simply didn't happen — so this is an INFRA failure,\n // NOT a data orphan: it gets the distinct `patch_attach_failed` bucket and is\n // NOT counted in patchesOrphaned. Best-effort: the saleable docs already\n // indexed, and a re-import re-attaches, so we warn + continue rather than fail.\n const reason = err instanceof Error ? err.message : String(err)\n for (const patch of slice) {\n warningTracker.addError(-1, 'patch_attach_failed', reason, patch.orphanWarning?.field, {\n target: patch.id,\n })\n }\n continue\n }\n patchesApplied += updateResult.succeeded\n if (updateResult.failures.length > 0) {\n const byId = new Map(slice.map((p) => [p.id, p]))\n for (const fail of updateResult.failures) {\n const w = byId.get(fail.id)?.orphanWarning\n // A missing target (`document_missing_exception` / 404) is a genuine data\n // orphan — the referenced parent isn't in this import (D004: warn, never a\n // phantom doc). A stable message (from the patch's orphanWarning) aggregates\n // orphans; the missing parent id rides in the sample. Any OTHER per-doc\n // failure (mapper error, etc.) is an infra/attach failure kept in a distinct\n // bucket so it can't masquerade as a data orphan.\n const isOrphan = fail.status === 404 || fail.type === 'document_missing_exception'\n if (isOrphan) {\n patchesOrphaned += 1\n warningTracker.addError(\n -1,\n w?.warningType ?? 'orphan_patch',\n w?.message ?? fail.reason,\n w?.field,\n {\n target: fail.id,\n },\n )\n } else {\n warningTracker.addError(-1, 'patch_attach_failed', fail.reason, w?.field, {\n target: fail.id,\n })\n }\n }\n }\n }\n\n const totalSec = (Date.now() - startedAt) / 1000\n logger?.info(\n {\n importRunId,\n rowsRead,\n rowsSucceeded,\n rowsFailed,\n rowsSkipped,\n batchesCompleted,\n patchesApplied,\n patchesOrphaned,\n totalSec: Math.round(totalSec * 10) / 10,\n rowsPerSecond: totalSec > 0 ? Math.round(rowsRead / totalSec) : 0,\n },\n 'imports.runner: finished',\n )\n\n return {\n rowsRead,\n rowsSucceeded,\n rowsFailed,\n rowsSkipped,\n rowsWarned,\n batchesCompleted,\n errors: errorTracker.snapshot(),\n warnings: warningTracker.snapshot(),\n pairs: collectedPairs,\n patchesApplied,\n patchesOrphaned,\n priceStats: priceStatsCollector.snapshot(),\n }\n}\n"],"mappings":"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"}
@@ -1,2 +1,2 @@
1
- import{a as e,i as t,r as n}from"./parts-schema-D0gMhNWj.mjs";const r={name:`discount_table`,fields:[{key:`discount_group`,label:`Discount group`,required:!0,type:`string`},{key:`discount_pct`,label:`Discount %`,required:!0,type:`number`}],buildRow(n,r){let i=typeof n.discount_group==`string`?n.discount_group.trim():``;if(i===``)return e(`missing_discount_group`,`discount_group is empty`,`discount_group`);let a=t(n.discount_pct);return a.kind===`invalid`?e(`invalid_discount_pct`,`discount_pct is not a number`,`discount_pct`):a.kind===`absent`?e(`missing_discount_pct`,`discount_pct is empty`,`discount_pct`):a.value<0||a.value>100?e(`discount_pct_out_of_range`,`discount_pct ${a.value} is outside 0–100`,`discount_pct`):{kind:`success`,id:i,doc:{discountGroup:i,discountPct:a.value}}}},i={[n.name]:n,[r.name]:r};function a(e){return i[e]}function o(){return Object.keys(i)}function s(){return Object.values(i).map(e=>({name:e.name,fields:e.fields}))}export{r as i,a as n,o as r,s as t};
2
- //# sourceMappingURL=schema-registry-C_lQje-K.mjs.map
1
+ import{a as e,i as t,r as n}from"./parts-schema-BBUINSdN.mjs";const r={name:`discount_table`,fields:[{key:`discount_group`,label:`Discount group`,required:!0,type:`string`},{key:`discount_pct`,label:`Discount %`,required:!0,type:`number`}],buildRow(n,r){let i=typeof n.discount_group==`string`?n.discount_group.trim():``;if(i===``)return e(`missing_discount_group`,`discount_group is empty`,`discount_group`);let a=t(n.discount_pct);return a.kind===`invalid`?e(`invalid_discount_pct`,`discount_pct is not a number`,`discount_pct`):a.kind===`absent`?e(`missing_discount_pct`,`discount_pct is empty`,`discount_pct`):a.value<0||a.value>100?e(`discount_pct_out_of_range`,`discount_pct ${a.value} is outside 0–100`,`discount_pct`):{kind:`success`,id:i,doc:{discountGroup:i,discountPct:a.value}}}},i={[n.name]:n,[r.name]:r};function a(e){return i[e]}function o(){return Object.keys(i)}function s(){return Object.values(i).map(e=>({name:e.name,fields:e.fields}))}export{r as i,a as n,o as r,s as t};
2
+ //# sourceMappingURL=schema-registry-B_7AweTa.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"schema-registry-C_lQje-K.mjs","names":[],"sources":["../src/mapping/discount-table-schema.ts","../src/mapping/schema-registry.ts"],"sourcesContent":["/**\n * `discount_table` target schema — the non-parts feed kind (commerce-demo phase\n * 02 / 03). A discount table (R03 Sample B: `Code,Discount`) maps to\n * `{ discountGroup, discountPct }` rows.\n *\n * PR2 ships the schema (whitelist + `buildRow`) so the engine can parse + validate\n * a discount feed; the PERSISTENCE sink (upserting `supplier_discount_group` rows\n * + the two-file purchase-price derivation it feeds) lands in PR4. The `id` is the\n * discount group token so a re-import idempotently overwrites.\n */\n\nimport {\n type BuildRowContext,\n type MappedRow,\n numberCell,\n rowError,\n type TargetField,\n type TargetSchema,\n} from './target-schema.js'\n\n/** One parsed discount-table row: a group token + the purchase discount % off list. */\nexport interface DiscountTableRow {\n discountGroup: string\n discountPct: number\n}\n\nconst DISCOUNT_TABLE_FIELDS: readonly TargetField[] = [\n { key: 'discount_group', label: 'Discount group', required: true, type: 'string' },\n { key: 'discount_pct', label: 'Discount %', required: true, type: 'number' },\n]\n\nexport const discountTableSchema: TargetSchema<DiscountTableRow> = {\n name: 'discount_table',\n fields: DISCOUNT_TABLE_FIELDS,\n // params unused — a discount table is supplier-scoped at the run level, not per row.\n buildRow(mapped: MappedRow, _ctx: BuildRowContext) {\n // Group tokens are single tokens INCLUDING `'0'` (R03 Sample B) — a non-empty\n // string check, so `'0'` is a valid group.\n const group = typeof mapped.discount_group === 'string' ? mapped.discount_group.trim() : ''\n if (group === '') {\n return rowError('missing_discount_group', 'discount_group is empty', 'discount_group')\n }\n\n const pct = numberCell(mapped.discount_pct)\n if (pct.kind === 'invalid') {\n return rowError('invalid_discount_pct', 'discount_pct is not a number', 'discount_pct')\n }\n if (pct.kind === 'absent') {\n return rowError('missing_discount_pct', 'discount_pct is empty', 'discount_pct')\n }\n if (pct.value < 0 || pct.value > 100) {\n return rowError(\n 'discount_pct_out_of_range',\n `discount_pct ${pct.value} is outside 0–100`,\n 'discount_pct',\n )\n }\n\n return { kind: 'success', id: group, doc: { discountGroup: group, discountPct: pct.value } }\n },\n}\n","/**\n * Registry of the concrete target schemas (commerce-demo phase 02, D005).\n *\n * The engine resolves a mapping's `target_schema` name to its `TargetSchema` here.\n * An unregistered name is rejected (deny-by-default — the first injection-boundary\n * whitelist; the second is each schema's field set).\n */\n\nimport { discountTableSchema } from './discount-table-schema.js'\nimport { partsSchema } from './parts-schema.js'\nimport type { TargetField, TargetSchema } from './target-schema.js'\n\n/**\n * `TargetSchema<TOut>` is covariant in `TOut` (it appears only in `buildRow`'s\n * return), so a concrete schema is assignable to `TargetSchema<unknown>` — the\n * shape callers that don't know the output type (the `commerce:mapping` transform)\n * consume. Callers that DO know the type (the carmaker wrapper) import the concrete\n * schema directly to keep `RowTransform<PartsDocument>`.\n */\nconst SCHEMAS: Readonly<Record<string, TargetSchema<unknown>>> = {\n [partsSchema.name]: partsSchema,\n [discountTableSchema.name]: discountTableSchema,\n}\n\n/** Resolve a registered schema by name, or `undefined` if not registered. */\nexport function getTargetSchema(name: string): TargetSchema<unknown> | undefined {\n return SCHEMAS[name]\n}\n\n/** All registered schema names (for the wizard's schema picker, PR3). */\nexport function listTargetSchemas(): string[] {\n return Object.keys(SCHEMAS)\n}\n\n/** Operator-facing summary of a target schema: its name + the field whitelist (PR3 wizard). */\nexport interface TargetSchemaSummary {\n name: string\n fields: readonly TargetField[]\n}\n\n/**\n * Describe every registered target schema (name + field whitelist) for the PR3\n * mapping-wizard UI. Returns only metadata (no `buildRow`), so the wizard's RSC page\n * can ship it across the client boundary as plain JSON without dragging the engine\n * into the client bundle.\n */\nexport function describeTargetSchemas(): TargetSchemaSummary[] {\n return Object.values(SCHEMAS).map((s) => ({ name: s.name, fields: s.fields }))\n}\n"],"mappings":"8DA+BA,MAAa,EAAsD,CACjE,KAAM,iBACN,OAAQ,CANR,CAAE,IAAK,iBAAkB,MAAO,iBAAkB,SAAU,GAAM,KAAM,QAAS,EACjF,CAAE,IAAK,eAAgB,MAAO,aAAc,SAAU,GAAM,KAAM,QAAS,CAKnE,EAER,SAAS,EAAmB,EAAuB,CAGjD,IAAM,EAAQ,OAAO,EAAO,gBAAmB,SAAW,EAAO,eAAe,KAAK,EAAI,GACzF,GAAI,IAAU,GACZ,OAAO,EAAS,yBAA0B,0BAA2B,gBAAgB,EAGvF,IAAM,EAAM,EAAW,EAAO,YAAY,EAe1C,OAdI,EAAI,OAAS,UACR,EAAS,uBAAwB,+BAAgC,cAAc,EAEpF,EAAI,OAAS,SACR,EAAS,uBAAwB,wBAAyB,cAAc,EAE7E,EAAI,MAAQ,GAAK,EAAI,MAAQ,IACxB,EACL,4BACA,gBAAgB,EAAI,MAAM,mBAC1B,cACF,EAGK,CAAE,KAAM,UAAW,GAAI,EAAO,IAAK,CAAE,cAAe,EAAO,YAAa,EAAI,KAAM,CAAE,CAC7F,CACF,ECzCM,EAA2D,EAC9D,EAAY,MAAO,GACnB,EAAoB,MAAO,CAC9B,EAGA,SAAgB,EAAgB,EAAiD,CAC/E,OAAO,EAAQ,EACjB,CAGA,SAAgB,GAA8B,CAC5C,OAAO,OAAO,KAAK,CAAO,CAC5B,CAcA,SAAgB,GAA+C,CAC7D,OAAO,OAAO,OAAO,CAAO,CAAC,CAAC,IAAK,IAAO,CAAE,KAAM,EAAE,KAAM,OAAQ,EAAE,MAAO,EAAE,CAC/E"}
1
+ {"version":3,"file":"schema-registry-B_7AweTa.mjs","names":[],"sources":["../src/mapping/discount-table-schema.ts","../src/mapping/schema-registry.ts"],"sourcesContent":["/**\n * `discount_table` target schema — the non-parts feed kind (commerce-demo phase\n * 02 / 03). A discount table (R03 Sample B: `Code,Discount`) maps to\n * `{ discountGroup, discountPct }` rows.\n *\n * PR2 ships the schema (whitelist + `buildRow`) so the engine can parse + validate\n * a discount feed; the PERSISTENCE sink (upserting `supplier_discount_group` rows\n * + the two-file purchase-price derivation it feeds) lands in PR4. The `id` is the\n * discount group token so a re-import idempotently overwrites.\n */\n\nimport {\n type BuildRowContext,\n type MappedRow,\n numberCell,\n rowError,\n type TargetField,\n type TargetSchema,\n} from './target-schema.js'\n\n/** One parsed discount-table row: a group token + the purchase discount % off list. */\nexport interface DiscountTableRow {\n discountGroup: string\n discountPct: number\n}\n\nconst DISCOUNT_TABLE_FIELDS: readonly TargetField[] = [\n { key: 'discount_group', label: 'Discount group', required: true, type: 'string' },\n { key: 'discount_pct', label: 'Discount %', required: true, type: 'number' },\n]\n\nexport const discountTableSchema: TargetSchema<DiscountTableRow> = {\n name: 'discount_table',\n fields: DISCOUNT_TABLE_FIELDS,\n // params unused — a discount table is supplier-scoped at the run level, not per row.\n buildRow(mapped: MappedRow, _ctx: BuildRowContext) {\n // Group tokens are single tokens INCLUDING `'0'` (R03 Sample B) — a non-empty\n // string check, so `'0'` is a valid group.\n const group = typeof mapped.discount_group === 'string' ? mapped.discount_group.trim() : ''\n if (group === '') {\n return rowError('missing_discount_group', 'discount_group is empty', 'discount_group')\n }\n\n const pct = numberCell(mapped.discount_pct)\n if (pct.kind === 'invalid') {\n return rowError('invalid_discount_pct', 'discount_pct is not a number', 'discount_pct')\n }\n if (pct.kind === 'absent') {\n return rowError('missing_discount_pct', 'discount_pct is empty', 'discount_pct')\n }\n if (pct.value < 0 || pct.value > 100) {\n return rowError(\n 'discount_pct_out_of_range',\n `discount_pct ${pct.value} is outside 0–100`,\n 'discount_pct',\n )\n }\n\n return { kind: 'success', id: group, doc: { discountGroup: group, discountPct: pct.value } }\n },\n}\n","/**\n * Registry of the concrete target schemas (commerce-demo phase 02, D005).\n *\n * The engine resolves a mapping's `target_schema` name to its `TargetSchema` here.\n * An unregistered name is rejected (deny-by-default — the first injection-boundary\n * whitelist; the second is each schema's field set).\n */\n\nimport { discountTableSchema } from './discount-table-schema.js'\nimport { partsSchema } from './parts-schema.js'\nimport type { TargetField, TargetSchema } from './target-schema.js'\n\n/**\n * `TargetSchema<TOut>` is covariant in `TOut` (it appears only in `buildRow`'s\n * return), so a concrete schema is assignable to `TargetSchema<unknown>` — the\n * shape callers that don't know the output type (the `commerce:mapping` transform)\n * consume. Callers that DO know the type (the carmaker wrapper) import the concrete\n * schema directly to keep `RowTransform<PartsDocument>`.\n */\nconst SCHEMAS: Readonly<Record<string, TargetSchema<unknown>>> = {\n [partsSchema.name]: partsSchema,\n [discountTableSchema.name]: discountTableSchema,\n}\n\n/** Resolve a registered schema by name, or `undefined` if not registered. */\nexport function getTargetSchema(name: string): TargetSchema<unknown> | undefined {\n return SCHEMAS[name]\n}\n\n/** All registered schema names (for the wizard's schema picker, PR3). */\nexport function listTargetSchemas(): string[] {\n return Object.keys(SCHEMAS)\n}\n\n/** Operator-facing summary of a target schema: its name + the field whitelist (PR3 wizard). */\nexport interface TargetSchemaSummary {\n name: string\n fields: readonly TargetField[]\n}\n\n/**\n * Describe every registered target schema (name + field whitelist) for the PR3\n * mapping-wizard UI. Returns only metadata (no `buildRow`), so the wizard's RSC page\n * can ship it across the client boundary as plain JSON without dragging the engine\n * into the client bundle.\n */\nexport function describeTargetSchemas(): TargetSchemaSummary[] {\n return Object.values(SCHEMAS).map((s) => ({ name: s.name, fields: s.fields }))\n}\n"],"mappings":"8DA+BA,MAAa,EAAsD,CACjE,KAAM,iBACN,OAAQ,CANR,CAAE,IAAK,iBAAkB,MAAO,iBAAkB,SAAU,GAAM,KAAM,QAAS,EACjF,CAAE,IAAK,eAAgB,MAAO,aAAc,SAAU,GAAM,KAAM,QAAS,CAKnE,EAER,SAAS,EAAmB,EAAuB,CAGjD,IAAM,EAAQ,OAAO,EAAO,gBAAmB,SAAW,EAAO,eAAe,KAAK,EAAI,GACzF,GAAI,IAAU,GACZ,OAAO,EAAS,yBAA0B,0BAA2B,gBAAgB,EAGvF,IAAM,EAAM,EAAW,EAAO,YAAY,EAe1C,OAdI,EAAI,OAAS,UACR,EAAS,uBAAwB,+BAAgC,cAAc,EAEpF,EAAI,OAAS,SACR,EAAS,uBAAwB,wBAAyB,cAAc,EAE7E,EAAI,MAAQ,GAAK,EAAI,MAAQ,IACxB,EACL,4BACA,gBAAgB,EAAI,MAAM,mBAC1B,cACF,EAGK,CAAE,KAAM,UAAW,GAAI,EAAO,IAAK,CAAE,cAAe,EAAO,YAAa,EAAI,KAAM,CAAE,CAC7F,CACF,ECzCM,EAA2D,EAC9D,EAAY,MAAO,GACnB,EAAoB,MAAO,CAC9B,EAGA,SAAgB,EAAgB,EAAiD,CAC/E,OAAO,EAAQ,EACjB,CAGA,SAAgB,GAA8B,CAC5C,OAAO,OAAO,KAAK,CAAO,CAC5B,CAcA,SAAgB,GAA+C,CAC7D,OAAO,OAAO,OAAO,CAAO,CAAC,CAAC,IAAK,IAAO,CAAE,KAAM,EAAE,KAAM,OAAQ,EAAE,MAAO,EAAE,CAC/E"}
@@ -1,4 +1,4 @@
1
- import { r as FilePathResolver } from "./worker-DRTgbtT7.mjs";
1
+ import { r as FilePathResolver } from "./worker-DH3SN-91.mjs";
2
2
 
3
3
  //#region src/storage-resolver.d.ts
4
4
  /**
@@ -0,0 +1,2 @@
1
+ import{t as e}from"./rolldown-runtime-DK3Fl9T5.mjs";import{c as t,u as n}from"./parts-schema-BBUINSdN.mjs";import{n as r,r as i}from"./schema-registry-B_7AweTa.mjs";import{behavior as a,defineEntity as o,field as s,searchable as c}from"@murumets-ee/entity";import{z as l}from"zod";import{sql as u}from"drizzle-orm";var d=e({IMPORT_RUN_STATUSES:()=>f,ImportRun:()=>p});const f=[`pending`,`running`,`succeeded`,`failed`,`cancelled`],p=o({name:`import_run`,fields:{label:s.text({required:!0,maxLength:255}),status:s.select({options:f,default:`pending`,indexed:!0}),filePath:s.text({required:!0,maxLength:2048}),transformName:s.text({required:!0,maxLength:128,indexed:!0}),params:s.json(),totals:s.json(),errorSummary:s.json(),warningSummary:s.json(),priceStats:s.json(),startedAt:s.date(),finishedAt:s.date(),queueJobId:s.text({maxLength:64,indexed:!0})},behaviors:[a.auditable(),c({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}}),m={decimal:`.`,thousands:``},h=l.array(t).max(512),g=l.strictObject({columns:l.array(l.string().min(1).max(256)).min(1).max(256),rows:l.array(l.record(l.string().max(500))).max(5)});function _(e){if(e.headerSnapshot!==void 0&&e.headerSnapshot!==null){let t=g.safeParse(e.headerSnapshot);if(!t.success)throw Error(`Invalid headerSnapshot: ${t.error.issues[0]?.message??`bad shape`}`);let n=JSON.stringify(e.headerSnapshot).length;if(n>1e5)throw Error(`headerSnapshot too large (${n} > 100000 serialized chars) — trim sample rows/cells`)}let t=typeof e.targetSchema==`string`?e.targetSchema:``,a=r(t);if(!a)throw Error(`Unknown target schema "${t}" (registered: ${i().join(`, `)})`);let o=n.safeParse(e.numberFormat);if(!o.success)throw Error(`Invalid numberFormat: ${o.error.issues[0]?.message??`bad shape`}`);let s=h.safeParse(e.columnMap);if(!s.success)throw Error(`Invalid columnMap: ${s.error.issues[0]?.message??`bad shape`}`);let c=JSON.stringify(e.columnMap).length;if(c>1e5)throw Error(`columnMap too large (${c} > 100000 serialized chars)`);let l=new Set(a.fields.map(e=>e.key)),u=new Set,d=new Set;for(let e of s.data){if(!l.has(e.target))throw Error(`Mapping target "${e.target}" is not a field of schema "${a.name}" (allowed: ${[...l].join(`, `)})`);u.has(e.target)&&d.add(e.target),u.add(e.target)}if(d.size>0)throw Error(`Mapping for schema "${a.name}" maps a target more than once: ${[...d].join(`, `)}`);let f=new Set(s.data.map(e=>e.target)),p=a.fields.filter(e=>e.required&&!f.has(e.key)).map(e=>e.key);if(p.length>0)throw Error(`Mapping for schema "${a.name}" is missing required target(s): ${p.join(`, `)}`)}const v={name:`imports:import-mapping-validation`,hooks:{beforeCreate:async e=>(_({targetSchema:e.targetSchema,numberFormat:e.numberFormat??m,columnMap:e.columnMap??[],headerSnapshot:e.headerSnapshot}),e),beforeUpdate:async(e,t,n)=>{let r=await n?.loadCurrent?.()??{};return _({targetSchema:`targetSchema`in t?t.targetSchema:r.targetSchema,numberFormat:`numberFormat`in t?t.numberFormat:r.numberFormat??m,columnMap:`columnMap`in t?t.columnMap:r.columnMap??[],headerSnapshot:`headerSnapshot`in t?t.headerSnapshot:void 0}),t}}},y=[`parts`,`discount_table`],b=[`csv`,`tsv`,`xlsx`],x=o({name:`import_mapping`,scope:`global`,fields:{supplierId:s.reference({entity:`supplier`,required:!0,indexed:!0}),brandId:s.reference({entity:`brand`,indexed:!0}),targetSchema:s.select({options:y,required:!0,indexed:!0}),name:s.text({required:!0,maxLength:255}),format:s.select({options:b,required:!0}),delimiter:s.text({maxLength:4}),hasHeader:s.boolean({default:!0}),headerRowIndex:s.number({integer:!0,min:0,default:0}),sheet:s.text({maxLength:128}),numberFormat:s.json({default:{decimal:`.`,thousands:``}}),columnMap:s.json({default:[]}),headerSnapshot:s.json(),active:s.boolean({default:!0,indexed:!0})},behaviors:[a.auditable(),c({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:u`${r} IS NOT NULL`},{kind:`uniqueIndex`,name:`uq_import_mapping_supplier_schema_default`,on:[n,i],where:u`${r} IS NULL`}]}},v],admin:{group:`imports`,label:`Import mappings`,labelSingular:`Import mapping`,icon:`table`,hideFromMenu:!0}});var S=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)}set(e,t){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 C=Symbol.for(`@murumets-ee/imports:transforms`);function w(){let e=globalThis,t=e[C];return t||(t=new S,e[C]=t),t}function T(e,t){w().set(e,t)}export{y as a,v as c,d,b as i,f as l,w as n,x as o,T as r,_ as s,S as t,p as u};
2
+ //# sourceMappingURL=transform-DwAQ4b-j.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"transform-DwAQ4b-j.mjs","names":[],"sources":["../src/entities/import-run.ts","../src/entities/import-mapping-validation.ts","../src/entities/import-mapping.ts","../src/transform.ts"],"sourcesContent":["/**\n * ImportRun — one feed-import attempt. Tracks file, transform, status,\n * row totals, and the top error patterns surfaced by `ErrorTracker`.\n *\n * Per PLAN-ECOMMERCE.md PR 7 (PoC scope): generic plumbing entity. The\n * commerce-specific transform parameters (brandId, supplierId, supplier\n * code prefix) live in the opaque `params` JSONB so the imports package\n * stays free of commerce-domain dependencies. PR 8 wires a concrete\n * carmaker-feed transform that reads those params; PR 8a renders a\n * bespoke `/admin/commerce/imports` page over this entity's history.\n *\n * `hideFromMenu: true` because PR 7 ships only the entity + plumbing —\n * the operator-facing surface is PR 8a. The auto-EntityListPage at\n * `/admin/import_run` still resolves for direct lookups, but no sidebar\n * entry points to it.\n */\n\nimport { behavior, defineEntity, field, searchable } from '@murumets-ee/entity'\nimport type { AdminClient } from '@murumets-ee/entity/admin'\n\n/**\n * Lifecycle stages.\n *\n * - `pending` — row exists, queue job has been enqueued, worker hasn't picked it up yet.\n * - `running` — worker is streaming + bulk-writing.\n * - `succeeded` — worker finished cleanly (note: per-row failures still possible — see `totals.failed`).\n * - `failed` — worker threw and the queue marked the job dead. `errorSummary.fatal` carries the cause.\n * - `cancelled` — manual operator action via PR 8a (out of scope for PR 7).\n */\nexport const IMPORT_RUN_STATUSES = [\n 'pending',\n 'running',\n 'succeeded',\n 'failed',\n 'cancelled',\n] as const\nexport type ImportRunStatus = (typeof IMPORT_RUN_STATUSES)[number]\n\nexport const ImportRun = defineEntity({\n name: 'import_run',\n fields: {\n /** Operator-visible label, e.g. `\"MERCEDES — ME_20251027075918.txt\"`. Free-form. */\n label: field.text({ required: true, maxLength: 255 }),\n /** See {@link IMPORT_RUN_STATUSES}. */\n status: field.select({\n options: IMPORT_RUN_STATUSES,\n default: 'pending',\n indexed: true,\n }),\n /**\n * Path or storage key of the uploaded feed file. Generic string —\n * could be `/var/lumi/uploads/<id>.txt` for the local-disk PoC or an\n * S3 object key once storage adapter integration lands. The worker\n * reads this with the configured `readFeed` resolver.\n */\n filePath: field.text({ required: true, maxLength: 2048 }),\n /**\n * Name of the registered transform applied to each row. Resolved at\n * worker-dispatch time against the transform registry contributed by\n * the consumer. PoC: `'commerce:carmaker-feed'`.\n */\n transformName: field.text({ required: true, maxLength: 128, indexed: true }),\n /**\n * Opaque per-transform parameters. The carmaker transform expects\n * `{ brandId, supplierId, codePrefix? }`. Validation happens inside\n * the transform — the imports package never inspects this shape so\n * a new transform with different params doesn't require a schema\n * migration.\n */\n params: field.json(),\n /**\n * Row counters: `{ submitted, succeeded, failed, skipped, batches }`.\n * Updated by the worker as each batch completes. Final values are\n * what the operator reads; intermediate progress comes from\n * `toolkit_jobs.progress` via the queue UI.\n */\n totals: field.json(),\n /**\n * Output of `ErrorTracker.getTopPatterns(totalRows)` — the top-50\n * error signatures with up to 5 sample rows each. Empty `[]` until\n * the worker writes it on completion.\n */\n errorSummary: field.json(),\n /**\n * NON-FATAL warning patterns (commerce-demo PR4) — same\n * `ErrorTrackerSnapshot` shape as {@link errorSummary} but for advisories\n * on rows that STILL succeeded (their doc was indexed), e.g.\n * `no_purchase_price` (a part imported list-only, cost pending). Kept\n * separate from `errorSummary` so a warning never reads as a failed row.\n * Null/absent until a SUCCESSFUL run that produced warnings writes it — a\n * fatal failure (`markFailed`) abandons the run mid-stream and only sets\n * `errorSummary.fatal`, so the two summaries are not co-present.\n *\n * NOTE: because the warning aggregator reuses `ErrorTracker`, the persisted\n * JSON reuses its field names — the `totalErrors` key inside this blob is\n * really the WARNING count (and `patterns[].errorType` the warning type).\n */\n warningSummary: field.json(),\n /**\n * Price-distribution snapshot (F010 / issue #602) — `{ count, nullRate,\n * min, median, max }`, computed over ES-accepted rows' derived purchase\n * price (`PartsDocument.purchase_price_minor`, integer minor units) in\n * the SAME streaming pass that builds `totals`. See\n * `@murumets-ee/imports`' `price-stats.ts` for the exact shape and the\n * bounded-sample cap.\n *\n * The pipeline's existing 6b header-drift check (commerce's\n * register-handler.ts) only catches STRUCTURAL drift — a mapped column\n * vanishing from the header row. It does NOT catch a supplier silently\n * flipping their decimal/thousands separator, which changes no header\n * at all and produces numerically valid but silently WRONG prices. The\n * worker compares THIS run's `priceStats.median` against the most\n * recent prior SUCCEEDED run for the same (transformName, brandId,\n * supplierId) and, on a magnitude-scale shift (>=10x or <=0.1x — see\n * `PRICE_ANOMALY_THRESHOLD_MULTIPLIER`), appends a NON-FATAL\n * `price_anomaly` entry to {@link warningSummary}. Unlike the 6b 409,\n * this never blocks the run — a real repricing is a legitimate reason\n * prices would shift, so it's an advisory only.\n *\n * `null`/absent for runs written before this field existed, or for a\n * schema/transform whose rows never carry a price sample.\n */\n priceStats: field.json(),\n /** Set when the worker picks up the job. */\n startedAt: field.date(),\n /** Set when the worker finishes (success OR fatal failure). */\n finishedAt: field.date(),\n /**\n * `toolkit_jobs.id` of the queue job processing this run — link\n * back so PR 8a can show live progress without a second lookup.\n */\n queueJobId: field.text({ maxLength: 64, indexed: true }),\n },\n behaviors: [\n behavior.auditable(),\n // Operator-facing search: by label (e.g. \"MERCEDES — ME_*.txt\"),\n // transform name, or queue job id. No FTS — labels are short\n // alphanumeric tags, not prose.\n searchable({\n fields: ['label', 'transformName', 'queueJobId', 'status', 'filePath'],\n projection: (row) => ({\n id: row.id as string,\n label: row.label as string,\n description: `${row.transformName} · ${row.status}`,\n }),\n }),\n ],\n scope: 'global',\n admin: {\n group: 'imports',\n label: 'Import runs',\n labelSingular: 'Import run',\n icon: 'upload',\n hideFromMenu: true,\n },\n})\n\nexport type ImportRunClient = AdminClient<typeof ImportRun.allFields>\n","/**\n * Save-time validation for `import_mapping` rows (commerce-demo phase 02 §3/§5).\n *\n * This is the R02 injection boundary realised as an entity behavior: a mapping's\n * `columnMap` may only point at fields the chosen `TargetSchema` whitelists, and a\n * mapping is incomplete unless every REQUIRED target of that schema is mapped. Both\n * checks run on EVERY write path (the PR3 wizard's generic-CRUD `POST`, a future\n * seed, a direct `AdminClient` call) — \"validate at the boundary\", not \"validate in\n * the one UI we happen to ship\".\n *\n * The transform compiler (`buildMappingTransform`) re-checks the whitelist at run\n * time, so this is defense-in-depth — but failing at SAVE gives the operator the\n * error in the wizard (where they can fix it) instead of as a per-row run failure.\n *\n * Lives next to the entity (same package) and imports the mapping engine + schema\n * registry one-directionally (engine/registry never import the entity), so no cycle.\n */\n\nimport type { Behavior } from '@murumets-ee/entity'\nimport { z } from 'zod'\nimport { mappingColumnSchema, numberFormatSchema } from '../mapping/engine.js'\nimport { getTargetSchema, listTargetSchemas } from '../mapping/schema-registry.js'\n\n/** Default applied by the `numberFormat` field — used when a create omits it (defaults run after hooks). */\nconst DEFAULT_NUMBER_FORMAT = { decimal: '.', thousands: '' } as const\n\n// Bounded, like every JSONB the entity accepts: a legitimate column map has at\n// most one entry per source column (same order of magnitude as the snapshot's\n// 256-column cap), so an arbitrarily long array is a bloat vector, not data.\nconst columnMapSchema = z.array(mappingColumnSchema).max(512)\n\n/**\n * File-less edit snapshot (#667) — header columns + a few sample rows, same\n * `{ columns, rows: [{column → cell}] }` shape as the preview endpoint's\n * response. Bounded on every axis (these are DISPLAY samples, not data):\n * a snapshot that exceeds the caps should have been trimmed/omitted by the\n * wizard, so an oversized one here is a bypass attempt or a bug — reject, and\n * additionally hard-cap the serialized size so 256-wide × max-cell rows can't\n * park ~600 KB of JSONB per mapping row.\n *\n * Strictness matters: the behavior hook persists the RAW input, so a\n * non-strict parse would let unknown top-level keys ride into JSONB unmeasured\n * (zod strips them from `snap.data`, but that stripped copy isn't what's\n * stored).\n */\nconst headerSnapshotSchema = z.strictObject({\n columns: z.array(z.string().min(1).max(256)).min(1).max(256),\n rows: z.array(z.record(z.string().max(500))).max(5),\n})\n\n/**\n * Serialized-size ceiling for a stored snapshot (defense-in-depth over the\n * per-axis caps). Measured in UTF-16 code units (`JSON.stringify(...).length`)\n * — multi-byte content can serialize up to ~3× larger on disk; the bound holds\n * either way. Mirrored client-side by `toHeaderSnapshot` in commerce-ui\n * (pinned by its round-trip test).\n */\nexport const HEADER_SNAPSHOT_MAX_CHARS = 100_000\n\n/**\n * Serialized-size ceiling for the RAW `columnMap` input. Same rationale + same\n * raw-not-parsed measurement as the snapshot ceiling: `mappingColumnSchema` is\n * shared with the RUN-TIME params parser (engine.ts), so it can't be made\n * `.strict()` without risking previously-persisted `import_run.params` — this\n * save-path-only ceiling bounds what unknown entry keys / unbounded per-entry\n * `transforms` arrays can park in JSONB without touching run-time parsing. A\n * legitimate 256-column map with transform chains serializes well under this.\n */\nexport const COLUMN_MAP_MAX_CHARS = 100_000\n\n/**\n * Throw a descriptive error unless the mapping is structurally valid AND honours its\n * schema's field whitelist + required targets. The thrown message is operator-facing\n * (surfaced verbatim by the admin CRUD route as a 4xx body).\n */\nexport function assertValidMapping(input: {\n targetSchema: unknown\n numberFormat: unknown\n columnMap: unknown\n headerSnapshot?: unknown\n}): void {\n // Snapshot is optional metadata — validate only when present (absent/null =\n // \"no file-less edit support for this row\", a valid state for pre-#667 rows).\n if (input.headerSnapshot !== undefined && input.headerSnapshot !== null) {\n const snap = headerSnapshotSchema.safeParse(input.headerSnapshot)\n if (!snap.success) {\n throw new Error(`Invalid headerSnapshot: ${snap.error.issues[0]?.message ?? 'bad shape'}`)\n }\n // Measure the RAW input, not `snap.data` — the raw value is what the write\n // path persists, and it's identical to the parse only because the schema\n // is `.strict()`. Measuring the raw keeps that true even if strictness is\n // ever relaxed.\n const serialized = JSON.stringify(input.headerSnapshot).length\n if (serialized > HEADER_SNAPSHOT_MAX_CHARS) {\n throw new Error(\n `headerSnapshot too large (${serialized} > ${HEADER_SNAPSHOT_MAX_CHARS} serialized chars) — trim sample rows/cells`,\n )\n }\n }\n const targetSchema = typeof input.targetSchema === 'string' ? input.targetSchema : ''\n const schema = getTargetSchema(targetSchema)\n if (!schema) {\n throw new Error(\n `Unknown target schema \"${targetSchema}\" (registered: ${listTargetSchemas().join(', ')})`,\n )\n }\n\n const nf = numberFormatSchema.safeParse(input.numberFormat)\n if (!nf.success) {\n throw new Error(`Invalid numberFormat: ${nf.error.issues[0]?.message ?? 'bad shape'}`)\n }\n\n const cm = columnMapSchema.safeParse(input.columnMap)\n if (!cm.success) {\n throw new Error(`Invalid columnMap: ${cm.error.issues[0]?.message ?? 'bad shape'}`)\n }\n const columnMapSerialized = JSON.stringify(input.columnMap).length\n if (columnMapSerialized > COLUMN_MAP_MAX_CHARS) {\n throw new Error(\n `columnMap too large (${columnMapSerialized} > ${COLUMN_MAP_MAX_CHARS} serialized chars)`,\n )\n }\n\n const allowed = new Set(schema.fields.map((f) => f.key))\n const seenTargets = new Set<string>()\n const dupeTargets = new Set<string>()\n for (const col of cm.data) {\n if (!allowed.has(col.target)) {\n throw new Error(\n `Mapping target \"${col.target}\" is not a field of schema \"${schema.name}\" ` +\n `(allowed: ${[...allowed].join(', ')})`,\n )\n }\n // Two source columns mapping to the SAME target collapse last-write-wins at run\n // time (`mapped[col.target] = …`), silently importing wrong data. The wizard\n // blocks this client-side; enforce it here too so a direct API call can't bypass\n // it (validate at the boundary).\n if (seenTargets.has(col.target)) dupeTargets.add(col.target)\n seenTargets.add(col.target)\n }\n if (dupeTargets.size > 0) {\n throw new Error(\n `Mapping for schema \"${schema.name}\" maps a target more than once: ${[...dupeTargets].join(', ')}`,\n )\n }\n\n const mappedTargets = new Set(cm.data.map((c) => c.target))\n const missing = schema.fields\n .filter((f) => f.required && !mappedTargets.has(f.key))\n .map((f) => f.key)\n if (missing.length > 0) {\n throw new Error(\n `Mapping for schema \"${schema.name}\" is missing required target(s): ${missing.join(', ')}`,\n )\n }\n}\n\n/**\n * Behavior wrapping {@link assertValidMapping}. Typed `Behavior<Record<never,\n * never>, …>` (NOT plain `Behavior`, whose wide `fields?` would collapse the\n * entity's inferred field types — the documented no-field-behavior gotcha).\n * Contributes a hook, no fields.\n */\nexport const importMappingValidation: Behavior<\n Record<never, never>,\n 'imports:import-mapping-validation'\n> = {\n name: 'imports:import-mapping-validation',\n hooks: {\n beforeCreate: async (data) => {\n // Field defaults run AFTER hooks, so an omitted numberFormat/columnMap is the\n // field default here — mirror those defaults so a minimal create validates\n // against the same effective config the row will hold.\n assertValidMapping({\n targetSchema: data.targetSchema,\n numberFormat: data.numberFormat ?? DEFAULT_NUMBER_FORMAT,\n columnMap: data.columnMap ?? [],\n headerSnapshot: data.headerSnapshot,\n })\n return data\n },\n beforeUpdate: async (_id, data, ctx) => {\n // A PATCH is partial — validate the EFFECTIVE config (patch overlaid on the\n // current row), so changing only `name` doesn't escape validation and changing\n // only `columnMap` is still checked against the persisted `targetSchema`.\n const current = (await ctx?.loadCurrent?.()) ?? {}\n assertValidMapping({\n targetSchema: 'targetSchema' in data ? data.targetSchema : current.targetSchema,\n numberFormat:\n 'numberFormat' in data\n ? data.numberFormat\n : (current.numberFormat ?? DEFAULT_NUMBER_FORMAT),\n columnMap: 'columnMap' in data ? data.columnMap : (current.columnMap ?? []),\n // Only the incoming value needs checking — the persisted snapshot was\n // validated when it was written.\n headerSnapshot: 'headerSnapshot' in data ? data.headerSnapshot : undefined,\n })\n return data\n },\n },\n}\n","/**\n * ImportMapping — a reusable, per-(supplier, brand) column-mapping profile that\n * drives the configurable feed-import engine (commerce-demo D005, phase 02).\n *\n * Replaces the hardcoded 14/15-column carmaker transform with operator-editable\n * mappings: which source column → which whitelisted target field, per-column\n * cleaning transforms, the file format (csv/tsv/xlsx), and the number format\n * (decimal + thousands separators — real supplier feeds vary, see R03).\n *\n * **Why this lives in `@murumets-ee/imports`, not `@murumets-ee/commerce`**\n * (D005 cycle constraint): the engine + the concrete target schemas need the\n * commerce `PartsDocument` shape, and `imports` already depends on\n * `@murumets-ee/search-elasticsearch` for it. Putting the mapping in commerce\n * would force `commerce → imports` and a forbidden import cycle. The entity\n * references the commerce `supplier` / `brand` entities **by name string**\n * (`field.reference({ entity: 'supplier' })`) — no Entity-object import, so the\n * leaf-package boundary holds.\n *\n * **Resolution** (most-specific, D002 ladder): for an upload tagged\n * `(supplier, brand)`, pick the `(supplier, brand)` mapping if present, else the\n * `(supplier, NULL)` supplier-default. See `resolveMapping()` in the engine.\n *\n * `hideFromMenu: true` — the mapping CRUD wizard lands in commerce-demo PR3. The\n * auto-EntityListPage at `/admin/import_mapping` still resolves for direct lookups\n * and for the admin-only AdminClient writes this engine relies on.\n */\n\nimport type { Behavior, SchemaFragment, SchemaTarget } from '@murumets-ee/entity'\nimport { behavior, defineEntity, field, searchable } from '@murumets-ee/entity'\nimport type { AdminClient } from '@murumets-ee/entity/admin'\nimport { sql } from 'drizzle-orm'\nimport type { PgColumn } from 'drizzle-orm/pg-core'\nimport { importMappingValidation } from './import-mapping-validation.js'\n\n/**\n * Registered target schemas a mapping may write into. This is the FIRST of two\n * whitelists (the second is each schema's target-field set) — a mapping whose\n * `targetSchema` is outside this list is rejected (deny-by-default, R02).\n */\nexport const IMPORT_MAPPING_TARGET_SCHEMAS = ['parts', 'discount_table'] as const\nexport type ImportMappingTargetSchema = (typeof IMPORT_MAPPING_TARGET_SCHEMAS)[number]\n\n/** File formats the reader dispatches on. `csv`/`tsv` → csv-parse; `xlsx` → exceljs. */\nexport const IMPORT_MAPPING_FORMATS = ['csv', 'tsv', 'xlsx'] as const\nexport type ImportMappingFormat = (typeof IMPORT_MAPPING_FORMATS)[number]\n\n/**\n * Declarative DDL: one mapping per `(supplier, brand, target_schema)`, enforced by\n * TWO PARTIAL unique indexes rather than one plain composite unique.\n *\n * Why partial: a plain `UNIQUE (supplierId, brandId, targetSchema)` treats NULL\n * `brandId` as distinct (Postgres NULL-distinct semantics), so it would allow MANY\n * supplier-default rows (`brandId IS NULL`) for the same `(supplier, schema)` —\n * ambiguous, since the engine resolves `brandId === null` as THE supplier default\n * (`resolveMapping`). Splitting it:\n * - brand-specific rows (`brandId IS NOT NULL`): unique on `(supplier, brand, schema)`;\n * - supplier-default rows (`brandId IS NULL`): unique on `(supplier, schema)`.\n *\n * The `where` predicates use raw `sql` with the live Drizzle column reference (so the\n * emitted identifier is correct regardless of casing) — `IS NULL` / `IS NOT NULL`,\n * NOT `eq(col, …)` which would serialize as an invalid bound `$1` in DDL.\n *\n * Typed `Behavior<Record<never, never>, …>` (NOT plain `Behavior`, whose wide\n * `fields?` would collapse the entity's inferred field types to `any` — the\n * documented no-field-behavior gotcha). Contributes constraints, no fields.\n */\nconst importMappingUnique: Behavior<Record<never, never>, 'imports:import-mapping-unique'> = {\n name: 'imports:import-mapping-unique',\n constraints: (table: Record<string, PgColumn>, target: SchemaTarget): SchemaFragment[] => {\n if (target !== 'main') return []\n const supplierId = table.supplierId\n const brandId = table.brandId\n const targetSchema = table.targetSchema\n // Defensive: only emit when all three columns exist.\n if (!supplierId || !brandId || !targetSchema) return []\n return [\n {\n kind: 'uniqueIndex',\n name: 'uq_import_mapping_supplier_brand_schema',\n on: [supplierId, brandId, targetSchema],\n where: sql`${brandId} IS NOT NULL`,\n },\n {\n kind: 'uniqueIndex',\n name: 'uq_import_mapping_supplier_schema_default',\n on: [supplierId, targetSchema],\n where: sql`${brandId} IS NULL`,\n },\n ]\n },\n}\n\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 /**\n * File-less edit snapshot (#667): `{ columns: string[], rows: [{column →\n * cell}] }` — the sample feed's header + a few sample rows, captured from\n * the wizard's preview at save time. Lets the editor re-open a saved\n * mapping WITHOUT re-uploading the (potentially huge) feed just to tweak a\n * transform. NULL on rows saved before this field existed (and when the\n * preview exceeded the size caps) — the editor falls back to the\n * upload-first flow. Staleness is benign: a source column that later\n * vanishes from the real feed is caught at run time by the 6b drift check\n * (409) / row errors, not by this snapshot. Size-capped by\n * `import-mapping-validation`.\n */\n headerSnapshot: field.json(),\n /** Inactive mappings are skipped by resolution. */\n active: field.boolean({ default: true, indexed: true }),\n },\n behaviors: [\n behavior.auditable(),\n // Operator-facing search (PR3 wizard): by profile name, schema, or format.\n // No `fts` → IlikeProvider, so this adds NO DDL (the 0036 migration is\n // unchanged). Names are short tags, not prose.\n searchable({\n fields: ['name', 'targetSchema', 'format'],\n projection: (row) => ({\n id: row.id as string,\n label: row.name as string,\n description: `${row.targetSchema} · ${row.format}`,\n }),\n }),\n importMappingUnique,\n // Save-time whitelist + required-target validation (R02 injection boundary).\n importMappingValidation,\n ],\n admin: {\n group: 'imports',\n label: 'Import mappings',\n labelSingular: 'Import mapping',\n icon: 'table',\n hideFromMenu: true,\n },\n})\n\nexport type ImportMappingClient = AdminClient<typeof ImportMapping.allFields>\n","/**\n * Per-feed transform plugin interface (PLAN-ECOMMERCE.md PR 7 / D14).\n *\n * The streaming reader reads raw rows out of the file (column key →\n * string). The transform turns one row into a typed `OutputDoc` ready\n * for the bulk-write surface (PoC: `PartsDocument` for the parts ES\n * index). The transform is what makes a generic feed-importer\n * commerce-aware — without it, the importer has no opinion on what a\n * row \"means\".\n *\n * Why an interface, not a function:\n * - The transform may need to reject a row (`RowSkip`) without that\n * counting as an error in `ErrorTracker` — e.g. blank lines, header\n * rows mistakenly retained, or \"this is a replacement-code marker\n * row, handled out-of-band\" decisions.\n * - The transform may need to fail a row (`RowError`) with a typed\n * error class so `ErrorTracker` collapses them by `errorType`.\n * - The transform may need access to the run-level params (brand /\n * supplier IDs, code prefix, batch ID) without those being\n * re-derived per row.\n *\n * PR 7 ships only the interface + a registry. PR 8 ships the first\n * concrete carmaker-feed transform.\n */\n\n/** Identifies which transform a registered handler implements. Stable across deploys. */\nexport type TransformName = string\n\n/** Per-run context provided to every row call. Kept narrow on purpose. */\nexport interface TransformContext {\n /** UUID of the `import_run` row. Forward to the output doc as `import_batch_id` for source attribution (D20). */\n importRunId: string\n /** Opaque per-run params copied from `import_run.params`. The transform validates the shape it expects. */\n params: Record<string, unknown>\n /** Operator-supplied label, useful for logging / debugging. */\n runLabel: string\n /** 1-based row number across the whole feed (header counts as row 0). Forward to `RowError.rowNumber`. */\n rowNumber: number\n}\n\n/**\n * A directional supersession pair harvested from a row as a SIDE-OUTPUT\n * (commerce-demo PR6, SD007). The edge ALWAYS runs `from` (older) → `to` (newer/current).\n *\n * Which end the emitting row occupies depends on which supersession column the mapping used,\n * so neither end can be described as \"the row's own code\": a `superseding_code` column names\n * the newer code and puts the row at `from` (the row is the obsolete one), while a\n * `replaces_code` column names the older code and puts the row at `to` (the row is current).\n * Both codes are already NORMALIZED (same normalization as the doc's `code_normalized`) so a\n * downstream equivalence-group merge collides codes correctly.\n *\n * This is PURE DATA. The runner buffers pairs in memory during the single ES pass\n * (an array append — it never blocks the `bulkUpsert` loop) and hands the buffer to\n * an INJECTED post-run sink (the commerce replacement-merge engine). The\n * `@murumets-ee/imports` leaf never writes the consumer's Postgres tables itself —\n * the boundary holds because the sink is injected, not imported.\n */\nexport interface DirectionalPair {\n /** The superseded (older) normalized code. */\n from: string\n /** The superseding (newer / current) normalized code. */\n to: string\n}\n\n/**\n * A partial-document PATCH emitted as a side-output (commerce-demo PR7): the row\n * itself produces no saleable doc, but it carries a partial update that must land\n * on a DIFFERENT doc — identified by `id` (the same `<code_normalized>__<supplier>`\n * key the parts sink uses). The runner buffers these during the single stream and,\n * AFTER the ES pass, applies them via a phase-2 `bulkPartialUpdate` (an ES `update`\n * merge, realtime by `_id` → order-independent). A patch whose target doesn't exist\n * surfaces as an `orphan_*` warning, never a phantom doc.\n *\n * Canonical use: a `<code>*S` core-fee row (D004) attaches its deposit to the parent\n * part's `core_charge_minor`. Kept GENERIC (`Partial<TDoc>`) so the runner never\n * imports a schema-specific doc type — the same leaf-clean shape as {@link DirectionalPair}.\n */\nexport interface DocPatch<TDoc> {\n /** The `_id` of the doc to merge into (a different row's doc than the one that emitted this). */\n id: string\n /** The partial fields to merge onto that doc. */\n doc: Partial<TDoc>\n /**\n * The warning to record if the target `id` isn't in the index (the phase-2\n * update 404s). The runner detects the orphan (it can't be known at buildRow\n * time — the parent may stream later) but the SCHEMA owns the bucket name +\n * message, so this rides on the patch. Absent → a generic `orphan_patch`\n * fallback. For `*S` core-fee rows the parts schema sets `orphan_core_charge`\n * (D004: warn, never silently drop).\n */\n orphanWarning?: RowWarning\n}\n\n/**\n * One of: a successful `OutputDoc` keyed by a stable `id`, an\n * intentional `skip` (counted in `totals.skipped`), or a row-level\n * error (counted in `totals.failed` AND aggregated by `ErrorTracker`).\n *\n * The `id` field on `success` is used as the bulk-upsert primary key\n * (`_id` in ES). Per D4 the parts index uses\n * `<code_normalized>__<supplier_id>` so a re-run of the same feed\n * idempotently overwrites instead of duplicating.\n *\n * **`warnings` on `success`** (commerce-demo PR4): a row can be a valid,\n * indexable doc AND still carry a non-fatal advisory — e.g. a part imported\n * with `list_price` but no resolvable purchase cost (`no_purchase_price`,\n * phase 03 §5). The runner indexes the doc (it's a success, NOT a failure)\n * and separately aggregates the warnings into a `warningSummary`, so the\n * operator sees \"120 rows cost-pending\" without those rows being dropped or\n * mis-counted as failures. Empty/absent `warnings` is the common case.\n *\n * **`pairs` on `success`** (commerce-demo PR6, SD007): a row can ALSO carry\n * directional supersession {@link DirectionalPair}s as a side-output — the `parts`\n * schema emits one when a row's `superseding_code` is present (a SPARSE column on a\n * full manufacturer catalogue, so most rows emit none). The runner buffers these\n * from ES-accepted rows and returns them for the injected `pairSink`. Empty/absent\n * `pairs` is the common case.\n *\n * **`patches` on `skip`** (commerce-demo PR7): a `<code>*S` core-fee row is NOT a\n * saleable doc (so it `skip`s the main index), but it carries a {@link DocPatch}\n * side-output that attaches its deposit to the PARENT part's `core_charge_minor`.\n * The runner buffers these (sparse — only reman parts carry `*S`) and applies them\n * in a phase-2 `bulkPartialUpdate` AFTER the stream; an unresolved parent becomes an\n * `orphan_core_charge` warning (D004). Empty/absent `patches` is the common case.\n *\n * **`priceSample` on `success`** (F010 / #602): an optional numeric price value,\n * in whatever unit scale the schema itself uses (the `parts` schema emits\n * `purchase_price_minor`, integer minor units), sampled for RUN-LEVEL value-drift\n * detection — see `price-stats.ts`. The runner accumulates these from ES-accepted\n * rows into a `PriceStats` distribution snapshot (min/median/max/nullRate) via the\n * SAME streaming pass that builds `totals`, no second pass over the file. A schema\n * that never emits `priceSample` (or a row with no resolvable price) simply omits\n * it — the collector counts that toward `nullRate` rather than treating it as an\n * error. Absent is the common case for schemas with no price notion at all.\n */\nexport type RowResult<TDoc> =\n | {\n kind: 'success'\n id: string\n doc: TDoc\n warnings?: readonly RowWarning[]\n pairs?: readonly DirectionalPair[]\n priceSample?: number\n }\n | { kind: 'skip'; reason: string; patches?: readonly DocPatch<TDoc>[] }\n | { kind: 'error'; error: RowError }\n\n/** Row-level error. The aggregator uses `(errorType, field, message)` as the dedup signature. */\nexport interface RowError {\n errorType: string\n message: string\n field?: string | undefined\n}\n\n/**\n * A non-fatal advisory attached to a SUCCESSFUL row (the doc is still\n * indexed). Same `(warningType, field, message)` dedup signature shape as\n * {@link RowError} so the runner can aggregate warnings through an\n * `ErrorTracker` instance into `import_run.warningSummary`.\n */\nexport interface RowWarning {\n warningType: string\n message: string\n field?: string | undefined\n}\n\n/**\n * The contract every per-feed transform implements. Pure function of\n * `(row, ctx) → RowResult` — no I/O. Async only because the future\n * carmaker variant might consult an in-memory taxonomy lookup.\n */\nexport type RowTransform<TDoc> = (\n rawRow: Record<string, string>,\n ctx: TransformContext,\n) => Promise<RowResult<TDoc>>\n\n/**\n * Holds the registered transforms for the current process. Each plugin\n * that ships a transform calls {@link TransformRegistry.register} from\n * its `init` hook.\n *\n * Per CLAUDE.md \"Whitelist, don't blacklist\": the worker dispatches\n * by `import_run.transformName` against this registry. An unregistered\n * name fails the run rather than running with a default transform.\n *\n * **Production callers MUST go through {@link getTransformRegistry}**, not\n * `new TransformRegistry()`. The constructor is exposed only so tests\n * (and rare custom-worker embeds) can build an isolated instance and\n * inject it into {@link createRunImportHandler} directly. Two `new`\n * instances do NOT share state — registering a transform on one will\n * NOT make it visible to a worker resolving against the other. The\n * package's queue handler (registered by `imports()` plugin) always\n * resolves against the singleton.\n */\nexport class TransformRegistry {\n private readonly entries = new Map<TransformName, RowTransform<unknown>>()\n\n /**\n * Register a transform under a stable name. Throws on duplicate\n * names so two plugins can't silently overwrite each other (same\n * fail-loud collision protection that `combineAdminRoutes` enforces\n * for duplicate `(prefix, method, path)` route registrations).\n */\n register<TDoc>(name: TransformName, transform: RowTransform<TDoc>): void {\n if (this.entries.has(name)) {\n throw new Error(\n `Transform \"${name}\" is already registered — two plugins cannot register the same transform name.`,\n )\n }\n this.entries.set(name, transform as RowTransform<unknown>)\n }\n\n /**\n * Last-wins registration — overwrites an existing entry instead of\n * throwing. The {@link registerImportTransform} consumer-app path uses\n * this (same semantics as `@murumets-ee/queue/client`'s `registerJob`):\n * under dev HMR a re-evaluated consumer module re-registers against the\n * surviving globalThis registry, and an overwrite picks up the edited\n * function where a skip-or-throw would keep dispatching the stale one\n * (the #186 staleness class). Plugin built-ins keep using\n * {@link register} behind `has()` guards so genuine cross-plugin name\n * collisions still fail loud.\n */\n set<TDoc>(name: TransformName, transform: RowTransform<TDoc>): void {\n this.entries.set(name, transform as RowTransform<unknown>)\n }\n\n /** Look up a transform by name. Returns `undefined` if no plugin has registered one. */\n get(name: TransformName): RowTransform<unknown> | undefined {\n return this.entries.get(name)\n }\n\n /** True iff a transform is registered for the given name. */\n has(name: TransformName): boolean {\n return this.entries.has(name)\n }\n\n /** All registered names. Useful for the admin Catalog tab and for tests. */\n list(): TransformName[] {\n return Array.from(this.entries.keys()).sort()\n }\n\n /** Drop all registrations. Tests only. */\n clear(): void {\n this.entries.clear()\n }\n}\n\n// ---------------------------------------------------------------------------\n// Process-global singleton (mirrors `@murumets-ee/queue`'s handler registry).\n// ---------------------------------------------------------------------------\n//\n// Why a singleton: registrations (the plugin's built-ins at init, plus\n// any consumer-app custom transforms) and the worker's `get` happen in\n// different modules that Next.js HMR / Turbopack can re-evaluate\n// independently. Holding the registry on `globalThis` via `Symbol.for`\n// means the same `Map` instance is shared across module evaluations —\n// `register` and `get` see each other regardless of evaluation order.\n// Same fix shape as the queue's handler-registry HMR bug (#186).\n\nconst REGISTRY_KEY = Symbol.for('@murumets-ee/imports:transforms')\n\ninterface GlobalThisWithTransforms {\n [REGISTRY_KEY]?: TransformRegistry\n}\n\n/** Returns the singleton registry, creating it on first access. */\nexport function getTransformRegistry(): TransformRegistry {\n const g = globalThis as GlobalThisWithTransforms\n let reg = g[REGISTRY_KEY]\n if (!reg) {\n reg = new TransformRegistry()\n g[REGISTRY_KEY] = reg\n }\n return reg\n}\n\n/**\n * Convenience wrapper around the singleton registry for CONSUMER apps that\n * ship their own custom transforms — same semantics as\n * `@murumets-ee/queue/client`'s `registerJob`: **last-wins overwrite, never\n * throws on a duplicate name.** Two reasons, both load-bearing:\n *\n * 1. **Upgrade safety.** The `imports()` plugin registers the built-in\n * transforms (`commerce:mapping`, `commerce:carmaker-feed`) during\n * `createApp`, and older hand-wired apps — following this module's\n * previous docs — call this helper UNGUARDED from their bootstrap right\n * after. A duplicate-name throw here would crash boot on the very\n * `pnpm up` upgrade that ships plugin-owned registration; re-registering\n * the identical stock function is a harmless overwrite instead.\n * 2. **HMR freshness.** The registry lives on globalThis and survives dev\n * module re-evaluation; an edited consumer transform re-registers on the\n * next eval and the overwrite picks up the edit — a skip would keep\n * dispatching the stale function forever (the #186 staleness class).\n *\n * Side effect worth knowing: an app can deliberately override a built-in\n * name (its bootstrap runs after plugin init, so last-wins = app wins).\n * Genuine collision protection (two plugins claiming one name) stays on the\n * raw `TransformRegistry.register`.\n */\nexport function registerImportTransform<TDoc>(\n name: TransformName,\n transform: RowTransform<TDoc>,\n): void {\n getTransformRegistry().set(name, transform)\n}\n"],"mappings":"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"}
@@ -118,6 +118,16 @@ interface DocPatch<TDoc> {
118
118
  * The runner buffers these (sparse — only reman parts carry `*S`) and applies them
119
119
  * in a phase-2 `bulkPartialUpdate` AFTER the stream; an unresolved parent becomes an
120
120
  * `orphan_core_charge` warning (D004). Empty/absent `patches` is the common case.
121
+ *
122
+ * **`priceSample` on `success`** (F010 / #602): an optional numeric price value,
123
+ * in whatever unit scale the schema itself uses (the `parts` schema emits
124
+ * `purchase_price_minor`, integer minor units), sampled for RUN-LEVEL value-drift
125
+ * detection — see `price-stats.ts`. The runner accumulates these from ES-accepted
126
+ * rows into a `PriceStats` distribution snapshot (min/median/max/nullRate) via the
127
+ * SAME streaming pass that builds `totals`, no second pass over the file. A schema
128
+ * that never emits `priceSample` (or a row with no resolvable price) simply omits
129
+ * it — the collector counts that toward `nullRate` rather than treating it as an
130
+ * error. Absent is the common case for schemas with no price notion at all.
121
131
  */
122
132
  type RowResult<TDoc> = {
123
133
  kind: 'success';
@@ -125,6 +135,7 @@ type RowResult<TDoc> = {
125
135
  doc: TDoc;
126
136
  warnings?: readonly RowWarning[];
127
137
  pairs?: readonly DirectionalPair[];
138
+ priceSample?: number;
128
139
  } | {
129
140
  kind: 'skip';
130
141
  reason: string;
@@ -232,4 +243,4 @@ declare function getTransformRegistry(): TransformRegistry;
232
243
  declare function registerImportTransform<TDoc>(name: TransformName, transform: RowTransform<TDoc>): void;
233
244
  //#endregion
234
245
  export { RowWarning as a, TransformRegistry as c, RowTransform as i, getTransformRegistry as l, RowError as n, TransformContext as o, RowResult as r, TransformName as s, DirectionalPair as t, registerImportTransform as u };
235
- //# sourceMappingURL=transform-CABBaYmF.d.mts.map
246
+ //# sourceMappingURL=transform-WeiiLdYP.d.mts.map