@murumets-ee/imports 0.30.0 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dist/{cleanup-DJHXcbpZ.mjs → cleanup-CFo7Pw0t.mjs} +2 -2
  2. package/dist/{cleanup-DJHXcbpZ.mjs.map → cleanup-CFo7Pw0t.mjs.map} +1 -1
  3. package/dist/index.d.mts +94 -2
  4. package/dist/index.d.mts.map +1 -1
  5. package/dist/index.mjs +1 -1
  6. package/dist/mapping/index.d.mts +367 -0
  7. package/dist/mapping/index.d.mts.map +1 -0
  8. package/dist/mapping/index.mjs +2 -0
  9. package/dist/mapping/index.mjs.map +1 -0
  10. package/dist/parts-schema-DQeMGPBC.mjs +2 -0
  11. package/dist/parts-schema-DQeMGPBC.mjs.map +1 -0
  12. package/dist/parts-schema-RI_ltubl.d.mts +260 -0
  13. package/dist/parts-schema-RI_ltubl.d.mts.map +1 -0
  14. package/dist/plugin.d.mts +1 -1
  15. package/dist/plugin.d.mts.map +1 -1
  16. package/dist/plugin.mjs +1 -1
  17. package/dist/plugin.mjs.map +1 -1
  18. package/dist/runner-DvKNfQJO.mjs +2 -0
  19. package/dist/runner-DvKNfQJO.mjs.map +1 -0
  20. package/dist/storage-resolver.d.mts +1 -1
  21. package/dist/transform-DloItB80.mjs +2 -0
  22. package/dist/transform-DloItB80.mjs.map +1 -0
  23. package/dist/transforms.d.mts +19 -33
  24. package/dist/transforms.d.mts.map +1 -1
  25. package/dist/transforms.mjs +1 -1
  26. package/dist/transforms.mjs.map +1 -1
  27. package/dist/{worker-BY0JQw39.d.mts → worker-ByPOP6Qe.d.mts} +29 -25
  28. package/dist/worker-ByPOP6Qe.d.mts.map +1 -0
  29. package/dist/worker.d.mts +1 -1
  30. package/dist/worker.mjs +1 -1
  31. package/dist/xlsx-reader-B2_F37jA.mjs +2 -0
  32. package/dist/xlsx-reader-B2_F37jA.mjs.map +1 -0
  33. package/package.json +13 -8
  34. package/dist/runner-CSlZ0JFO.mjs +0 -2
  35. package/dist/runner-CSlZ0JFO.mjs.map +0 -1
  36. package/dist/transform-BzqTvH7B.mjs +0 -2
  37. package/dist/transform-BzqTvH7B.mjs.map +0 -1
  38. package/dist/worker-BY0JQw39.d.mts.map +0 -1
@@ -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-BzqTvH7B.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-BzqTvH7B.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-DJHXcbpZ.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-DloItB80.mjs`).then(e=>e.l),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-DloItB80.mjs`).then(e=>e.l),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-CFo7Pw0t.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"cleanup-DJHXcbpZ.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-CFo7Pw0t.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,8 +1,100 @@
1
- import { C as ErrorSample, D as ImportRun, E as IMPORT_RUN_STATUSES, O as ImportRunClient, S as ErrorPattern, T as ErrorTrackerConfig, _ as StreamFeedRow, b as DEFAULT_MAX_PATTERNS, d as DEFAULT_BATCH_SIZE, f as ImportRunProgress, g as StreamFeedOptions, h as runImport, k as ImportRunStatus, m as RunImportResult, n as FilePathResolver, p as RunImportOptions, t as EsClientResolver, v as countDataRows, w as ErrorTracker, x as DEFAULT_MAX_SAMPLES_PER_PATTERN, y as streamFeed } from "./worker-BY0JQw39.mjs";
1
+ import { A as ImportRunClient, C as DEFAULT_MAX_SAMPLES_PER_PATTERN, D as ErrorTrackerConfig, E as ErrorTracker, O as IMPORT_RUN_STATUSES, S as DEFAULT_MAX_PATTERNS, T as ErrorSample, _ as readFeed, b as countDataRows, d as DEFAULT_BATCH_SIZE, f as ImportRunProgress, g as ReadFeedOptions, h as runImport, j as ImportRunStatus, k as ImportRun, m as RunImportResult, n as FilePathResolver, p as RunImportOptions, t as EsClientResolver, v as StreamFeedOptions, w as ErrorPattern, x as streamFeed, y as StreamFeedRow } from "./worker-ByPOP6Qe.mjs";
2
2
  import { a as TransformName, c as registerImportTransform, i as TransformContext, n as RowResult, o as TransformRegistry, r as RowTransform, s as getTransformRegistry, t as RowError } from "./transform-dO4z0Ze6.mjs";
3
+ import { Behavior } from "@murumets-ee/entity";
3
4
  import { ToolkitApp } from "@murumets-ee/core";
4
5
  import { Logger as Logger$1 } from "@murumets-ee/logging";
6
+ import { AdminClient } from "@murumets-ee/entity/admin";
5
7
 
8
+ //#region src/entities/import-mapping.d.ts
9
+ /**
10
+ * Registered target schemas a mapping may write into. This is the FIRST of two
11
+ * whitelists (the second is each schema's target-field set) — a mapping whose
12
+ * `targetSchema` is outside this list is rejected (deny-by-default, R02).
13
+ */
14
+ declare const IMPORT_MAPPING_TARGET_SCHEMAS: readonly ["parts", "discount_table"];
15
+ type ImportMappingTargetSchema = (typeof IMPORT_MAPPING_TARGET_SCHEMAS)[number];
16
+ /** File formats the reader dispatches on. `csv`/`tsv` → csv-parse; `xlsx` → exceljs. */
17
+ declare const IMPORT_MAPPING_FORMATS: readonly ["csv", "tsv", "xlsx"];
18
+ type ImportMappingFormat = (typeof IMPORT_MAPPING_FORMATS)[number];
19
+ declare const ImportMapping: import("@murumets-ee/entity").Entity<{
20
+ id: import("@murumets-ee/entity").IdField;
21
+ } & import("@murumets-ee/entity").AuditableFields & Record<never, never> & {
22
+ /** FK to commerce `supplier` (by name string — no cross-package import). Required. */supplierId: import("@murumets-ee/entity").ReferenceField & {
23
+ cardinality: "one" | "many";
24
+ } & {
25
+ readonly entity: "supplier";
26
+ readonly required: true;
27
+ readonly indexed: true;
28
+ }; /** FK to commerce `brand`. NULL = supplier default (applies to all that supplier's brands). */
29
+ brandId: import("@murumets-ee/entity").ReferenceField & {
30
+ cardinality: "one" | "many";
31
+ } & {
32
+ readonly entity: "brand";
33
+ readonly indexed: true;
34
+ }; /** Which registered target schema this mapping feeds — `parts` or `discount_table`. */
35
+ targetSchema: import("@murumets-ee/entity").SelectField & {
36
+ options: readonly ["parts", "discount_table"];
37
+ } & {
38
+ readonly options: readonly ["parts", "discount_table"];
39
+ readonly required: true;
40
+ readonly indexed: true;
41
+ }; /** Operator-facing profile name (a supplier can have e.g. "monthly full" vs "weekly delta"). */
42
+ name: import("@murumets-ee/entity").TextField & {
43
+ readonly required: true;
44
+ readonly maxLength: 255;
45
+ }; /** File format — drives reader dispatch + the default delimiter. */
46
+ format: import("@murumets-ee/entity").SelectField & {
47
+ options: readonly ["csv", "tsv", "xlsx"];
48
+ } & {
49
+ readonly options: readonly ["csv", "tsv", "xlsx"];
50
+ readonly required: true;
51
+ };
52
+ /**
53
+ * Explicit single-char field delimiter override. When omitted, the reader
54
+ * defaults from `format` (`csv` → `,`, `tsv` → `\t`). Ignored for `xlsx`.
55
+ */
56
+ delimiter: import("@murumets-ee/entity").TextField & {
57
+ readonly maxLength: 4;
58
+ }; /** First row is the header. `false` → positional `{ "0": …, "1": … }` source keys. */
59
+ hasHeader: import("@murumets-ee/entity").BooleanField & {
60
+ readonly default: true;
61
+ }; /** 0-based header row index (header hygiene — R03 feeds have leading/trailing-space headers). */
62
+ headerRowIndex: import("@murumets-ee/entity").NumberField & {
63
+ readonly integer: true;
64
+ readonly min: 0;
65
+ readonly default: 0;
66
+ }; /** xlsx sheet name (or 1-based index as a string). Ignored for csv/tsv. */
67
+ sheet: import("@murumets-ee/entity").TextField & {
68
+ readonly maxLength: 128;
69
+ };
70
+ /**
71
+ * Number format for the `number` per-column transform:
72
+ * `{ decimal: '.'|',', thousands: ''|'.'|','|' ' }`. Opaque json; validated
73
+ * by the engine's Zod schema at consume time (matches `import_run.params`).
74
+ */
75
+ numberFormat: import("@murumets-ee/entity").JsonField & {
76
+ readonly default: {
77
+ readonly decimal: ".";
78
+ readonly thousands: "";
79
+ };
80
+ };
81
+ /**
82
+ * Ordered column map: `[{ source, target, transforms? }]`. `source` is a
83
+ * header name (or positional index string); `target` MUST be a field in the
84
+ * chosen schema's whitelist (validated by the engine's Zod schema, R02
85
+ * injection boundary); `transforms` is a bounded, enum-keyed list. Opaque
86
+ * json on the entity; typed + validated by the engine.
87
+ */
88
+ columnMap: import("@murumets-ee/entity").JsonField & {
89
+ readonly default: readonly [];
90
+ }; /** Inactive mappings are skipped by resolution. */
91
+ active: import("@murumets-ee/entity").BooleanField & {
92
+ readonly default: true;
93
+ readonly indexed: true;
94
+ };
95
+ }, "import_mapping", [Behavior<import("@murumets-ee/entity").AuditableFields, string>, import("@murumets-ee/entity").SearchableBehavior, Behavior<Record<never, never>, "imports:import-mapping-unique">]>;
96
+ type ImportMappingClient = AdminClient<typeof ImportMapping.allFields>;
97
+ //#endregion
6
98
  //#region src/admin/cleanup.d.ts
7
99
  interface CleanupOrphanedImportsArgs {
8
100
  /** Toolkit app (provides db). */
@@ -82,5 +174,5 @@ interface CleanupProcessedImportsArgs {
82
174
  */
83
175
  declare function cleanupProcessedImports(args: CleanupProcessedImportsArgs): Promise<number>;
84
176
  //#endregion
85
- export { type CleanupOrphanedImportsArgs, type CleanupProcessedImportsArgs, DEFAULT_BATCH_SIZE, DEFAULT_MAX_PATTERNS, DEFAULT_MAX_SAMPLES_PER_PATTERN, type ErrorPattern, type ErrorSample, ErrorTracker, type ErrorTrackerConfig, type EsClientResolver, type FilePathResolver, IMPORT_RUN_STATUSES, ImportRun, type ImportRunClient, type ImportRunProgress, type ImportRunStatus, type RowError, type RowResult, type RowTransform, type RunImportOptions, type RunImportResult, type StreamFeedOptions, type StreamFeedRow, type TransformContext, type TransformName, TransformRegistry, cleanupOrphanedImports, cleanupProcessedImports, countDataRows, getTransformRegistry, registerImportTransform, runImport, streamFeed };
177
+ export { type CleanupOrphanedImportsArgs, type CleanupProcessedImportsArgs, DEFAULT_BATCH_SIZE, DEFAULT_MAX_PATTERNS, DEFAULT_MAX_SAMPLES_PER_PATTERN, type ErrorPattern, type ErrorSample, ErrorTracker, type ErrorTrackerConfig, type EsClientResolver, 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, type ReadFeedOptions, type RowError, type RowResult, type RowTransform, type RunImportOptions, type RunImportResult, type StreamFeedOptions, type StreamFeedRow, type TransformContext, type TransformName, TransformRegistry, cleanupOrphanedImports, cleanupProcessedImports, countDataRows, getTransformRegistry, readFeed, registerImportTransform, runImport, streamFeed };
86
178
  //# sourceMappingURL=index.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.mts","names":[],"sources":["../src/admin/cleanup.ts"],"mappings":";;;;;;UAkCiB,0BAAA;EAmEsE;EAjErF,GAAA,EAAK,UAAA;EA2JqC;;;;;;EApJ1C,MAAA;EAsJA;;;;;;;;;EA5IA,QAAA;EAmLoB;;;;;;EA5KpB,KAAA;EA4KuF;EA1KvF,MAAA,EAAQ,IAAA,CAAK,QAAA;AAAA;;;;;;iBAuCO,sBAAA,CAAuB,IAAA,EAAM,0BAAA,GAA6B,OAAO;AAAA,UA0FtE,2BAAA;;EAEf,GAAA,EAAK,UAAA;;EAEL,MAAA;;;;;;;;;;;;;EAaA,aAAA;;EAEA,KAAA;EACA,MAAA,EAAQ,IAAA,CAAK,QAAA;AAAA;;;;;;;;;;;;;;;;;;;iBAqBO,uBAAA,CAAwB,IAAA,EAAM,2BAAA,GAA8B,OAAO"}
1
+ {"version":3,"file":"index.d.mts","names":[],"sources":["../src/entities/import-mapping.ts","../src/admin/cleanup.ts"],"mappings":";;;;;;;;AA2FA;;;;;AAAA,cArDa,6BAAA;AAAA,KACD,yBAAA,WAAoC,6BAA6B;;cAGhE,sBAAA;AAAA,KACD,mBAAA,WAA8B,sBAAsB;AAAA,cAgDnD,aAAA,gCAAa,MAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAsE1B;;;;;;;EAA4E;AAAA;;;;AC/H5E;;;;KA4BU;;;;;;KDmGE,mBAAA,GAAsB,WAAW,QAAQ,aAAA,CAAc,SAAA;;;UC/HlD,0BAAA;;EAEf,GAAA,EAAK,UAAA;;;;;;;EAOL,MAAA;;;;;;;;;;EAUA,QAAA;;;;;;;EAOA,KAAA;;EAEA,MAAA,EAAQ,IAAA,CAAK,QAAA;AAAA;;;;;;iBAuCO,sBAAA,CAAuB,IAAA,EAAM,0BAAA,GAA6B,OAAO;AAAA,UA0FtE,2BAAA;;EAEf,GAAA,EAAK,UAAA;;EAEL,MAAA;;;;;;;;;;;;;EAaA,aAAA;;EAEA,KAAA;EACA,MAAA,EAAQ,IAAA,CAAK,QAAA;AAAA;;;;;;;;;;;;;ADlDf;;;;;;iBCuEsB,uBAAA,CAAwB,IAAA,EAAM,2BAAA,GAA8B,OAAO"}
package/dist/index.mjs CHANGED
@@ -1 +1 @@
1
- import{a as e,i as t,n,r,t as i}from"./transform-BzqTvH7B.mjs";import{a,i as o,n as s,o as c,r as l,s as u,t as d}from"./runner-CSlZ0JFO.mjs";import{n as f,t as p}from"./cleanup-DJHXcbpZ.mjs";export{d as DEFAULT_BATCH_SIZE,a as DEFAULT_MAX_PATTERNS,c as DEFAULT_MAX_SAMPLES_PER_PATTERN,u as ErrorTracker,t as IMPORT_RUN_STATUSES,e as ImportRun,i as TransformRegistry,p as cleanupOrphanedImports,f as cleanupProcessedImports,l as countDataRows,n as getTransformRegistry,r as registerImportTransform,s as runImport,o as streamFeed};
1
+ import{a as e,c as t,i as n,n as r,o as i,r as a,s as o,t as s}from"./transform-DloItB80.mjs";import{a as c,c as l,l as u,n as d,o as f,r as p,s as m,t as h}from"./runner-DvKNfQJO.mjs";import{n as g,t as _}from"./cleanup-CFo7Pw0t.mjs";export{h as DEFAULT_BATCH_SIZE,m as DEFAULT_MAX_PATTERNS,l as DEFAULT_MAX_SAMPLES_PER_PATTERN,u as ErrorTracker,n as IMPORT_MAPPING_FORMATS,e as IMPORT_MAPPING_TARGET_SCHEMAS,o as IMPORT_RUN_STATUSES,i as ImportMapping,t as ImportRun,s as TransformRegistry,_ as cleanupOrphanedImports,g as cleanupProcessedImports,c as countDataRows,r as getTransformRegistry,p as readFeed,a as registerImportTransform,d as runImport,f as streamFeed};
@@ -0,0 +1,367 @@
1
+ import { r as RowTransform } from "../transform-dO4z0Ze6.mjs";
2
+ import { _ as parseEuropeanNumber, a as MappedRow, c as TargetSchema, d as textOrNull, f as CellValue, g as NumberFormat, h as columnTransformSchema, i as BuildRowContext, l as numberCell, m as applyColumnTransforms, n as partsParamsSchema, o as NumberOutcome, p as ColumnTransform, r as partsSchema, s as TargetField, t as PartsParams, u as rowError, v as parseNumber, y as toMinorUnits } from "../parts-schema-RI_ltubl.mjs";
3
+ import { z } from "zod";
4
+
5
+ //#region src/mapping/engine.d.ts
6
+ /** One column-map entry. `target` is checked against the schema whitelist separately. */
7
+ declare const mappingColumnSchema: z.ZodObject<{
8
+ /** Source header name (or positional index string for headerless feeds). */source: z.ZodString; /** Target field key — must be in the chosen schema's whitelist (enforced in `buildMappingTransform`). */
9
+ target: z.ZodString; /** Ordered per-column cleaning ops. Defaults to none. */
10
+ transforms: z.ZodDefault<z.ZodArray<z.ZodDiscriminatedUnion<"op", [z.ZodObject<{
11
+ op: z.ZodLiteral<"trim">;
12
+ }, "strip", z.ZodTypeAny, {
13
+ op: "trim";
14
+ }, {
15
+ op: "trim";
16
+ }>, z.ZodObject<{
17
+ op: z.ZodLiteral<"uppercase">;
18
+ }, "strip", z.ZodTypeAny, {
19
+ op: "uppercase";
20
+ }, {
21
+ op: "uppercase";
22
+ }>, z.ZodObject<{
23
+ op: z.ZodLiteral<"lowercase">;
24
+ }, "strip", z.ZodTypeAny, {
25
+ op: "lowercase";
26
+ }, {
27
+ op: "lowercase";
28
+ }>, z.ZodObject<{
29
+ op: z.ZodLiteral<"stripPrefix">;
30
+ value: z.ZodString;
31
+ }, "strip", z.ZodTypeAny, {
32
+ value: string;
33
+ op: "stripPrefix";
34
+ }, {
35
+ value: string;
36
+ op: "stripPrefix";
37
+ }>, z.ZodObject<{
38
+ op: z.ZodLiteral<"stripSuffix">;
39
+ value: z.ZodString;
40
+ }, "strip", z.ZodTypeAny, {
41
+ value: string;
42
+ op: "stripSuffix";
43
+ }, {
44
+ value: string;
45
+ op: "stripSuffix";
46
+ }>, z.ZodObject<{
47
+ op: z.ZodLiteral<"defaultIfEmpty">;
48
+ value: z.ZodString;
49
+ }, "strip", z.ZodTypeAny, {
50
+ value: string;
51
+ op: "defaultIfEmpty";
52
+ }, {
53
+ value: string;
54
+ op: "defaultIfEmpty";
55
+ }>, z.ZodObject<{
56
+ op: z.ZodLiteral<"number">;
57
+ }, "strip", z.ZodTypeAny, {
58
+ op: "number";
59
+ }, {
60
+ op: "number";
61
+ }>, z.ZodObject<{
62
+ op: z.ZodLiteral<"europeanNumber">;
63
+ }, "strip", z.ZodTypeAny, {
64
+ op: "europeanNumber";
65
+ }, {
66
+ op: "europeanNumber";
67
+ }>]>, "many">>;
68
+ }, "strip", z.ZodTypeAny, {
69
+ source: string;
70
+ target: string;
71
+ transforms: ({
72
+ op: "trim";
73
+ } | {
74
+ op: "uppercase";
75
+ } | {
76
+ op: "lowercase";
77
+ } | {
78
+ value: string;
79
+ op: "stripPrefix";
80
+ } | {
81
+ value: string;
82
+ op: "stripSuffix";
83
+ } | {
84
+ value: string;
85
+ op: "defaultIfEmpty";
86
+ } | {
87
+ op: "number";
88
+ } | {
89
+ op: "europeanNumber";
90
+ })[];
91
+ }, {
92
+ source: string;
93
+ target: string;
94
+ transforms?: ({
95
+ op: "trim";
96
+ } | {
97
+ op: "uppercase";
98
+ } | {
99
+ op: "lowercase";
100
+ } | {
101
+ value: string;
102
+ op: "stripPrefix";
103
+ } | {
104
+ value: string;
105
+ op: "stripSuffix";
106
+ } | {
107
+ value: string;
108
+ op: "defaultIfEmpty";
109
+ } | {
110
+ op: "number";
111
+ } | {
112
+ op: "europeanNumber";
113
+ })[] | undefined;
114
+ }>;
115
+ type MappingColumn = z.infer<typeof mappingColumnSchema>;
116
+ /** Decimal + thousands separators for the `number` transform. */
117
+ declare const numberFormatSchema: z.ZodObject<{
118
+ decimal: z.ZodEnum<[".", ","]>;
119
+ thousands: z.ZodEnum<["", ".", ",", " "]>;
120
+ }, "strip", z.ZodTypeAny, {
121
+ decimal: "." | ",";
122
+ thousands: "" | "." | "," | " ";
123
+ }, {
124
+ decimal: "." | ",";
125
+ thousands: "" | "." | "," | " ";
126
+ }>;
127
+ /**
128
+ * The portable mapping config — the subset of an `import_mapping` row the engine
129
+ * needs. The enqueuer SNAPSHOTS this into `import_run.params.mapping` so a run
130
+ * records the exact mapping it used (reproducible) and the transform stays a pure
131
+ * function of `(row, ctx)` with no per-row DB I/O.
132
+ */
133
+ declare const mappingConfigSchema: z.ZodObject<{
134
+ targetSchema: z.ZodString;
135
+ numberFormat: z.ZodObject<{
136
+ decimal: z.ZodEnum<[".", ","]>;
137
+ thousands: z.ZodEnum<["", ".", ",", " "]>;
138
+ }, "strip", z.ZodTypeAny, {
139
+ decimal: "." | ",";
140
+ thousands: "" | "." | "," | " ";
141
+ }, {
142
+ decimal: "." | ",";
143
+ thousands: "" | "." | "," | " ";
144
+ }>;
145
+ columnMap: z.ZodArray<z.ZodObject<{
146
+ /** Source header name (or positional index string for headerless feeds). */source: z.ZodString; /** Target field key — must be in the chosen schema's whitelist (enforced in `buildMappingTransform`). */
147
+ target: z.ZodString; /** Ordered per-column cleaning ops. Defaults to none. */
148
+ transforms: z.ZodDefault<z.ZodArray<z.ZodDiscriminatedUnion<"op", [z.ZodObject<{
149
+ op: z.ZodLiteral<"trim">;
150
+ }, "strip", z.ZodTypeAny, {
151
+ op: "trim";
152
+ }, {
153
+ op: "trim";
154
+ }>, z.ZodObject<{
155
+ op: z.ZodLiteral<"uppercase">;
156
+ }, "strip", z.ZodTypeAny, {
157
+ op: "uppercase";
158
+ }, {
159
+ op: "uppercase";
160
+ }>, z.ZodObject<{
161
+ op: z.ZodLiteral<"lowercase">;
162
+ }, "strip", z.ZodTypeAny, {
163
+ op: "lowercase";
164
+ }, {
165
+ op: "lowercase";
166
+ }>, z.ZodObject<{
167
+ op: z.ZodLiteral<"stripPrefix">;
168
+ value: z.ZodString;
169
+ }, "strip", z.ZodTypeAny, {
170
+ value: string;
171
+ op: "stripPrefix";
172
+ }, {
173
+ value: string;
174
+ op: "stripPrefix";
175
+ }>, z.ZodObject<{
176
+ op: z.ZodLiteral<"stripSuffix">;
177
+ value: z.ZodString;
178
+ }, "strip", z.ZodTypeAny, {
179
+ value: string;
180
+ op: "stripSuffix";
181
+ }, {
182
+ value: string;
183
+ op: "stripSuffix";
184
+ }>, z.ZodObject<{
185
+ op: z.ZodLiteral<"defaultIfEmpty">;
186
+ value: z.ZodString;
187
+ }, "strip", z.ZodTypeAny, {
188
+ value: string;
189
+ op: "defaultIfEmpty";
190
+ }, {
191
+ value: string;
192
+ op: "defaultIfEmpty";
193
+ }>, z.ZodObject<{
194
+ op: z.ZodLiteral<"number">;
195
+ }, "strip", z.ZodTypeAny, {
196
+ op: "number";
197
+ }, {
198
+ op: "number";
199
+ }>, z.ZodObject<{
200
+ op: z.ZodLiteral<"europeanNumber">;
201
+ }, "strip", z.ZodTypeAny, {
202
+ op: "europeanNumber";
203
+ }, {
204
+ op: "europeanNumber";
205
+ }>]>, "many">>;
206
+ }, "strip", z.ZodTypeAny, {
207
+ source: string;
208
+ target: string;
209
+ transforms: ({
210
+ op: "trim";
211
+ } | {
212
+ op: "uppercase";
213
+ } | {
214
+ op: "lowercase";
215
+ } | {
216
+ value: string;
217
+ op: "stripPrefix";
218
+ } | {
219
+ value: string;
220
+ op: "stripSuffix";
221
+ } | {
222
+ value: string;
223
+ op: "defaultIfEmpty";
224
+ } | {
225
+ op: "number";
226
+ } | {
227
+ op: "europeanNumber";
228
+ })[];
229
+ }, {
230
+ source: string;
231
+ target: string;
232
+ transforms?: ({
233
+ op: "trim";
234
+ } | {
235
+ op: "uppercase";
236
+ } | {
237
+ op: "lowercase";
238
+ } | {
239
+ value: string;
240
+ op: "stripPrefix";
241
+ } | {
242
+ value: string;
243
+ op: "stripSuffix";
244
+ } | {
245
+ value: string;
246
+ op: "defaultIfEmpty";
247
+ } | {
248
+ op: "number";
249
+ } | {
250
+ op: "europeanNumber";
251
+ })[] | undefined;
252
+ }>, "many">;
253
+ }, "strip", z.ZodTypeAny, {
254
+ targetSchema: string;
255
+ numberFormat: {
256
+ decimal: "." | ",";
257
+ thousands: "" | "." | "," | " ";
258
+ };
259
+ columnMap: {
260
+ source: string;
261
+ target: string;
262
+ transforms: ({
263
+ op: "trim";
264
+ } | {
265
+ op: "uppercase";
266
+ } | {
267
+ op: "lowercase";
268
+ } | {
269
+ value: string;
270
+ op: "stripPrefix";
271
+ } | {
272
+ value: string;
273
+ op: "stripSuffix";
274
+ } | {
275
+ value: string;
276
+ op: "defaultIfEmpty";
277
+ } | {
278
+ op: "number";
279
+ } | {
280
+ op: "europeanNumber";
281
+ })[];
282
+ }[];
283
+ }, {
284
+ targetSchema: string;
285
+ numberFormat: {
286
+ decimal: "." | ",";
287
+ thousands: "" | "." | "," | " ";
288
+ };
289
+ columnMap: {
290
+ source: string;
291
+ target: string;
292
+ transforms?: ({
293
+ op: "trim";
294
+ } | {
295
+ op: "uppercase";
296
+ } | {
297
+ op: "lowercase";
298
+ } | {
299
+ value: string;
300
+ op: "stripPrefix";
301
+ } | {
302
+ value: string;
303
+ op: "stripSuffix";
304
+ } | {
305
+ value: string;
306
+ op: "defaultIfEmpty";
307
+ } | {
308
+ op: "number";
309
+ } | {
310
+ op: "europeanNumber";
311
+ })[] | undefined;
312
+ }[];
313
+ }>;
314
+ type MappingConfig = z.infer<typeof mappingConfigSchema>;
315
+ /**
316
+ * Build a `RowTransform` from a validated mapping config + its target schema.
317
+ *
318
+ * Throws at BUILD time if any `column_map` target is outside the schema's
319
+ * whitelist (deny-by-default, R02) — so a misconfigured mapping fails loudly
320
+ * before a single row is written, rather than silently dropping a column.
321
+ *
322
+ * Generic over the schema's output type, so passing the concrete `partsSchema`
323
+ * yields a `RowTransform<PartsDocument>` (the carmaker path keeps its typing); the
324
+ * registry-resolved `TargetSchema<unknown>` yields `RowTransform<unknown>` (the
325
+ * `commerce:mapping` path, where the output type isn't known until runtime).
326
+ */
327
+ declare function buildMappingTransform<TOut>(config: MappingConfig, schema: TargetSchema<TOut>): RowTransform<TOut>;
328
+ /** The minimal shape `resolveMapping` needs — satisfied by an `import_mapping` row or a test fixture. */
329
+ interface ResolvableMapping {
330
+ /** `null` = supplier default (applies to all brands). */
331
+ brandId: string | null;
332
+ active: boolean;
333
+ }
334
+ /**
335
+ * Most-specific resolution (D002 ladder): given the candidate mappings for one
336
+ * `(supplier, target_schema)` and the requested brand, return the brand-specific
337
+ * mapping if one exists, else the supplier-default (`brandId === null`). Inactive
338
+ * mappings are skipped. Returns `undefined` when nothing matches.
339
+ */
340
+ declare function resolveMapping<M extends ResolvableMapping>(mappings: readonly M[], brandId: string | null): M | undefined;
341
+ //#endregion
342
+ //#region src/mapping/discount-table-schema.d.ts
343
+ /** One parsed discount-table row: a group token + the purchase discount % off list. */
344
+ interface DiscountTableRow {
345
+ discountGroup: string;
346
+ discountPct: number;
347
+ }
348
+ declare const discountTableSchema: TargetSchema<DiscountTableRow>;
349
+ //#endregion
350
+ //#region src/mapping/schema-registry.d.ts
351
+ /** Resolve a registered schema by name, or `undefined` if not registered. */
352
+ declare function getTargetSchema(name: string): TargetSchema<unknown> | undefined;
353
+ /** All registered schema names (for the wizard's schema picker, PR3). */
354
+ declare function listTargetSchemas(): string[];
355
+ //#endregion
356
+ //#region src/mapping/mapping-transform.d.ts
357
+ /** Stable name under which the mapping-driven transform is registered. */
358
+ declare const MAPPING_TRANSFORM_NAME = "commerce:mapping";
359
+ /**
360
+ * The registered transform. Resolves + compiles the mapping snapshot from
361
+ * `ctx.params` (memoized), then delegates each row to it. A malformed snapshot or
362
+ * unknown schema throws — surfaced by the runner as a row-level `transform_threw`.
363
+ */
364
+ declare const mappingTransform: RowTransform<unknown>;
365
+ //#endregion
366
+ export { type BuildRowContext, type CellValue, type ColumnTransform, type DiscountTableRow, MAPPING_TRANSFORM_NAME, type MappedRow, type MappingColumn, type MappingConfig, type NumberFormat, type NumberOutcome, type PartsParams, type ResolvableMapping, type TargetField, type TargetSchema, applyColumnTransforms, buildMappingTransform, columnTransformSchema, discountTableSchema, getTargetSchema, listTargetSchemas, mappingColumnSchema, mappingConfigSchema, mappingTransform, numberCell, numberFormatSchema, parseEuropeanNumber, parseNumber, partsParamsSchema, partsSchema, resolveMapping, rowError, textOrNull, toMinorUnits };
367
+ //# sourceMappingURL=index.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.mts","names":[],"sources":["../../src/mapping/engine.ts","../../src/mapping/discount-table-schema.ts","../../src/mapping/schema-registry.ts","../../src/mapping/mapping-transform.ts"],"mappings":";;;;;;cAwBa,mBAAA,EAAmB,CAAA,CAAA,SAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;KAQpB,aAAA,GAAgB,CAAA,CAAE,KAAK,QAAQ,mBAAA;;cAG9B,kBAAA,EAAkB,CAAA,CAAA,SAAA;;;;;;;;;;;;;;;;cAWlB,mBAAA,EAAmB,CAAA,CAAA,SAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;KAKpB,aAAA,GAAgB,CAAA,CAAE,KAAK,QAAQ,mBAAA;;;;;;;;;;;;;iBAc3B,qBAAA,MAAA,CACd,MAAA,EAAQ,aAAA,EACR,MAAA,EAAQ,YAAA,CAAa,IAAA,IACpB,YAAA,CAAa,IAAA;;UA+BC,iBAAA;;EAEf,OAAA;EACA,MAAM;AAAA;;;;;;;iBASQ,cAAA,WAAyB,iBAAA,CAAA,CACvC,QAAA,WAAmB,CAAA,IACnB,OAAA,kBACC,CAAA;;;;UC7Fc,gBAAA;EACf,aAAA;EACA,WAAW;AAAA;AAAA,cAQA,mBAAA,EAAqB,YAAY,CAAC,gBAAA;;;;iBCN/B,eAAA,CAAgB,IAAA,WAAe,YAAY;;iBAK3C,iBAAA,CAAA;;;;cCCH,sBAAA;;;;;;cA4BA,gBAAA,EAAkB,YAAY"}
@@ -0,0 +1,2 @@
1
+ import{a as e,c as t,d as n,f as r,h as i,i as a,l as o,m as s,n as c,o as l,p as u,r as d,s as f,t as p,u as m}from"../parts-schema-DQeMGPBC.mjs";import{z as h}from"zod";const g={name:`discount_table`,fields:[{key:`discount_group`,label:`Discount group`,required:!0,type:`string`},{key:`discount_pct`,label:`Discount %`,required:!0,type:`number`}],buildRow(e,t){let n=typeof e.discount_group==`string`?e.discount_group.trim():``;if(n===``)return a(`missing_discount_group`,`discount_group is empty`,`discount_group`);let r=d(e.discount_pct);return r.kind===`invalid`?a(`invalid_discount_pct`,`discount_pct is not a number`,`discount_pct`):r.kind===`absent`?a(`missing_discount_pct`,`discount_pct is empty`,`discount_pct`):r.value<0||r.value>100?a(`discount_pct_out_of_range`,`discount_pct ${r.value} is outside 0–100`,`discount_pct`):{kind:`success`,id:n,doc:{discountGroup:n,discountPct:r.value}}}},_={[c.name]:c,[g.name]:g};function v(e){return _[e]}function y(){return Object.keys(_)}const b=`commerce:mapping`,x=h.object({mapping:t}),S=new WeakMap;function C(e){let t=S.get(e);if(t)return t;let{mapping:n}=x.parse(e),r=v(n.targetSchema);if(!r)throw Error(`Unknown target schema "${n.targetSchema}" (registered: ${y().join(`, `)})`);let i=l(n,r);return S.set(e,i),i}const w=async(e,t)=>C(t.params)(e,t);export{b as MAPPING_TRANSFORM_NAME,n as applyColumnTransforms,l as buildMappingTransform,r as columnTransformSchema,g as discountTableSchema,v as getTargetSchema,y as listTargetSchemas,f as mappingColumnSchema,t as mappingConfigSchema,w as mappingTransform,d as numberCell,o as numberFormatSchema,u as parseEuropeanNumber,s as parseNumber,p as partsParamsSchema,c as partsSchema,m as resolveMapping,a as rowError,e as textOrNull,i as toMinorUnits};
2
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.mjs","names":[],"sources":["../../src/mapping/discount-table-schema.ts","../../src/mapping/schema-registry.ts","../../src/mapping/mapping-transform.ts"],"sourcesContent":["/**\n * `discount_table` target schema — the non-parts feed kind (commerce-demo phase\n * 02 / 03). A discount table (R03 Sample B: `Code,Discount`) maps to\n * `{ discountGroup, discountPct }` rows.\n *\n * PR2 ships the schema (whitelist + `buildRow`) so the engine can parse + validate\n * a discount feed; the PERSISTENCE sink (upserting `supplier_discount_group` rows\n * + the two-file purchase-price derivation it feeds) lands in PR4. The `id` is the\n * discount group token so a re-import idempotently overwrites.\n */\n\nimport {\n type BuildRowContext,\n type MappedRow,\n numberCell,\n rowError,\n type TargetField,\n type TargetSchema,\n} from './target-schema.js'\n\n/** One parsed discount-table row: a group token + the purchase discount % off list. */\nexport interface DiscountTableRow {\n discountGroup: string\n discountPct: number\n}\n\nconst DISCOUNT_TABLE_FIELDS: readonly TargetField[] = [\n { key: 'discount_group', label: 'Discount group', required: true, type: 'string' },\n { key: 'discount_pct', label: 'Discount %', required: true, type: 'number' },\n]\n\nexport const discountTableSchema: TargetSchema<DiscountTableRow> = {\n name: 'discount_table',\n fields: DISCOUNT_TABLE_FIELDS,\n // params unused — a discount table is supplier-scoped at the run level, not per row.\n buildRow(mapped: MappedRow, _ctx: BuildRowContext) {\n // Group tokens are single tokens INCLUDING `'0'` (R03 Sample B) — a non-empty\n // string check, so `'0'` is a valid group.\n const group = typeof mapped.discount_group === 'string' ? mapped.discount_group.trim() : ''\n if (group === '') {\n return rowError('missing_discount_group', 'discount_group is empty', 'discount_group')\n }\n\n const pct = numberCell(mapped.discount_pct)\n if (pct.kind === 'invalid') {\n return rowError('invalid_discount_pct', 'discount_pct is not a number', 'discount_pct')\n }\n if (pct.kind === 'absent') {\n return rowError('missing_discount_pct', 'discount_pct is empty', 'discount_pct')\n }\n if (pct.value < 0 || pct.value > 100) {\n return rowError(\n 'discount_pct_out_of_range',\n `discount_pct ${pct.value} is outside 0–100`,\n 'discount_pct',\n )\n }\n\n return { kind: 'success', id: group, doc: { discountGroup: group, discountPct: pct.value } }\n },\n}\n","/**\n * Registry of the concrete target schemas (commerce-demo phase 02, D005).\n *\n * The engine resolves a mapping's `target_schema` name to its `TargetSchema` here.\n * An unregistered name is rejected (deny-by-default — the first injection-boundary\n * whitelist; the second is each schema's field set).\n */\n\nimport { discountTableSchema } from './discount-table-schema.js'\nimport { partsSchema } from './parts-schema.js'\nimport type { TargetSchema } from './target-schema.js'\n\n/**\n * `TargetSchema<TOut>` is covariant in `TOut` (it appears only in `buildRow`'s\n * return), so a concrete schema is assignable to `TargetSchema<unknown>` — the\n * shape callers that don't know the output type (the `commerce:mapping` transform)\n * consume. Callers that DO know the type (the carmaker wrapper) import the concrete\n * schema directly to keep `RowTransform<PartsDocument>`.\n */\nconst SCHEMAS: Readonly<Record<string, TargetSchema<unknown>>> = {\n [partsSchema.name]: partsSchema,\n [discountTableSchema.name]: discountTableSchema,\n}\n\n/** Resolve a registered schema by name, or `undefined` if not registered. */\nexport function getTargetSchema(name: string): TargetSchema<unknown> | undefined {\n return SCHEMAS[name]\n}\n\n/** All registered schema names (for the wizard's schema picker, PR3). */\nexport function listTargetSchemas(): string[] {\n return Object.keys(SCHEMAS)\n}\n","/**\n * `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":"2KA+BA,MAAa,EAAsD,CACjE,KAAM,iBACN,OAAQ,CANR,CAAE,IAAK,iBAAkB,MAAO,iBAAkB,SAAU,GAAM,KAAM,QAAS,EACjF,CAAE,IAAK,eAAgB,MAAO,aAAc,SAAU,GAAM,KAAM,QAAS,CAKnE,EAER,SAAS,EAAmB,EAAuB,CAGjD,IAAM,EAAQ,OAAO,EAAO,gBAAmB,SAAW,EAAO,eAAe,KAAK,EAAI,GACzF,GAAI,IAAU,GACZ,OAAO,EAAS,yBAA0B,0BAA2B,gBAAgB,EAGvF,IAAM,EAAM,EAAW,EAAO,YAAY,EAe1C,OAdI,EAAI,OAAS,UACR,EAAS,uBAAwB,+BAAgC,cAAc,EAEpF,EAAI,OAAS,SACR,EAAS,uBAAwB,wBAAyB,cAAc,EAE7E,EAAI,MAAQ,GAAK,EAAI,MAAQ,IACxB,EACL,4BACA,gBAAgB,EAAI,MAAM,mBAC1B,cACF,EAGK,CAAE,KAAM,UAAW,GAAI,EAAO,IAAK,CAAE,cAAe,EAAO,YAAa,EAAI,KAAM,CAAE,CAC7F,CACF,ECzCM,EAA2D,EAC9D,EAAY,MAAO,GACnB,EAAoB,MAAO,CAC9B,EAGA,SAAgB,EAAgB,EAAiD,CAC/E,OAAO,EAAQ,EACjB,CAGA,SAAgB,GAA8B,CAC5C,OAAO,OAAO,KAAK,CAAO,CAC5B,CCDA,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"}