@murumets-ee/imports 0.32.3 → 0.34.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 (45) hide show
  1. package/dist/{cleanup-CFo7Pw0t.mjs → cleanup-BzqyX9Jo.mjs} +2 -2
  2. package/dist/{cleanup-CFo7Pw0t.mjs.map → cleanup-BzqyX9Jo.mjs.map} +1 -1
  3. package/dist/index.d.mts +122 -77
  4. package/dist/index.d.mts.map +1 -1
  5. package/dist/index.mjs +2 -1
  6. package/dist/index.mjs.map +1 -0
  7. package/dist/mapping/index.d.mts +51 -20
  8. package/dist/mapping/index.d.mts.map +1 -1
  9. package/dist/mapping/index.mjs +1 -1
  10. package/dist/mapping/index.mjs.map +1 -1
  11. package/dist/{parts-schema-RI_ltubl.d.mts → parts-schema-Bqau0MrJ.d.mts} +29 -4
  12. package/dist/parts-schema-Bqau0MrJ.d.mts.map +1 -0
  13. package/dist/parts-schema-Vv0EJCjj.mjs +2 -0
  14. package/dist/parts-schema-Vv0EJCjj.mjs.map +1 -0
  15. package/dist/plugin.d.mts +1 -1
  16. package/dist/plugin.mjs +1 -1
  17. package/dist/runner-0r6UICjo.mjs +2 -0
  18. package/dist/runner-0r6UICjo.mjs.map +1 -0
  19. package/dist/schema-registry-JU4gsC8E.mjs +2 -0
  20. package/dist/schema-registry-JU4gsC8E.mjs.map +1 -0
  21. package/dist/storage-resolver.d.mts +1 -1
  22. package/dist/{transform-dO4z0Ze6.d.mts → transform-BRJJLSXE.d.mts} +22 -2
  23. package/dist/transform-BRJJLSXE.d.mts.map +1 -0
  24. package/dist/transform-BzacGXbo.mjs +2 -0
  25. package/dist/transform-BzacGXbo.mjs.map +1 -0
  26. package/dist/transforms.d.mts +5 -2
  27. package/dist/transforms.d.mts.map +1 -1
  28. package/dist/transforms.mjs +1 -1
  29. package/dist/{worker-C-5Xqf8_.d.mts → worker-Ck67AOnh.d.mts} +65 -5
  30. package/dist/worker-Ck67AOnh.d.mts.map +1 -0
  31. package/dist/worker.d.mts +1 -1
  32. package/dist/worker.mjs +1 -1
  33. package/dist/worker.mjs.map +1 -1
  34. package/dist/{xlsx-reader-B2_F37jA.mjs → xlsx-reader-oApgZRJd.mjs} +2 -2
  35. package/dist/{xlsx-reader-B2_F37jA.mjs.map → xlsx-reader-oApgZRJd.mjs.map} +1 -1
  36. package/package.json +8 -8
  37. package/dist/parts-schema-DQeMGPBC.mjs +0 -2
  38. package/dist/parts-schema-DQeMGPBC.mjs.map +0 -1
  39. package/dist/parts-schema-RI_ltubl.d.mts.map +0 -1
  40. package/dist/runner-DvKNfQJO.mjs +0 -2
  41. package/dist/runner-DvKNfQJO.mjs.map +0 -1
  42. package/dist/transform-DloItB80.mjs +0 -2
  43. package/dist/transform-DloItB80.mjs.map +0 -1
  44. package/dist/transform-dO4z0Ze6.d.mts.map +0 -1
  45. package/dist/worker-C-5Xqf8_.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-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
+ 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 +1 @@
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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"}
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":"4KAkEA,MAGM,EAAY,IAUlB,SAAS,EACP,EAC2C,CAC3C,GAAM,CAAE,iBAAgB,WAAU,QAAQ,KAAkB,EAC5D,GAAI,CAAC,OAAO,SAAS,CAAc,GAAK,EAAiB,EACvD,MAAU,MACR,oBAAoB,EAAS,8CAA8C,EAAe,EAC5F,EAEF,GAAI,CAAC,OAAO,SAAS,CAAK,GAAK,CAAC,OAAO,UAAU,CAAK,GAAK,EAAQ,GAAK,EAAQ,EAC9E,MAAU,MACR,oDAAoD,EAAU,SAAS,EAAM,EAC/E,EAEF,MAAO,CAAE,iBAAgB,OAAM,CACjC,CAOA,eAAsB,EAAuB,EAAmD,CAC9F,GAAM,CAAE,MAAK,SAAQ,UAAW,EAC1B,EAAK,EAAI,GAAG,UACZ,CAAE,eAAgB,EAAU,SAAU,EAAoB,CAC9D,eAAgB,EAAK,SACrB,SAAU,WAIV,GAAI,EAAK,QAAU,IAAA,IAAa,CAAE,MAAO,EAAK,KAAM,CACtD,CAAC,EAMK,CAAE,MAAK,MAAK,KAAI,SAAQ,KAAI,mBAAoB,MAAM,OAAO,eAC7D,CAAE,gBAAiB,MAAM,OAAO,wBAChC,CAAE,qBAAsB,MAAM,OAAO,6BACrC,CAAE,aAAc,MAAM,OAAO,2BAA4B,CAAA,KAAA,GAAA,EAAA,CAAA,EAGzD,EAAkB,EAAkB,CAAS,CAAC,CAAC,SAAS,EAMxD,EAAW,EAAgB,CAAY,EACvC,EAAU,EAAS,IACnB,EAAa,EAAS,OACtB,EAAgB,EAAS,UAC/B,GAAI,CAAC,GAAW,CAAC,GAAc,CAAC,EAC9B,MAAU,MACR,8FACF,EAEF,IAAM,EAAgB,EAAgB,CAAe,EAC/C,EAAa,EAAc,SAC3B,EAAO,EAAc,GAC3B,GAAI,CAAC,GAAc,CAAC,EAClB,MAAU,MACR,kFACF,EAGF,IAAM,EAAS,IAAI,KAAK,KAAK,IAAI,EAAI,EAAW,GAAK,GAAK,GAAI,EASxD,EAAU,MAAM,EACnB,OAAO,CAAE,IAAK,CAAQ,CAAC,CAAC,CACxB,KAAK,CAAY,CAAC,CAClB,SAAS,EAAiB,EAAG,EAAY,CAAO,CAAC,CAAC,CAClD,MAAM,EAAI,EAAG,EAAY,CAAM,EAAG,EAAG,EAAe,CAAM,EAAG,EAAO,CAAI,CAAC,CAAC,CAAC,CAC3E,QAAQ,EAAI,CAAa,CAAC,CAAC,CAC3B,MAAM,CAAK,EAEd,GAAI,EAAQ,SAAW,EAErB,OADA,EAAO,MAAM,CAAE,SAAQ,UAAS,EAAG,mCAAmC,EAC/D,EAGT,IAAM,EAAU,MAAM,EAAkB,EACpC,EAAU,EACd,IAAK,IAAM,KAAO,EAAS,CACzB,IAAM,EAAM,EAAI,IAChB,GAAI,CACF,MAAM,EAAQ,OAAO,CAAG,EACxB,GACF,OAAS,EAAK,CACZ,EAAO,KAAK,CAAE,MAAK,KAAI,EAAG,0CAA0C,CACtE,CACF,CAMA,OAJA,EAAO,KACL,CAAE,UAAS,MAAO,EAAQ,OAAQ,SAAQ,UAAS,EACnD,qCACF,EACO,CACT,CAGA,MAAM,EAA+B,CAAC,YAAa,SAAU,WAAW,EA2CxE,eAAsB,EAAwB,EAAoD,CAChG,GAAM,CAAE,MAAK,SAAQ,UAAW,EAC1B,EAAK,EAAI,GAAG,UACZ,CAAE,eAAgB,EAAe,SAAU,EAAoB,CACnE,eAAgB,EAAK,cACrB,SAAU,gBAIV,GAAI,EAAK,QAAU,IAAA,IAAa,CAAE,MAAO,EAAK,KAAM,CACtD,CAAC,EAEK,CAAE,MAAK,MAAK,KAAI,kBAAiB,UAAS,MAAO,MAAM,OAAO,eAC9D,CAAE,gBAAiB,MAAM,OAAO,wBAChC,CAAE,qBAAsB,MAAM,OAAO,6BACrC,CAAE,aAAc,MAAM,OAAO,2BAA4B,CAAA,KAAA,GAAA,EAAA,CAAA,EAGzD,EAAkB,EAAkB,CAAS,CAAC,CAAC,SAAS,EAExD,EAAW,EAAgB,CAAY,EACvC,EAAU,EAAS,IACnB,EAAa,EAAS,OAC5B,GAAI,CAAC,GAAW,CAAC,EACf,MAAU,MACR,qFACF,EAEF,IAAM,EAAgB,EAAgB,CAAe,EAC/C,EAAa,EAAc,SAC3B,EAAW,EAAc,OACzB,EAAe,EAAc,WACnC,GAAI,CAAC,GAAc,CAAC,GAAY,CAAC,EAC/B,MAAU,MACR,kGACF,EAGF,IAAM,EAAS,IAAI,KAAK,KAAK,IAAI,EAAI,EAAgB,GAAK,GAAK,GAAK,GAAI,EAkClE,EAAa,MAAM,EACtB,iBAAiB,CAAC,CAAO,EAAG,CAAE,IAAK,CAAQ,CAAC,CAAC,CAC7C,KAAK,CAAY,CAAC,CAClB,UAAU,EAAiB,EAAG,EAAY,CAAO,CAAC,CAAC,CACnD,MACC,EACE,EAAG,EAAY,CAAM,EACrB,EAAQ,EAAU,CAAC,GAAG,CAA4B,CAAC,EACnD,EAAG,EAAc,CAAM,CACzB,CACF,CAAC,CACA,QAAQ,EAAI,CAAO,EAAG,EAAI,CAAY,CAAC,CAAC,CACxC,MAAM,CAAK,EAEd,GAAI,EAAW,SAAW,EAExB,OADA,EAAO,MAAM,CAAE,SAAQ,eAAc,EAAG,gDAAgD,EACjF,EAGT,IAAM,EAAU,MAAM,EAAkB,EACpC,EAAU,EACd,IAAK,IAAM,KAAO,EAAY,CAC5B,IAAM,EAAM,EAAI,IAChB,GAAI,CACF,MAAM,EAAQ,OAAO,CAAG,EACxB,GACF,OAAS,EAAK,CACZ,EAAO,KAAK,CAAE,MAAK,KAAI,EAAG,2CAA2C,CACvE,CACF,CAMA,OAJA,EAAO,KACL,CAAE,UAAS,MAAO,EAAW,OAAQ,SAAQ,eAAc,EAC3D,sCACF,EACO,CACT"}
package/dist/index.d.mts CHANGED
@@ -1,10 +1,89 @@
1
- import { A as ImportRun, C as DEFAULT_MAX_PATTERNS, D as ErrorTracker, E as ErrorSample, M as ImportRunStatus, O as ErrorTrackerConfig, S as streamFeed, T as ErrorPattern, _ as ReadFeedOptions, b as StreamFeedRow, f as DEFAULT_BATCH_SIZE, g as runImport, h as RunImportResult, j as ImportRunClient, k as IMPORT_RUN_STATUSES, m as RunImportOptions, n as EsClientResolver, p as ImportRunProgress, r as FilePathResolver, t as EnsureIndexResolver, v as readFeed, w as DEFAULT_MAX_SAMPLES_PER_PATTERN, x as countDataRows, y as StreamFeedOptions } from "./worker-C-5Xqf8_.mjs";
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";
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";
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";
6
6
  import { AdminClient } from "@murumets-ee/entity/admin";
7
7
 
8
+ //#region src/admin/cleanup.d.ts
9
+ interface CleanupOrphanedImportsArgs {
10
+ /** Toolkit app (provides db). */
11
+ app: ToolkitApp;
12
+ /**
13
+ * Bucket name the imports flow uses. Rows in `toolkit_files` are
14
+ * filtered to this bucket so the cleanup doesn't touch media,
15
+ * ticketing attachments, or any other consumer's uploads even if
16
+ * they share the same Postgres instance.
17
+ */
18
+ bucket: string;
19
+ /**
20
+ * Hours after which an unreferenced upload is considered orphaned.
21
+ * Operator-grace window: should be long enough that a normal
22
+ * upload-then-register round-trip never wipes a fresh upload, but
23
+ * short enough that storage waste from failed uploads doesn't
24
+ * accumulate. 2 hours is the default in the imports plugin
25
+ * config — calibrated against the typical "operator clicks
26
+ * upload, walks away, comes back to check" workflow.
27
+ */
28
+ ttlHours: number;
29
+ /**
30
+ * Maximum rows to process per run. Bounded so a sudden flood of
31
+ * orphans (e.g. after a brand-new supplier with unrecognised
32
+ * filenames floods the queue with rejected uploads) doesn't
33
+ * block the queue worker on a single multi-hour cleanup.
34
+ */
35
+ limit?: number;
36
+ /** Logger for diagnostics. */
37
+ logger: Pick<Logger$1, 'debug' | 'info' | 'warn'>;
38
+ }
39
+ /**
40
+ * Run one cleanup pass. Returns the count of rows actually deleted
41
+ * (which may be less than the count of orphans found if some
42
+ * `storage.delete` calls failed — see the `warn` log inside the loop).
43
+ */
44
+ declare function cleanupOrphanedImports(args: CleanupOrphanedImportsArgs): Promise<number>;
45
+ interface CleanupProcessedImportsArgs {
46
+ /** Toolkit app (provides db). */
47
+ app: ToolkitApp;
48
+ /** Bucket name to scope the cleanup. Same semantics as `cleanupOrphanedImports`. */
49
+ bucket: string;
50
+ /**
51
+ * Days after which a terminal-status `import_run`'s source file is
52
+ * deleted. Operator-grace window: long enough that an operator can
53
+ * inspect the source file if a transform bug surfaces shortly after
54
+ * a successful import; short enough that storage doesn't accumulate
55
+ * months of supplier feeds.
56
+ *
57
+ * The plugin default is 7. Set to 0 to delete the source file
58
+ * immediately at the next cleanup tick after the import row
59
+ * reaches terminal status — useful when storage cost matters more
60
+ * than forensic reprocess capability.
61
+ */
62
+ retentionDays: number;
63
+ /** Max rows per run. Defaults to 100 (same cap as the orphan sweep). */
64
+ limit?: number;
65
+ logger: Pick<Logger$1, 'debug' | 'info' | 'warn'>;
66
+ }
67
+ /**
68
+ * Cleanup source files for `import_run`s that finished (succeeded /
69
+ * failed / cancelled) more than `retentionDays` days ago.
70
+ *
71
+ * Counterpart to `cleanupOrphanedImports`: that one targets uploads
72
+ * with NO matching `import_run` (handshake never completed); this one
73
+ * targets uploads whose `import_run` row IS present and terminal.
74
+ * Together they cover every "useless storage row" case the imports
75
+ * flow can produce.
76
+ *
77
+ * Deletes ONLY the storage row (via `StorageClient.delete`); the
78
+ * `import_run` row is kept so the History panel + audit trail still
79
+ * surface the past run. The `import_run.filePath` value points at a
80
+ * now-deleted key after this runs — consumers that try to re-download
81
+ * the source file (e.g. a future "reprocess" button) will get a
82
+ * `getMetadata` → null and should show "source file unavailable" in
83
+ * the UI rather than crashing.
84
+ */
85
+ declare function cleanupProcessedImports(args: CleanupProcessedImportsArgs): Promise<number>;
86
+ //#endregion
8
87
  //#region src/entities/import-mapping.d.ts
9
88
  /**
10
89
  * Registered target schemas a mapping may write into. This is the FIRST of two
@@ -20,14 +99,14 @@ declare const ImportMapping: import("@murumets-ee/entity").Entity<{
20
99
  id: import("@murumets-ee/entity").IdField;
21
100
  } & import("@murumets-ee/entity").AuditableFields & Record<never, never> & {
22
101
  /** FK to commerce `supplier` (by name string — no cross-package import). Required. */supplierId: import("@murumets-ee/entity").ReferenceField & {
23
- cardinality: "many" | "one";
102
+ cardinality: "one" | "many";
24
103
  } & {
25
104
  readonly entity: "supplier";
26
105
  readonly required: true;
27
106
  readonly indexed: true;
28
107
  }; /** FK to commerce `brand`. NULL = supplier default (applies to all that supplier's brands). */
29
108
  brandId: import("@murumets-ee/entity").ReferenceField & {
30
- cardinality: "many" | "one";
109
+ cardinality: "one" | "many";
31
110
  } & {
32
111
  readonly entity: "brand";
33
112
  readonly indexed: true;
@@ -92,87 +171,53 @@ declare const ImportMapping: import("@murumets-ee/entity").Entity<{
92
171
  readonly default: true;
93
172
  readonly indexed: true;
94
173
  };
95
- }, "import_mapping", [Behavior<import("@murumets-ee/entity").AuditableFields, string>, import("@murumets-ee/entity").SearchableBehavior, Behavior<Record<never, never>, "imports:import-mapping-unique">]>;
174
+ }, "import_mapping", [Behavior<import("@murumets-ee/entity").AuditableFields, string>, import("@murumets-ee/entity").SearchableBehavior, Behavior<Record<never, never>, "imports:import-mapping-unique">, Behavior<Record<never, never>, "imports:import-mapping-validation">]>;
96
175
  type ImportMappingClient = AdminClient<typeof ImportMapping.allFields>;
97
176
  //#endregion
98
- //#region src/admin/cleanup.d.ts
99
- interface CleanupOrphanedImportsArgs {
100
- /** Toolkit app (provides db). */
101
- app: ToolkitApp;
102
- /**
103
- * Bucket name the imports flow uses. Rows in `toolkit_files` are
104
- * filtered to this bucket so the cleanup doesn't touch media,
105
- * ticketing attachments, or any other consumer's uploads even if
106
- * they share the same Postgres instance.
107
- */
108
- bucket: string;
109
- /**
110
- * Hours after which an unreferenced upload is considered orphaned.
111
- * Operator-grace window: should be long enough that a normal
112
- * upload-then-register round-trip never wipes a fresh upload, but
113
- * short enough that storage waste from failed uploads doesn't
114
- * accumulate. 2 hours is the default in the imports plugin
115
- * config — calibrated against the typical "operator clicks
116
- * upload, walks away, comes back to check" workflow.
117
- */
118
- ttlHours: number;
119
- /**
120
- * Maximum rows to process per run. Bounded so a sudden flood of
121
- * orphans (e.g. after a brand-new supplier with unrecognised
122
- * filenames floods the queue with rejected uploads) doesn't
123
- * block the queue worker on a single multi-hour cleanup.
124
- */
125
- limit?: number;
126
- /** Logger for diagnostics. */
127
- logger: Pick<Logger$1, 'debug' | 'info' | 'warn'>;
128
- }
177
+ //#region src/entities/import-mapping-validation.d.ts
129
178
  /**
130
- * Run one cleanup pass. Returns the count of rows actually deleted
131
- * (which may be less than the count of orphans found if some
132
- * `storage.delete` calls failed — see the `warn` log inside the loop).
179
+ * Throw a descriptive error unless the mapping is structurally valid AND honours its
180
+ * schema's field whitelist + required targets. The thrown message is operator-facing
181
+ * (surfaced verbatim by the admin CRUD route as a 4xx body).
133
182
  */
134
- declare function cleanupOrphanedImports(args: CleanupOrphanedImportsArgs): Promise<number>;
135
- interface CleanupProcessedImportsArgs {
136
- /** Toolkit app (provides db). */
137
- app: ToolkitApp;
138
- /** Bucket name to scope the cleanup. Same semantics as `cleanupOrphanedImports`. */
139
- bucket: string;
183
+ declare function assertValidMapping(input: {
184
+ targetSchema: unknown;
185
+ numberFormat: unknown;
186
+ columnMap: unknown;
187
+ }): void;
188
+ /**
189
+ * Behavior wrapping {@link assertValidMapping}. Typed `Behavior<Record<never,
190
+ * never>, …>` (NOT plain `Behavior`, whose wide `fields?` would collapse the
191
+ * entity's inferred field types — the documented no-field-behavior gotcha).
192
+ * Contributes a hook, no fields.
193
+ */
194
+ declare const importMappingValidation: Behavior<Record<never, never>, 'imports:import-mapping-validation'>;
195
+ //#endregion
196
+ //#region src/preview-feed.d.ts
197
+ /** Hard ceiling on preview rows regardless of caller request — bounds work + payload. */
198
+ declare const MAX_PREVIEW_ROWS = 100;
199
+ /** Default sample size when the caller doesn't specify. */
200
+ declare const DEFAULT_PREVIEW_ROWS = 20;
201
+ interface PreviewFeedOptions extends ReadFeedOptions {
202
+ /** Rows to sample. Defaults to {@link DEFAULT_PREVIEW_ROWS}; clamped to [1, {@link MAX_PREVIEW_ROWS}]. */
203
+ maxRows?: number;
204
+ }
205
+ interface FeedPreview {
140
206
  /**
141
- * Days after which a terminal-status `import_run`'s source file is
142
- * deleted. Operator-grace window: long enough that an operator can
143
- * inspect the source file if a transform bug surfaces shortly after
144
- * a successful import; short enough that storage doesn't accumulate
145
- * months of supplier feeds.
146
- *
147
- * The plugin default is 7. Set to 0 to delete the source file
148
- * immediately at the next cleanup tick after the import row
149
- * reaches terminal status — useful when storage cost matters more
150
- * than forensic reprocess capability.
207
+ * Source column names in first-seen order. With a header these are the (trimmed)
208
+ * header names; headerless feeds yield positional `"0"`, `"1"`, … keys. The union
209
+ * across sampled rows covers feeds whose first row omits trailing empty cells.
151
210
  */
152
- retentionDays: number;
153
- /** Max rows per run. Defaults to 100 (same cap as the orphan sweep). */
154
- limit?: number;
155
- logger: Pick<Logger$1, 'debug' | 'info' | 'warn'>;
211
+ columns: string[];
212
+ /** Up to `maxRows` sampled data rows, each a `{ column → cell }` map (cells are strings). */
213
+ rows: Array<Record<string, string>>;
156
214
  }
157
215
  /**
158
- * Cleanup source files for `import_run`s that finished (succeeded /
159
- * failed / cancelled) more than `retentionDays` days ago.
160
- *
161
- * Counterpart to `cleanupOrphanedImports`: that one targets uploads
162
- * with NO matching `import_run` (handshake never completed); this one
163
- * targets uploads whose `import_run` row IS present and terminal.
164
- * Together they cover every "useless storage row" case the imports
165
- * flow can produce.
166
- *
167
- * Deletes ONLY the storage row (via `StorageClient.delete`); the
168
- * `import_run` row is kept so the History panel + audit trail still
169
- * surface the past run. The `import_run.filePath` value points at a
170
- * now-deleted key after this runs — consumers that try to re-download
171
- * the source file (e.g. a future "reprocess" button) will get a
172
- * `getMetadata` → null and should show "source file unavailable" in
173
- * the UI rather than crashing.
216
+ * Read up to `maxRows` data rows from a feed file and return the detected columns +
217
+ * sample rows. Throws whatever the underlying reader throws (bad sheet index,
218
+ * duplicate header, unreadable file) — the caller maps that to a 4xx.
174
219
  */
175
- declare function cleanupProcessedImports(args: CleanupProcessedImportsArgs): Promise<number>;
220
+ declare function previewFeed(options: PreviewFeedOptions): Promise<FeedPreview>;
176
221
  //#endregion
177
- export { type CleanupOrphanedImportsArgs, type CleanupProcessedImportsArgs, DEFAULT_BATCH_SIZE, DEFAULT_MAX_PATTERNS, DEFAULT_MAX_SAMPLES_PER_PATTERN, type EnsureIndexResolver, 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 };
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 };
178
223
  //# sourceMappingURL=index.d.mts.map
@@ -1 +1 @@
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"}
1
+ {"version":3,"file":"index.d.mts","names":[],"sources":["../src/admin/cleanup.ts","../src/entities/import-mapping.ts","../src/entities/import-mapping-validation.ts","../src/preview-feed.ts"],"mappings":";;;;;;;;UAkCiB,0BAAA;EAmE+D;EAjE9E,GAAA,EAAK,UAAA;EAiEgF;AA0FvF;;;;;EApJE,MAAA;EAwKY;;;;;;;;;EA9JZ,QAAA;EA8JmB;AAAA;AAqBrB;;;;EA5KE,KAAA;EA4K4C;EA1K5C,MAAA,EAAQ,IAAA,CAAK,QAAA;AAAA;AA0K0E;;;;ACjMzF;ADiMyF,iBAnInE,sBAAA,CAAuB,IAAA,EAAM,0BAAA,GAA6B,OAAO;AAAA,UA0FtE,2BAAA;ECxJgE;ED0J/E,GAAA,EAAK,UAAA;ECzJK;ED2JV,MAAA;;;AC3J2E;AAG7E;;;;AAAqE;AACrE;;;;EDoKE,aAAA;ECpHW;EDsHX,KAAA;EACA,MAAA,EAAQ,IAAA,CAAK,QAAA;AAAA;;;;;;;;;;;;;;;;;;;iBAqBO,uBAAA,CAAwB,IAAA,EAAM,2BAAA,GAA8B,OAAO;;;;;;;;cCjM5E,6BAAA;AAAA,KACD,yBAAA,WAAoC,6BAA6B;ADuJ7E;AAAA,cCpJa,sBAAA;AAAA,KACD,mBAAA,WAA8B,sBAAsB;AAAA,cAgDnD,aAAA,gCAAa,MAAA;;;EDuHZ;;;;;;KAAJ;;;;;;KAqB+E;;;;;;;;;;;KCjMR;;;;;;;EAIZ;;;AAAA;;;KACL;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;KAwHpD,mBAAA,GAAsB,WAAW,QAAQ,aAAA,CAAc,SAAA;;;;;;;;iBCnInD,kBAAA,CAAmB,KAAA;EACjC,YAAA;EACA,YAAA;EACA,SAAA;AAAA;;;;;;;cA4DW,uBAAA,EAAyB,QAAQ,CAC5C,MAAA;;;;cC/EW,gBAAA;;cAEA,oBAAA;AAAA,UAEI,kBAAA,SAA2B,eAAe;EHsCzD;EGpCA,OAAO;AAAA;AAAA,UAGQ,WAAA;EHmCI;AAAA;AAuCrB;;;EGpEE,OAAA;EHoEiD;EGlEjD,IAAA,EAAM,KAAK,CAAC,MAAA;AAAA;;AHkEyE;AA0FvF;;;iBGpJsB,WAAA,CAAY,OAAA,EAAS,kBAAA,GAAqB,OAAA,CAAQ,WAAA"}
package/dist/index.mjs CHANGED
@@ -1 +1,2 @@
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};
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};
2
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.mjs","names":[],"sources":["../src/preview-feed.ts"],"sourcesContent":["/**\n * `previewFeed` — read the first N data rows of a feed for the PR3 import wizard\n * (commerce-demo phase 02 §4). Reuses {@link readFeed} so the preview is parsed by\n * the EXACT same reader (csv-parse / exceljs), with the same delimiter / header /\n * sheet config, that the run will use — the operator maps against what they'll get,\n * not a re-implemented best-guess parser (R02: drift between preview + run is a\n * mapping foot-gun).\n *\n * Bounded: it consumes only `maxRows` rows then stops iterating, so on a delimited\n * feed it streams a tiny prefix rather than the whole file. (The caller materialises\n * the storage object to a local path first — see the commerce preview route — so for\n * a very large upload the materialise cost dominates; that's a demo-depth trade-off,\n * noted on the route.)\n */\n\nimport { type ReadFeedOptions, readFeed } from './read-feed.js'\n\n/** Hard ceiling on preview rows regardless of caller request — bounds work + payload. */\nexport const MAX_PREVIEW_ROWS = 100\n/** Default sample size when the caller doesn't specify. */\nexport const DEFAULT_PREVIEW_ROWS = 20\n\nexport interface PreviewFeedOptions extends ReadFeedOptions {\n /** Rows to sample. Defaults to {@link DEFAULT_PREVIEW_ROWS}; clamped to [1, {@link MAX_PREVIEW_ROWS}]. */\n maxRows?: number\n}\n\nexport interface FeedPreview {\n /**\n * Source column names in first-seen order. With a header these are the (trimmed)\n * header names; headerless feeds yield positional `\"0\"`, `\"1\"`, … keys. The union\n * across sampled rows covers feeds whose first row omits trailing empty cells.\n */\n columns: string[]\n /** Up to `maxRows` sampled data rows, each a `{ column → cell }` map (cells are strings). */\n rows: Array<Record<string, string>>\n}\n\n/**\n * Read up to `maxRows` data rows from a feed file and return the detected columns +\n * sample rows. Throws whatever the underlying reader throws (bad sheet index,\n * duplicate header, unreadable file) — the caller maps that to a 4xx.\n */\nexport async function previewFeed(options: PreviewFeedOptions): Promise<FeedPreview> {\n const { maxRows, ...feedOptions } = options\n // Coerce to a finite integer BEFORE clamping: `previewFeed` is public API, so a\n // caller passing `NaN`/`Infinity`/fractional must not slip through. `NaN` in\n // particular would make `rows.length >= limit` always false → read the whole file.\n const requested = maxRows ?? DEFAULT_PREVIEW_ROWS\n const safeRows = Number.isFinite(requested) ? Math.floor(requested) : DEFAULT_PREVIEW_ROWS\n const limit = Math.min(MAX_PREVIEW_ROWS, Math.max(1, safeRows))\n\n const rows: Array<Record<string, string>> = []\n // First-seen-order column set: a Map preserves insertion order and dedupes.\n const columns = new Map<string, true>()\n\n // Preview is EXPLORATORY — force lenient column counts (csv/tsv). With the wrong\n // delimiter a feed parses as a single odd column, OR a row's field count differs\n // from the header; strict parsing would abort the whole preview with a cryptic\n // \"Invalid Record Length\" instead of letting the operator SEE the bad parse and\n // fix the delimiter/format, then re-preview. The actual run keeps its own\n // (stricter) settings.\n for await (const { row } of readFeed({ ...feedOptions, relaxColumnCount: true })) {\n for (const key of Object.keys(row)) columns.set(key, true)\n rows.push(row)\n if (rows.length >= limit) break\n }\n\n return { columns: [...columns.keys()], rows }\n}\n"],"mappings":"2PAkBA,MAAa,EAAmB,IAEnB,EAAuB,GAuBpC,eAAsB,EAAY,EAAmD,CACnF,GAAM,CAAE,UAAS,GAAG,GAAgB,EAI9B,EAAY,GAAA,GAEZ,EAAQ,KAAK,IAAA,IAAsB,KAAK,IAAI,EADjC,OAAO,SAAS,CAAS,EAAI,KAAK,MAAM,CAAS,EAAA,EACL,CAAC,EAExD,EAAsC,CAAC,EAEvC,EAAU,IAAI,IAQpB,UAAW,GAAM,CAAE,SAAS,EAAS,CAAE,GAAG,EAAa,iBAAkB,EAAK,CAAC,EAAG,CAChF,IAAK,IAAM,KAAO,OAAO,KAAK,CAAG,EAAG,EAAQ,IAAI,EAAK,EAAI,EAEzD,GADA,EAAK,KAAK,CAAG,EACT,EAAK,QAAU,EAAO,KAC5B,CAEA,MAAO,CAAE,QAAS,CAAC,GAAG,EAAQ,KAAK,CAAC,EAAG,MAAK,CAC9C"}
@@ -1,7 +1,15 @@
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";
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";
3
3
  import { z } from "zod";
4
4
 
5
+ //#region src/mapping/discount-table-schema.d.ts
6
+ /** One parsed discount-table row: a group token + the purchase discount % off list. */
7
+ interface DiscountTableRow {
8
+ discountGroup: string;
9
+ discountPct: number;
10
+ }
11
+ declare const discountTableSchema: TargetSchema<DiscountTableRow>;
12
+ //#endregion
5
13
  //#region src/mapping/engine.d.ts
6
14
  /** One column-map entry. `target` is checked against the schema whitelist separately. */
7
15
  declare const mappingColumnSchema: z.ZodObject<{
@@ -113,8 +121,15 @@ declare const mappingColumnSchema: z.ZodObject<{
113
121
  })[] | undefined;
114
122
  }>;
115
123
  type MappingColumn = z.infer<typeof mappingColumnSchema>;
116
- /** Decimal + thousands separators for the `number` transform. */
117
- declare const numberFormatSchema: z.ZodObject<{
124
+ /**
125
+ * Decimal + thousands separators for the `number` transform.
126
+ *
127
+ * The two MUST differ: `parseNumber` strips every thousands separator FIRST, so a
128
+ * config where `decimal === thousands` (e.g. both `,`) would strip the decimal
129
+ * separator too — `326,88` → `32688`, a silent 100× error rather than a row error.
130
+ * Rejected at save (`assertValidMapping`) AND at run (`mappingConfigSchema`).
131
+ */
132
+ declare const numberFormatSchema: z.ZodEffects<z.ZodObject<{
118
133
  decimal: z.ZodEnum<[".", ","]>;
119
134
  thousands: z.ZodEnum<["", ".", ",", " "]>;
120
135
  }, "strip", z.ZodTypeAny, {
@@ -123,6 +138,12 @@ declare const numberFormatSchema: z.ZodObject<{
123
138
  }, {
124
139
  decimal: "." | ",";
125
140
  thousands: "" | "." | "," | " ";
141
+ }>, {
142
+ decimal: "." | ",";
143
+ thousands: "" | "." | "," | " ";
144
+ }, {
145
+ decimal: "." | ",";
146
+ thousands: "" | "." | "," | " ";
126
147
  }>;
127
148
  /**
128
149
  * The portable mapping config — the subset of an `import_mapping` row the engine
@@ -132,7 +153,7 @@ declare const numberFormatSchema: z.ZodObject<{
132
153
  */
133
154
  declare const mappingConfigSchema: z.ZodObject<{
134
155
  targetSchema: z.ZodString;
135
- numberFormat: z.ZodObject<{
156
+ numberFormat: z.ZodEffects<z.ZodObject<{
136
157
  decimal: z.ZodEnum<[".", ","]>;
137
158
  thousands: z.ZodEnum<["", ".", ",", " "]>;
138
159
  }, "strip", z.ZodTypeAny, {
@@ -141,6 +162,12 @@ declare const mappingConfigSchema: z.ZodObject<{
141
162
  }, {
142
163
  decimal: "." | ",";
143
164
  thousands: "" | "." | "," | " ";
165
+ }>, {
166
+ decimal: "." | ",";
167
+ thousands: "" | "." | "," | " ";
168
+ }, {
169
+ decimal: "." | ",";
170
+ thousands: "" | "." | "," | " ";
144
171
  }>;
145
172
  columnMap: z.ZodArray<z.ZodObject<{
146
173
  /** 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`). */
@@ -339,20 +366,6 @@ interface ResolvableMapping {
339
366
  */
340
367
  declare function resolveMapping<M extends ResolvableMapping>(mappings: readonly M[], brandId: string | null): M | undefined;
341
368
  //#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
369
  //#region src/mapping/mapping-transform.d.ts
357
370
  /** Stable name under which the mapping-driven transform is registered. */
358
371
  declare const MAPPING_TRANSFORM_NAME = "commerce:mapping";
@@ -363,5 +376,23 @@ declare const MAPPING_TRANSFORM_NAME = "commerce:mapping";
363
376
  */
364
377
  declare const mappingTransform: RowTransform<unknown>;
365
378
  //#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 };
379
+ //#region src/mapping/schema-registry.d.ts
380
+ /** Resolve a registered schema by name, or `undefined` if not registered. */
381
+ declare function getTargetSchema(name: string): TargetSchema<unknown> | undefined;
382
+ /** All registered schema names (for the wizard's schema picker, PR3). */
383
+ declare function listTargetSchemas(): string[];
384
+ /** Operator-facing summary of a target schema: its name + the field whitelist (PR3 wizard). */
385
+ interface TargetSchemaSummary {
386
+ name: string;
387
+ fields: readonly TargetField[];
388
+ }
389
+ /**
390
+ * Describe every registered target schema (name + field whitelist) for the PR3
391
+ * mapping-wizard UI. Returns only metadata (no `buildRow`), so the wizard's RSC page
392
+ * can ship it across the client boundary as plain JSON without dragging the engine
393
+ * into the client bundle.
394
+ */
395
+ declare function describeTargetSchemas(): TargetSchemaSummary[];
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 };
367
398
  //# sourceMappingURL=index.d.mts.map
@@ -1 +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"}
1
+ {"version":3,"file":"index.d.mts","names":[],"sources":["../../src/mapping/discount-table-schema.ts","../../src/mapping/engine.ts","../../src/mapping/mapping-transform.ts","../../src/mapping/schema-registry.ts"],"mappings":";;;;;;UAqBiB,gBAAA;EACf,aAAA;EACA,WAAW;AAAA;AAAA,cAQA,mBAAA,EAAqB,YAAY,CAAC,gBAAA;;;;cCPlC,mBAAA,EAAmB,CAAA,CAAA,SAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;KAQpB,aAAA,GAAgB,CAAA,CAAE,KAAK,QAAQ,mBAAA;;;;;;;;;cAU9B,kBAAA,EAAkB,CAAA,CAAA,UAAA,CAAA,CAAA,CAAA,SAAA;;;;;;;;;;;;;;;;;;;;;;cAgBlB,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;;;;cC/FU,sBAAA;;;;;;cA4BA,gBAAA,EAAkB,YAAY;;;;iBClC3B,eAAA,CAAgB,IAAA,WAAe,YAAY;AHM3D;AAAA,iBGDgB,iBAAA,CAAA;;UAKC,mBAAA;EACf,IAAA;EACA,MAAA,WAAiB,WAAW;AAAA;;AFb9B;;;;;iBEsBgB,qBAAA,CAAA,GAAyB,mBAAmB"}
@@ -1,2 +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};
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};
2
2
  //# sourceMappingURL=index.mjs.map
@@ -1 +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"}
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"}