@murumets-ee/imports 0.34.0 → 0.35.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 (40) hide show
  1. package/dist/{cleanup-BzqyX9Jo.mjs → cleanup-DsQ42ki-.mjs} +2 -2
  2. package/dist/{cleanup-BzqyX9Jo.mjs.map → cleanup-DsQ42ki-.mjs.map} +1 -1
  3. package/dist/index.d.mts +3 -3
  4. package/dist/index.mjs +1 -1
  5. package/dist/mapping/index.d.mts +3 -3
  6. package/dist/mapping/index.mjs +1 -1
  7. package/dist/mapping/index.mjs.map +1 -1
  8. package/dist/{parts-schema-Bqau0MrJ.d.mts → parts-schema-Cmb0URGO.d.mts} +11 -3
  9. package/dist/{parts-schema-Bqau0MrJ.d.mts.map → parts-schema-Cmb0URGO.d.mts.map} +1 -1
  10. package/dist/{parts-schema-Vv0EJCjj.mjs → parts-schema-RH48v5VW.mjs} +2 -2
  11. package/dist/parts-schema-RH48v5VW.mjs.map +1 -0
  12. package/dist/plugin.d.mts +9 -1
  13. package/dist/plugin.d.mts.map +1 -1
  14. package/dist/plugin.mjs +1 -1
  15. package/dist/plugin.mjs.map +1 -1
  16. package/dist/runner-BOwPUm7W.mjs +2 -0
  17. package/dist/runner-BOwPUm7W.mjs.map +1 -0
  18. package/dist/{schema-registry-JU4gsC8E.mjs → schema-registry-TK4sBFmD.mjs} +2 -2
  19. package/dist/{schema-registry-JU4gsC8E.mjs.map → schema-registry-TK4sBFmD.mjs.map} +1 -1
  20. package/dist/storage-resolver.d.mts +1 -1
  21. package/dist/{transform-BRJJLSXE.d.mts → transform-DqteN-Lm.d.mts} +65 -2
  22. package/dist/transform-DqteN-Lm.d.mts.map +1 -0
  23. package/dist/{transform-BzacGXbo.mjs → transform-ytS1L5p6.mjs} +2 -2
  24. package/dist/{transform-BzacGXbo.mjs.map → transform-ytS1L5p6.mjs.map} +1 -1
  25. package/dist/transforms.d.mts +2 -2
  26. package/dist/transforms.d.mts.map +1 -1
  27. package/dist/transforms.mjs +1 -1
  28. package/dist/transforms.mjs.map +1 -1
  29. package/dist/{worker-Ck67AOnh.d.mts → worker-CO5zK62E.d.mts} +68 -3
  30. package/dist/{worker-Ck67AOnh.d.mts.map → worker-CO5zK62E.d.mts.map} +1 -1
  31. package/dist/worker.d.mts +2 -2
  32. package/dist/worker.mjs +1 -1
  33. package/dist/worker.mjs.map +1 -1
  34. package/dist/{xlsx-reader-oApgZRJd.mjs → xlsx-reader-CUbaPt3-.mjs} +2 -2
  35. package/dist/{xlsx-reader-oApgZRJd.mjs.map → xlsx-reader-CUbaPt3-.mjs.map} +1 -1
  36. package/package.json +8 -8
  37. package/dist/parts-schema-Vv0EJCjj.mjs.map +0 -1
  38. package/dist/runner-0r6UICjo.mjs +0 -2
  39. package/dist/runner-0r6UICjo.mjs.map +0 -1
  40. package/dist/transform-BRJJLSXE.d.mts.map +0 -1
@@ -1,2 +1,2 @@
1
- import{t as e}from"./rolldown-runtime-DK3Fl9T5.mjs";import{getImportsStorage as t}from"./storage.mjs";var n=e({cleanupOrphanedImports:()=>a,cleanupProcessedImports:()=>s});const r=1e3;function i(e){let{ttlOrRetention:t,ttlLabel:n,limit:i=100}=e;if(!Number.isFinite(t)||t<0)throw Error(`imports cleanup: ${n} must be a finite, non-negative number (got ${t})`);if(!Number.isFinite(i)||!Number.isInteger(i)||i<1||i>r)throw Error(`imports cleanup: limit must be an integer in [1, ${r}] (got ${i})`);return{ttlOrRetention:t,limit:i}}async function a(e){let{app:n,bucket:r,logger:a}=e,o=n.db.readWrite,{ttlOrRetention:s,limit:c}=i({ttlOrRetention:e.ttlHours,ttlLabel:`ttlHours`,...e.limit!==void 0&&{limit:e.limit}}),{and:l,asc:u,eq:d,isNull:f,lt:p,getTableColumns:m}=await import(`drizzle-orm`),{toolkitFiles:h}=await import(`@murumets-ee/storage`),{createAdminClient:g}=await import(`@murumets-ee/core/clients`),{ImportRun:_}=await import(`./transform-BzacGXbo.mjs`).then(e=>e.o),v=g(_).getTable(),y=m(h),b=y.key,x=y.bucket,S=y.createdAt;if(!b||!x||!S)throw Error(`cleanupOrphanedImports: toolkit_files schema missing expected columns (key/bucket/createdAt)`);let C=m(v),w=C.filePath,T=C.id;if(!w||!T)throw Error(`cleanupOrphanedImports: import_run schema missing expected columns (filePath/id)`);let E=new Date(Date.now()-s*60*60*1e3),D=await o.select({key:b}).from(h).leftJoin(v,d(w,b)).where(l(d(x,r),p(S,E),f(T))).orderBy(u(S)).limit(c);if(D.length===0)return a.debug({bucket:r,ttlHours:s},`No orphaned imports uploads found`),0;let O=await t(),k=0;for(let e of D){let t=e.key;try{await O.delete(t),k++}catch(e){a.warn({key:t,err:e},`Failed to delete orphaned imports upload`)}}return a.info({cleaned:k,total:D.length,bucket:r,ttlHours:s},`Cleaned up orphaned imports uploads`),k}const o=[`succeeded`,`failed`,`cancelled`];async function s(e){let{app:n,bucket:r,logger:a}=e,s=n.db.readWrite,{ttlOrRetention:c,limit:l}=i({ttlOrRetention:e.retentionDays,ttlLabel:`retentionDays`,...e.limit!==void 0&&{limit:e.limit}}),{and:u,asc:d,eq:f,getTableColumns:p,inArray:m,lt:h}=await import(`drizzle-orm`),{toolkitFiles:g}=await import(`@murumets-ee/storage`),{createAdminClient:_}=await import(`@murumets-ee/core/clients`),{ImportRun:v}=await import(`./transform-BzacGXbo.mjs`).then(e=>e.o),y=_(v).getTable(),b=p(g),x=b.key,S=b.bucket;if(!x||!S)throw Error(`cleanupProcessedImports: toolkit_files schema missing expected columns (key/bucket)`);let C=p(y),w=C.filePath,T=C.status,E=C.finishedAt;if(!w||!T||!E)throw Error(`cleanupProcessedImports: import_run schema missing expected columns (filePath/status/finishedAt)`);let D=new Date(Date.now()-c*24*60*60*1e3),O=await s.selectDistinctOn([x],{key:x}).from(g).innerJoin(y,f(w,x)).where(u(f(S,r),m(T,[...o]),h(E,D))).orderBy(d(x),d(E)).limit(l);if(O.length===0)return a.debug({bucket:r,retentionDays:c},`No processed-imports uploads ready for cleanup`),0;let k=await t(),A=0;for(let e of O){let t=e.key;try{await k.delete(t),A++}catch(e){a.warn({key:t,err:e},`Failed to delete processed-imports upload`)}}return a.info({cleaned:A,total:O.length,bucket:r,retentionDays:c},`Cleaned up processed imports uploads`),A}export{s as n,n as r,a as t};
2
- //# sourceMappingURL=cleanup-BzqyX9Jo.mjs.map
1
+ import{t as e}from"./rolldown-runtime-DK3Fl9T5.mjs";import{getImportsStorage as t}from"./storage.mjs";var n=e({cleanupOrphanedImports:()=>a,cleanupProcessedImports:()=>s});const r=1e3;function i(e){let{ttlOrRetention:t,ttlLabel:n,limit:i=100}=e;if(!Number.isFinite(t)||t<0)throw Error(`imports cleanup: ${n} must be a finite, non-negative number (got ${t})`);if(!Number.isFinite(i)||!Number.isInteger(i)||i<1||i>r)throw Error(`imports cleanup: limit must be an integer in [1, ${r}] (got ${i})`);return{ttlOrRetention:t,limit:i}}async function a(e){let{app:n,bucket:r,logger:a}=e,o=n.db.readWrite,{ttlOrRetention:s,limit:c}=i({ttlOrRetention:e.ttlHours,ttlLabel:`ttlHours`,...e.limit!==void 0&&{limit:e.limit}}),{and:l,asc:u,eq:d,isNull:f,lt:p,getTableColumns:m}=await import(`drizzle-orm`),{toolkitFiles:h}=await import(`@murumets-ee/storage`),{createAdminClient:g}=await import(`@murumets-ee/core/clients`),{ImportRun:_}=await import(`./transform-ytS1L5p6.mjs`).then(e=>e.o),v=g(_).getTable(),y=m(h),b=y.key,x=y.bucket,S=y.createdAt;if(!b||!x||!S)throw Error(`cleanupOrphanedImports: toolkit_files schema missing expected columns (key/bucket/createdAt)`);let C=m(v),w=C.filePath,T=C.id;if(!w||!T)throw Error(`cleanupOrphanedImports: import_run schema missing expected columns (filePath/id)`);let E=new Date(Date.now()-s*60*60*1e3),D=await o.select({key:b}).from(h).leftJoin(v,d(w,b)).where(l(d(x,r),p(S,E),f(T))).orderBy(u(S)).limit(c);if(D.length===0)return a.debug({bucket:r,ttlHours:s},`No orphaned imports uploads found`),0;let O=await t(),k=0;for(let e of D){let t=e.key;try{await O.delete(t),k++}catch(e){a.warn({key:t,err:e},`Failed to delete orphaned imports upload`)}}return a.info({cleaned:k,total:D.length,bucket:r,ttlHours:s},`Cleaned up orphaned imports uploads`),k}const o=[`succeeded`,`failed`,`cancelled`];async function s(e){let{app:n,bucket:r,logger:a}=e,s=n.db.readWrite,{ttlOrRetention:c,limit:l}=i({ttlOrRetention:e.retentionDays,ttlLabel:`retentionDays`,...e.limit!==void 0&&{limit:e.limit}}),{and:u,asc:d,eq:f,getTableColumns:p,inArray:m,lt:h}=await import(`drizzle-orm`),{toolkitFiles:g}=await import(`@murumets-ee/storage`),{createAdminClient:_}=await import(`@murumets-ee/core/clients`),{ImportRun:v}=await import(`./transform-ytS1L5p6.mjs`).then(e=>e.o),y=_(v).getTable(),b=p(g),x=b.key,S=b.bucket;if(!x||!S)throw Error(`cleanupProcessedImports: toolkit_files schema missing expected columns (key/bucket)`);let C=p(y),w=C.filePath,T=C.status,E=C.finishedAt;if(!w||!T||!E)throw Error(`cleanupProcessedImports: import_run schema missing expected columns (filePath/status/finishedAt)`);let D=new Date(Date.now()-c*24*60*60*1e3),O=await s.selectDistinctOn([x],{key:x}).from(g).innerJoin(y,f(w,x)).where(u(f(S,r),m(T,[...o]),h(E,D))).orderBy(d(x),d(E)).limit(l);if(O.length===0)return a.debug({bucket:r,retentionDays:c},`No processed-imports uploads ready for cleanup`),0;let k=await t(),A=0;for(let e of O){let t=e.key;try{await k.delete(t),A++}catch(e){a.warn({key:t,err:e},`Failed to delete processed-imports upload`)}}return a.info({cleaned:A,total:O.length,bucket:r,retentionDays:c},`Cleaned up processed imports uploads`),A}export{s as n,n as r,a as t};
2
+ //# sourceMappingURL=cleanup-DsQ42ki-.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"cleanup-BzqyX9Jo.mjs","names":[],"sources":["../src/admin/cleanup.ts"],"sourcesContent":["/**\n * Cleanup orphaned imports uploads.\n *\n * Finds rows in `toolkit_files` within the configured imports bucket\n * that:\n * - were created more than `ttlHours` ago, AND\n * - have no matching `import_run.filePath` reference\n *\n * For each match, calls `StorageClient.delete(key)` — which removes\n * BOTH the adapter bytes (local file / R2 object) AND the\n * `toolkit_files` metadata row in one transactional unit (see\n * `@murumets-ee/storage` `StorageClient.delete`).\n *\n * Why this exists: the Tus upload flow stores bytes + metadata BEFORE\n * the client's downstream `register` step inserts an `import_run` row.\n * If the register call fails (validation, MIME mismatch, network\n * blip, agent abandons the workflow), the storage row is left\n * dangling. The commerce-side register handler does opportunistic\n * cleanup on its OWN failure paths, but rejections from the Zod\n * schema (before the handler runs) and rejections where the agent\n * never calls register at all both leak.\n *\n * Scheduling: driven by the `defineJob({ schedule: '@every 1h' })`\n * declaration in `plugin.ts`. The queue worker bootstraps a\n * `toolkit_scheduled_jobs` row for this job at startup and its cron\n * loop (`processDueSchedules`) fires the registered handler on each\n * tick. The handler does NOT re-enqueue itself — the schedule is\n * driven by the worker, not the handler.\n */\n\nimport type { ToolkitApp } from '@murumets-ee/core'\nimport type { Logger } from '@murumets-ee/logging'\nimport { getImportsStorage } from '../storage.js'\n\nexport interface CleanupOrphanedImportsArgs {\n /** Toolkit app (provides db). */\n app: ToolkitApp\n /**\n * Bucket name the imports flow uses. Rows in `toolkit_files` are\n * filtered to this bucket so the cleanup doesn't touch media,\n * ticketing attachments, or any other consumer's uploads even if\n * they share the same Postgres instance.\n */\n bucket: string\n /**\n * Hours after which an unreferenced upload is considered orphaned.\n * Operator-grace window: should be long enough that a normal\n * upload-then-register round-trip never wipes a fresh upload, but\n * short enough that storage waste from failed uploads doesn't\n * accumulate. 2 hours is the default in the imports plugin\n * config — calibrated against the typical \"operator clicks\n * upload, walks away, comes back to check\" workflow.\n */\n ttlHours: number\n /**\n * Maximum rows to process per run. Bounded so a sudden flood of\n * orphans (e.g. after a brand-new supplier with unrecognised\n * filenames floods the queue with rejected uploads) doesn't\n * block the queue worker on a single multi-hour cleanup.\n */\n limit?: number\n /** Logger for diagnostics. */\n logger: Pick<Logger, 'debug' | 'info' | 'warn'>\n}\n\n/** Default per-run cap. Tuned against ticketing's `cleanupOrphanedUploads` (also 100). */\nconst DEFAULT_LIMIT = 100\n\n/** Hard cap on per-run limit. Protects against pathological caller input. */\nconst MAX_LIMIT = 1000\n\n/**\n * Validate the numeric inputs every cleanup helper accepts. Returns\n * the sanitised values OR throws — the queue worker logs thrown\n * errors against the scheduled-job row so a misconfigured TTL\n * doesn't silently delete every bucket file (which would happen if\n * a negative `ttlHours` reached the date arithmetic and pushed the\n * cutoff into the future).\n */\nfunction validateCleanupArgs(\n raw: { ttlOrRetention: number; ttlLabel: string; limit?: number },\n): { ttlOrRetention: number; limit: number } {\n const { ttlOrRetention, ttlLabel, limit = DEFAULT_LIMIT } = raw\n if (!Number.isFinite(ttlOrRetention) || ttlOrRetention < 0) {\n throw new Error(\n `imports cleanup: ${ttlLabel} must be a finite, non-negative number (got ${ttlOrRetention})`,\n )\n }\n if (!Number.isFinite(limit) || !Number.isInteger(limit) || limit < 1 || limit > MAX_LIMIT) {\n throw new Error(\n `imports cleanup: limit must be an integer in [1, ${MAX_LIMIT}] (got ${limit})`,\n )\n }\n return { ttlOrRetention, limit }\n}\n\n/**\n * Run one cleanup pass. Returns the count of rows actually deleted\n * (which may be less than the count of orphans found if some\n * `storage.delete` calls failed — see the `warn` log inside the loop).\n */\nexport async function cleanupOrphanedImports(args: CleanupOrphanedImportsArgs): Promise<number> {\n const { app, bucket, logger } = args\n const db = app.db.readWrite\n const { ttlOrRetention: ttlHours, limit } = validateCleanupArgs({\n ttlOrRetention: args.ttlHours,\n ttlLabel: 'ttlHours',\n // `exactOptionalPropertyTypes` rejects an explicit `undefined`\n // for the optional `limit?: number` slot. Spread the field only\n // when defined so the validator's default-via-destructure kicks in.\n ...(args.limit !== undefined && { limit: args.limit }),\n })\n\n // Cross-table query: `toolkit_files` LEFT JOIN `import_run` — same\n // documented exception for cross-entity JOINs that ticketing's\n // cleanup uses. Two-table joins can't go through a single-entity\n // AdminClient (per CLAUDE.md \"Database Queries\").\n const { and, asc, eq, isNull, lt, getTableColumns } = await import('drizzle-orm')\n const { toolkitFiles } = await import('@murumets-ee/storage')\n const { createAdminClient } = await import('@murumets-ee/core/clients')\n const { ImportRun } = await import('../entities/import-run.js')\n type AnyPgColumn = import('drizzle-orm/pg-core').AnyPgColumn\n\n const importRunsTable = createAdminClient(ImportRun).getTable()\n\n // Both `toolkitFiles` and the `importRunsTable` are exported as\n // generic PgTables so direct dot-access widens to `T | undefined`.\n // Pull the column refs through `getTableColumns` (same shape as\n // ticketing's cleanup + the entity-version helper).\n const fileCols = getTableColumns(toolkitFiles) as Record<string, AnyPgColumn>\n const fileKey = fileCols.key\n const fileBucket = fileCols.bucket\n const fileCreatedAt = fileCols.createdAt\n if (!fileKey || !fileBucket || !fileCreatedAt) {\n throw new Error(\n 'cleanupOrphanedImports: toolkit_files schema missing expected columns (key/bucket/createdAt)',\n )\n }\n const importRunCols = getTableColumns(importRunsTable) as Record<string, AnyPgColumn>\n const irFilePath = importRunCols.filePath\n const irId = importRunCols.id\n if (!irFilePath || !irId) {\n throw new Error(\n 'cleanupOrphanedImports: import_run schema missing expected columns (filePath/id)',\n )\n }\n\n const cutoff = new Date(Date.now() - ttlHours * 60 * 60 * 1000)\n\n // Find toolkit_files rows in the imports bucket, older than cutoff,\n // where the storage key is NOT referenced by any import_run row.\n // ORDER BY createdAt ASC delivers strict FIFO so a sustained\n // orphan stream makes deterministic progress on the oldest rows\n // first — without the orderBy, Postgres returns rows in heap\n // order and a LIMIT 100 sweep could keep recycling the same\n // young rows while ancient orphans never get touched.\n const orphans = await db\n .select({ key: fileKey })\n .from(toolkitFiles)\n .leftJoin(importRunsTable, eq(irFilePath, fileKey))\n .where(and(eq(fileBucket, bucket), lt(fileCreatedAt, cutoff), isNull(irId)))\n .orderBy(asc(fileCreatedAt))\n .limit(limit)\n\n if (orphans.length === 0) {\n logger.debug({ bucket, ttlHours }, 'No orphaned imports uploads found')\n return 0\n }\n\n const storage = await getImportsStorage()\n let cleaned = 0\n for (const row of orphans) {\n const key = row.key as unknown as string\n try {\n await storage.delete(key)\n cleaned++\n } catch (err) {\n logger.warn({ key, err }, 'Failed to delete orphaned imports upload')\n }\n }\n\n logger.info(\n { cleaned, total: orphans.length, bucket, ttlHours },\n 'Cleaned up orphaned imports uploads',\n )\n return cleaned\n}\n\n/** Statuses considered \"done\" for the post-success/failure cleanup. */\nconst TERMINAL_IMPORT_RUN_STATUSES = ['succeeded', 'failed', 'cancelled'] as const\n\nexport interface CleanupProcessedImportsArgs {\n /** Toolkit app (provides db). */\n app: ToolkitApp\n /** Bucket name to scope the cleanup. Same semantics as `cleanupOrphanedImports`. */\n bucket: string\n /**\n * Days after which a terminal-status `import_run`'s source file is\n * deleted. Operator-grace window: long enough that an operator can\n * inspect the source file if a transform bug surfaces shortly after\n * a successful import; short enough that storage doesn't accumulate\n * months of supplier feeds.\n *\n * The plugin default is 7. Set to 0 to delete the source file\n * immediately at the next cleanup tick after the import row\n * reaches terminal status — useful when storage cost matters more\n * than forensic reprocess capability.\n */\n retentionDays: number\n /** Max rows per run. Defaults to 100 (same cap as the orphan sweep). */\n limit?: number\n logger: Pick<Logger, 'debug' | 'info' | 'warn'>\n}\n\n/**\n * Cleanup source files for `import_run`s that finished (succeeded /\n * failed / cancelled) more than `retentionDays` days ago.\n *\n * Counterpart to `cleanupOrphanedImports`: that one targets uploads\n * with NO matching `import_run` (handshake never completed); this one\n * targets uploads whose `import_run` row IS present and terminal.\n * Together they cover every \"useless storage row\" case the imports\n * flow can produce.\n *\n * Deletes ONLY the storage row (via `StorageClient.delete`); the\n * `import_run` row is kept so the History panel + audit trail still\n * surface the past run. The `import_run.filePath` value points at a\n * now-deleted key after this runs — consumers that try to re-download\n * the source file (e.g. a future \"reprocess\" button) will get a\n * `getMetadata` → null and should show \"source file unavailable\" in\n * the UI rather than crashing.\n */\nexport async function cleanupProcessedImports(args: CleanupProcessedImportsArgs): Promise<number> {\n const { app, bucket, logger } = args\n const db = app.db.readWrite\n const { ttlOrRetention: retentionDays, limit } = validateCleanupArgs({\n ttlOrRetention: args.retentionDays,\n ttlLabel: 'retentionDays',\n // `exactOptionalPropertyTypes` rejects an explicit `undefined`\n // for the optional `limit?: number` slot. Spread the field only\n // when defined so the validator's default-via-destructure kicks in.\n ...(args.limit !== undefined && { limit: args.limit }),\n })\n\n const { and, asc, eq, getTableColumns, inArray, lt } = await import('drizzle-orm')\n const { toolkitFiles } = await import('@murumets-ee/storage')\n const { createAdminClient } = await import('@murumets-ee/core/clients')\n const { ImportRun } = await import('../entities/import-run.js')\n type AnyPgColumn = import('drizzle-orm/pg-core').AnyPgColumn\n\n const importRunsTable = createAdminClient(ImportRun).getTable()\n\n const fileCols = getTableColumns(toolkitFiles) as Record<string, AnyPgColumn>\n const fileKey = fileCols.key\n const fileBucket = fileCols.bucket\n if (!fileKey || !fileBucket) {\n throw new Error(\n 'cleanupProcessedImports: toolkit_files schema missing expected columns (key/bucket)',\n )\n }\n const importRunCols = getTableColumns(importRunsTable) as Record<string, AnyPgColumn>\n const irFilePath = importRunCols.filePath\n const irStatus = importRunCols.status\n const irFinishedAt = importRunCols.finishedAt\n if (!irFilePath || !irStatus || !irFinishedAt) {\n throw new Error(\n 'cleanupProcessedImports: import_run schema missing expected columns (filePath/status/finishedAt)',\n )\n }\n\n const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000)\n\n // INNER JOIN so we only consider keys that DO have a matching\n // import_run row — the inverse of the orphan-cleanup query. Filter\n // on terminal status + finishedAt cutoff so a long-running batch\n // doesn't get its still-being-processed file ripped out from under\n // the worker.\n //\n // `selectDistinctOn([fileKey])` is the right primitive here:\n //\n // (a) **Dedupe before LIMIT.** `toolkit_files.key` is UNIQUE\n // within its own table, but `import_run.filePath` has no\n // unique constraint — a future re-run / retry flow that\n // creates two terminal `import_run` rows pointing at the\n // same `storageKey` would make the INNER JOIN project the\n // key twice. Without dedupe in SQL, a pathological cluster\n // of 100 duplicates sharing 1 storageKey would burn the\n // LIMIT cap to clean 1 file.\n //\n // (b) **Deterministic ordering** (NOT strict global FIFO).\n // `DISTINCT ON` requires the ORDER BY to start with the\n // distinct columns (Postgres rule). So we sort by `fileKey`\n // first, then `irFinishedAt` within each key group. The\n // LIMIT then picks the first N keys in `fileKey` order —\n // i.e. alphabetical by storage path, not globally oldest by\n // finishedAt. In practice this is roughly chronological\n // anyway: storage keys are `uploads/YYYY/MM/<uuid>/...` so\n // earlier months sort first. Within a month, ordering is\n // UUID-random — but every key eventually gets cleaned\n // (no starvation), so over ~10 ticks the entire backlog\n // drains either way. A subquery-wrapped \"true global FIFO\"\n // (DISTINCT ON inner, ORDER BY finishedAt outer) is\n // possible but adds complexity for marginal benefit; the\n // practical-FIFO-via-key-prefix property is acceptable.\n const candidates = await db\n .selectDistinctOn([fileKey], { key: fileKey })\n .from(toolkitFiles)\n .innerJoin(importRunsTable, eq(irFilePath, fileKey))\n .where(\n and(\n eq(fileBucket, bucket),\n inArray(irStatus, [...TERMINAL_IMPORT_RUN_STATUSES]),\n lt(irFinishedAt, cutoff),\n ),\n )\n .orderBy(asc(fileKey), asc(irFinishedAt))\n .limit(limit)\n\n if (candidates.length === 0) {\n logger.debug({ bucket, retentionDays }, 'No processed-imports uploads ready for cleanup')\n return 0\n }\n\n const storage = await getImportsStorage()\n let cleaned = 0\n for (const row of candidates) {\n const key = row.key as unknown as string\n try {\n await storage.delete(key)\n cleaned++\n } catch (err) {\n logger.warn({ key, err }, 'Failed to delete processed-imports upload')\n }\n }\n\n logger.info(\n { cleaned, total: candidates.length, bucket, retentionDays },\n 'Cleaned up processed imports uploads',\n )\n return cleaned\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"cleanup-DsQ42ki-.mjs","names":[],"sources":["../src/admin/cleanup.ts"],"sourcesContent":["/**\n * Cleanup orphaned imports uploads.\n *\n * Finds rows in `toolkit_files` within the configured imports bucket\n * that:\n * - were created more than `ttlHours` ago, AND\n * - have no matching `import_run.filePath` reference\n *\n * For each match, calls `StorageClient.delete(key)` — which removes\n * BOTH the adapter bytes (local file / R2 object) AND the\n * `toolkit_files` metadata row in one transactional unit (see\n * `@murumets-ee/storage` `StorageClient.delete`).\n *\n * Why this exists: the Tus upload flow stores bytes + metadata BEFORE\n * the client's downstream `register` step inserts an `import_run` row.\n * If the register call fails (validation, MIME mismatch, network\n * blip, agent abandons the workflow), the storage row is left\n * dangling. The commerce-side register handler does opportunistic\n * cleanup on its OWN failure paths, but rejections from the Zod\n * schema (before the handler runs) and rejections where the agent\n * never calls register at all both leak.\n *\n * Scheduling: driven by the `defineJob({ schedule: '@every 1h' })`\n * declaration in `plugin.ts`. The queue worker bootstraps a\n * `toolkit_scheduled_jobs` row for this job at startup and its cron\n * loop (`processDueSchedules`) fires the registered handler on each\n * tick. The handler does NOT re-enqueue itself — the schedule is\n * driven by the worker, not the handler.\n */\n\nimport type { ToolkitApp } from '@murumets-ee/core'\nimport type { Logger } from '@murumets-ee/logging'\nimport { getImportsStorage } from '../storage.js'\n\nexport interface CleanupOrphanedImportsArgs {\n /** Toolkit app (provides db). */\n app: ToolkitApp\n /**\n * Bucket name the imports flow uses. Rows in `toolkit_files` are\n * filtered to this bucket so the cleanup doesn't touch media,\n * ticketing attachments, or any other consumer's uploads even if\n * they share the same Postgres instance.\n */\n bucket: string\n /**\n * Hours after which an unreferenced upload is considered orphaned.\n * Operator-grace window: should be long enough that a normal\n * upload-then-register round-trip never wipes a fresh upload, but\n * short enough that storage waste from failed uploads doesn't\n * accumulate. 2 hours is the default in the imports plugin\n * config — calibrated against the typical \"operator clicks\n * upload, walks away, comes back to check\" workflow.\n */\n ttlHours: number\n /**\n * Maximum rows to process per run. Bounded so a sudden flood of\n * orphans (e.g. after a brand-new supplier with unrecognised\n * filenames floods the queue with rejected uploads) doesn't\n * block the queue worker on a single multi-hour cleanup.\n */\n limit?: number\n /** Logger for diagnostics. */\n logger: Pick<Logger, 'debug' | 'info' | 'warn'>\n}\n\n/** Default per-run cap. Tuned against ticketing's `cleanupOrphanedUploads` (also 100). */\nconst DEFAULT_LIMIT = 100\n\n/** Hard cap on per-run limit. Protects against pathological caller input. */\nconst MAX_LIMIT = 1000\n\n/**\n * Validate the numeric inputs every cleanup helper accepts. Returns\n * the sanitised values OR throws — the queue worker logs thrown\n * errors against the scheduled-job row so a misconfigured TTL\n * doesn't silently delete every bucket file (which would happen if\n * a negative `ttlHours` reached the date arithmetic and pushed the\n * cutoff into the future).\n */\nfunction validateCleanupArgs(\n raw: { ttlOrRetention: number; ttlLabel: string; limit?: number },\n): { ttlOrRetention: number; limit: number } {\n const { ttlOrRetention, ttlLabel, limit = DEFAULT_LIMIT } = raw\n if (!Number.isFinite(ttlOrRetention) || ttlOrRetention < 0) {\n throw new Error(\n `imports cleanup: ${ttlLabel} must be a finite, non-negative number (got ${ttlOrRetention})`,\n )\n }\n if (!Number.isFinite(limit) || !Number.isInteger(limit) || limit < 1 || limit > MAX_LIMIT) {\n throw new Error(\n `imports cleanup: limit must be an integer in [1, ${MAX_LIMIT}] (got ${limit})`,\n )\n }\n return { ttlOrRetention, limit }\n}\n\n/**\n * Run one cleanup pass. Returns the count of rows actually deleted\n * (which may be less than the count of orphans found if some\n * `storage.delete` calls failed — see the `warn` log inside the loop).\n */\nexport async function cleanupOrphanedImports(args: CleanupOrphanedImportsArgs): Promise<number> {\n const { app, bucket, logger } = args\n const db = app.db.readWrite\n const { ttlOrRetention: ttlHours, limit } = validateCleanupArgs({\n ttlOrRetention: args.ttlHours,\n ttlLabel: 'ttlHours',\n // `exactOptionalPropertyTypes` rejects an explicit `undefined`\n // for the optional `limit?: number` slot. Spread the field only\n // when defined so the validator's default-via-destructure kicks in.\n ...(args.limit !== undefined && { limit: args.limit }),\n })\n\n // Cross-table query: `toolkit_files` LEFT JOIN `import_run` — same\n // documented exception for cross-entity JOINs that ticketing's\n // cleanup uses. Two-table joins can't go through a single-entity\n // AdminClient (per CLAUDE.md \"Database Queries\").\n const { and, asc, eq, isNull, lt, getTableColumns } = await import('drizzle-orm')\n const { toolkitFiles } = await import('@murumets-ee/storage')\n const { createAdminClient } = await import('@murumets-ee/core/clients')\n const { ImportRun } = await import('../entities/import-run.js')\n type AnyPgColumn = import('drizzle-orm/pg-core').AnyPgColumn\n\n const importRunsTable = createAdminClient(ImportRun).getTable()\n\n // Both `toolkitFiles` and the `importRunsTable` are exported as\n // generic PgTables so direct dot-access widens to `T | undefined`.\n // Pull the column refs through `getTableColumns` (same shape as\n // ticketing's cleanup + the entity-version helper).\n const fileCols = getTableColumns(toolkitFiles) as Record<string, AnyPgColumn>\n const fileKey = fileCols.key\n const fileBucket = fileCols.bucket\n const fileCreatedAt = fileCols.createdAt\n if (!fileKey || !fileBucket || !fileCreatedAt) {\n throw new Error(\n 'cleanupOrphanedImports: toolkit_files schema missing expected columns (key/bucket/createdAt)',\n )\n }\n const importRunCols = getTableColumns(importRunsTable) as Record<string, AnyPgColumn>\n const irFilePath = importRunCols.filePath\n const irId = importRunCols.id\n if (!irFilePath || !irId) {\n throw new Error(\n 'cleanupOrphanedImports: import_run schema missing expected columns (filePath/id)',\n )\n }\n\n const cutoff = new Date(Date.now() - ttlHours * 60 * 60 * 1000)\n\n // Find toolkit_files rows in the imports bucket, older than cutoff,\n // where the storage key is NOT referenced by any import_run row.\n // ORDER BY createdAt ASC delivers strict FIFO so a sustained\n // orphan stream makes deterministic progress on the oldest rows\n // first — without the orderBy, Postgres returns rows in heap\n // order and a LIMIT 100 sweep could keep recycling the same\n // young rows while ancient orphans never get touched.\n const orphans = await db\n .select({ key: fileKey })\n .from(toolkitFiles)\n .leftJoin(importRunsTable, eq(irFilePath, fileKey))\n .where(and(eq(fileBucket, bucket), lt(fileCreatedAt, cutoff), isNull(irId)))\n .orderBy(asc(fileCreatedAt))\n .limit(limit)\n\n if (orphans.length === 0) {\n logger.debug({ bucket, ttlHours }, 'No orphaned imports uploads found')\n return 0\n }\n\n const storage = await getImportsStorage()\n let cleaned = 0\n for (const row of orphans) {\n const key = row.key as unknown as string\n try {\n await storage.delete(key)\n cleaned++\n } catch (err) {\n logger.warn({ key, err }, 'Failed to delete orphaned imports upload')\n }\n }\n\n logger.info(\n { cleaned, total: orphans.length, bucket, ttlHours },\n 'Cleaned up orphaned imports uploads',\n )\n return cleaned\n}\n\n/** Statuses considered \"done\" for the post-success/failure cleanup. */\nconst TERMINAL_IMPORT_RUN_STATUSES = ['succeeded', 'failed', 'cancelled'] as const\n\nexport interface CleanupProcessedImportsArgs {\n /** Toolkit app (provides db). */\n app: ToolkitApp\n /** Bucket name to scope the cleanup. Same semantics as `cleanupOrphanedImports`. */\n bucket: string\n /**\n * Days after which a terminal-status `import_run`'s source file is\n * deleted. Operator-grace window: long enough that an operator can\n * inspect the source file if a transform bug surfaces shortly after\n * a successful import; short enough that storage doesn't accumulate\n * months of supplier feeds.\n *\n * The plugin default is 7. Set to 0 to delete the source file\n * immediately at the next cleanup tick after the import row\n * reaches terminal status — useful when storage cost matters more\n * than forensic reprocess capability.\n */\n retentionDays: number\n /** Max rows per run. Defaults to 100 (same cap as the orphan sweep). */\n limit?: number\n logger: Pick<Logger, 'debug' | 'info' | 'warn'>\n}\n\n/**\n * Cleanup source files for `import_run`s that finished (succeeded /\n * failed / cancelled) more than `retentionDays` days ago.\n *\n * Counterpart to `cleanupOrphanedImports`: that one targets uploads\n * with NO matching `import_run` (handshake never completed); this one\n * targets uploads whose `import_run` row IS present and terminal.\n * Together they cover every \"useless storage row\" case the imports\n * flow can produce.\n *\n * Deletes ONLY the storage row (via `StorageClient.delete`); the\n * `import_run` row is kept so the History panel + audit trail still\n * surface the past run. The `import_run.filePath` value points at a\n * now-deleted key after this runs — consumers that try to re-download\n * the source file (e.g. a future \"reprocess\" button) will get a\n * `getMetadata` → null and should show \"source file unavailable\" in\n * the UI rather than crashing.\n */\nexport async function cleanupProcessedImports(args: CleanupProcessedImportsArgs): Promise<number> {\n const { app, bucket, logger } = args\n const db = app.db.readWrite\n const { ttlOrRetention: retentionDays, limit } = validateCleanupArgs({\n ttlOrRetention: args.retentionDays,\n ttlLabel: 'retentionDays',\n // `exactOptionalPropertyTypes` rejects an explicit `undefined`\n // for the optional `limit?: number` slot. Spread the field only\n // when defined so the validator's default-via-destructure kicks in.\n ...(args.limit !== undefined && { limit: args.limit }),\n })\n\n const { and, asc, eq, getTableColumns, inArray, lt } = await import('drizzle-orm')\n const { toolkitFiles } = await import('@murumets-ee/storage')\n const { createAdminClient } = await import('@murumets-ee/core/clients')\n const { ImportRun } = await import('../entities/import-run.js')\n type AnyPgColumn = import('drizzle-orm/pg-core').AnyPgColumn\n\n const importRunsTable = createAdminClient(ImportRun).getTable()\n\n const fileCols = getTableColumns(toolkitFiles) as Record<string, AnyPgColumn>\n const fileKey = fileCols.key\n const fileBucket = fileCols.bucket\n if (!fileKey || !fileBucket) {\n throw new Error(\n 'cleanupProcessedImports: toolkit_files schema missing expected columns (key/bucket)',\n )\n }\n const importRunCols = getTableColumns(importRunsTable) as Record<string, AnyPgColumn>\n const irFilePath = importRunCols.filePath\n const irStatus = importRunCols.status\n const irFinishedAt = importRunCols.finishedAt\n if (!irFilePath || !irStatus || !irFinishedAt) {\n throw new Error(\n 'cleanupProcessedImports: import_run schema missing expected columns (filePath/status/finishedAt)',\n )\n }\n\n const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000)\n\n // INNER JOIN so we only consider keys that DO have a matching\n // import_run row — the inverse of the orphan-cleanup query. Filter\n // on terminal status + finishedAt cutoff so a long-running batch\n // doesn't get its still-being-processed file ripped out from under\n // the worker.\n //\n // `selectDistinctOn([fileKey])` is the right primitive here:\n //\n // (a) **Dedupe before LIMIT.** `toolkit_files.key` is UNIQUE\n // within its own table, but `import_run.filePath` has no\n // unique constraint — a future re-run / retry flow that\n // creates two terminal `import_run` rows pointing at the\n // same `storageKey` would make the INNER JOIN project the\n // key twice. Without dedupe in SQL, a pathological cluster\n // of 100 duplicates sharing 1 storageKey would burn the\n // LIMIT cap to clean 1 file.\n //\n // (b) **Deterministic ordering** (NOT strict global FIFO).\n // `DISTINCT ON` requires the ORDER BY to start with the\n // distinct columns (Postgres rule). So we sort by `fileKey`\n // first, then `irFinishedAt` within each key group. The\n // LIMIT then picks the first N keys in `fileKey` order —\n // i.e. alphabetical by storage path, not globally oldest by\n // finishedAt. In practice this is roughly chronological\n // anyway: storage keys are `uploads/YYYY/MM/<uuid>/...` so\n // earlier months sort first. Within a month, ordering is\n // UUID-random — but every key eventually gets cleaned\n // (no starvation), so over ~10 ticks the entire backlog\n // drains either way. A subquery-wrapped \"true global FIFO\"\n // (DISTINCT ON inner, ORDER BY finishedAt outer) is\n // possible but adds complexity for marginal benefit; the\n // practical-FIFO-via-key-prefix property is acceptable.\n const candidates = await db\n .selectDistinctOn([fileKey], { key: fileKey })\n .from(toolkitFiles)\n .innerJoin(importRunsTable, eq(irFilePath, fileKey))\n .where(\n and(\n eq(fileBucket, bucket),\n inArray(irStatus, [...TERMINAL_IMPORT_RUN_STATUSES]),\n lt(irFinishedAt, cutoff),\n ),\n )\n .orderBy(asc(fileKey), asc(irFinishedAt))\n .limit(limit)\n\n if (candidates.length === 0) {\n logger.debug({ bucket, retentionDays }, 'No processed-imports uploads ready for cleanup')\n return 0\n }\n\n const storage = await getImportsStorage()\n let cleaned = 0\n for (const row of candidates) {\n const key = row.key as unknown as string\n try {\n await storage.delete(key)\n cleaned++\n } catch (err) {\n logger.warn({ key, err }, 'Failed to delete processed-imports upload')\n }\n }\n\n logger.info(\n { cleaned, total: candidates.length, bucket, retentionDays },\n 'Cleaned up processed imports uploads',\n )\n return cleaned\n}\n"],"mappings":"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"}
package/dist/index.d.mts CHANGED
@@ -1,5 +1,5 @@
1
- import { A as ErrorTrackerConfig, C as countDataRows, D as ErrorPattern, E as DEFAULT_MAX_SAMPLES_PER_PATTERN, M as ImportRun, N as ImportRunClient, O as ErrorSample, P as ImportRunStatus, S as StreamFeedRow, T as DEFAULT_MAX_PATTERNS, _ as FeedReaderConfig, b as readFeed, f as DEFAULT_BATCH_SIZE, g as runImport, h as RunImportResult, j as IMPORT_RUN_STATUSES, k as ErrorTracker, m as RunImportOptions, n as EsClientResolver, p as ImportRunProgress, r as FilePathResolver, t as EnsureIndexResolver, v as ReadFeedOptions, w as streamFeed, x as StreamFeedOptions, y as feedReaderConfigSchema } from "./worker-Ck67AOnh.mjs";
2
- import { a as TransformContext, c as getTransformRegistry, i as RowWarning, l as registerImportTransform, n as RowResult, o as TransformName, r as RowTransform, s as TransformRegistry, t as RowError } from "./transform-BRJJLSXE.mjs";
1
+ import { A as ErrorSample, C as StreamFeedOptions, D as DEFAULT_MAX_PATTERNS, E as streamFeed, F as ImportRunClient, I as ImportRunStatus, M as ErrorTrackerConfig, N as IMPORT_RUN_STATUSES, O as DEFAULT_MAX_SAMPLES_PER_PATTERN, P as ImportRun, S as readFeed, T as countDataRows, _ as RunImportResult, b as ReadFeedOptions, c as PairSinkContext, g as RunImportOptions, h as ImportRunProgress, j as ErrorTracker, k as ErrorPattern, m as DEFAULT_BATCH_SIZE, n as EsClientResolver, r as FilePathResolver, s as PairSink, t as EnsureIndexResolver, v as runImport, w as StreamFeedRow, x as feedReaderConfigSchema, y as FeedReaderConfig } from "./worker-CO5zK62E.mjs";
2
+ import { a as RowWarning, c as TransformRegistry, i as RowTransform, l as getTransformRegistry, n as RowError, o as TransformContext, r as RowResult, s as TransformName, t as DirectionalPair, u as registerImportTransform } from "./transform-DqteN-Lm.mjs";
3
3
  import { Behavior } from "@murumets-ee/entity";
4
4
  import { ToolkitApp } from "@murumets-ee/core";
5
5
  import { Logger as Logger$1 } from "@murumets-ee/logging";
@@ -219,5 +219,5 @@ interface FeedPreview {
219
219
  */
220
220
  declare function previewFeed(options: PreviewFeedOptions): Promise<FeedPreview>;
221
221
  //#endregion
222
- export { type CleanupOrphanedImportsArgs, type CleanupProcessedImportsArgs, DEFAULT_BATCH_SIZE, DEFAULT_MAX_PATTERNS, DEFAULT_MAX_SAMPLES_PER_PATTERN, DEFAULT_PREVIEW_ROWS, type EnsureIndexResolver, type ErrorPattern, type ErrorSample, ErrorTracker, type ErrorTrackerConfig, type EsClientResolver, type FeedPreview, type FeedReaderConfig, type FilePathResolver, IMPORT_MAPPING_FORMATS, IMPORT_MAPPING_TARGET_SCHEMAS, IMPORT_RUN_STATUSES, ImportMapping, type ImportMappingClient, type ImportMappingFormat, type ImportMappingTargetSchema, ImportRun, type ImportRunClient, type ImportRunProgress, type ImportRunStatus, MAX_PREVIEW_ROWS, type PreviewFeedOptions, type ReadFeedOptions, type RowError, type RowResult, type RowTransform, type RowWarning, type RunImportOptions, type RunImportResult, type StreamFeedOptions, type StreamFeedRow, type TransformContext, type TransformName, TransformRegistry, assertValidMapping, cleanupOrphanedImports, cleanupProcessedImports, countDataRows, feedReaderConfigSchema, getTransformRegistry, importMappingValidation, previewFeed, readFeed, registerImportTransform, runImport, streamFeed };
222
+ export { type CleanupOrphanedImportsArgs, type CleanupProcessedImportsArgs, DEFAULT_BATCH_SIZE, DEFAULT_MAX_PATTERNS, DEFAULT_MAX_SAMPLES_PER_PATTERN, DEFAULT_PREVIEW_ROWS, type DirectionalPair, type EnsureIndexResolver, type ErrorPattern, type ErrorSample, ErrorTracker, type ErrorTrackerConfig, type EsClientResolver, type FeedPreview, type FeedReaderConfig, type FilePathResolver, IMPORT_MAPPING_FORMATS, IMPORT_MAPPING_TARGET_SCHEMAS, IMPORT_RUN_STATUSES, ImportMapping, type ImportMappingClient, type ImportMappingFormat, type ImportMappingTargetSchema, ImportRun, type ImportRunClient, type ImportRunProgress, type ImportRunStatus, MAX_PREVIEW_ROWS, type PairSink, type PairSinkContext, type PreviewFeedOptions, type ReadFeedOptions, type RowError, type RowResult, type RowTransform, type RowWarning, type RunImportOptions, type RunImportResult, type StreamFeedOptions, type StreamFeedRow, type TransformContext, type TransformName, TransformRegistry, assertValidMapping, cleanupOrphanedImports, cleanupProcessedImports, countDataRows, feedReaderConfigSchema, getTransformRegistry, importMappingValidation, previewFeed, readFeed, registerImportTransform, runImport, streamFeed };
223
223
  //# sourceMappingURL=index.d.mts.map
package/dist/index.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import{n as e,t}from"./cleanup-BzqyX9Jo.mjs";import{a as n,c as r,d as i,i as a,l as o,n as s,r as c,s as l,t as u,u as d}from"./transform-BzacGXbo.mjs";import{c as f,i as p,l as m,n as h,o as g,r as _,s as v,t as y,u as b}from"./runner-0r6UICjo.mjs";const x=100,S=20;async function C(e){let{maxRows:t,...n}=e,r=t??20,i=Math.min(100,Math.max(1,Number.isFinite(r)?Math.floor(r):20)),a=[],o=new Map;for await(let{row:e}of p({...n,relaxColumnCount:!0})){for(let t of Object.keys(e))o.set(t,!0);if(a.push(e),a.length>=i)break}return{columns:[...o.keys()],rows:a}}export{y as DEFAULT_BATCH_SIZE,f as DEFAULT_MAX_PATTERNS,m as DEFAULT_MAX_SAMPLES_PER_PATTERN,S as DEFAULT_PREVIEW_ROWS,b as ErrorTracker,l as IMPORT_MAPPING_FORMATS,r as IMPORT_MAPPING_TARGET_SCHEMAS,a as IMPORT_RUN_STATUSES,o as ImportMapping,n as ImportRun,x as MAX_PREVIEW_ROWS,u as TransformRegistry,d as assertValidMapping,t as cleanupOrphanedImports,e as cleanupProcessedImports,g as countDataRows,_ as feedReaderConfigSchema,s as getTransformRegistry,i as importMappingValidation,C as previewFeed,p as readFeed,c as registerImportTransform,h as runImport,v as streamFeed};
1
+ import{n as e,t}from"./cleanup-DsQ42ki-.mjs";import{a as n,c as r,d as i,i as a,l as o,n as s,r as c,s as l,t as u,u as d}from"./transform-ytS1L5p6.mjs";import{c as f,i as p,l as m,n as h,o as g,r as _,s as v,t as y,u as b}from"./runner-BOwPUm7W.mjs";const x=100,S=20;async function C(e){let{maxRows:t,...n}=e,r=t??20,i=Math.min(100,Math.max(1,Number.isFinite(r)?Math.floor(r):20)),a=[],o=new Map;for await(let{row:e}of p({...n,relaxColumnCount:!0})){for(let t of Object.keys(e))o.set(t,!0);if(a.push(e),a.length>=i)break}return{columns:[...o.keys()],rows:a}}export{y as DEFAULT_BATCH_SIZE,f as DEFAULT_MAX_PATTERNS,m as DEFAULT_MAX_SAMPLES_PER_PATTERN,S as DEFAULT_PREVIEW_ROWS,b as ErrorTracker,l as IMPORT_MAPPING_FORMATS,r as IMPORT_MAPPING_TARGET_SCHEMAS,a as IMPORT_RUN_STATUSES,o as ImportMapping,n as ImportRun,x as MAX_PREVIEW_ROWS,u as TransformRegistry,d as assertValidMapping,t as cleanupOrphanedImports,e as cleanupProcessedImports,g as countDataRows,_ as feedReaderConfigSchema,s as getTransformRegistry,i as importMappingValidation,C as previewFeed,p as readFeed,c as registerImportTransform,h as runImport,v as streamFeed};
2
2
  //# sourceMappingURL=index.mjs.map
@@ -1,5 +1,5 @@
1
- import { r as RowTransform } from "../transform-BRJJLSXE.mjs";
2
- import { S as toMinorUnits, _ as applyColumnTransforms, a as MappedRow, b as parseEuropeanNumber, c as TargetSchema, d as textOrNull, f as COLUMN_TRANSFORM_OPS, g as ColumnTransformOpDescriptor, h as ColumnTransformOp, i as BuildRowContext, l as numberCell, m as ColumnTransform, n as partsParamsSchema, o as NumberOutcome, p as CellValue, r as partsSchema, s as TargetField, t as PartsParams, u as rowError, v as columnTransformSchema, x as parseNumber, y as NumberFormat } from "../parts-schema-Bqau0MrJ.mjs";
1
+ import { i as RowTransform } from "../transform-DqteN-Lm.mjs";
2
+ import { C as toMinorUnits, S as parseNumber, _ as ColumnTransformOpDescriptor, a as BuildRowContext, b as NumberFormat, c as TargetField, d as rowError, f as textOrNull, g as ColumnTransformOp, h as ColumnTransform, i as partsSchema, l as TargetSchema, m as CellValue, n as normalizeCode, o as MappedRow, p as COLUMN_TRANSFORM_OPS, r as partsParamsSchema, s as NumberOutcome, t as PartsParams, u as numberCell, v as applyColumnTransforms, x as parseEuropeanNumber, y as columnTransformSchema } from "../parts-schema-Cmb0URGO.mjs";
3
3
  import { z } from "zod";
4
4
 
5
5
  //#region src/mapping/discount-table-schema.d.ts
@@ -394,5 +394,5 @@ interface TargetSchemaSummary {
394
394
  */
395
395
  declare function describeTargetSchemas(): TargetSchemaSummary[];
396
396
  //#endregion
397
- export { type BuildRowContext, COLUMN_TRANSFORM_OPS, type CellValue, type ColumnTransform, type ColumnTransformOp, type ColumnTransformOpDescriptor, type DiscountTableRow, MAPPING_TRANSFORM_NAME, type MappedRow, type MappingColumn, type MappingConfig, type NumberFormat, type NumberOutcome, type PartsParams, type ResolvableMapping, type TargetField, type TargetSchema, type TargetSchemaSummary, applyColumnTransforms, buildMappingTransform, columnTransformSchema, describeTargetSchemas, discountTableSchema, getTargetSchema, listTargetSchemas, mappingColumnSchema, mappingConfigSchema, mappingTransform, numberCell, numberFormatSchema, parseEuropeanNumber, parseNumber, partsParamsSchema, partsSchema, resolveMapping, rowError, textOrNull, toMinorUnits };
397
+ export { type BuildRowContext, COLUMN_TRANSFORM_OPS, type CellValue, type ColumnTransform, type ColumnTransformOp, type ColumnTransformOpDescriptor, type DiscountTableRow, MAPPING_TRANSFORM_NAME, type MappedRow, type MappingColumn, type MappingConfig, type NumberFormat, type NumberOutcome, type PartsParams, type ResolvableMapping, type TargetField, type TargetSchema, type TargetSchemaSummary, applyColumnTransforms, buildMappingTransform, columnTransformSchema, describeTargetSchemas, discountTableSchema, getTargetSchema, listTargetSchemas, mappingColumnSchema, mappingConfigSchema, mappingTransform, normalizeCode, numberCell, numberFormatSchema, parseEuropeanNumber, parseNumber, partsParamsSchema, partsSchema, resolveMapping, rowError, textOrNull, toMinorUnits };
398
398
  //# sourceMappingURL=index.d.mts.map
@@ -1,2 +1,2 @@
1
- import{a as e,c as t,d as n,f as r,g as i,h as a,i as o,l as s,m as c,n as l,o as u,p as d,r as f,s as p,t as m,u as h}from"../parts-schema-Vv0EJCjj.mjs";import{i as g,n as _,r as v,t as y}from"../schema-registry-JU4gsC8E.mjs";import{z as b}from"zod";const x=`commerce:mapping`,S=b.object({mapping:t}),C=new WeakMap;function w(e){let t=C.get(e);if(t)return t;let{mapping:n}=S.parse(e),r=_(n.targetSchema);if(!r)throw Error(`Unknown target schema "${n.targetSchema}" (registered: ${v().join(`, `)})`);let i=u(n,r);return C.set(e,i),i}const T=async(e,t)=>w(t.params)(e,t);export{n as COLUMN_TRANSFORM_OPS,x as MAPPING_TRANSFORM_NAME,r as applyColumnTransforms,u as buildMappingTransform,d as columnTransformSchema,y as describeTargetSchemas,g as discountTableSchema,_ as getTargetSchema,v as listTargetSchemas,p as mappingColumnSchema,t as mappingConfigSchema,T as mappingTransform,f as numberCell,s as numberFormatSchema,c as parseEuropeanNumber,a as parseNumber,m as partsParamsSchema,l as partsSchema,h as resolveMapping,o as rowError,e as textOrNull,i as toMinorUnits};
1
+ import{_ as e,a as t,c as n,d as r,f as i,g as a,h as o,i as s,l as c,m as l,n as u,o as d,p as f,r as p,s as m,t as h,u as g}from"../parts-schema-RH48v5VW.mjs";import{i as _,n as v,r as y,t as b}from"../schema-registry-TK4sBFmD.mjs";import{z as x}from"zod";const S=`commerce:mapping`,C=x.object({mapping:c}),w=new WeakMap;function T(e){let t=w.get(e);if(t)return t;let{mapping:n}=C.parse(e),r=v(n.targetSchema);if(!r)throw Error(`Unknown target schema "${n.targetSchema}" (registered: ${y().join(`, `)})`);let i=m(n,r);return w.set(e,i),i}const E=async(e,t)=>T(t.params)(e,t);export{i as COLUMN_TRANSFORM_OPS,S as MAPPING_TRANSFORM_NAME,f as applyColumnTransforms,m as buildMappingTransform,l as columnTransformSchema,b as describeTargetSchemas,_ as discountTableSchema,v as getTargetSchema,y as listTargetSchemas,n as mappingColumnSchema,c as mappingConfigSchema,E as mappingTransform,h as normalizeCode,s as numberCell,g as numberFormatSchema,o as parseEuropeanNumber,a as parseNumber,u as partsParamsSchema,p as partsSchema,r as resolveMapping,t as rowError,d as textOrNull,e as toMinorUnits};
2
2
  //# sourceMappingURL=index.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.mjs","names":[],"sources":["../../src/mapping/mapping-transform.ts"],"sourcesContent":["/**\n * `commerce:mapping` — the registered transform for runs driven by a saved\n * `import_mapping` (commerce-demo phase 02; the PR3 wizard enqueues these).\n *\n * The run's `params.mapping` carries a SNAPSHOT of the mapping config, so the run\n * is reproducible (it records the exact mapping used) and the transform stays a\n * pure function of `(row, ctx)` with no per-row DB I/O. This transform compiles\n * the snapshot once per `ctx.params` reference (memoized via a `WeakMap`, the same\n * shape as the carmaker path) and applies it.\n *\n * Output type is `unknown` because the target schema (parts vs discount_table)\n * isn't known until the snapshot is read at runtime. The parts schema additionally\n * reads brand/supplier identity from the SAME `params` object (alongside `mapping`).\n *\n * **Sink caveat.** The `runImport` runner bulk-writes whatever doc this produces\n * into the ES `parts` index. That's correct for a `parts` mapping but WRONG for a\n * `discount_table` mapping — its rows belong in Postgres (`supplier_discount_group`),\n * not ES. PR2 ships the discount_table SCHEMA (so the engine parses it) but no sink;\n * a `discount_table` run must NOT be enqueued through the parts-ES `runImport` path.\n * PR4 wires the Postgres sink + the route that enqueues discount runs.\n *\n * PR2 ships + tests this transform; the production registration (and the wizard\n * that creates the mappings + sets `params.feed` for the reader) land in PR3.\n */\n\nimport { z } from 'zod'\nimport type { RowTransform } from '../transform.js'\nimport { buildMappingTransform, mappingConfigSchema } from './engine.js'\nimport { getTargetSchema, listTargetSchemas } from './schema-registry.js'\n\n/** Stable name under which the mapping-driven transform is registered. */\nexport const MAPPING_TRANSFORM_NAME = 'commerce:mapping'\n\n/** The mapping snapshot lives under `params.mapping`; other params flow to the schema. */\nconst mappingParamsSchema = z.object({ mapping: mappingConfigSchema })\n\n/** Compiled-transform cache, keyed on the `ctx.params` object identity (per run). */\nconst compiledCache = new WeakMap<object, RowTransform<unknown>>()\n\nfunction compile(params: Record<string, unknown>): RowTransform<unknown> {\n const cached = compiledCache.get(params)\n if (cached) return cached\n const { mapping } = mappingParamsSchema.parse(params)\n const schema = getTargetSchema(mapping.targetSchema)\n if (!schema) {\n throw new Error(\n `Unknown target schema \"${mapping.targetSchema}\" (registered: ${listTargetSchemas().join(', ')})`,\n )\n }\n const transform = buildMappingTransform(mapping, schema)\n compiledCache.set(params, transform)\n return transform\n}\n\n/**\n * The registered transform. Resolves + compiles the mapping snapshot from\n * `ctx.params` (memoized), then delegates each row to it. A malformed snapshot or\n * unknown schema throws — surfaced by the runner as a row-level `transform_threw`.\n */\nexport const mappingTransform: RowTransform<unknown> = async (row, ctx) => {\n const transform = compile(ctx.params)\n return transform(row, ctx)\n}\n"],"mappings":"2PA+BA,MAAa,EAAyB,mBAGhC,EAAsB,EAAE,OAAO,CAAE,QAAS,CAAoB,CAAC,EAG/D,EAAgB,IAAI,QAE1B,SAAS,EAAQ,EAAwD,CACvE,IAAM,EAAS,EAAc,IAAI,CAAM,EACvC,GAAI,EAAQ,OAAO,EACnB,GAAM,CAAE,WAAY,EAAoB,MAAM,CAAM,EAC9C,EAAS,EAAgB,EAAQ,YAAY,EACnD,GAAI,CAAC,EACH,MAAU,MACR,0BAA0B,EAAQ,aAAa,iBAAiB,EAAkB,CAAC,CAAC,KAAK,IAAI,EAAE,EACjG,EAEF,IAAM,EAAY,EAAsB,EAAS,CAAM,EAEvD,OADA,EAAc,IAAI,EAAQ,CAAS,EAC5B,CACT,CAOA,MAAa,EAA0C,MAAO,EAAK,IAC/C,EAAQ,EAAI,MACf,CAAC,CAAC,EAAK,CAAG"}
1
+ {"version":3,"file":"index.mjs","names":[],"sources":["../../src/mapping/mapping-transform.ts"],"sourcesContent":["/**\n * `commerce:mapping` — the registered transform for runs driven by a saved\n * `import_mapping` (commerce-demo phase 02; the PR3 wizard enqueues these).\n *\n * The run's `params.mapping` carries a SNAPSHOT of the mapping config, so the run\n * is reproducible (it records the exact mapping used) and the transform stays a\n * pure function of `(row, ctx)` with no per-row DB I/O. This transform compiles\n * the snapshot once per `ctx.params` reference (memoized via a `WeakMap`, the same\n * shape as the carmaker path) and applies it.\n *\n * Output type is `unknown` because the target schema (parts vs discount_table)\n * isn't known until the snapshot is read at runtime. The parts schema additionally\n * reads brand/supplier identity from the SAME `params` object (alongside `mapping`).\n *\n * **Sink caveat.** The `runImport` runner bulk-writes whatever doc this produces\n * into the ES `parts` index. That's correct for a `parts` mapping but WRONG for a\n * `discount_table` mapping — its rows belong in Postgres (`supplier_discount_group`),\n * not ES. PR2 ships the discount_table SCHEMA (so the engine parses it) but no sink;\n * a `discount_table` run must NOT be enqueued through the parts-ES `runImport` path.\n * PR4 wires the Postgres sink + the route that enqueues discount runs.\n *\n * PR2 ships + tests this transform; the production registration (and the wizard\n * that creates the mappings + sets `params.feed` for the reader) land in PR3.\n */\n\nimport { z } from 'zod'\nimport type { RowTransform } from '../transform.js'\nimport { buildMappingTransform, mappingConfigSchema } from './engine.js'\nimport { getTargetSchema, listTargetSchemas } from './schema-registry.js'\n\n/** Stable name under which the mapping-driven transform is registered. */\nexport const MAPPING_TRANSFORM_NAME = 'commerce:mapping'\n\n/** The mapping snapshot lives under `params.mapping`; other params flow to the schema. */\nconst mappingParamsSchema = z.object({ mapping: mappingConfigSchema })\n\n/** Compiled-transform cache, keyed on the `ctx.params` object identity (per run). */\nconst compiledCache = new WeakMap<object, RowTransform<unknown>>()\n\nfunction compile(params: Record<string, unknown>): RowTransform<unknown> {\n const cached = compiledCache.get(params)\n if (cached) return cached\n const { mapping } = mappingParamsSchema.parse(params)\n const schema = getTargetSchema(mapping.targetSchema)\n if (!schema) {\n throw new Error(\n `Unknown target schema \"${mapping.targetSchema}\" (registered: ${listTargetSchemas().join(', ')})`,\n )\n }\n const transform = buildMappingTransform(mapping, schema)\n compiledCache.set(params, transform)\n return transform\n}\n\n/**\n * The registered transform. Resolves + compiles the mapping snapshot from\n * `ctx.params` (memoized), then delegates each row to it. A malformed snapshot or\n * unknown schema throws — surfaced by the runner as a row-level `transform_threw`.\n */\nexport const mappingTransform: RowTransform<unknown> = async (row, ctx) => {\n const transform = compile(ctx.params)\n return transform(row, ctx)\n}\n"],"mappings":"kQA+BA,MAAa,EAAyB,mBAGhC,EAAsB,EAAE,OAAO,CAAE,QAAS,CAAoB,CAAC,EAG/D,EAAgB,IAAI,QAE1B,SAAS,EAAQ,EAAwD,CACvE,IAAM,EAAS,EAAc,IAAI,CAAM,EACvC,GAAI,EAAQ,OAAO,EACnB,GAAM,CAAE,WAAY,EAAoB,MAAM,CAAM,EAC9C,EAAS,EAAgB,EAAQ,YAAY,EACnD,GAAI,CAAC,EACH,MAAU,MACR,0BAA0B,EAAQ,aAAa,iBAAiB,EAAkB,CAAC,CAAC,KAAK,IAAI,EAAE,EACjG,EAEF,IAAM,EAAY,EAAsB,EAAS,CAAM,EAEvD,OADA,EAAc,IAAI,EAAQ,CAAS,EAC5B,CACT,CAOA,MAAa,EAA0C,MAAO,EAAK,IAC/C,EAAQ,EAAI,MACf,CAAC,CAAC,EAAK,CAAG"}
@@ -1,4 +1,4 @@
1
- import { n as RowResult } from "./transform-BRJJLSXE.mjs";
1
+ import { r as RowResult } from "./transform-DqteN-Lm.mjs";
2
2
  import { z } from "zod";
3
3
  import { PartsDocument } from "@murumets-ee/search-elasticsearch";
4
4
 
@@ -274,6 +274,14 @@ declare const partsParamsSchema: z.ZodObject<{
274
274
  discountGroups?: Record<string, number> | undefined;
275
275
  }>;
276
276
  type PartsParams = z.infer<typeof partsParamsSchema>;
277
+ /**
278
+ * Uppercase + dash/whitespace-stripped (D2). `*`/other chars are preserved (e.g. `*S`
279
+ * markers). EXPORTED so downstream consumers that key off `code_normalized` — the
280
+ * replacement editor (commerce-demo PR6), which normalizes admin-typed codes before
281
+ * matching members — use the IDENTICAL rule (R05 Q7: normalization must match across
282
+ * every code source or merge-on-collision silently fails to collide).
283
+ */
284
+ declare function normalizeCode(s: string): string;
277
285
  /**
278
286
  * The `parts` schema. `buildRow` THROWS on invalid run params (so a misconfigured
279
287
  * run surfaces as a transform error, matching the carmaker contract) and returns a
@@ -281,5 +289,5 @@ type PartsParams = z.infer<typeof partsParamsSchema>;
281
289
  */
282
290
  declare const partsSchema: TargetSchema<PartsDocument>;
283
291
  //#endregion
284
- export { toMinorUnits as S, applyColumnTransforms as _, MappedRow as a, parseEuropeanNumber as b, TargetSchema as c, textOrNull as d, COLUMN_TRANSFORM_OPS as f, ColumnTransformOpDescriptor as g, ColumnTransformOp as h, BuildRowContext as i, numberCell as l, ColumnTransform as m, partsParamsSchema as n, NumberOutcome as o, CellValue as p, partsSchema as r, TargetField as s, PartsParams as t, rowError as u, columnTransformSchema as v, parseNumber as x, NumberFormat as y };
285
- //# sourceMappingURL=parts-schema-Bqau0MrJ.d.mts.map
292
+ export { toMinorUnits as C, parseNumber as S, ColumnTransformOpDescriptor as _, BuildRowContext as a, NumberFormat as b, TargetField as c, rowError as d, textOrNull as f, ColumnTransformOp as g, ColumnTransform as h, partsSchema as i, TargetSchema as l, CellValue as m, normalizeCode as n, MappedRow as o, COLUMN_TRANSFORM_OPS as p, partsParamsSchema as r, NumberOutcome as s, PartsParams as t, numberCell as u, applyColumnTransforms as v, parseEuropeanNumber as x, columnTransformSchema as y };
293
+ //# sourceMappingURL=parts-schema-Cmb0URGO.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"parts-schema-Bqau0MrJ.d.mts","names":[],"sources":["../src/mapping/number-format.ts","../src/mapping/column-transforms.ts","../src/mapping/target-schema.ts","../src/mapping/parts-schema.ts"],"mappings":";;;;;;;;;;AAqBA;;;;AAIW;AAsBX;;;;;;;;AAA6D;AA+B7D;AAAA,UAzDiB,YAAA;;EAEf,OAAA;EAuDwC;EArDxC,SAAS;AAAA;;;AA+EkC;;;;AC9E7C;;;;;;;;;;iBDqBgB,WAAA,CAAY,GAAA,UAAa,MAAA,EAAQ,YAAY;;;;;;;;iBA+B7C,YAAA,CAAa,KAAa;;;;;;;;;;;;;;;;;;;;;;;iBA0B1B,mBAAA,CAAoB,CAAS;;;;;AAzDgB;AA+B7D;;cCpDa,qBAAA,EAAqB,CAAA,CAAA,qBAAA,QAAA,CAAA,CAAA,SAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;KAYtB,eAAA,GAAkB,CAAA,CAAE,KAAK,QAAQ,qBAAA;;KAGjC,iBAAA,GAAoB,eAAe;;UAG9B,2BAAA;EACf,EAAA,EAAI,iBAAiB;EACrB,KAAA;;EAEA,QAAA;AAAA;;;;;;cAQW,oBAAA,WAA+B,2BAA2B;;KAY3D,SAAA;;;;;;;;iBASI,qBAAA,CACd,GAAA,UACA,UAAA,WAAqB,eAAA,IACrB,MAAA,EAAQ,YAAA,GACP,SAAA;;;;UC/Dc,WAAA;EF6B4C;EE3B3D,GAAA;EF0Dc;EExDd,KAAA;;EAEA,QAAA;EFsDwC;EEpDxC,IAAA;AAAA;;KAIU,SAAA,GAAY,MAAM,SAAS,SAAA;AF0EM;AAAA,UEvE5B,eAAA;EACf,WAAA;EACA,SAAA;EDTW;;;;ECcX,MAAA,EAAQ,MAAM;AAAA;;UAIC,YAAA;;EAEf,IAAA;;EAEA,MAAA,WAAiB,WAAA;;EAEjB,QAAA,CAAS,MAAA,EAAQ,SAAA,EAAW,GAAA,EAAK,eAAA,GAAkB,SAAA,CAAU,IAAA;AAAA;;;;;;iBAY/C,UAAA,CAAW,CAAwB,EAArB,SAAS;;KAO3B,aAAA;EACN,IAAA;EAAe,KAAA;AAAA;EACf,IAAA;AAAA;EACA,IAAA;AAAA;;;;;;iBAOU,UAAA,CAAW,CAAA,EAAG,SAAA,eAAwB,aAAa;;iBASnD,QAAA,CAAS,SAAA,UAAmB,OAAA,UAAiB,KAAA;;;;;;;;;;AFgBhB;;;;AC9E7C;;AD8E6C,cG1DhC,iBAAA,EAAiB,CAAA,CAAA,SAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;KAqBlB,WAAA,GAAc,CAAA,CAAE,KAAK,QAAQ,iBAAA;;;;;;cA6D5B,WAAA,EAAa,YAAY,CAAC,aAAA"}
1
+ {"version":3,"file":"parts-schema-Cmb0URGO.d.mts","names":[],"sources":["../src/mapping/number-format.ts","../src/mapping/column-transforms.ts","../src/mapping/target-schema.ts","../src/mapping/parts-schema.ts"],"mappings":";;;;;;;;;;AAqBA;;;;AAIW;AAsBX;;;;;;;;AAA6D;AA+B7D;AAAA,UAzDiB,YAAA;;EAEf,OAAA;EAuDwC;EArDxC,SAAS;AAAA;;;AA+EkC;;;;AC9E7C;;;;;;;;;;iBDqBgB,WAAA,CAAY,GAAA,UAAa,MAAA,EAAQ,YAAY;;;;;;;;iBA+B7C,YAAA,CAAa,KAAa;;;;;;;;;;;;;;;;;;;;;;;iBA0B1B,mBAAA,CAAoB,CAAS;;;;;AAzDgB;AA+B7D;;cCpDa,qBAAA,EAAqB,CAAA,CAAA,qBAAA,QAAA,CAAA,CAAA,SAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;KAYtB,eAAA,GAAkB,CAAA,CAAE,KAAK,QAAQ,qBAAA;;KAGjC,iBAAA,GAAoB,eAAe;;UAG9B,2BAAA;EACf,EAAA,EAAI,iBAAiB;EACrB,KAAA;;EAEA,QAAA;AAAA;;;;;;cAQW,oBAAA,WAA+B,2BAA2B;;KAY3D,SAAA;;;;;;;;iBASI,qBAAA,CACd,GAAA,UACA,UAAA,WAAqB,eAAA,IACrB,MAAA,EAAQ,YAAA,GACP,SAAA;;;;UC/Dc,WAAA;EF6B4C;EE3B3D,GAAA;EF0Dc;EExDd,KAAA;;EAEA,QAAA;EFsDwC;EEpDxC,IAAA;AAAA;;KAIU,SAAA,GAAY,MAAM,SAAS,SAAA;AF0EM;AAAA,UEvE5B,eAAA;EACf,WAAA;EACA,SAAA;EDTW;;;;ECcX,MAAA,EAAQ,MAAM;AAAA;;UAIC,YAAA;;EAEf,IAAA;;EAEA,MAAA,WAAiB,WAAA;;EAEjB,QAAA,CAAS,MAAA,EAAQ,SAAA,EAAW,GAAA,EAAK,eAAA,GAAkB,SAAA,CAAU,IAAA;AAAA;;;;;;iBAY/C,UAAA,CAAW,CAAwB,EAArB,SAAS;;KAO3B,aAAA;EACN,IAAA;EAAe,KAAA;AAAA;EACf,IAAA;AAAA;EACA,IAAA;AAAA;;;;;;iBAOU,UAAA,CAAW,CAAA,EAAG,SAAA,eAAwB,aAAa;;iBASnD,QAAA,CAAS,SAAA,UAAmB,OAAA,UAAiB,KAAA;;;;;;;;;;;;AD9D7D;;;;cEsBa,iBAAA,EAAiB,CAAA,CAAA,SAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;KAqBlB,WAAA,GAAc,CAAA,CAAE,KAAK,QAAQ,iBAAA;;;;;;;;iBAoDzB,aAAA,CAAc,CAAS;;;;;;cA8D1B,WAAA,EAAa,YAAY,CAAC,aAAA"}
@@ -1,2 +1,2 @@
1
- import{z as e}from"zod";const t=/^-?\d+(\.\d+)?$/;function n(e,n){let r=e.trim();if(r===``||r.toUpperCase()===`NA`)return null;let{decimal:i,thousands:a}=n,o=r;if(a!==``&&(o=o.split(a).join(``)),i===`,`){if(o.includes(`.`))return NaN;o=o.split(`,`).join(`.`)}else if(o.includes(`,`))return NaN;return t.test(o)?Number.parseFloat(o):NaN}function r(e){return Math.round(e*100)}function i(e){let n=e.trim();if(n===``||n.toUpperCase()===`NA`)return null;let r=n.includes(`,`),i=n.includes(`.`),a;if(r&&i)a=n.replace(/\./g,``).replace(`,`,`.`);else if(r)a=n.replace(`,`,`.`);else if(i)return NaN;else a=n;return t.test(a)?Number.parseFloat(a):NaN}const a=e.discriminatedUnion(`op`,[e.object({op:e.literal(`trim`)}),e.object({op:e.literal(`uppercase`)}),e.object({op:e.literal(`lowercase`)}),e.object({op:e.literal(`stripPrefix`),value:e.string().min(1).max(32)}),e.object({op:e.literal(`stripSuffix`),value:e.string().min(1).max(32)}),e.object({op:e.literal(`defaultIfEmpty`),value:e.string().max(255)}),e.object({op:e.literal(`number`)}),e.object({op:e.literal(`europeanNumber`)})]),o=[{op:`trim`,label:`Trim whitespace`,hasValue:!1},{op:`uppercase`,label:`Uppercase`,hasValue:!1},{op:`lowercase`,label:`Lowercase`,hasValue:!1},{op:`stripPrefix`,label:`Strip prefix`,hasValue:!0},{op:`stripSuffix`,label:`Strip suffix`,hasValue:!0},{op:`defaultIfEmpty`,label:`Default if empty`,hasValue:!0},{op:`number`,label:`Parse number (uses number format)`,hasValue:!1},{op:`europeanNumber`,label:`Parse European number (1.234,56)`,hasValue:!1}];function s(e,t,r){let a=e;for(let e of t)if(typeof a==`string`)switch(e.op){case`trim`:a=a.trim();break;case`uppercase`:a=a.toUpperCase();break;case`lowercase`:a=a.toLowerCase();break;case`stripPrefix`:a=a.startsWith(e.value)?a.slice(e.value.length):a;break;case`stripSuffix`:a=a.endsWith(e.value)?a.slice(0,a.length-e.value.length):a;break;case`defaultIfEmpty`:a=a.trim()===``?e.value:a;break;case`number`:a=n(a,r);break;case`europeanNumber`:a=i(a);break}return a}const c=e.object({source:e.string().min(1).max(256),target:e.string().min(1).max(64),transforms:e.array(a).default([])}),l=e.object({decimal:e.enum([`.`,`,`]),thousands:e.enum([``,`.`,`,`,` `])}).refine(e=>e.decimal!==e.thousands,{message:`decimal and thousands separators must differ`,path:[`thousands`]}),u=e.object({targetSchema:e.string().min(1).max(64),numberFormat:l,columnMap:e.array(c)});function d(e,t){let n=new Set(t.fields.map(e=>e.key));for(let r of e.columnMap)if(!n.has(r.target))throw Error(`Mapping target "${r.target}" is not a field of schema "${t.name}" (allowed: ${[...n].join(`, `)})`);let r=e.numberFormat,i=e.columnMap;return async(e,n)=>{let a={};for(let t of i){let n=e[t.source]??``;a[t.target]=s(n,t.transforms,r)}return t.buildRow(a,{importRunId:n.importRunId,rowNumber:n.rowNumber,params:n.params})}}function f(e,t){let n=e.filter(e=>e.active);if(t!==null){let e=n.find(e=>e.brandId===t);if(e)return e}return n.find(e=>e.brandId===null)}function p(e){if(typeof e!=`string`)return null;let t=e.trim();return t===``?null:t}function m(e){return e==null?{kind:`absent`}:typeof e==`number`?Number.isNaN(e)?{kind:`invalid`}:{kind:`value`,value:e}:e.trim()===``?{kind:`absent`}:{kind:`invalid`}}function h(e,t,n){return{kind:`error`,error:{errorType:e,message:t,field:n}}}const g=e.object({brandId:e.string().uuid(),brandSlug:e.string().min(1).max(64),supplierId:e.string().uuid(),supplierDisplayName:e.string().min(1).max(255),importBatchId:e.string().uuid(),supplierCodePrefix:e.string().max(8).regex(/^[A-Z0-9-]+$/,`must be uppercase alphanumeric or "-"`).optional(),discountGroups:e.record(e.string(),e.number().min(0).max(100)).optional()}),_=new WeakMap;function v(e){let t=_.get(e);if(t)return t;let n=g.parse(e);return _.set(e,n),n}const y=[`de`,`en`,`es`,`fr`,`it`,`nl`,`pt`,`ro`],b=e=>`name_${e}`,x=[{key:`code`,label:`Code`,required:!0,type:`string`},{key:`net_price`,label:`Net price`,required:!1,type:`number`},{key:`gross_price`,label:`Gross price`,required:!1,type:`number`},{key:`list_price`,label:`List price`,required:!1,type:`number`},{key:`currency`,label:`Currency`,required:!1,type:`string`},{key:`barcode`,label:`Barcode`,required:!1,type:`string`},{key:`description1`,label:`Description 1`,required:!1,type:`string`},{key:`description2`,label:`Description 2`,required:!1,type:`string`},{key:`discount_group`,label:`Discount group`,required:!1,type:`string`},{key:`superseding_code`,label:`Superseding code`,required:!1,type:`string`},...y.map(e=>({key:b(e),label:`Name (${e})`,required:!1,type:`string`}))],S=/[\s-]/g;function C(e){return e.toUpperCase().replace(S,``)}function w(e,t){return Math.round(e*(1-t/100))}const T={name:`parts`,fields:x,buildRow(e,t){let n=v(t.params),i=typeof e.code==`string`?e.code.trim():``;if(!i)return h(`missing_code`,`code is empty`,`code`);let a=C(n.supplierCodePrefix&&i.startsWith(n.supplierCodePrefix)?i.slice(n.supplierCodePrefix.length):i);if(!a)return h(`empty_normalized_code`,`Code "${i}" is empty after normalization`,`code`);let o=p(e.currency)??`EUR`;if(o!==`EUR`)return h(`unsupported_currency`,`Currency "${o}" not supported (EUR-only)`,`currency`);let s=m(e.net_price);if(s.kind===`invalid`)return h(`invalid_net_price`,`net_price is not a number`,`net_price`);let c=m(e.gross_price);if(c.kind===`invalid`)return h(`invalid_gross_price`,`gross_price is not a number`,`gross_price`);let l=m(e.list_price);if(l.kind===`invalid`)return h(`invalid_list_price`,`list_price is not a number`,`list_price`);let u=s.kind===`value`?s.value:null,d=l.kind===`value`?l.value:null,f=u??d;if(f===null)return h(`missing_price`,`no net_price or list_price provided`,`price`);let g=p(e.discount_group),_=d===null?null:r(d),y=null,b=[];if(u!==null)y=r(u);else if(_!==null){let e=g===null?void 0:n.discountGroups?.[g];e===void 0?b.push({warningType:`no_purchase_price`,message:g===null?`list price present but no discount group on the row — cost pending`:`no discount group "${g}" configured for this supplier — cost pending`,field:`discount_group`}):y=w(_,e)}let x=`${a}__${n.supplierId}`,S={doc_id:x,code:i,code_normalized:a,brand_id:n.brandId,brand_slug:n.brandSlug,supplier_id:n.supplierId,supplier_display_name:n.supplierDisplayName,net_price_eur:f,gross_price_eur:c.kind===`value`?c.value:null,currency:`EUR`,barcode:p(e.barcode),name_de:p(e.name_de),name_en:p(e.name_en),name_es:p(e.name_es),name_fr:p(e.name_fr),name_it:p(e.name_it),name_nl:p(e.name_nl),name_pt:p(e.name_pt),name_ro:p(e.name_ro),description1:p(e.description1),description2:p(e.description2),list_price_minor:_,discount_group:g,superseding_code:p(e.superseding_code),purchase_price_minor:y,core_charge_minor:null,core_charge_currency:null,core_charge_refundable:null,core_charge_tax_class:null,core_charge_tax_inclusive:null,import_batch_id:n.importBatchId,imported_at:new Date().toISOString()};return b.length>0?{kind:`success`,id:x,doc:S,warnings:b}:{kind:`success`,id:x,doc:S}}};export{p as a,u as c,o as d,s as f,r as g,n as h,h as i,l,i as m,T as n,d as o,a as p,m as r,c as s,g as t,f as u};
2
- //# sourceMappingURL=parts-schema-Vv0EJCjj.mjs.map
1
+ import{z as e}from"zod";const t=/^-?\d+(\.\d+)?$/;function n(e,n){let r=e.trim();if(r===``||r.toUpperCase()===`NA`)return null;let{decimal:i,thousands:a}=n,o=r;if(a!==``&&(o=o.split(a).join(``)),i===`,`){if(o.includes(`.`))return NaN;o=o.split(`,`).join(`.`)}else if(o.includes(`,`))return NaN;return t.test(o)?Number.parseFloat(o):NaN}function r(e){return Math.round(e*100)}function i(e){let n=e.trim();if(n===``||n.toUpperCase()===`NA`)return null;let r=n.includes(`,`),i=n.includes(`.`),a;if(r&&i)a=n.replace(/\./g,``).replace(`,`,`.`);else if(r)a=n.replace(`,`,`.`);else if(i)return NaN;else a=n;return t.test(a)?Number.parseFloat(a):NaN}const a=e.discriminatedUnion(`op`,[e.object({op:e.literal(`trim`)}),e.object({op:e.literal(`uppercase`)}),e.object({op:e.literal(`lowercase`)}),e.object({op:e.literal(`stripPrefix`),value:e.string().min(1).max(32)}),e.object({op:e.literal(`stripSuffix`),value:e.string().min(1).max(32)}),e.object({op:e.literal(`defaultIfEmpty`),value:e.string().max(255)}),e.object({op:e.literal(`number`)}),e.object({op:e.literal(`europeanNumber`)})]),o=[{op:`trim`,label:`Trim whitespace`,hasValue:!1},{op:`uppercase`,label:`Uppercase`,hasValue:!1},{op:`lowercase`,label:`Lowercase`,hasValue:!1},{op:`stripPrefix`,label:`Strip prefix`,hasValue:!0},{op:`stripSuffix`,label:`Strip suffix`,hasValue:!0},{op:`defaultIfEmpty`,label:`Default if empty`,hasValue:!0},{op:`number`,label:`Parse number (uses number format)`,hasValue:!1},{op:`europeanNumber`,label:`Parse European number (1.234,56)`,hasValue:!1}];function s(e,t,r){let a=e;for(let e of t)if(typeof a==`string`)switch(e.op){case`trim`:a=a.trim();break;case`uppercase`:a=a.toUpperCase();break;case`lowercase`:a=a.toLowerCase();break;case`stripPrefix`:a=a.startsWith(e.value)?a.slice(e.value.length):a;break;case`stripSuffix`:a=a.endsWith(e.value)?a.slice(0,a.length-e.value.length):a;break;case`defaultIfEmpty`:a=a.trim()===``?e.value:a;break;case`number`:a=n(a,r);break;case`europeanNumber`:a=i(a);break}return a}const c=e.object({source:e.string().min(1).max(256),target:e.string().min(1).max(64),transforms:e.array(a).default([])}),l=e.object({decimal:e.enum([`.`,`,`]),thousands:e.enum([``,`.`,`,`,` `])}).refine(e=>e.decimal!==e.thousands,{message:`decimal and thousands separators must differ`,path:[`thousands`]}),u=e.object({targetSchema:e.string().min(1).max(64),numberFormat:l,columnMap:e.array(c)});function d(e,t){let n=new Set(t.fields.map(e=>e.key));for(let r of e.columnMap)if(!n.has(r.target))throw Error(`Mapping target "${r.target}" is not a field of schema "${t.name}" (allowed: ${[...n].join(`, `)})`);let r=e.numberFormat,i=e.columnMap;return async(e,n)=>{let a={};for(let t of i){let n=e[t.source]??``;a[t.target]=s(n,t.transforms,r)}return t.buildRow(a,{importRunId:n.importRunId,rowNumber:n.rowNumber,params:n.params})}}function f(e,t){let n=e.filter(e=>e.active);if(t!==null){let e=n.find(e=>e.brandId===t);if(e)return e}return n.find(e=>e.brandId===null)}function p(e){if(typeof e!=`string`)return null;let t=e.trim();return t===``?null:t}function m(e){return e==null?{kind:`absent`}:typeof e==`number`?Number.isNaN(e)?{kind:`invalid`}:{kind:`value`,value:e}:e.trim()===``?{kind:`absent`}:{kind:`invalid`}}function h(e,t,n){return{kind:`error`,error:{errorType:e,message:t,field:n}}}const g=e.object({brandId:e.string().uuid(),brandSlug:e.string().min(1).max(64),supplierId:e.string().uuid(),supplierDisplayName:e.string().min(1).max(255),importBatchId:e.string().uuid(),supplierCodePrefix:e.string().max(8).regex(/^[A-Z0-9-]+$/,`must be uppercase alphanumeric or "-"`).optional(),discountGroups:e.record(e.string(),e.number().min(0).max(100)).optional()}),_=new WeakMap;function v(e){let t=_.get(e);if(t)return t;let n=g.parse(e);return _.set(e,n),n}const y=[`de`,`en`,`es`,`fr`,`it`,`nl`,`pt`,`ro`],b=e=>`name_${e}`,x=[{key:`code`,label:`Code`,required:!0,type:`string`},{key:`net_price`,label:`Net price`,required:!1,type:`number`},{key:`gross_price`,label:`Gross price`,required:!1,type:`number`},{key:`list_price`,label:`List price`,required:!1,type:`number`},{key:`currency`,label:`Currency`,required:!1,type:`string`},{key:`barcode`,label:`Barcode`,required:!1,type:`string`},{key:`description1`,label:`Description 1`,required:!1,type:`string`},{key:`description2`,label:`Description 2`,required:!1,type:`string`},{key:`discount_group`,label:`Discount group`,required:!1,type:`string`},{key:`superseding_code`,label:`Superseding code`,required:!1,type:`string`},{key:`core_charge`,label:`Core charge`,required:!1,type:`number`},...y.map(e=>({key:b(e),label:`Name (${e})`,required:!1,type:`string`}))],S=/[\s-]/g;function C(e){return e.toUpperCase().replace(S,``)}function w(e,t){return Math.round(e*(1-t/100))}function T(e){return e===null?{core_charge_minor:null,core_charge_currency:null,core_charge_refundable:null,core_charge_tax_class:null,core_charge_tax_inclusive:null}:{core_charge_minor:e,core_charge_currency:`EUR`,core_charge_refundable:!0,core_charge_tax_class:null,core_charge_tax_inclusive:!1}}const E={name:`parts`,fields:x,buildRow(e,t){let n=v(t.params),i=typeof e.code==`string`?e.code.trim():``;if(!i)return h(`missing_code`,`code is empty`,`code`);let a=C(n.supplierCodePrefix&&i.startsWith(n.supplierCodePrefix)?i.slice(n.supplierCodePrefix.length):i);if(!a)return h(`empty_normalized_code`,`Code "${i}" is empty after normalization`,`code`);let o=p(e.currency)??`EUR`;if(o!==`EUR`)return h(`unsupported_currency`,`Currency "${o}" not supported (EUR-only)`,`currency`);if(a.endsWith(`*S`)){let t=a.slice(0,-2);if(!t)return h(`core_charge_no_parent`,`Core-fee row "${i}" has no part code before the "*S" marker`,`code`);let o=m(e.net_price);if(o.kind===`invalid`)return h(`invalid_core_charge`,`core-fee (*S) row net_price is not a number`,`net_price`);if(o.kind===`absent`)return h(`missing_core_charge`,`core-fee (*S) row has no net_price to use as the deposit`,`net_price`);let s=r(o.value);return s<=0?{kind:`skip`,reason:`core_fee_zero`}:{kind:`skip`,reason:`core_charge_marker`,patches:[{id:`${t}__${n.supplierId}`,doc:T(s),orphanWarning:{warningType:`orphan_core_charge`,message:`core-fee (*S) row references a part not present in this import`,field:`code`}}]}}let s=m(e.net_price);if(s.kind===`invalid`)return h(`invalid_net_price`,`net_price is not a number`,`net_price`);let c=m(e.gross_price);if(c.kind===`invalid`)return h(`invalid_gross_price`,`gross_price is not a number`,`gross_price`);let l=m(e.list_price);if(l.kind===`invalid`)return h(`invalid_list_price`,`list_price is not a number`,`list_price`);let u=s.kind===`value`?s.value:null,d=l.kind===`value`?l.value:null,f=u??d;if(f===null)return h(`missing_price`,`no net_price or list_price provided`,`price`);let g=p(e.discount_group),_=d===null?null:r(d),y=null,b=[];if(u!==null)y=r(u);else if(_!==null){let e=g===null?void 0:n.discountGroups?.[g];e===void 0?b.push({warningType:`no_purchase_price`,message:g===null?`list price present but no discount group on the row — cost pending`:`no discount group "${g}" configured for this supplier — cost pending`,field:`discount_group`}):y=w(_,e)}let x=p(e.superseding_code),S=x===null?null:C(x),E=S!==null&&S!==``&&S!==a?[{from:a,to:S}]:[],D=m(e.core_charge);if(D.kind===`invalid`)return h(`invalid_core_charge`,`core_charge is not a number`,`core_charge`);let O=D.kind===`value`?r(D.value):null,k=O!==null&&O>0?O:null,A=`${a}__${n.supplierId}`;return{kind:`success`,id:A,doc:{doc_id:A,code:i,code_normalized:a,brand_id:n.brandId,brand_slug:n.brandSlug,supplier_id:n.supplierId,supplier_display_name:n.supplierDisplayName,net_price_eur:f,gross_price_eur:c.kind===`value`?c.value:null,currency:`EUR`,barcode:p(e.barcode),name_de:p(e.name_de),name_en:p(e.name_en),name_es:p(e.name_es),name_fr:p(e.name_fr),name_it:p(e.name_it),name_nl:p(e.name_nl),name_pt:p(e.name_pt),name_ro:p(e.name_ro),description1:p(e.description1),description2:p(e.description2),list_price_minor:_,discount_group:g,superseding_code:p(e.superseding_code),purchase_price_minor:y,...T(k),import_batch_id:n.importBatchId,imported_at:new Date().toISOString()},...b.length>0&&{warnings:b},...E.length>0&&{pairs:E}}}};export{r as _,h as a,c,f as d,o as f,n as g,i as h,m as i,u as l,a as m,g as n,p as o,s as p,E as r,d as s,C as t,l as u};
2
+ //# sourceMappingURL=parts-schema-RH48v5VW.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"parts-schema-RH48v5VW.mjs","names":[],"sources":["../src/mapping/number-format.ts","../src/mapping/column-transforms.ts","../src/mapping/engine.ts","../src/mapping/target-schema.ts","../src/mapping/parts-schema.ts"],"sourcesContent":["/**\n * Number parsing for the configurable mapping engine (commerce-demo phase 02).\n *\n * Real supplier feeds (R03) disagree on decimal AND thousands separators:\n * `55.02` (dot decimal), `326,88` (comma decimal), `1.234,56` (EU\n * thousands+decimal), `1,234.56` (US), `1 234,56` (space-grouped). The mapping\n * stores an explicit `{ decimal, thousands }` per profile so there is no guessing\n * — `parseNumber` trusts the configured format.\n *\n * Two parsers live here:\n * - `parseNumber(raw, format)` — the format-driven parser used by the generic\n * `number` per-column transform.\n * - `parseEuropeanNumber(raw)` — the carmaker-feed legacy parser (comma decimal,\n * dot thousands, **bare-dot rejected as ambiguous**). Carries the carmaker feed's\n * documented format with two intentional broadenings vs the old inline parser:\n * it trims surrounding whitespace, and returns `null` (not `NaN`) for an empty /\n * `'NA'` cell so both number ops share absent-vs-malformed semantics. The\n * carmaker doc-shape suite (27 tests) is the regression net.\n */\n\n/** Separator config for {@link parseNumber}. Stored per `import_mapping`. */\nexport interface NumberFormat {\n /** Decimal separator. */\n decimal: '.' | ','\n /** Thousands grouping separator. `''` = none. */\n thousands: '' | '.' | ',' | ' '\n}\n\n/** Post-cleanup numeric form: optional sign, digits, optional single decimal. */\nconst STRICT_NUMBER_RE = /^-?\\d+(\\.\\d+)?$/\n\n/**\n * Parse a feed cell into a number using an EXPLICIT `{ decimal, thousands }`\n * format. No ambiguity: thousands separators are stripped, the decimal separator\n * is normalised to `.`, and the result is strict-validated.\n *\n * Returns:\n * - `null` for an empty / `'NA'` cell (a legitimately-absent value — the caller\n * decides whether that's allowed for a given target field).\n * - `NaN` for a non-empty but unparseable cell (malformed — the caller surfaces\n * a row-level error rather than coercing to 0).\n * - the parsed number otherwise.\n *\n * A character that is neither a digit, the configured decimal, the configured\n * thousands separator, nor a leading `-` makes the cell unparseable (`NaN`) —\n * e.g. a literal `.` when the format's decimal is `,` and thousands is not `.`.\n */\nexport function parseNumber(raw: string, format: NumberFormat): number | null {\n const trimmed = raw.trim()\n if (trimmed === '' || trimmed.toUpperCase() === 'NA') return null\n\n const { decimal, thousands } = format\n let s = trimmed\n // 1. Remove every thousands separator (when one is configured).\n if (thousands !== '') s = s.split(thousands).join('')\n\n // 2. Normalise the decimal separator to `.`. After thousands removal, the\n // \"other\" separator must not appear — if it does, the cell doesn't match\n // the declared format and is unparseable.\n if (decimal === ',') {\n if (s.includes('.')) return Number.NaN\n s = s.split(',').join('.')\n } else {\n // decimal === '.'\n if (s.includes(',')) return Number.NaN\n }\n\n if (!STRICT_NUMBER_RE.test(s)) return Number.NaN\n return Number.parseFloat(s)\n}\n\n/**\n * Convert a major-unit money value (e.g. `55.02` EUR) to integer MINOR units\n * (`5502`). Rounds to the nearest cent to absorb float drift. The commerce-demo\n * PartsDocument money fields (`list_price_minor`, `purchase_price_minor`,\n * `core_charge_minor`) are integer minor units so derived prices (PR4) don't\n * accrue rounding error — see F003 in progress.md.\n */\nexport function toMinorUnits(value: number): number {\n return Math.round(value * 100)\n}\n\n/**\n * Parse a European-formatted decimal: `,` is the decimal separator, `.` is the\n * (optional) thousands separator. Returns `null` for an empty / `'NA'` cell and\n * `NaN` on unparseable (malformed) input.\n *\n * This is the carmaker-feed legacy parser, preserved from the hardcoded transform.\n * Crucially it **rejects bare-dot** input (`1.98`, `1.234`) as ambiguous rather\n * than guessing — the carmaker feeds are documented as comma-decimal, and a `.`\n * without a `,` could mean a US-format decimal OR a thousands marker. The\n * format-driven {@link parseNumber} doesn't need this guard (its separators are\n * explicit); the carmaker path keeps it.\n *\n * The `null` (absent) vs `NaN` (malformed) split mirrors {@link parseNumber} so\n * the mapping engine treats both number ops uniformly: a target field's `buildRow`\n * reads `null` as \"not provided\" and `NaN` as \"invalid\". This is what lets the\n * reimplemented carmaker transform keep its `GrossPrice 'NA' → null` behaviour\n * (optional field) while still erroring on a malformed `NetPrice` (required).\n *\n * Cases: `'1,98'`→`1.98`, `'1.234,56'`→`1234.56`, `'5'`→`5`, `'-5,00'`→`-5`,\n * empty / `'NA'` → `null`. Rejected (`NaN`): bare-dot (`'1.98'`, `'1.234'`),\n * trailing junk (`'1,98EUR'`), multiple commas (`'1,2,3'`), other non-numeric.\n */\nexport function parseEuropeanNumber(s: string): number | null {\n const trimmed = s.trim()\n if (trimmed === '' || trimmed.toUpperCase() === 'NA') return null\n const hasComma = trimmed.includes(',')\n const hasDot = trimmed.includes('.')\n let cleaned: string\n if (hasComma && hasDot) {\n // European thousands+decimal: strip every `.`, then swap the (sole) `,` for\n // `.`. The strict regex below catches multi-comma input.\n cleaned = trimmed.replace(/\\./g, '').replace(',', '.')\n } else if (hasComma) {\n cleaned = trimmed.replace(',', '.')\n } else if (hasDot) {\n return Number.NaN\n } else {\n cleaned = trimmed\n }\n if (!STRICT_NUMBER_RE.test(cleaned)) return Number.NaN\n return Number.parseFloat(cleaned)\n}\n","/**\n * Per-column transforms for the mapping engine (commerce-demo phase 02, D005).\n *\n * A **bounded, enum-keyed** library of cleaning operations applied to a source\n * cell before it lands on a target field. This is the security model from R02:\n * transforms are declarative operations with Zod-validated params, NEVER\n * user-supplied executable code. The carmaker feed's logic decomposes exactly\n * into `stripPrefix` + `europeanNumber` (+ the schema's code-normalize).\n *\n * Number-producing ops (`number`, `europeanNumber`) are TERMINAL by convention —\n * they convert the string cell into `number | null`; string ops after them are\n * no-ops. The engine reads the result as `string | number | null` and hands it to\n * the target schema's `buildRow`.\n */\n\nimport { z } from 'zod'\nimport { type NumberFormat, parseEuropeanNumber, parseNumber } from './number-format.js'\n\n/** Max length for a strip-affix value — bounds the param, no ReDoS surface. */\nconst MAX_AFFIX_LEN = 32\n\n/**\n * Discriminated union of bounded transform ops. The TS type is INFERRED from\n * this schema so there's a single source of truth + the snapshot is validated at\n * the engine boundary.\n */\nexport const columnTransformSchema = z.discriminatedUnion('op', [\n z.object({ op: z.literal('trim') }),\n z.object({ op: z.literal('uppercase') }),\n z.object({ op: z.literal('lowercase') }),\n z.object({ op: z.literal('stripPrefix'), value: z.string().min(1).max(MAX_AFFIX_LEN) }),\n z.object({ op: z.literal('stripSuffix'), value: z.string().min(1).max(MAX_AFFIX_LEN) }),\n z.object({ op: z.literal('defaultIfEmpty'), value: z.string().max(255) }),\n z.object({ op: z.literal('number') }),\n z.object({ op: z.literal('europeanNumber') }),\n])\n\n/** One declarative cleaning operation. */\nexport type ColumnTransform = z.infer<typeof columnTransformSchema>\n\n/** The discriminator literal of a {@link ColumnTransform}. */\nexport type ColumnTransformOp = ColumnTransform['op']\n\n/** UI descriptor for one transform op: its key, a label, and whether it takes a `value` param. */\nexport interface ColumnTransformOpDescriptor {\n op: ColumnTransformOp\n label: string\n /** `true` → the op carries a `value` string param (`stripPrefix`/`stripSuffix`/`defaultIfEmpty`). */\n hasValue: boolean\n}\n\n/**\n * Static catalogue of the available per-column transform ops, for the PR3 wizard's\n * transform picker. The ORDER is the suggested UI order; `number`/`europeanNumber`\n * are terminal (see {@link applyColumnTransforms}) and labelled accordingly.\n */\nexport const COLUMN_TRANSFORM_OPS: readonly ColumnTransformOpDescriptor[] = [\n { op: 'trim', label: 'Trim whitespace', hasValue: false },\n { op: 'uppercase', label: 'Uppercase', hasValue: false },\n { op: 'lowercase', label: 'Lowercase', hasValue: false },\n { op: 'stripPrefix', label: 'Strip prefix', hasValue: true },\n { op: 'stripSuffix', label: 'Strip suffix', hasValue: true },\n { op: 'defaultIfEmpty', label: 'Default if empty', hasValue: true },\n { op: 'number', label: 'Parse number (uses number format)', hasValue: false },\n { op: 'europeanNumber', label: 'Parse European number (1.234,56)', hasValue: false },\n] as const\n\n/** The value a column carries as it moves through the transform pipeline. */\nexport type CellValue = string | number | null\n\n/**\n * Apply a transform pipeline to a raw cell, left-to-right. String ops act on the\n * string value; the `number` / `europeanNumber` ops are terminal (convert to\n * `number | null`, with `NaN` for a malformed numeric — the schema's `buildRow`\n * surfaces that as a row error). Once the value is non-string, later string ops\n * pass through unchanged.\n */\nexport function applyColumnTransforms(\n raw: string,\n transforms: readonly ColumnTransform[],\n format: NumberFormat,\n): CellValue {\n let value: CellValue = raw\n for (const t of transforms) {\n if (typeof value !== 'string') {\n // A prior number op already produced number|null — string ops are no-ops\n // past this point (number ops are terminal by convention).\n continue\n }\n switch (t.op) {\n case 'trim':\n value = value.trim()\n break\n case 'uppercase':\n value = value.toUpperCase()\n break\n case 'lowercase':\n value = value.toLowerCase()\n break\n case 'stripPrefix':\n value = value.startsWith(t.value) ? value.slice(t.value.length) : value\n break\n case 'stripSuffix':\n value = value.endsWith(t.value) ? value.slice(0, value.length - t.value.length) : value\n break\n case 'defaultIfEmpty':\n value = value.trim() === '' ? t.value : value\n break\n case 'number':\n value = parseNumber(value, format)\n break\n case 'europeanNumber':\n value = parseEuropeanNumber(value)\n break\n }\n }\n return value\n}\n","/**\n * Mapping engine (commerce-demo phase 02, D005).\n *\n * Turns a declarative `MappingConfig` (which source column → which whitelisted\n * target field, plus per-column transforms + a number format) into a\n * `RowTransform` the existing runner drives. The pipeline per row is:\n *\n * raw cells → applyColumnTransforms(per column) → MappedRow → schema.buildRow\n *\n * Two whitelists guard the boundary (R02 injection model): the `target_schema`\n * name resolves to a registered `TargetSchema`, and every `column_map[].target`\n * must be one of that schema's fields — enforced here at build time (throws),\n * before any row is processed.\n *\n * `resolveMapping` implements the D002 most-specific ladder used at enqueue time\n * to pick the `(supplier, brand)` mapping over the `(supplier, NULL)` default.\n */\n\nimport { z } from 'zod'\nimport type { RowTransform } from '../transform.js'\nimport { applyColumnTransforms, columnTransformSchema } from './column-transforms.js'\nimport type { MappedRow, TargetSchema } from './target-schema.js'\n\n/** One column-map entry. `target` is checked against the schema whitelist separately. */\nexport const mappingColumnSchema = z.object({\n /** Source header name (or positional index string for headerless feeds). */\n source: z.string().min(1).max(256),\n /** Target field key — must be in the chosen schema's whitelist (enforced in `buildMappingTransform`). */\n target: z.string().min(1).max(64),\n /** Ordered per-column cleaning ops. Defaults to none. */\n transforms: z.array(columnTransformSchema).default([]),\n})\nexport type MappingColumn = z.infer<typeof mappingColumnSchema>\n\n/**\n * Decimal + thousands separators for the `number` transform.\n *\n * The two MUST differ: `parseNumber` strips every thousands separator FIRST, so a\n * config where `decimal === thousands` (e.g. both `,`) would strip the decimal\n * separator too — `326,88` → `32688`, a silent 100× error rather than a row error.\n * Rejected at save (`assertValidMapping`) AND at run (`mappingConfigSchema`).\n */\nexport const numberFormatSchema = z\n .object({\n decimal: z.enum(['.', ',']),\n thousands: z.enum(['', '.', ',', ' ']),\n })\n .refine((f) => f.decimal !== f.thousands, {\n message: 'decimal and thousands separators must differ',\n path: ['thousands'],\n })\n\n/**\n * The portable mapping config — the subset of an `import_mapping` row the engine\n * needs. The enqueuer SNAPSHOTS this into `import_run.params.mapping` so a run\n * records the exact mapping it used (reproducible) and the transform stays a pure\n * function of `(row, ctx)` with no per-row DB I/O.\n */\nexport const mappingConfigSchema = z.object({\n targetSchema: z.string().min(1).max(64),\n numberFormat: numberFormatSchema,\n columnMap: z.array(mappingColumnSchema),\n})\nexport type MappingConfig = z.infer<typeof mappingConfigSchema>\n\n/**\n * Build a `RowTransform` from a validated mapping config + its target schema.\n *\n * Throws at BUILD time if any `column_map` target is outside the schema's\n * whitelist (deny-by-default, R02) — so a misconfigured mapping fails loudly\n * before a single row is written, rather than silently dropping a column.\n *\n * Generic over the schema's output type, so passing the concrete `partsSchema`\n * yields a `RowTransform<PartsDocument>` (the carmaker path keeps its typing); the\n * registry-resolved `TargetSchema<unknown>` yields `RowTransform<unknown>` (the\n * `commerce:mapping` path, where the output type isn't known until runtime).\n */\nexport function buildMappingTransform<TOut>(\n config: MappingConfig,\n schema: TargetSchema<TOut>,\n): RowTransform<TOut> {\n const allowed = new Set(schema.fields.map((f) => f.key))\n for (const col of config.columnMap) {\n if (!allowed.has(col.target)) {\n throw new Error(\n `Mapping target \"${col.target}\" is not a field of schema \"${schema.name}\" ` +\n `(allowed: ${[...allowed].join(', ')})`,\n )\n }\n }\n\n const format = config.numberFormat\n const columnMap = config.columnMap\n\n return async (row, ctx) => {\n const mapped: MappedRow = {}\n for (const col of columnMap) {\n // `source` is read as data from the parsed row map — never used as a key\n // into framework objects (R02). A missing source column yields `''`.\n const raw = row[col.source] ?? ''\n mapped[col.target] = applyColumnTransforms(raw, col.transforms, format)\n }\n return schema.buildRow(mapped, {\n importRunId: ctx.importRunId,\n rowNumber: ctx.rowNumber,\n params: ctx.params,\n })\n }\n}\n\n/** The minimal shape `resolveMapping` needs — satisfied by an `import_mapping` row or a test fixture. */\nexport interface ResolvableMapping {\n /** `null` = supplier default (applies to all brands). */\n brandId: string | null\n active: boolean\n}\n\n/**\n * Most-specific resolution (D002 ladder): given the candidate mappings for one\n * `(supplier, target_schema)` and the requested brand, return the brand-specific\n * mapping if one exists, else the supplier-default (`brandId === null`). Inactive\n * mappings are skipped. Returns `undefined` when nothing matches.\n */\nexport function resolveMapping<M extends ResolvableMapping>(\n mappings: readonly M[],\n brandId: string | null,\n): M | undefined {\n const active = mappings.filter((m) => m.active)\n if (brandId !== null) {\n const specific = active.find((m) => m.brandId === brandId)\n if (specific) return specific\n }\n return active.find((m) => m.brandId === null)\n}\n","/**\n * TargetSchema — the contract a mapping writes INTO (commerce-demo phase 02, D005).\n *\n * A mapping's `column_map` points source columns at a schema's whitelisted target\n * fields; the engine applies the per-column transforms to build a `MappedRow`, then\n * hands it to `schema.buildRow` to produce the typed output document (an ES\n * `PartsDocument` for `parts`, a discount row for `discount_table`).\n *\n * The `fields` list is the SECOND injection-boundary whitelist (the first is the\n * registered schema name): a `column_map` entry whose `target` is not a field key\n * here is rejected at validation time (R02). Two schemas ship in PR2 — `parts` and\n * `discount_table`.\n */\n\nimport type { RowResult } from '../transform.js'\nimport type { CellValue } from './column-transforms.js'\n\n/** One whitelisted target field on a schema. */\nexport interface TargetField {\n /** Stable target key referenced by `column_map[].target`. */\n key: string\n /** Human label for the wizard dropdown (PR3). */\n label: string\n /** Whether a mapping for this schema MUST include this target. */\n required: boolean\n /** Coarse type — drives the wizard's transform suggestions; `buildRow` does the real coercion. */\n type: 'string' | 'number'\n}\n\n/** The per-row product of the engine: target field key → cleaned value. */\nexport type MappedRow = Record<string, CellValue>\n\n/** Per-row context handed to `buildRow`. `params` is the opaque per-run params object. */\nexport interface BuildRowContext {\n importRunId: string\n rowNumber: number\n /**\n * Opaque per-run params (the parts schema reads brand/supplier identity from\n * it; `discount_table` ignores it). The schema validates the shape it needs.\n */\n params: Record<string, unknown>\n}\n\n/** A registered target schema: a whitelist + a row builder. */\nexport interface TargetSchema<TOut> {\n /** Registered name — also the `import_mapping.target_schema` value. */\n name: string\n /** The target-field whitelist (injection boundary). */\n fields: readonly TargetField[]\n /** Build a typed output (success) / skip / error from a mapped row. */\n buildRow(mapped: MappedRow, ctx: BuildRowContext): RowResult<TOut>\n}\n\n// ---------------------------------------------------------------------------\n// Shared `buildRow` helpers (used by every concrete schema).\n// ---------------------------------------------------------------------------\n\n/**\n * A nullable text cell → trimmed string, or `null` for empty / non-string. Accepts\n * `undefined` (an UNMAPPED target — indexed access under `noUncheckedIndexedAccess`)\n * and treats it as absent.\n */\nexport function textOrNull(v: CellValue | undefined): string | null {\n if (typeof v !== 'string') return null\n const t = v.trim()\n return t === '' ? null : t\n}\n\n/** The three outcomes a `number` / `europeanNumber` op produces for a cell. */\nexport type NumberOutcome =\n | { kind: 'value'; value: number }\n | { kind: 'absent' }\n | { kind: 'invalid' }\n\n/**\n * Classify a cell that should hold a number. `undefined` (unmapped) / `null` →\n * absent; a real number → value (`NaN` → invalid); a non-empty raw string (no\n * number op declared) → invalid; a blank raw string → absent.\n */\nexport function numberCell(v: CellValue | undefined): NumberOutcome {\n if (v === undefined || v === null) return { kind: 'absent' }\n if (typeof v === 'number') {\n return Number.isNaN(v) ? { kind: 'invalid' } : { kind: 'value', value: v }\n }\n return v.trim() === '' ? { kind: 'absent' } : { kind: 'invalid' }\n}\n\n/** Build a `RowResult` error variant (assignable to any `TargetSchema<TOut>`). */\nexport function rowError(errorType: string, message: string, field: string) {\n return { kind: 'error' as const, error: { errorType, message, field } }\n}\n","/**\n * `parts` target schema — maps a cleaned feed row into the ES `PartsDocument`\n * (commerce-demo phase 02, D005). This is the SHARED assembly + validation logic\n * the reimplemented carmaker transform and every operator-built `parts` mapping\n * run through, so the row taxonomy (code normalization, currency gate, price\n * validation) is uniform across all supplier feeds.\n *\n * **Field coverage.** This schema populates the direct passthroughs (the existing\n * `PartsDocument` columns plus `list_price_minor`, `discount_group`,\n * `superseding_code`) AND the DERIVED `purchase_price_minor` (PR4 — direct net, or\n * two-file `list × (1 − groupDiscount)` resolved from `params.discountGroups`). Core\n * charge (Korpusetasu, PR7, D004) has TWO encodings → one `core_charge_minor`:\n * Encoding A is a `surcharge`/`core_charge` COLUMN on the saleable row (written here);\n * Encoding B is a separate `<code>*S` core-fee row that skips the saleable index and\n * emits a doc-patch attaching its deposit to the PARENT part in the runner's phase-2\n * pass. Replacement-pair SIDE EFFECTS (separate entity) are PR6.\n *\n * **Money.** Every money field is integer minor units via `toMinorUnits` (F003), and\n * the two-file derivation stays in minor units end-to-end (no major↔minor\n * round-trip). The legacy `net_price_eur` stays a major-unit float for back-compat:\n * it holds the direct net price, or — for a two-file LIST feed with no direct net —\n * the list price as a display placeholder (the true cost is `purchase_price_minor`).\n *\n * **Cost-pending warning.** A list-only row whose `discount_group` doesn't resolve\n * against `params.discountGroups` is still indexed (list set, `purchase_price_minor`\n * null) and carries a `no_purchase_price` WARNING — not a row error (phase 03 §5).\n */\n\nimport type { PartsDocument } from '@murumets-ee/search-elasticsearch'\nimport { z } from 'zod'\nimport type { DirectionalPair, DocPatch, RowWarning } from '../transform.js'\nimport { toMinorUnits } from './number-format.js'\nimport {\n type BuildRowContext,\n type MappedRow,\n numberCell,\n rowError,\n type TargetField,\n type TargetSchema,\n textOrNull,\n} from './target-schema.js'\n\n/**\n * Params the `parts` schema reads from the run — the per-(supplier, brand)\n * identity resolved at enqueue time. Identical to the carmaker transform's params\n * (the carmaker path re-exports this), so brand/supplier injection + the optional\n * supplier code-prefix strip are validated the same way everywhere.\n */\nexport const partsParamsSchema = z.object({\n brandId: z.string().uuid(),\n brandSlug: z.string().min(1).max(64),\n supplierId: z.string().uuid(),\n supplierDisplayName: z.string().min(1).max(255),\n importBatchId: z.string().uuid(),\n /** Supplier code prefix to strip before normalization (e.g. `'ME-'`). Optional. */\n supplierCodePrefix: z\n .string()\n .max(8)\n .regex(/^[A-Z0-9-]+$/, 'must be uppercase alphanumeric or \"-\"')\n .optional(),\n /**\n * Purchase-discount map for this supplier (commerce-demo PR4, phase 03 §2):\n * `{ groupCode → discountPct }`, resolved from `supplier_discount_group` at\n * enqueue time and snapshotted onto the run so `buildRow` stays a pure function\n * (no per-row DB I/O; `@murumets-ee/imports` stays a leaf). Absent for direct-net\n * suppliers + the carmaker path. Percentages are 0–100 (the entity constrains them).\n */\n discountGroups: z.record(z.string(), z.number().min(0).max(100)).optional(),\n})\nexport type PartsParams = z.infer<typeof partsParamsSchema>\n\n/** Per-run memoization of the parsed params (keyed on the `ctx.params` reference). */\nconst paramsCache = new WeakMap<object, PartsParams>()\n\nfunction resolveParams(raw: Record<string, unknown>): PartsParams {\n const cached = paramsCache.get(raw)\n if (cached) return cached\n const parsed = partsParamsSchema.parse(raw)\n paramsCache.set(raw, parsed)\n return parsed\n}\n\n/** Per-locale display-name target fields (8 locales). */\nconst NAME_LOCALES = ['de', 'en', 'es', 'fr', 'it', 'nl', 'pt', 'ro'] as const\ntype NameLocale = (typeof NAME_LOCALES)[number]\nconst nameKey = (l: NameLocale): `name_${NameLocale}` => `name_${l}`\n\n/** The `parts` target-field whitelist (PR2 subset — `core_charge`/`purchase_price` are later PRs). */\nconst PARTS_FIELDS: readonly TargetField[] = [\n { key: 'code', label: 'Code', required: true, type: 'string' },\n { key: 'net_price', label: 'Net price', required: false, type: 'number' },\n { key: 'gross_price', label: 'Gross price', required: false, type: 'number' },\n { key: 'list_price', label: 'List price', required: false, type: 'number' },\n { key: 'currency', label: 'Currency', required: false, type: 'string' },\n { key: 'barcode', label: 'Barcode', required: false, type: 'string' },\n { key: 'description1', label: 'Description 1', required: false, type: 'string' },\n { key: 'description2', label: 'Description 2', required: false, type: 'string' },\n { key: 'discount_group', label: 'Discount group', required: false, type: 'string' },\n { key: 'superseding_code', label: 'Superseding code', required: false, type: 'string' },\n // Core charge (Korpusetasu, commerce-demo PR7, D004). Encoding A: a supplier's\n // `surcharge`/`core_charge` COLUMN maps here → this row's deposit. (Encoding B —\n // a separate `<code>*S` row — needs no whitelist field; it's detected on the code.)\n { key: 'core_charge', label: 'Core charge', required: false, type: 'number' },\n ...NAME_LOCALES.map(\n (l): TargetField => ({\n key: nameKey(l),\n label: `Name (${l})`,\n required: false,\n type: 'string',\n }),\n ),\n] as const\n\nconst NORMALIZE_CODE_RE = /[\\s-]/g\n/**\n * Uppercase + dash/whitespace-stripped (D2). `*`/other chars are preserved (e.g. `*S`\n * markers). EXPORTED so downstream consumers that key off `code_normalized` — the\n * replacement editor (commerce-demo PR6), which normalizes admin-typed codes before\n * matching members — use the IDENTICAL rule (R05 Q7: normalization must match across\n * every code source or merge-on-collision silently fails to collide).\n */\nexport function normalizeCode(s: string): string {\n return s.toUpperCase().replace(NORMALIZE_CODE_RE, '')\n}\n\n/**\n * Two-file purchase price (phase 03 §2): `list × (1 − discountPct/100)`, computed\n * ENTIRELY in integer minor units so no major↔minor round-trip can introduce drift\n * (F003). `listMinor` is already minor units; the result is rounded to the nearest\n * minor unit. Worked (R03 A+B): `5502 × 0.74 = 4071.48 → 4071` (€40.71).\n */\nfunction deriveTwoFilePurchaseMinor(listMinor: number, discountPct: number): number {\n return Math.round(listMinor * (1 - discountPct / 100))\n}\n\n/**\n * The core-charge marker suffix on a NORMALIZED code (D004, SD002). `normalizeCode`\n * uppercases + strips dashes/spaces but PRESERVES `*`, so a `<code>*S` core-fee row\n * arrives here intact — e.g. `1120698*S`. A code ending in `*S` is a core-fee line,\n * not a saleable part (Encoding B).\n */\nconst CORE_FEE_SUFFIX = '*S'\n\n/** The five core-charge doc fields the two encodings write. */\ntype CoreChargeFields = Pick<\n PartsDocument,\n | 'core_charge_minor'\n | 'core_charge_currency'\n | 'core_charge_refundable'\n | 'core_charge_tax_class'\n | 'core_charge_tax_inclusive'\n>\n\n/**\n * The tax-NEUTRAL core-charge block (D004 / D007) for a deposit amount in minor\n * units, or all-null when there's no core charge. EUR (D9) + refundable by default;\n * the imported EU feed amounts are net so `tax_inclusive = false`; `tax_class` stays\n * null — no rate is baked in here, the future pluggable EU/US tax engine maps it (D007).\n */\nfunction coreChargeFields(minor: number | null): CoreChargeFields {\n if (minor === null) {\n return {\n core_charge_minor: null,\n core_charge_currency: null,\n core_charge_refundable: null,\n core_charge_tax_class: null,\n core_charge_tax_inclusive: null,\n }\n }\n return {\n core_charge_minor: minor,\n core_charge_currency: 'EUR',\n core_charge_refundable: true,\n core_charge_tax_class: null,\n core_charge_tax_inclusive: false,\n }\n}\n\n/**\n * The `parts` schema. `buildRow` THROWS on invalid run params (so a misconfigured\n * run surfaces as a transform error, matching the carmaker contract) and returns a\n * typed `RowResult` per row otherwise.\n */\nexport const partsSchema: TargetSchema<PartsDocument> = {\n name: 'parts',\n fields: PARTS_FIELDS,\n buildRow(mapped: MappedRow, ctx: BuildRowContext) {\n const params = resolveParams(ctx.params)\n\n // 1. Code (required) → raw `code` + normalized `code_normalized` + doc id.\n const codeRaw = typeof mapped.code === 'string' ? mapped.code.trim() : ''\n if (!codeRaw) return rowError('missing_code', 'code is empty', 'code')\n const stripped =\n params.supplierCodePrefix && codeRaw.startsWith(params.supplierCodePrefix)\n ? codeRaw.slice(params.supplierCodePrefix.length)\n : codeRaw\n const codeNormalized = normalizeCode(stripped)\n if (!codeNormalized) {\n return rowError(\n 'empty_normalized_code',\n `Code \"${codeRaw}\" is empty after normalization`,\n 'code',\n )\n }\n\n // 2. Currency gate (D9 EUR-only). Unmapped/blank defaults to EUR. Runs BEFORE the\n // `*S` detection so a non-EUR core-fee row is rejected too — Encoding A and B keep\n // the SAME currency contract (a `*S` deposit is never silently coerced to EUR).\n const currencyText = textOrNull(mapped.currency)\n const currency = currencyText ?? 'EUR'\n if (currency !== 'EUR') {\n return rowError(\n 'unsupported_currency',\n `Currency \"${currency}\" not supported (EUR-only)`,\n 'currency',\n )\n }\n\n // 2b. Core-fee line — Encoding B (D004, SD002). A `<code>*S` normalized code is a\n // core-fee MARKER row, not a saleable part: strip the `*S`, take THIS row's net\n // price as the deposit, and emit a doc-patch that attaches it onto the PARENT\n // part's core-charge fields. The row itself SKIPS the saleable index (never a\n // `*S` SKU — D004). The runner applies the patch in a phase-2 pass; a parent\n // that isn't in the import becomes an `orphan_core_charge` warning (never a\n // phantom doc, never a silent drop). A `*S` with no parent code, or no/invalid\n // amount, is a row error.\n if (codeNormalized.endsWith(CORE_FEE_SUFFIX)) {\n const parentNormalized = codeNormalized.slice(0, -CORE_FEE_SUFFIX.length)\n if (!parentNormalized) {\n return rowError(\n 'core_charge_no_parent',\n `Core-fee row \"${codeRaw}\" has no part code before the \"*S\" marker`,\n 'code',\n )\n }\n const deposit = numberCell(mapped.net_price)\n if (deposit.kind === 'invalid') {\n return rowError('invalid_core_charge', 'core-fee (*S) row net_price is not a number', 'net_price')\n }\n if (deposit.kind === 'absent') {\n return rowError(\n 'missing_core_charge',\n 'core-fee (*S) row has no net_price to use as the deposit',\n 'net_price',\n )\n }\n // Gate on the MINOR-unit result (a sub-cent value like 0.004 → round to 0):\n // 0 / sub-cent / negative deposit → nothing to attach; drop it without a patch\n // rather than zeroing a parent's core charge.\n const depositMinor = toMinorUnits(deposit.value)\n if (depositMinor <= 0) {\n return { kind: 'skip', reason: 'core_fee_zero' }\n }\n const patch: DocPatch<PartsDocument> = {\n id: `${parentNormalized}__${params.supplierId}`,\n doc: coreChargeFields(depositMinor),\n orphanWarning: {\n warningType: 'orphan_core_charge',\n message: 'core-fee (*S) row references a part not present in this import',\n field: 'code',\n },\n }\n return { kind: 'skip', reason: 'core_charge_marker', patches: [patch] }\n }\n\n // 3. Prices. net/gross/list each: absent → null, invalid → error, value → number.\n const net = numberCell(mapped.net_price)\n if (net.kind === 'invalid')\n return rowError('invalid_net_price', 'net_price is not a number', 'net_price')\n const gross = numberCell(mapped.gross_price)\n if (gross.kind === 'invalid') {\n return rowError('invalid_gross_price', 'gross_price is not a number', 'gross_price')\n }\n const list = numberCell(mapped.list_price)\n if (list.kind === 'invalid')\n return rowError('invalid_list_price', 'list_price is not a number', 'list_price')\n\n const netValue = net.kind === 'value' ? net.value : null\n const listValue = list.kind === 'value' ? list.value : null\n // At least one price must resolve. `net_price_eur` is non-nullable on the doc,\n // so it takes the direct net or — for a list-only two-file feed — the list\n // price as a display placeholder (the true cost is `purchase_price_minor`).\n const displayPrice = netValue ?? listValue\n if (displayPrice === null) {\n return rowError('missing_price', 'no net_price or list_price provided', 'price')\n }\n\n // 4. Purchase-price derivation (phase 03 §2, PR4). All money stays in integer\n // minor units (F003 — never round-trip back to major).\n // - direct net column (R03 C/D) → purchase = net\n // - two-file list × (1 − groupDiscount) → purchase = round(list × (1 − d/100))\n // - neither resolvable → cost pending: index list-only + WARN\n // (a `no_purchase_price` warning, NOT a row error — the part is still indexed).\n const discountGroup = textOrNull(mapped.discount_group)\n const listMinor = listValue === null ? null : toMinorUnits(listValue)\n let purchasePriceMinor: number | null = null\n const warnings: RowWarning[] = []\n if (netValue !== null) {\n purchasePriceMinor = toMinorUnits(netValue)\n } else if (listMinor !== null) {\n // List-only row: resolve the supplier's discount group → derive cost.\n const discountPct =\n discountGroup !== null ? params.discountGroups?.[discountGroup] : undefined\n if (discountPct !== undefined) {\n purchasePriceMinor = deriveTwoFilePurchaseMinor(listMinor, discountPct)\n } else {\n warnings.push({\n warningType: 'no_purchase_price',\n message:\n discountGroup === null\n ? 'list price present but no discount group on the row — cost pending'\n : `no discount group \"${discountGroup}\" configured for this supplier — cost pending`,\n field: 'discount_group',\n })\n }\n }\n\n // 5. Replacement-pair side-output (commerce-demo PR6, SD007). A row's\n // `superseding_code` (a SPARSE column on a full manufacturer catalogue) names\n // the newer/current code that supersedes THIS row's code. Normalize it the SAME\n // way as `code_normalized` so the downstream chain merge collides codes (R05 Q7),\n // and emit one directional pair `(from = old → to = new)`. A superseding code that\n // is empty, normalizes to empty (e.g. `'---'`), or self-pairs (`to === from`)\n // yields no edge.\n const supersedingRaw = textOrNull(mapped.superseding_code)\n const supersedingNormalized = supersedingRaw === null ? null : normalizeCode(supersedingRaw)\n const pairs: DirectionalPair[] =\n supersedingNormalized !== null &&\n supersedingNormalized !== '' &&\n supersedingNormalized !== codeNormalized\n ? [{ from: codeNormalized, to: supersedingNormalized }]\n : []\n\n // 6. Core charge — Encoding A (D004). A `surcharge`/`core_charge` COLUMN on this\n // saleable row is the deposit for THIS part. 0 / empty / absent / sub-cent → no\n // core charge. (Encoding B — a separate `<code>*S` row — is handled at step 1b\n // and attaches to the parent in phase-2, so it never reaches here. In the unusual\n // case a part has BOTH a `core_charge` column AND a sibling `*S` row — suppliers\n // use one encoding — the `*S` phase-2 patch runs after indexing and wins.)\n const coreChargeCell = numberCell(mapped.core_charge)\n if (coreChargeCell.kind === 'invalid') {\n return rowError('invalid_core_charge', 'core_charge is not a number', 'core_charge')\n }\n // Gate on the MINOR-unit result so a sub-cent value (rounds to 0) yields no charge.\n const coreChargeMinorRaw =\n coreChargeCell.kind === 'value' ? toMinorUnits(coreChargeCell.value) : null\n const coreChargeMinor =\n coreChargeMinorRaw !== null && coreChargeMinorRaw > 0 ? coreChargeMinorRaw : null\n\n const docId = `${codeNormalized}__${params.supplierId}`\n const doc: PartsDocument = {\n doc_id: docId,\n code: codeRaw,\n code_normalized: codeNormalized,\n brand_id: params.brandId,\n brand_slug: params.brandSlug,\n supplier_id: params.supplierId,\n supplier_display_name: params.supplierDisplayName,\n net_price_eur: displayPrice,\n gross_price_eur: gross.kind === 'value' ? gross.value : null,\n currency: 'EUR',\n barcode: textOrNull(mapped.barcode),\n name_de: textOrNull(mapped.name_de),\n name_en: textOrNull(mapped.name_en),\n name_es: textOrNull(mapped.name_es),\n name_fr: textOrNull(mapped.name_fr),\n name_it: textOrNull(mapped.name_it),\n name_nl: textOrNull(mapped.name_nl),\n name_pt: textOrNull(mapped.name_pt),\n name_ro: textOrNull(mapped.name_ro),\n description1: textOrNull(mapped.description1),\n description2: textOrNull(mapped.description2),\n // commerce-demo: list price → minor units; group + code passthroughs.\n list_price_minor: listMinor,\n discount_group: discountGroup,\n superseding_code: textOrNull(mapped.superseding_code),\n // Derived purchase cost (PR4 — direct net or two-file list × (1 − d)); null = cost pending.\n purchase_price_minor: purchasePriceMinor,\n // Core charge (PR7, Encoding A column) — or all-null when this row has none.\n ...coreChargeFields(coreChargeMinor),\n import_batch_id: params.importBatchId,\n imported_at: new Date().toISOString(),\n }\n\n return {\n kind: 'success',\n id: docId,\n doc,\n ...(warnings.length > 0 && { warnings }),\n ...(pairs.length > 0 && { pairs }),\n }\n },\n}\n"],"mappings":"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"}
package/dist/plugin.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { n as EsClientResolver, r as FilePathResolver, t as EnsureIndexResolver } from "./worker-Ck67AOnh.mjs";
1
+ import { n as EsClientResolver, r as FilePathResolver, s as PairSink, t as EnsureIndexResolver } from "./worker-CO5zK62E.mjs";
2
2
  import { ImportsStorageFactory } from "./storage.mjs";
3
3
 
4
4
  //#region src/plugin.d.ts
@@ -42,6 +42,14 @@ interface ImportsPluginOptions {
42
42
  * the absolute path on `import_run.filePath`.
43
43
  */
44
44
  resolveFilePath?: FilePathResolver;
45
+ /**
46
+ * Optional consumer for the directional supersession pairs the runner harvests as
47
+ * a side-output (commerce-demo PR6, SD007). The app composes it — commerce exports
48
+ * the replacement-merge sink and passes it here, same injection shape as
49
+ * {@link esClient} / {@link resolveFilePath}. Omit when not ingesting replacement
50
+ * codes; the pairs are then discarded after the run.
51
+ */
52
+ pairSink?: PairSink;
45
53
  /**
46
54
  * Optional dedicated `StorageClient` factory for imports. When
47
55
  * provided, the upload route (in `@murumets-ee/commerce`) and the
@@ -1 +1 @@
1
- {"version":3,"file":"plugin.d.mts","names":[],"sources":["../src/plugin.ts"],"mappings":";;;;UAuEiB,oBAAA;EAuGoC;;;;;EAjGnD,QAAA,EAAU,gBAAA;;;;;;;EAOV,OAAA;;;;;;;;;;;EAWA,WAAA,GAAc,mBAAA;;;;;;;;;;;;;;;EAed,eAAA,GAAkB,gBAAA;;;;;;;;;;;;;;;;;;EAkBlB,OAAA,GAAU,qBAAA;;;;;;;;;;;;;;;EAeV,aAAA;;;;;;;;;;;;;;;EAeA,oBAAA;;;;;;;;;;;;;EAaA,sBAAA;AAAA;AAAA,iBAGc,OAAA,CAAQ,OAAA,EAAS,oBAAA,GAAoB,IAAA,6BAAA,MAAA;EAAA"}
1
+ {"version":3,"file":"plugin.d.mts","names":[],"sources":["../src/plugin.ts"],"mappings":";;;;UAwEiB,oBAAA;EA+GD;;;;;EAzGd,QAAA,EAAU,gBAAA;EAyGyC;;;;;;EAlGnD,OAAA;;;;;;;;;;;EAWA,WAAA,GAAc,mBAAA;;;;;;;;;;;;;;;EAed,eAAA,GAAkB,gBAAA;;;;;;;;EAQlB,QAAA,GAAW,QAAA;;;;;;;;;;;;;;;;;;EAkBX,OAAA,GAAU,qBAAA;;;;;;;;;;;;;;;EAeV,aAAA;;;;;;;;;;;;;;;EAeA,oBAAA;;;;;;;;;;;;;EAaA,sBAAA;AAAA;AAAA,iBAGc,OAAA,CAAQ,OAAA,EAAS,oBAAA,GAAoB,IAAA,6BAAA,MAAA;EAAA"}
package/dist/plugin.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import{registerImportsStorageFactory as e}from"./storage.mjs";import{a as t,l as n,n as r}from"./transform-BzacGXbo.mjs";import{createImportsRunDeadListener as i,createRunImportHandler as a,importsRunJob as o}from"./worker.mjs";import{definePlugin as s}from"@murumets-ee/core";const c=Symbol.for(`@murumets-ee/imports:dead-listener-unsubscribe`);function l(l){let u=l.esIndex??`parts`,d=l.storageBucket===null?null:{bucket:l.storageBucket??`lumi-imports`,orphanTtlHours:l.orphanUploadTtlHours??2,processedRetentionDays:l.processedRetentionDays??7};return l.storage&&e(l.storage),s({name:`@murumets-ee/imports`,server:{entities:[t,n],init:async e=>{if(!e.plugins.all().some(e=>e.name===`@murumets-ee/queue`)){e.logger.warn(`imports: queue() plugin not in plugins array — imports:run jobs will not be processed`);return}let n=await import(`@murumets-ee/queue/client`),s=e.getClient(t),f=r();n.registerJob(o,a({importRuns:s,transforms:f,esClient:l.esClient,esIndex:u,...l.ensureIndex!==void 0&&{ensureIndex:l.ensureIndex},...l.resolveFilePath!==void 0&&{resolveFilePath:l.resolveFilePath},logger:e.logger.child({pkg:`imports`})}));let p=globalThis[c];p&&p();let m=n.addQueueTerminalListener(i({importRuns:s,logger:e.logger.child({pkg:`imports`})}));if(globalThis[c]=m,d!==null){let{cleanupOrphanedImports:t,cleanupProcessedImports:r}=await import(`./cleanup-BzqyX9Jo.mjs`).then(e=>e.r),{runAsCli:i}=await import(`@murumets-ee/core`),a=e.logger.child({pkg:`imports`,job:`cleanup`});n.registerJob(n.defineJob({name:`imports:cleanup-orphaned-uploads`,description:`Delete toolkit_files rows in the imports bucket with no matching import_run reference and createdAt older than the configured TTL.`,schedule:`@every 1h`}),async()=>{await i(()=>t({app:e,bucket:d.bucket,ttlHours:d.orphanTtlHours,logger:a}))}),n.registerJob(n.defineJob({name:`imports:cleanup-processed-uploads`,description:`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.`,schedule:`@every 1h`}),async()=>{await i(()=>r({app:e,bucket:d.bucket,retentionDays:d.processedRetentionDays,logger:a}))})}e.logger.info({esIndex:u,cleanup:d===null?{enabled:!1}:{enabled:!0,...d}},`Imports plugin initialized`)}}})}export{l as imports};
1
+ import{registerImportsStorageFactory as e}from"./storage.mjs";import{a as t,l as n,n as r}from"./transform-ytS1L5p6.mjs";import{createImportsRunDeadListener as i,createRunImportHandler as a,importsRunJob as o}from"./worker.mjs";import{definePlugin as s}from"@murumets-ee/core";const c=Symbol.for(`@murumets-ee/imports:dead-listener-unsubscribe`);function l(l){let u=l.esIndex??`parts`,d=l.storageBucket===null?null:{bucket:l.storageBucket??`lumi-imports`,orphanTtlHours:l.orphanUploadTtlHours??2,processedRetentionDays:l.processedRetentionDays??7};return l.storage&&e(l.storage),s({name:`@murumets-ee/imports`,server:{entities:[t,n],init:async e=>{if(!e.plugins.all().some(e=>e.name===`@murumets-ee/queue`)){e.logger.warn(`imports: queue() plugin not in plugins array — imports:run jobs will not be processed`);return}let n=await import(`@murumets-ee/queue/client`),s=e.getClient(t),f=r();n.registerJob(o,a({importRuns:s,transforms:f,esClient:l.esClient,esIndex:u,...l.ensureIndex!==void 0&&{ensureIndex:l.ensureIndex},...l.resolveFilePath!==void 0&&{resolveFilePath:l.resolveFilePath},...l.pairSink!==void 0&&{pairSink:l.pairSink},logger:e.logger.child({pkg:`imports`})}));let p=globalThis[c];p&&p();let m=n.addQueueTerminalListener(i({importRuns:s,logger:e.logger.child({pkg:`imports`})}));if(globalThis[c]=m,d!==null){let{cleanupOrphanedImports:t,cleanupProcessedImports:r}=await import(`./cleanup-DsQ42ki-.mjs`).then(e=>e.r),{runAsCli:i}=await import(`@murumets-ee/core`),a=e.logger.child({pkg:`imports`,job:`cleanup`});n.registerJob(n.defineJob({name:`imports:cleanup-orphaned-uploads`,description:`Delete toolkit_files rows in the imports bucket with no matching import_run reference and createdAt older than the configured TTL.`,schedule:`@every 1h`}),async()=>{await i(()=>t({app:e,bucket:d.bucket,ttlHours:d.orphanTtlHours,logger:a}))}),n.registerJob(n.defineJob({name:`imports:cleanup-processed-uploads`,description:`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.`,schedule:`@every 1h`}),async()=>{await i(()=>r({app:e,bucket:d.bucket,retentionDays:d.processedRetentionDays,logger:a}))})}e.logger.info({esIndex:u,cleanup:d===null?{enabled:!1}:{enabled:!0,...d}},`Imports plugin initialized`)}}})}export{l as imports};
2
2
  //# sourceMappingURL=plugin.mjs.map
@@ -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. Per-feed\n * transforms (carmaker, …) live in PR 8 and self-register against the\n * process-global transform registry — this plugin's init does NOT\n * import any transform.\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() and the carmaker-transform plugin must be present too.\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 { type ImportsStorageFactory, registerImportsStorageFactory } from './storage.js'\nimport { getTransformRegistry } from './transform.js'\nimport {\n createImportsRunDeadListener,\n createRunImportHandler,\n type EnsureIndexResolver,\n type EsClientResolver,\n type FilePathResolver,\n importsRunJob,\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 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-playground/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 // 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 const transforms = getTransformRegistry()\n\n queueClientModule.registerJob(\n importsRunJob,\n createRunImportHandler({\n importRuns,\n transforms,\n esClient: options.esClient,\n esIndex,\n ...(options.ensureIndex !== undefined && { ensureIndex: options.ensureIndex }),\n ...(options.resolveFilePath !== undefined && {\n resolveFilePath: options.resolveFilePath,\n }),\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":"qRAiEA,MAAM,EAA4B,OAAO,IAAI,gDAAgD,EA6G7F,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,CAKnB,GAAI,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,EACpC,EAAa,EAAqB,EAExC,EAAkB,YAChB,EACA,EAAuB,CACrB,aACA,aACA,SAAU,EAAQ,SAClB,UACA,GAAI,EAAQ,cAAgB,IAAA,IAAa,CAAE,YAAa,EAAQ,WAAY,EAC5E,GAAI,EAAQ,kBAAoB,IAAA,IAAa,CAC3C,gBAAiB,EAAQ,eAC3B,EACA,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/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. Per-feed\n * transforms (carmaker, …) live in PR 8 and self-register against the\n * process-global transform registry — this plugin's init does NOT\n * import any transform.\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() and the carmaker-transform plugin must be present too.\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 { type ImportsStorageFactory, registerImportsStorageFactory } from './storage.js'\nimport { getTransformRegistry } from './transform.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-playground/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 // 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 const transforms = getTransformRegistry()\n\n queueClientModule.registerJob(\n importsRunJob,\n createRunImportHandler({\n importRuns,\n transforms,\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":"qRAkEA,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,CAKnB,GAAI,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,EACpC,EAAa,EAAqB,EAExC,EAAkB,YAChB,EACA,EAAuB,CACrB,aACA,aACA,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"}
@@ -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";const a=50,o=5;var s=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 c(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 l(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*u(e){let{filePath:r,delimiter:i=` `,hasHeader:a=!0,columns:o,relaxColumnCount:s=!1,quote:l=!1}=e,u=o?Array.from(o):a?e=>{let t=e.map(e=>e.trim());return c(t),t}:!1,d=t(r),f=d.pipe(n({delimiter:i,columns:u,bom:!0,skip_empty_lines:!0,relax_column_count:s,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 d=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 f(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*p(e){if(e.format===`xlsx`){let{streamXlsx:t}=await import(`./xlsx-reader-CUbaPt3-.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*u(f(e))}const m=1e3;async function h(e){let{importRunId:t,runLabel:n,params:a,transform:o,feed:c,esClient:l,esIndex:u,batchSize:d=m,onProgress:f,rowLimit:h,signal:g,errorTracker:_=new s,warningTracker:v=new s,logger:y}=e;if(d<1)throw Error(`batchSize must be >= 1 (got ${d})`);let b=Date.now();y?.info({importRunId:t,runLabel:n,batchSize:d,esIndex:u},`imports.runner: started`);let x=0,S=0,C=0,w=0,T=0,E=0,D=[],O=[],k=[],A=async()=>{if(k.length===0)return;let e=k;k=[];let n=Date.now(),r;try{r=await i(l,{index:u,docs:e.map(({id:e,doc:t})=>({id:e,doc:t})),...g!==void 0&&{signal:g}})}catch(t){let n=g?.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)_.addError(t,n,r,void 0,null);throw C+=e.length,t}S+=r.succeeded,C+=r.failures.length,E+=1;let a=Date.now()-n,o=(Date.now()-b)/1e3;if(y?.info({importRunId:t,batch:E,flushMs:a,submitted:e.length,succeeded:r.succeeded,failed:r.failures.length,rowsRead:x,rowsPerSecond:o>0?Math.round(x/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;_.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)){T+=1;for(let e of t.warnings)v.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)||D.push(...t.pairs);if(f){let e=(Date.now()-b)/1e3;f({rowsRead:x,rowsSucceeded:S,rowsFailed:C,rowsSkipped:w,rowsWarned:T,batchesCompleted:E,elapsedSeconds:e,rowsPerSecond:e>0?x/e:0,distinctErrorPatterns:_.getDistinctPatternCount()})}},j=d,M=new Map;for await(let{rowNumber:e,row:r}of p(c)){if(g?.aborted||h!==void 0&&x>=h)break;if(x+=1,x===1&&y?.info({importRunId:t,rowNumber:e,sampleKeys:Object.keys(r)},`imports.runner: STREAM_FIRST_ROW`),x%j===0){let e=(Date.now()-b)/1e3;y?.info({importRunId:t,rowsRead:x,rowsSucceeded:S,rowsFailed:C,rowsSkipped:w,pending:k.length,elapsedSec:Math.round(e*10)/10,rowsPerSecond:e>0?Math.round(x/e):0,distinctFirstErrorTypes:M.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);_.addError(e,`transform_threw`,i,void 0,r),M.has(`transform_threw`)||(M.set(`transform_threw`,{rowNumber:e,message:i}),y?.warn({importRunId:t,rowNumber:e,errorType:`transform_threw`,message:i},`imports.runner: FIRST_ERROR transform_threw`)),C+=1;continue}if(s.kind===`skip`){w+=1,s.patches&&s.patches.length>0&&O.push(...s.patches);continue}if(s.kind===`error`){_.addError(e,s.error.errorType,s.error.message,s.error.field,r),M.has(s.error.errorType)||(M.set(s.error.errorType,{rowNumber:e,message:s.error.message}),y?.warn({importRunId:t,rowNumber:e,errorType:s.error.errorType,field:s.error.field,message:s.error.message},`imports.runner: FIRST_ERROR ${s.error.errorType}`)),C+=1;continue}k.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}}),k.length>=d&&await A()}await A();let N=0,P=0;for(let e=0;e<O.length;e+=d){if(g?.aborted)throw Error(`imports: run aborted during core-charge attach (phase 2)`);let t=O.slice(e,e+d),n;try{n=await r(l,{index:u,updates:t.map(({id:e,doc:t})=>({id:e,doc:t})),...g!==void 0&&{signal:g}})}catch(e){if(g?.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)v.addError(-1,`patch_attach_failed`,n,e.orphanWarning?.field,{target:e.id});continue}if(N+=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`?(P+=1,v.addError(-1,n?.warningType??`orphan_patch`,n?.message??t.reason,n?.field,{target:t.id})):v.addError(-1,`patch_attach_failed`,t.reason,n?.field,{target:t.id})}}}let F=(Date.now()-b)/1e3;return y?.info({importRunId:t,rowsRead:x,rowsSucceeded:S,rowsFailed:C,rowsSkipped:w,batchesCompleted:E,patchesApplied:N,patchesOrphaned:P,totalSec:Math.round(F*10)/10,rowsPerSecond:F>0?Math.round(x/F):0},`imports.runner: finished`),{rowsRead:x,rowsSucceeded:S,rowsFailed:C,rowsSkipped:w,rowsWarned:T,batchesCompleted:E,errors:_.snapshot(),warnings:v.snapshot(),pairs:D,patchesApplied:N,patchesOrphaned:P}}export{c as a,a as c,p as i,o as l,h as n,l as o,d as r,u as s,m as t,s as u};
2
+ //# sourceMappingURL=runner-BOwPUm7W.mjs.map