@murumets-ee/imports 0.43.0 → 0.46.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 (34) hide show
  1. package/dist/{carmaker-feed-DtfAYS1P.mjs → carmaker-feed-CxhZBrkO.mjs} +2 -2
  2. package/dist/{carmaker-feed-DtfAYS1P.mjs.map → carmaker-feed-CxhZBrkO.mjs.map} +1 -1
  3. package/dist/{cleanup-CUfYcxFc.mjs → cleanup-CHjDgvKT.mjs} +2 -2
  4. package/dist/{cleanup-CUfYcxFc.mjs.map → cleanup-CHjDgvKT.mjs.map} +1 -1
  5. package/dist/index.d.mts +4 -4
  6. package/dist/index.mjs +1 -1
  7. package/dist/mapping/index.d.mts +2 -2
  8. package/dist/mapping/index.mjs +1 -1
  9. package/dist/{mapping-transform-D9n8D-Fo.mjs → mapping-transform-D76b-P-p.mjs} +2 -2
  10. package/dist/{mapping-transform-D9n8D-Fo.mjs.map → mapping-transform-D76b-P-p.mjs.map} +1 -1
  11. package/dist/parts-schema-D0gMhNWj.mjs +2 -0
  12. package/dist/parts-schema-D0gMhNWj.mjs.map +1 -0
  13. package/dist/{parts-schema-D6mvqSKA.d.mts → parts-schema-x8_GRYWc.d.mts} +20 -4
  14. package/dist/{parts-schema-D6mvqSKA.d.mts.map → parts-schema-x8_GRYWc.d.mts.map} +1 -1
  15. package/dist/plugin.d.mts +1 -1
  16. package/dist/plugin.mjs +1 -1
  17. package/dist/{schema-registry-TK4sBFmD.mjs → schema-registry-C_lQje-K.mjs} +2 -2
  18. package/dist/{schema-registry-TK4sBFmD.mjs.map → schema-registry-C_lQje-K.mjs.map} +1 -1
  19. package/dist/storage-resolver.d.mts +1 -1
  20. package/dist/{transform-CulePAbJ.d.mts → transform-CABBaYmF.d.mts} +11 -9
  21. package/dist/transform-CABBaYmF.d.mts.map +1 -0
  22. package/dist/{transform-D7uHod4E.mjs → transform-DIzWppgJ.mjs} +2 -2
  23. package/dist/{transform-D7uHod4E.mjs.map → transform-DIzWppgJ.mjs.map} +1 -1
  24. package/dist/transforms.d.mts +4 -4
  25. package/dist/transforms.mjs +1 -1
  26. package/dist/{worker-ZWkDs7gz.d.mts → worker-DRTgbtT7.d.mts} +2 -2
  27. package/dist/{worker-ZWkDs7gz.d.mts.map → worker-DRTgbtT7.d.mts.map} +1 -1
  28. package/dist/worker.d.mts +1 -1
  29. package/dist/worker.mjs +1 -1
  30. package/dist/worker.mjs.map +1 -1
  31. package/package.json +8 -8
  32. package/dist/parts-schema-RH48v5VW.mjs +0 -2
  33. package/dist/parts-schema-RH48v5VW.mjs.map +0 -1
  34. package/dist/transform-CulePAbJ.d.mts.map +0 -1
@@ -38,9 +38,13 @@ interface TransformContext {
38
38
  }
39
39
  /**
40
40
  * A directional supersession pair harvested from a row as a SIDE-OUTPUT
41
- * (commerce-demo PR6, SD007): `from` (the row's own code) is superseded by `to`
42
- * (the row's `superseding_code`), so `to` is the newer/current code. Both are
43
- * already NORMALIZED (same normalization as the doc's `code_normalized`) so a
41
+ * (commerce-demo PR6, SD007). The edge ALWAYS runs `from` (older) → `to` (newer/current).
42
+ *
43
+ * Which end the emitting row occupies depends on which supersession column the mapping used,
44
+ * so neither end can be described as "the row's own code": a `superseding_code` column names
45
+ * the newer code and puts the row at `from` (the row is the obsolete one), while a
46
+ * `replaces_code` column names the older code and puts the row at `to` (the row is current).
47
+ * Both codes are already NORMALIZED (same normalization as the doc's `code_normalized`) so a
44
48
  * downstream equivalence-group merge collides codes correctly.
45
49
  *
46
50
  * This is PURE DATA. The runner buffers pairs in memory during the single ES pass
@@ -50,9 +54,9 @@ interface TransformContext {
50
54
  * the boundary holds because the sink is injected, not imported.
51
55
  */
52
56
  interface DirectionalPair {
53
- /** The superseded (older) normalized code — the row's own `code_normalized`. */
57
+ /** The superseded (older) normalized code. */
54
58
  from: string;
55
- /** The superseding (newer / current) normalized code — normalized `superseding_code`. */
59
+ /** The superseding (newer / current) normalized code. */
56
60
  to: string;
57
61
  }
58
62
  /**
@@ -151,9 +155,7 @@ interface RowWarning {
151
155
  * `(row, ctx) → RowResult` — no I/O. Async only because the future
152
156
  * carmaker variant might consult an in-memory taxonomy lookup.
153
157
  */
154
- interface RowTransform<TDoc> {
155
- (rawRow: Record<string, string>, ctx: TransformContext): Promise<RowResult<TDoc>>;
156
- }
158
+ type RowTransform<TDoc> = (rawRow: Record<string, string>, ctx: TransformContext) => Promise<RowResult<TDoc>>;
157
159
  /**
158
160
  * Holds the registered transforms for the current process. Each plugin
159
161
  * that ships a transform calls {@link TransformRegistry.register} from
@@ -230,4 +232,4 @@ declare function getTransformRegistry(): TransformRegistry;
230
232
  declare function registerImportTransform<TDoc>(name: TransformName, transform: RowTransform<TDoc>): void;
231
233
  //#endregion
232
234
  export { RowWarning as a, TransformRegistry as c, RowTransform as i, getTransformRegistry as l, RowError as n, TransformContext as o, RowResult as r, TransformName as s, DirectionalPair as t, registerImportTransform as u };
233
- //# sourceMappingURL=transform-CulePAbJ.d.mts.map
235
+ //# sourceMappingURL=transform-CABBaYmF.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"transform-CABBaYmF.d.mts","names":[],"sources":["../src/transform.ts"],"mappings":";;AA0BA;;;;AAAyB;AAGzB;;;;;;;;;;AAQW;AAoBX;;;;AAII;AAgBJ;;;KAnDY,aAAA;;UAGK,gBAAA;EA6DW;EA3D1B,WAAA;EA8CwB;EA5CxB,MAAA,EAAQ,MAAM;EAgDd;EA9CA,QAAA;EA8Ca;EA5Cb,SAAA;AAAA;;AAqD0B;AAmC5B;;;;;;;;;;;;;;;UApEiB,eAAA;EAyEX;EAvEJ,IAAA;EAwEI;EAtEJ,EAAE;AAAA;;;;;;;;;AAyEgC;AAGpC;;;;UA5DiB,QAAA;EA8Df;EA5DA,EAAA;EA6DK;EA3DL,GAAA,EAAK,OAAA,CAAQ,IAAA;EAoEE;;;;;;;;EA3Df,aAAA,GAAgB,UAAA;AAAA;;;;;;;;;;;;;;;;;;;AAyES;AAoB3B;;;;;;;;;;;;;KA1DY,SAAA;EAEN,IAAA;EACA,EAAA;EACA,GAAA,EAAK,IAAA;EACL,QAAA,YAAoB,UAAA;EACpB,KAAA,YAAiB,eAAA;AAAA;EAEjB,IAAA;EAAc,MAAA;EAAgB,OAAA,YAAmB,QAAA,CAAS,IAAA;AAAA;EAC1D,IAAA;EAAe,KAAA,EAAO,QAAA;AAAA;;UAGX,QAAA;EACf,SAAA;EACA,OAAA;EACA,KAAA;AAAA;;;;;;;UASe,UAAA;EACf,WAAA;EACA,OAAA;EACA,KAAA;AAAA;;;AAwGuD;AAiCzD;;KAjIY,YAAA,UACV,MAAA,EAAQ,MAAA,kBACR,GAAA,EAAK,gBAAA,KACF,OAAA,CAAQ,SAAA,CAAU,IAAA;;;;;;;;;;;;;AAgIQ;;;;;;cA5GlB,iBAAA;EAAA,iBACM,OAAA;;;;;;;EAQjB,QAAA,MAAA,CAAe,IAAA,EAAM,aAAA,EAAe,SAAA,EAAW,YAAA,CAAa,IAAA;;;;;;;;;;;;EAoB5D,GAAA,MAAA,CAAU,IAAA,EAAM,aAAA,EAAe,SAAA,EAAW,YAAA,CAAa,IAAA;;EAKvD,GAAA,CAAI,IAAA,EAAM,aAAA,GAAgB,YAAA;;EAK1B,GAAA,CAAI,IAAA,EAAM,aAAA;;EAKV,IAAA,CAAA,GAAQ,aAAA;;EAKR,KAAA,CAAA;AAAA;;iBAwBc,oBAAA,CAAA,GAAwB,iBAAiB;;;;;;;;;;;;;;;;;;;;;;;;iBAiCzC,uBAAA,MAAA,CACd,IAAA,EAAM,aAAA,EACN,SAAA,EAAW,YAAA,CAAa,IAAA"}
@@ -1,2 +1,2 @@
1
- import{t as e}from"./rolldown-runtime-DK3Fl9T5.mjs";import{c as t,u as n}from"./parts-schema-RH48v5VW.mjs";import{n as r,r as i}from"./schema-registry-TK4sBFmD.mjs";import{behavior as a,defineEntity as o,field as s,searchable as c}from"@murumets-ee/entity";import{z as l}from"zod";import{sql as u}from"drizzle-orm";var d=e({IMPORT_RUN_STATUSES:()=>f,ImportRun:()=>p});const f=[`pending`,`running`,`succeeded`,`failed`,`cancelled`],p=o({name:`import_run`,fields:{label:s.text({required:!0,maxLength:255}),status:s.select({options:f,default:`pending`,indexed:!0}),filePath:s.text({required:!0,maxLength:2048}),transformName:s.text({required:!0,maxLength:128,indexed:!0}),params:s.json(),totals:s.json(),errorSummary:s.json(),warningSummary:s.json(),startedAt:s.date(),finishedAt:s.date(),queueJobId:s.text({maxLength:64,indexed:!0})},behaviors:[a.auditable(),c({fields:[`label`,`transformName`,`queueJobId`,`status`,`filePath`],projection:e=>({id:e.id,label:e.label,description:`${e.transformName} · ${e.status}`})})],scope:`global`,admin:{group:`imports`,label:`Import runs`,labelSingular:`Import run`,icon:`upload`,hideFromMenu:!0}}),m={decimal:`.`,thousands:``},h=l.array(t).max(512),g=l.strictObject({columns:l.array(l.string().min(1).max(256)).min(1).max(256),rows:l.array(l.record(l.string().max(500))).max(5)});function _(e){if(e.headerSnapshot!==void 0&&e.headerSnapshot!==null){let t=g.safeParse(e.headerSnapshot);if(!t.success)throw Error(`Invalid headerSnapshot: ${t.error.issues[0]?.message??`bad shape`}`);let n=JSON.stringify(e.headerSnapshot).length;if(n>1e5)throw Error(`headerSnapshot too large (${n} > 100000 serialized chars) — trim sample rows/cells`)}let t=typeof e.targetSchema==`string`?e.targetSchema:``,a=r(t);if(!a)throw Error(`Unknown target schema "${t}" (registered: ${i().join(`, `)})`);let o=n.safeParse(e.numberFormat);if(!o.success)throw Error(`Invalid numberFormat: ${o.error.issues[0]?.message??`bad shape`}`);let s=h.safeParse(e.columnMap);if(!s.success)throw Error(`Invalid columnMap: ${s.error.issues[0]?.message??`bad shape`}`);let c=JSON.stringify(e.columnMap).length;if(c>1e5)throw Error(`columnMap too large (${c} > 100000 serialized chars)`);let l=new Set(a.fields.map(e=>e.key)),u=new Set,d=new Set;for(let e of s.data){if(!l.has(e.target))throw Error(`Mapping target "${e.target}" is not a field of schema "${a.name}" (allowed: ${[...l].join(`, `)})`);u.has(e.target)&&d.add(e.target),u.add(e.target)}if(d.size>0)throw Error(`Mapping for schema "${a.name}" maps a target more than once: ${[...d].join(`, `)}`);let f=new Set(s.data.map(e=>e.target)),p=a.fields.filter(e=>e.required&&!f.has(e.key)).map(e=>e.key);if(p.length>0)throw Error(`Mapping for schema "${a.name}" is missing required target(s): ${p.join(`, `)}`)}const v={name:`imports:import-mapping-validation`,hooks:{beforeCreate:async e=>(_({targetSchema:e.targetSchema,numberFormat:e.numberFormat??m,columnMap:e.columnMap??[],headerSnapshot:e.headerSnapshot}),e),beforeUpdate:async(e,t,n)=>{let r=await n?.loadCurrent?.()??{};return _({targetSchema:`targetSchema`in t?t.targetSchema:r.targetSchema,numberFormat:`numberFormat`in t?t.numberFormat:r.numberFormat??m,columnMap:`columnMap`in t?t.columnMap:r.columnMap??[],headerSnapshot:`headerSnapshot`in t?t.headerSnapshot:void 0}),t}}},y=[`parts`,`discount_table`],b=[`csv`,`tsv`,`xlsx`],x=o({name:`import_mapping`,scope:`global`,fields:{supplierId:s.reference({entity:`supplier`,required:!0,indexed:!0}),brandId:s.reference({entity:`brand`,indexed:!0}),targetSchema:s.select({options:y,required:!0,indexed:!0}),name:s.text({required:!0,maxLength:255}),format:s.select({options:b,required:!0}),delimiter:s.text({maxLength:4}),hasHeader:s.boolean({default:!0}),headerRowIndex:s.number({integer:!0,min:0,default:0}),sheet:s.text({maxLength:128}),numberFormat:s.json({default:{decimal:`.`,thousands:``}}),columnMap:s.json({default:[]}),headerSnapshot:s.json(),active:s.boolean({default:!0,indexed:!0})},behaviors:[a.auditable(),c({fields:[`name`,`targetSchema`,`format`],projection:e=>({id:e.id,label:e.name,description:`${e.targetSchema} · ${e.format}`})}),{name:`imports:import-mapping-unique`,constraints:(e,t)=>{if(t!==`main`)return[];let n=e.supplierId,r=e.brandId,i=e.targetSchema;return!n||!r||!i?[]:[{kind:`uniqueIndex`,name:`uq_import_mapping_supplier_brand_schema`,on:[n,r,i],where:u`${r} IS NOT NULL`},{kind:`uniqueIndex`,name:`uq_import_mapping_supplier_schema_default`,on:[n,i],where:u`${r} IS NULL`}]}},v],admin:{group:`imports`,label:`Import mappings`,labelSingular:`Import mapping`,icon:`table`,hideFromMenu:!0}});var S=class{entries=new Map;register(e,t){if(this.entries.has(e))throw Error(`Transform "${e}" is already registered — two plugins cannot register the same transform name.`);this.entries.set(e,t)}set(e,t){this.entries.set(e,t)}get(e){return this.entries.get(e)}has(e){return this.entries.has(e)}list(){return Array.from(this.entries.keys()).sort()}clear(){this.entries.clear()}};const C=Symbol.for(`@murumets-ee/imports:transforms`);function w(){let e=globalThis,t=e[C];return t||(t=new S,e[C]=t),t}function T(e,t){w().set(e,t)}export{y as a,v as c,d,b as i,f as l,w as n,x as o,T as r,_ as s,S as t,p as u};
2
- //# sourceMappingURL=transform-D7uHod4E.mjs.map
1
+ import{t as e}from"./rolldown-runtime-DK3Fl9T5.mjs";import{c as t,u as n}from"./parts-schema-D0gMhNWj.mjs";import{n as r,r as i}from"./schema-registry-C_lQje-K.mjs";import{behavior as a,defineEntity as o,field as s,searchable as c}from"@murumets-ee/entity";import{z as l}from"zod";import{sql as u}from"drizzle-orm";var d=e({IMPORT_RUN_STATUSES:()=>f,ImportRun:()=>p});const f=[`pending`,`running`,`succeeded`,`failed`,`cancelled`],p=o({name:`import_run`,fields:{label:s.text({required:!0,maxLength:255}),status:s.select({options:f,default:`pending`,indexed:!0}),filePath:s.text({required:!0,maxLength:2048}),transformName:s.text({required:!0,maxLength:128,indexed:!0}),params:s.json(),totals:s.json(),errorSummary:s.json(),warningSummary:s.json(),startedAt:s.date(),finishedAt:s.date(),queueJobId:s.text({maxLength:64,indexed:!0})},behaviors:[a.auditable(),c({fields:[`label`,`transformName`,`queueJobId`,`status`,`filePath`],projection:e=>({id:e.id,label:e.label,description:`${e.transformName} · ${e.status}`})})],scope:`global`,admin:{group:`imports`,label:`Import runs`,labelSingular:`Import run`,icon:`upload`,hideFromMenu:!0}}),m={decimal:`.`,thousands:``},h=l.array(t).max(512),g=l.strictObject({columns:l.array(l.string().min(1).max(256)).min(1).max(256),rows:l.array(l.record(l.string().max(500))).max(5)});function _(e){if(e.headerSnapshot!==void 0&&e.headerSnapshot!==null){let t=g.safeParse(e.headerSnapshot);if(!t.success)throw Error(`Invalid headerSnapshot: ${t.error.issues[0]?.message??`bad shape`}`);let n=JSON.stringify(e.headerSnapshot).length;if(n>1e5)throw Error(`headerSnapshot too large (${n} > 100000 serialized chars) — trim sample rows/cells`)}let t=typeof e.targetSchema==`string`?e.targetSchema:``,a=r(t);if(!a)throw Error(`Unknown target schema "${t}" (registered: ${i().join(`, `)})`);let o=n.safeParse(e.numberFormat);if(!o.success)throw Error(`Invalid numberFormat: ${o.error.issues[0]?.message??`bad shape`}`);let s=h.safeParse(e.columnMap);if(!s.success)throw Error(`Invalid columnMap: ${s.error.issues[0]?.message??`bad shape`}`);let c=JSON.stringify(e.columnMap).length;if(c>1e5)throw Error(`columnMap too large (${c} > 100000 serialized chars)`);let l=new Set(a.fields.map(e=>e.key)),u=new Set,d=new Set;for(let e of s.data){if(!l.has(e.target))throw Error(`Mapping target "${e.target}" is not a field of schema "${a.name}" (allowed: ${[...l].join(`, `)})`);u.has(e.target)&&d.add(e.target),u.add(e.target)}if(d.size>0)throw Error(`Mapping for schema "${a.name}" maps a target more than once: ${[...d].join(`, `)}`);let f=new Set(s.data.map(e=>e.target)),p=a.fields.filter(e=>e.required&&!f.has(e.key)).map(e=>e.key);if(p.length>0)throw Error(`Mapping for schema "${a.name}" is missing required target(s): ${p.join(`, `)}`)}const v={name:`imports:import-mapping-validation`,hooks:{beforeCreate:async e=>(_({targetSchema:e.targetSchema,numberFormat:e.numberFormat??m,columnMap:e.columnMap??[],headerSnapshot:e.headerSnapshot}),e),beforeUpdate:async(e,t,n)=>{let r=await n?.loadCurrent?.()??{};return _({targetSchema:`targetSchema`in t?t.targetSchema:r.targetSchema,numberFormat:`numberFormat`in t?t.numberFormat:r.numberFormat??m,columnMap:`columnMap`in t?t.columnMap:r.columnMap??[],headerSnapshot:`headerSnapshot`in t?t.headerSnapshot:void 0}),t}}},y=[`parts`,`discount_table`],b=[`csv`,`tsv`,`xlsx`],x=o({name:`import_mapping`,scope:`global`,fields:{supplierId:s.reference({entity:`supplier`,required:!0,indexed:!0}),brandId:s.reference({entity:`brand`,indexed:!0}),targetSchema:s.select({options:y,required:!0,indexed:!0}),name:s.text({required:!0,maxLength:255}),format:s.select({options:b,required:!0}),delimiter:s.text({maxLength:4}),hasHeader:s.boolean({default:!0}),headerRowIndex:s.number({integer:!0,min:0,default:0}),sheet:s.text({maxLength:128}),numberFormat:s.json({default:{decimal:`.`,thousands:``}}),columnMap:s.json({default:[]}),headerSnapshot:s.json(),active:s.boolean({default:!0,indexed:!0})},behaviors:[a.auditable(),c({fields:[`name`,`targetSchema`,`format`],projection:e=>({id:e.id,label:e.name,description:`${e.targetSchema} · ${e.format}`})}),{name:`imports:import-mapping-unique`,constraints:(e,t)=>{if(t!==`main`)return[];let n=e.supplierId,r=e.brandId,i=e.targetSchema;return!n||!r||!i?[]:[{kind:`uniqueIndex`,name:`uq_import_mapping_supplier_brand_schema`,on:[n,r,i],where:u`${r} IS NOT NULL`},{kind:`uniqueIndex`,name:`uq_import_mapping_supplier_schema_default`,on:[n,i],where:u`${r} IS NULL`}]}},v],admin:{group:`imports`,label:`Import mappings`,labelSingular:`Import mapping`,icon:`table`,hideFromMenu:!0}});var S=class{entries=new Map;register(e,t){if(this.entries.has(e))throw Error(`Transform "${e}" is already registered — two plugins cannot register the same transform name.`);this.entries.set(e,t)}set(e,t){this.entries.set(e,t)}get(e){return this.entries.get(e)}has(e){return this.entries.has(e)}list(){return Array.from(this.entries.keys()).sort()}clear(){this.entries.clear()}};const C=Symbol.for(`@murumets-ee/imports:transforms`);function w(){let e=globalThis,t=e[C];return t||(t=new S,e[C]=t),t}function T(e,t){w().set(e,t)}export{y as a,v as c,d,b as i,f as l,w as n,x as o,T as r,_ as s,S as t,p as u};
2
+ //# sourceMappingURL=transform-DIzWppgJ.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"transform-D7uHod4E.mjs","names":[],"sources":["../src/entities/import-run.ts","../src/entities/import-mapping-validation.ts","../src/entities/import-mapping.ts","../src/transform.ts"],"sourcesContent":["/**\n * ImportRun — one feed-import attempt. Tracks file, transform, status,\n * row totals, and the top error patterns surfaced by `ErrorTracker`.\n *\n * Per PLAN-ECOMMERCE.md PR 7 (PoC scope): generic plumbing entity. The\n * commerce-specific transform parameters (brandId, supplierId, supplier\n * code prefix) live in the opaque `params` JSONB so the imports package\n * stays free of commerce-domain dependencies. PR 8 wires a concrete\n * carmaker-feed transform that reads those params; PR 8a renders a\n * bespoke `/admin/commerce/imports` page over this entity's history.\n *\n * `hideFromMenu: true` because PR 7 ships only the entity + plumbing —\n * the operator-facing surface is PR 8a. The auto-EntityListPage at\n * `/admin/import_run` still resolves for direct lookups, but no sidebar\n * entry points to it.\n */\n\nimport { behavior, defineEntity, field, searchable } from '@murumets-ee/entity'\nimport type { AdminClient } from '@murumets-ee/entity/admin'\n\n/**\n * Lifecycle stages.\n *\n * - `pending` — row exists, queue job has been enqueued, worker hasn't picked it up yet.\n * - `running` — worker is streaming + bulk-writing.\n * - `succeeded` — worker finished cleanly (note: per-row failures still possible — see `totals.failed`).\n * - `failed` — worker threw and the queue marked the job dead. `errorSummary.fatal` carries the cause.\n * - `cancelled` — manual operator action via PR 8a (out of scope for PR 7).\n */\nexport const IMPORT_RUN_STATUSES = [\n 'pending',\n 'running',\n 'succeeded',\n 'failed',\n 'cancelled',\n] as const\nexport type ImportRunStatus = (typeof IMPORT_RUN_STATUSES)[number]\n\nexport const ImportRun = defineEntity({\n name: 'import_run',\n fields: {\n /** Operator-visible label, e.g. `\"MERCEDES — ME_20251027075918.txt\"`. Free-form. */\n label: field.text({ required: true, maxLength: 255 }),\n /** See {@link IMPORT_RUN_STATUSES}. */\n status: field.select({\n options: IMPORT_RUN_STATUSES,\n default: 'pending',\n indexed: true,\n }),\n /**\n * Path or storage key of the uploaded feed file. Generic string —\n * could be `/var/lumi/uploads/<id>.txt` for the local-disk PoC or an\n * S3 object key once storage adapter integration lands. The worker\n * reads this with the configured `readFeed` resolver.\n */\n filePath: field.text({ required: true, maxLength: 2048 }),\n /**\n * Name of the registered transform applied to each row. Resolved at\n * worker-dispatch time against the transform registry contributed by\n * the consumer. PoC: `'commerce:carmaker-feed'`.\n */\n transformName: field.text({ required: true, maxLength: 128, indexed: true }),\n /**\n * Opaque per-transform parameters. The carmaker transform expects\n * `{ brandId, supplierId, codePrefix? }`. Validation happens inside\n * the transform — the imports package never inspects this shape so\n * a new transform with different params doesn't require a schema\n * migration.\n */\n params: field.json(),\n /**\n * Row counters: `{ submitted, succeeded, failed, skipped, batches }`.\n * Updated by the worker as each batch completes. Final values are\n * what the operator reads; intermediate progress comes from\n * `toolkit_jobs.progress` via the queue UI.\n */\n totals: field.json(),\n /**\n * Output of `ErrorTracker.getTopPatterns(totalRows)` — the top-50\n * error signatures with up to 5 sample rows each. Empty `[]` until\n * the worker writes it on completion.\n */\n errorSummary: field.json(),\n /**\n * NON-FATAL warning patterns (commerce-demo PR4) — same\n * `ErrorTrackerSnapshot` shape as {@link errorSummary} but for advisories\n * on rows that STILL succeeded (their doc was indexed), e.g.\n * `no_purchase_price` (a part imported list-only, cost pending). Kept\n * separate from `errorSummary` so a warning never reads as a failed row.\n * Null/absent until a SUCCESSFUL run that produced warnings writes it — a\n * fatal failure (`markFailed`) abandons the run mid-stream and only sets\n * `errorSummary.fatal`, so the two summaries are not co-present.\n *\n * NOTE: because the warning aggregator reuses `ErrorTracker`, the persisted\n * JSON reuses its field names — the `totalErrors` key inside this blob is\n * really the WARNING count (and `patterns[].errorType` the warning type).\n */\n warningSummary: field.json(),\n /** Set when the worker picks up the job. */\n startedAt: field.date(),\n /** Set when the worker finishes (success OR fatal failure). */\n finishedAt: field.date(),\n /**\n * `toolkit_jobs.id` of the queue job processing this run — link\n * back so PR 8a can show live progress without a second lookup.\n */\n queueJobId: field.text({ maxLength: 64, indexed: true }),\n },\n behaviors: [\n behavior.auditable(),\n // Operator-facing search: by label (e.g. \"MERCEDES — ME_*.txt\"),\n // transform name, or queue job id. No FTS — labels are short\n // alphanumeric tags, not prose.\n searchable({\n fields: ['label', 'transformName', 'queueJobId', 'status', 'filePath'],\n projection: (row) => ({\n id: row.id as string,\n label: row.label as string,\n description: `${row.transformName} · ${row.status}`,\n }),\n }),\n ],\n scope: 'global',\n admin: {\n group: 'imports',\n label: 'Import runs',\n labelSingular: 'Import run',\n icon: 'upload',\n hideFromMenu: true,\n },\n})\n\nexport type ImportRunClient = AdminClient<typeof ImportRun.allFields>\n","/**\n * Save-time validation for `import_mapping` rows (commerce-demo phase 02 §3/§5).\n *\n * This is the R02 injection boundary realised as an entity behavior: a mapping's\n * `columnMap` may only point at fields the chosen `TargetSchema` whitelists, and a\n * mapping is incomplete unless every REQUIRED target of that schema is mapped. Both\n * checks run on EVERY write path (the PR3 wizard's generic-CRUD `POST`, a future\n * seed, a direct `AdminClient` call) — \"validate at the boundary\", not \"validate in\n * the one UI we happen to ship\".\n *\n * The transform compiler (`buildMappingTransform`) re-checks the whitelist at run\n * time, so this is defense-in-depth — but failing at SAVE gives the operator the\n * error in the wizard (where they can fix it) instead of as a per-row run failure.\n *\n * Lives next to the entity (same package) and imports the mapping engine + schema\n * registry one-directionally (engine/registry never import the entity), so no cycle.\n */\n\nimport type { Behavior } from '@murumets-ee/entity'\nimport { z } from 'zod'\nimport { mappingColumnSchema, numberFormatSchema } from '../mapping/engine.js'\nimport { getTargetSchema, listTargetSchemas } from '../mapping/schema-registry.js'\n\n/** Default applied by the `numberFormat` field — used when a create omits it (defaults run after hooks). */\nconst DEFAULT_NUMBER_FORMAT = { decimal: '.', thousands: '' } as const\n\n// Bounded, like every JSONB the entity accepts: a legitimate column map has at\n// most one entry per source column (same order of magnitude as the snapshot's\n// 256-column cap), so an arbitrarily long array is a bloat vector, not data.\nconst columnMapSchema = z.array(mappingColumnSchema).max(512)\n\n/**\n * File-less edit snapshot (#667) — header columns + a few sample rows, same\n * `{ columns, rows: [{column → cell}] }` shape as the preview endpoint's\n * response. Bounded on every axis (these are DISPLAY samples, not data):\n * a snapshot that exceeds the caps should have been trimmed/omitted by the\n * wizard, so an oversized one here is a bypass attempt or a bug — reject, and\n * additionally hard-cap the serialized size so 256-wide × max-cell rows can't\n * park ~600 KB of JSONB per mapping row.\n *\n * Strictness matters: the behavior hook persists the RAW input, so a\n * non-strict parse would let unknown top-level keys ride into JSONB unmeasured\n * (zod strips them from `snap.data`, but that stripped copy isn't what's\n * stored).\n */\nconst headerSnapshotSchema = z.strictObject({\n columns: z.array(z.string().min(1).max(256)).min(1).max(256),\n rows: z.array(z.record(z.string().max(500))).max(5),\n})\n\n/**\n * Serialized-size ceiling for a stored snapshot (defense-in-depth over the\n * per-axis caps). Measured in UTF-16 code units (`JSON.stringify(...).length`)\n * — multi-byte content can serialize up to ~3× larger on disk; the bound holds\n * either way. Mirrored client-side by `toHeaderSnapshot` in commerce-ui\n * (pinned by its round-trip test).\n */\nexport const HEADER_SNAPSHOT_MAX_CHARS = 100_000\n\n/**\n * Serialized-size ceiling for the RAW `columnMap` input. Same rationale + same\n * raw-not-parsed measurement as the snapshot ceiling: `mappingColumnSchema` is\n * shared with the RUN-TIME params parser (engine.ts), so it can't be made\n * `.strict()` without risking previously-persisted `import_run.params` — this\n * save-path-only ceiling bounds what unknown entry keys / unbounded per-entry\n * `transforms` arrays can park in JSONB without touching run-time parsing. A\n * legitimate 256-column map with transform chains serializes well under this.\n */\nexport const COLUMN_MAP_MAX_CHARS = 100_000\n\n/**\n * Throw a descriptive error unless the mapping is structurally valid AND honours its\n * schema's field whitelist + required targets. The thrown message is operator-facing\n * (surfaced verbatim by the admin CRUD route as a 4xx body).\n */\nexport function assertValidMapping(input: {\n targetSchema: unknown\n numberFormat: unknown\n columnMap: unknown\n headerSnapshot?: unknown\n}): void {\n // Snapshot is optional metadata — validate only when present (absent/null =\n // \"no file-less edit support for this row\", a valid state for pre-#667 rows).\n if (input.headerSnapshot !== undefined && input.headerSnapshot !== null) {\n const snap = headerSnapshotSchema.safeParse(input.headerSnapshot)\n if (!snap.success) {\n throw new Error(`Invalid headerSnapshot: ${snap.error.issues[0]?.message ?? 'bad shape'}`)\n }\n // Measure the RAW input, not `snap.data` — the raw value is what the write\n // path persists, and it's identical to the parse only because the schema\n // is `.strict()`. Measuring the raw keeps that true even if strictness is\n // ever relaxed.\n const serialized = JSON.stringify(input.headerSnapshot).length\n if (serialized > HEADER_SNAPSHOT_MAX_CHARS) {\n throw new Error(\n `headerSnapshot too large (${serialized} > ${HEADER_SNAPSHOT_MAX_CHARS} serialized chars) — trim sample rows/cells`,\n )\n }\n }\n const targetSchema = typeof input.targetSchema === 'string' ? input.targetSchema : ''\n const schema = getTargetSchema(targetSchema)\n if (!schema) {\n throw new Error(\n `Unknown target schema \"${targetSchema}\" (registered: ${listTargetSchemas().join(', ')})`,\n )\n }\n\n const nf = numberFormatSchema.safeParse(input.numberFormat)\n if (!nf.success) {\n throw new Error(`Invalid numberFormat: ${nf.error.issues[0]?.message ?? 'bad shape'}`)\n }\n\n const cm = columnMapSchema.safeParse(input.columnMap)\n if (!cm.success) {\n throw new Error(`Invalid columnMap: ${cm.error.issues[0]?.message ?? 'bad shape'}`)\n }\n const columnMapSerialized = JSON.stringify(input.columnMap).length\n if (columnMapSerialized > COLUMN_MAP_MAX_CHARS) {\n throw new Error(\n `columnMap too large (${columnMapSerialized} > ${COLUMN_MAP_MAX_CHARS} serialized chars)`,\n )\n }\n\n const allowed = new Set(schema.fields.map((f) => f.key))\n const seenTargets = new Set<string>()\n const dupeTargets = new Set<string>()\n for (const col of cm.data) {\n if (!allowed.has(col.target)) {\n throw new Error(\n `Mapping target \"${col.target}\" is not a field of schema \"${schema.name}\" ` +\n `(allowed: ${[...allowed].join(', ')})`,\n )\n }\n // Two source columns mapping to the SAME target collapse last-write-wins at run\n // time (`mapped[col.target] = …`), silently importing wrong data. The wizard\n // blocks this client-side; enforce it here too so a direct API call can't bypass\n // it (validate at the boundary).\n if (seenTargets.has(col.target)) dupeTargets.add(col.target)\n seenTargets.add(col.target)\n }\n if (dupeTargets.size > 0) {\n throw new Error(\n `Mapping for schema \"${schema.name}\" maps a target more than once: ${[...dupeTargets].join(', ')}`,\n )\n }\n\n const mappedTargets = new Set(cm.data.map((c) => c.target))\n const missing = schema.fields\n .filter((f) => f.required && !mappedTargets.has(f.key))\n .map((f) => f.key)\n if (missing.length > 0) {\n throw new Error(\n `Mapping for schema \"${schema.name}\" is missing required target(s): ${missing.join(', ')}`,\n )\n }\n}\n\n/**\n * Behavior wrapping {@link assertValidMapping}. Typed `Behavior<Record<never,\n * never>, …>` (NOT plain `Behavior`, whose wide `fields?` would collapse the\n * entity's inferred field types — the documented no-field-behavior gotcha).\n * Contributes a hook, no fields.\n */\nexport const importMappingValidation: Behavior<\n Record<never, never>,\n 'imports:import-mapping-validation'\n> = {\n name: 'imports:import-mapping-validation',\n hooks: {\n beforeCreate: async (data) => {\n // Field defaults run AFTER hooks, so an omitted numberFormat/columnMap is the\n // field default here — mirror those defaults so a minimal create validates\n // against the same effective config the row will hold.\n assertValidMapping({\n targetSchema: data.targetSchema,\n numberFormat: data.numberFormat ?? DEFAULT_NUMBER_FORMAT,\n columnMap: data.columnMap ?? [],\n headerSnapshot: data.headerSnapshot,\n })\n return data\n },\n beforeUpdate: async (_id, data, ctx) => {\n // A PATCH is partial — validate the EFFECTIVE config (patch overlaid on the\n // current row), so changing only `name` doesn't escape validation and changing\n // only `columnMap` is still checked against the persisted `targetSchema`.\n const current = (await ctx?.loadCurrent?.()) ?? {}\n assertValidMapping({\n targetSchema: 'targetSchema' in data ? data.targetSchema : current.targetSchema,\n numberFormat:\n 'numberFormat' in data\n ? data.numberFormat\n : (current.numberFormat ?? DEFAULT_NUMBER_FORMAT),\n columnMap: 'columnMap' in data ? data.columnMap : (current.columnMap ?? []),\n // Only the incoming value needs checking — the persisted snapshot was\n // validated when it was written.\n headerSnapshot: 'headerSnapshot' in data ? data.headerSnapshot : undefined,\n })\n return data\n },\n },\n}\n","/**\n * ImportMapping — a reusable, per-(supplier, brand) column-mapping profile that\n * drives the configurable feed-import engine (commerce-demo D005, phase 02).\n *\n * Replaces the hardcoded 14/15-column carmaker transform with operator-editable\n * mappings: which source column → which whitelisted target field, per-column\n * cleaning transforms, the file format (csv/tsv/xlsx), and the number format\n * (decimal + thousands separators — real supplier feeds vary, see R03).\n *\n * **Why this lives in `@murumets-ee/imports`, not `@murumets-ee/commerce`**\n * (D005 cycle constraint): the engine + the concrete target schemas need the\n * commerce `PartsDocument` shape, and `imports` already depends on\n * `@murumets-ee/search-elasticsearch` for it. Putting the mapping in commerce\n * would force `commerce → imports` and a forbidden import cycle. The entity\n * references the commerce `supplier` / `brand` entities **by name string**\n * (`field.reference({ entity: 'supplier' })`) — no Entity-object import, so the\n * leaf-package boundary holds.\n *\n * **Resolution** (most-specific, D002 ladder): for an upload tagged\n * `(supplier, brand)`, pick the `(supplier, brand)` mapping if present, else the\n * `(supplier, NULL)` supplier-default. See `resolveMapping()` in the engine.\n *\n * `hideFromMenu: true` — the mapping CRUD wizard lands in commerce-demo PR3. The\n * auto-EntityListPage at `/admin/import_mapping` still resolves for direct lookups\n * and for the admin-only AdminClient writes this engine relies on.\n */\n\nimport type { Behavior, SchemaFragment, SchemaTarget } from '@murumets-ee/entity'\nimport { behavior, defineEntity, field, searchable } from '@murumets-ee/entity'\nimport type { AdminClient } from '@murumets-ee/entity/admin'\nimport { sql } from 'drizzle-orm'\nimport type { PgColumn } from 'drizzle-orm/pg-core'\nimport { importMappingValidation } from './import-mapping-validation.js'\n\n/**\n * Registered target schemas a mapping may write into. This is the FIRST of two\n * whitelists (the second is each schema's target-field set) — a mapping whose\n * `targetSchema` is outside this list is rejected (deny-by-default, R02).\n */\nexport const IMPORT_MAPPING_TARGET_SCHEMAS = ['parts', 'discount_table'] as const\nexport type ImportMappingTargetSchema = (typeof IMPORT_MAPPING_TARGET_SCHEMAS)[number]\n\n/** File formats the reader dispatches on. `csv`/`tsv` → csv-parse; `xlsx` → exceljs. */\nexport const IMPORT_MAPPING_FORMATS = ['csv', 'tsv', 'xlsx'] as const\nexport type ImportMappingFormat = (typeof IMPORT_MAPPING_FORMATS)[number]\n\n/**\n * Declarative DDL: one mapping per `(supplier, brand, target_schema)`, enforced by\n * TWO PARTIAL unique indexes rather than one plain composite unique.\n *\n * Why partial: a plain `UNIQUE (supplierId, brandId, targetSchema)` treats NULL\n * `brandId` as distinct (Postgres NULL-distinct semantics), so it would allow MANY\n * supplier-default rows (`brandId IS NULL`) for the same `(supplier, schema)` —\n * ambiguous, since the engine resolves `brandId === null` as THE supplier default\n * (`resolveMapping`). Splitting it:\n * - brand-specific rows (`brandId IS NOT NULL`): unique on `(supplier, brand, schema)`;\n * - supplier-default rows (`brandId IS NULL`): unique on `(supplier, schema)`.\n *\n * The `where` predicates use raw `sql` with the live Drizzle column reference (so the\n * emitted identifier is correct regardless of casing) — `IS NULL` / `IS NOT NULL`,\n * NOT `eq(col, …)` which would serialize as an invalid bound `$1` in DDL.\n *\n * Typed `Behavior<Record<never, never>, …>` (NOT plain `Behavior`, whose wide\n * `fields?` would collapse the entity's inferred field types to `any` — the\n * documented no-field-behavior gotcha). Contributes constraints, no fields.\n */\nconst importMappingUnique: Behavior<Record<never, never>, 'imports:import-mapping-unique'> = {\n name: 'imports:import-mapping-unique',\n constraints: (table: Record<string, PgColumn>, target: SchemaTarget): SchemaFragment[] => {\n if (target !== 'main') return []\n const supplierId = table.supplierId\n const brandId = table.brandId\n const targetSchema = table.targetSchema\n // Defensive: only emit when all three columns exist.\n if (!supplierId || !brandId || !targetSchema) return []\n return [\n {\n kind: 'uniqueIndex',\n name: 'uq_import_mapping_supplier_brand_schema',\n on: [supplierId, brandId, targetSchema],\n where: sql`${brandId} IS NOT NULL`,\n },\n {\n kind: 'uniqueIndex',\n name: 'uq_import_mapping_supplier_schema_default',\n on: [supplierId, targetSchema],\n where: sql`${brandId} IS NULL`,\n },\n ]\n },\n}\n\nexport const ImportMapping = defineEntity({\n name: 'import_mapping',\n scope: 'global',\n fields: {\n /** FK to commerce `supplier` (by name string — no cross-package import). Required. */\n supplierId: field.reference({ entity: 'supplier', required: true, indexed: true }),\n /** FK to commerce `brand`. NULL = supplier default (applies to all that supplier's brands). */\n brandId: field.reference({ entity: 'brand', indexed: true }),\n /** Which registered target schema this mapping feeds — `parts` or `discount_table`. */\n targetSchema: field.select({\n options: IMPORT_MAPPING_TARGET_SCHEMAS,\n required: true,\n indexed: true,\n }),\n /** Operator-facing profile name (a supplier can have e.g. \"monthly full\" vs \"weekly delta\"). */\n name: field.text({ required: true, maxLength: 255 }),\n /** File format — drives reader dispatch + the default delimiter. */\n format: field.select({ options: IMPORT_MAPPING_FORMATS, required: true }),\n /**\n * Explicit single-char field delimiter override. When omitted, the reader\n * defaults from `format` (`csv` → `,`, `tsv` → `\\t`). Ignored for `xlsx`.\n */\n delimiter: field.text({ maxLength: 4 }),\n /** First row is the header. `false` → positional `{ \"0\": …, \"1\": … }` source keys. */\n hasHeader: field.boolean({ default: true }),\n /** 0-based header row index (header hygiene — R03 feeds have leading/trailing-space headers). */\n headerRowIndex: field.number({ integer: true, min: 0, default: 0 }),\n /** xlsx sheet name (or 1-based index as a string). Ignored for csv/tsv. */\n sheet: field.text({ maxLength: 128 }),\n /**\n * Number format for the `number` per-column transform:\n * `{ decimal: '.'|',', thousands: ''|'.'|','|' ' }`. Opaque json; validated\n * by the engine's Zod schema at consume time (matches `import_run.params`).\n */\n numberFormat: field.json({ default: { decimal: '.', thousands: '' } }),\n /**\n * Ordered column map: `[{ source, target, transforms? }]`. `source` is a\n * header name (or positional index string); `target` MUST be a field in the\n * chosen schema's whitelist (validated by the engine's Zod schema, R02\n * injection boundary); `transforms` is a bounded, enum-keyed list. Opaque\n * json on the entity; typed + validated by the engine.\n */\n columnMap: field.json({ default: [] }),\n /**\n * File-less edit snapshot (#667): `{ columns: string[], rows: [{column →\n * cell}] }` — the sample feed's header + a few sample rows, captured from\n * the wizard's preview at save time. Lets the editor re-open a saved\n * mapping WITHOUT re-uploading the (potentially huge) feed just to tweak a\n * transform. NULL on rows saved before this field existed (and when the\n * preview exceeded the size caps) — the editor falls back to the\n * upload-first flow. Staleness is benign: a source column that later\n * vanishes from the real feed is caught at run time by the 6b drift check\n * (409) / row errors, not by this snapshot. Size-capped by\n * `import-mapping-validation`.\n */\n headerSnapshot: field.json(),\n /** Inactive mappings are skipped by resolution. */\n active: field.boolean({ default: true, indexed: true }),\n },\n behaviors: [\n behavior.auditable(),\n // Operator-facing search (PR3 wizard): by profile name, schema, or format.\n // No `fts` → IlikeProvider, so this adds NO DDL (the 0036 migration is\n // unchanged). Names are short tags, not prose.\n searchable({\n fields: ['name', 'targetSchema', 'format'],\n projection: (row) => ({\n id: row.id as string,\n label: row.name as string,\n description: `${row.targetSchema} · ${row.format}`,\n }),\n }),\n importMappingUnique,\n // Save-time whitelist + required-target validation (R02 injection boundary).\n importMappingValidation,\n ],\n admin: {\n group: 'imports',\n label: 'Import mappings',\n labelSingular: 'Import mapping',\n icon: 'table',\n hideFromMenu: true,\n },\n})\n\nexport type ImportMappingClient = AdminClient<typeof ImportMapping.allFields>\n","/**\n * Per-feed transform plugin interface (PLAN-ECOMMERCE.md PR 7 / D14).\n *\n * The streaming reader reads raw rows out of the file (column key →\n * string). The transform turns one row into a typed `OutputDoc` ready\n * for the bulk-write surface (PoC: `PartsDocument` for the parts ES\n * index). The transform is what makes a generic feed-importer\n * commerce-aware — without it, the importer has no opinion on what a\n * row \"means\".\n *\n * Why an interface, not a function:\n * - The transform may need to reject a row (`RowSkip`) without that\n * counting as an error in `ErrorTracker` — e.g. blank lines, header\n * rows mistakenly retained, or \"this is a replacement-code marker\n * row, handled out-of-band\" decisions.\n * - The transform may need to fail a row (`RowError`) with a typed\n * error class so `ErrorTracker` collapses them by `errorType`.\n * - The transform may need access to the run-level params (brand /\n * supplier IDs, code prefix, batch ID) without those being\n * re-derived per row.\n *\n * PR 7 ships only the interface + a registry. PR 8 ships the first\n * concrete carmaker-feed transform.\n */\n\n/** Identifies which transform a registered handler implements. Stable across deploys. */\nexport type TransformName = string\n\n/** Per-run context provided to every row call. Kept narrow on purpose. */\nexport interface TransformContext {\n /** UUID of the `import_run` row. Forward to the output doc as `import_batch_id` for source attribution (D20). */\n importRunId: string\n /** Opaque per-run params copied from `import_run.params`. The transform validates the shape it expects. */\n params: Record<string, unknown>\n /** Operator-supplied label, useful for logging / debugging. */\n runLabel: string\n /** 1-based row number across the whole feed (header counts as row 0). Forward to `RowError.rowNumber`. */\n rowNumber: number\n}\n\n/**\n * A directional supersession pair harvested from a row as a SIDE-OUTPUT\n * (commerce-demo PR6, SD007): `from` (the row's own code) is superseded by `to`\n * (the row's `superseding_code`), so `to` is the newer/current code. Both are\n * already NORMALIZED (same normalization as the doc's `code_normalized`) so a\n * downstream equivalence-group merge collides codes correctly.\n *\n * This is PURE DATA. The runner buffers pairs in memory during the single ES pass\n * (an array append — it never blocks the `bulkUpsert` loop) and hands the buffer to\n * an INJECTED post-run sink (the commerce replacement-merge engine). The\n * `@murumets-ee/imports` leaf never writes the consumer's Postgres tables itself —\n * the boundary holds because the sink is injected, not imported.\n */\nexport interface DirectionalPair {\n /** The superseded (older) normalized code — the row's own `code_normalized`. */\n from: string\n /** The superseding (newer / current) normalized code — normalized `superseding_code`. */\n to: string\n}\n\n/**\n * A partial-document PATCH emitted as a side-output (commerce-demo PR7): the row\n * itself produces no saleable doc, but it carries a partial update that must land\n * on a DIFFERENT doc — identified by `id` (the same `<code_normalized>__<supplier>`\n * key the parts sink uses). The runner buffers these during the single stream and,\n * AFTER the ES pass, applies them via a phase-2 `bulkPartialUpdate` (an ES `update`\n * merge, realtime by `_id` → order-independent). A patch whose target doesn't exist\n * surfaces as an `orphan_*` warning, never a phantom doc.\n *\n * Canonical use: a `<code>*S` core-fee row (D004) attaches its deposit to the parent\n * part's `core_charge_minor`. Kept GENERIC (`Partial<TDoc>`) so the runner never\n * imports a schema-specific doc type — the same leaf-clean shape as {@link DirectionalPair}.\n */\nexport interface DocPatch<TDoc> {\n /** The `_id` of the doc to merge into (a different row's doc than the one that emitted this). */\n id: string\n /** The partial fields to merge onto that doc. */\n doc: Partial<TDoc>\n /**\n * The warning to record if the target `id` isn't in the index (the phase-2\n * update 404s). The runner detects the orphan (it can't be known at buildRow\n * time — the parent may stream later) but the SCHEMA owns the bucket name +\n * message, so this rides on the patch. Absent → a generic `orphan_patch`\n * fallback. For `*S` core-fee rows the parts schema sets `orphan_core_charge`\n * (D004: warn, never silently drop).\n */\n orphanWarning?: RowWarning\n}\n\n/**\n * One of: a successful `OutputDoc` keyed by a stable `id`, an\n * intentional `skip` (counted in `totals.skipped`), or a row-level\n * error (counted in `totals.failed` AND aggregated by `ErrorTracker`).\n *\n * The `id` field on `success` is used as the bulk-upsert primary key\n * (`_id` in ES). Per D4 the parts index uses\n * `<code_normalized>__<supplier_id>` so a re-run of the same feed\n * idempotently overwrites instead of duplicating.\n *\n * **`warnings` on `success`** (commerce-demo PR4): a row can be a valid,\n * indexable doc AND still carry a non-fatal advisory — e.g. a part imported\n * with `list_price` but no resolvable purchase cost (`no_purchase_price`,\n * phase 03 §5). The runner indexes the doc (it's a success, NOT a failure)\n * and separately aggregates the warnings into a `warningSummary`, so the\n * operator sees \"120 rows cost-pending\" without those rows being dropped or\n * mis-counted as failures. Empty/absent `warnings` is the common case.\n *\n * **`pairs` on `success`** (commerce-demo PR6, SD007): a row can ALSO carry\n * directional supersession {@link DirectionalPair}s as a side-output — the `parts`\n * schema emits one when a row's `superseding_code` is present (a SPARSE column on a\n * full manufacturer catalogue, so most rows emit none). The runner buffers these\n * from ES-accepted rows and returns them for the injected `pairSink`. Empty/absent\n * `pairs` is the common case.\n *\n * **`patches` on `skip`** (commerce-demo PR7): a `<code>*S` core-fee row is NOT a\n * saleable doc (so it `skip`s the main index), but it carries a {@link DocPatch}\n * side-output that attaches its deposit to the PARENT part's `core_charge_minor`.\n * The runner buffers these (sparse — only reman parts carry `*S`) and applies them\n * in a phase-2 `bulkPartialUpdate` AFTER the stream; an unresolved parent becomes an\n * `orphan_core_charge` warning (D004). Empty/absent `patches` is the common case.\n */\nexport type RowResult<TDoc> =\n | {\n kind: 'success'\n id: string\n doc: TDoc\n warnings?: readonly RowWarning[]\n pairs?: readonly DirectionalPair[]\n }\n | { kind: 'skip'; reason: string; patches?: readonly DocPatch<TDoc>[] }\n | { kind: 'error'; error: RowError }\n\n/** Row-level error. The aggregator uses `(errorType, field, message)` as the dedup signature. */\nexport interface RowError {\n errorType: string\n message: string\n field?: string | undefined\n}\n\n/**\n * A non-fatal advisory attached to a SUCCESSFUL row (the doc is still\n * indexed). Same `(warningType, field, message)` dedup signature shape as\n * {@link RowError} so the runner can aggregate warnings through an\n * `ErrorTracker` instance into `import_run.warningSummary`.\n */\nexport interface RowWarning {\n warningType: string\n message: string\n field?: string | undefined\n}\n\n/**\n * The contract every per-feed transform implements. Pure function of\n * `(row, ctx) → RowResult` — no I/O. Async only because the future\n * carmaker variant might consult an in-memory taxonomy lookup.\n */\nexport interface RowTransform<TDoc> {\n (rawRow: Record<string, string>, ctx: TransformContext): Promise<RowResult<TDoc>>\n}\n\n/**\n * Holds the registered transforms for the current process. Each plugin\n * that ships a transform calls {@link TransformRegistry.register} from\n * its `init` hook.\n *\n * Per CLAUDE.md \"Whitelist, don't blacklist\": the worker dispatches\n * by `import_run.transformName` against this registry. An unregistered\n * name fails the run rather than running with a default transform.\n *\n * **Production callers MUST go through {@link getTransformRegistry}**, not\n * `new TransformRegistry()`. The constructor is exposed only so tests\n * (and rare custom-worker embeds) can build an isolated instance and\n * inject it into {@link createRunImportHandler} directly. Two `new`\n * instances do NOT share state — registering a transform on one will\n * NOT make it visible to a worker resolving against the other. The\n * package's queue handler (registered by `imports()` plugin) always\n * resolves against the singleton.\n */\nexport class TransformRegistry {\n private readonly entries = new Map<TransformName, RowTransform<unknown>>()\n\n /**\n * Register a transform under a stable name. Throws on duplicate\n * names so two plugins can't silently overwrite each other (same\n * fail-loud collision protection that `combineAdminRoutes` enforces\n * for duplicate `(prefix, method, path)` route registrations).\n */\n register<TDoc>(name: TransformName, transform: RowTransform<TDoc>): void {\n if (this.entries.has(name)) {\n throw new Error(\n `Transform \"${name}\" is already registered — two plugins cannot register the same transform name.`,\n )\n }\n this.entries.set(name, transform as RowTransform<unknown>)\n }\n\n /**\n * Last-wins registration — overwrites an existing entry instead of\n * throwing. The {@link registerImportTransform} consumer-app path uses\n * this (same semantics as `@murumets-ee/queue/client`'s `registerJob`):\n * under dev HMR a re-evaluated consumer module re-registers against the\n * surviving globalThis registry, and an overwrite picks up the edited\n * function where a skip-or-throw would keep dispatching the stale one\n * (the #186 staleness class). Plugin built-ins keep using\n * {@link register} behind `has()` guards so genuine cross-plugin name\n * collisions still fail loud.\n */\n set<TDoc>(name: TransformName, transform: RowTransform<TDoc>): void {\n this.entries.set(name, transform as RowTransform<unknown>)\n }\n\n /** Look up a transform by name. Returns `undefined` if no plugin has registered one. */\n get(name: TransformName): RowTransform<unknown> | undefined {\n return this.entries.get(name)\n }\n\n /** True iff a transform is registered for the given name. */\n has(name: TransformName): boolean {\n return this.entries.has(name)\n }\n\n /** All registered names. Useful for the admin Catalog tab and for tests. */\n list(): TransformName[] {\n return Array.from(this.entries.keys()).sort()\n }\n\n /** Drop all registrations. Tests only. */\n clear(): void {\n this.entries.clear()\n }\n}\n\n// ---------------------------------------------------------------------------\n// Process-global singleton (mirrors `@murumets-ee/queue`'s handler registry).\n// ---------------------------------------------------------------------------\n//\n// Why a singleton: registrations (the plugin's built-ins at init, plus\n// any consumer-app custom transforms) and the worker's `get` happen in\n// different modules that Next.js HMR / Turbopack can re-evaluate\n// independently. Holding the registry on `globalThis` via `Symbol.for`\n// means the same `Map` instance is shared across module evaluations —\n// `register` and `get` see each other regardless of evaluation order.\n// Same fix shape as the queue's handler-registry HMR bug (#186).\n\nconst REGISTRY_KEY = Symbol.for('@murumets-ee/imports:transforms')\n\ninterface GlobalThisWithTransforms {\n [REGISTRY_KEY]?: TransformRegistry\n}\n\n/** Returns the singleton registry, creating it on first access. */\nexport function getTransformRegistry(): TransformRegistry {\n const g = globalThis as GlobalThisWithTransforms\n let reg = g[REGISTRY_KEY]\n if (!reg) {\n reg = new TransformRegistry()\n g[REGISTRY_KEY] = reg\n }\n return reg\n}\n\n/**\n * Convenience wrapper around the singleton registry for CONSUMER apps that\n * ship their own custom transforms — same semantics as\n * `@murumets-ee/queue/client`'s `registerJob`: **last-wins overwrite, never\n * throws on a duplicate name.** Two reasons, both load-bearing:\n *\n * 1. **Upgrade safety.** The `imports()` plugin registers the built-in\n * transforms (`commerce:mapping`, `commerce:carmaker-feed`) during\n * `createApp`, and older hand-wired apps — following this module's\n * previous docs — call this helper UNGUARDED from their bootstrap right\n * after. A duplicate-name throw here would crash boot on the very\n * `pnpm up` upgrade that ships plugin-owned registration; re-registering\n * the identical stock function is a harmless overwrite instead.\n * 2. **HMR freshness.** The registry lives on globalThis and survives dev\n * module re-evaluation; an edited consumer transform re-registers on the\n * next eval and the overwrite picks up the edit — a skip would keep\n * dispatching the stale function forever (the #186 staleness class).\n *\n * Side effect worth knowing: an app can deliberately override a built-in\n * name (its bootstrap runs after plugin init, so last-wins = app wins).\n * Genuine collision protection (two plugins claiming one name) stays on the\n * raw `TransformRegistry.register`.\n */\nexport function registerImportTransform<TDoc>(\n name: TransformName,\n transform: RowTransform<TDoc>,\n): void {\n getTransformRegistry().set(name, transform)\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"transform-DIzWppgJ.mjs","names":[],"sources":["../src/entities/import-run.ts","../src/entities/import-mapping-validation.ts","../src/entities/import-mapping.ts","../src/transform.ts"],"sourcesContent":["/**\n * ImportRun — one feed-import attempt. Tracks file, transform, status,\n * row totals, and the top error patterns surfaced by `ErrorTracker`.\n *\n * Per PLAN-ECOMMERCE.md PR 7 (PoC scope): generic plumbing entity. The\n * commerce-specific transform parameters (brandId, supplierId, supplier\n * code prefix) live in the opaque `params` JSONB so the imports package\n * stays free of commerce-domain dependencies. PR 8 wires a concrete\n * carmaker-feed transform that reads those params; PR 8a renders a\n * bespoke `/admin/commerce/imports` page over this entity's history.\n *\n * `hideFromMenu: true` because PR 7 ships only the entity + plumbing —\n * the operator-facing surface is PR 8a. The auto-EntityListPage at\n * `/admin/import_run` still resolves for direct lookups, but no sidebar\n * entry points to it.\n */\n\nimport { behavior, defineEntity, field, searchable } from '@murumets-ee/entity'\nimport type { AdminClient } from '@murumets-ee/entity/admin'\n\n/**\n * Lifecycle stages.\n *\n * - `pending` — row exists, queue job has been enqueued, worker hasn't picked it up yet.\n * - `running` — worker is streaming + bulk-writing.\n * - `succeeded` — worker finished cleanly (note: per-row failures still possible — see `totals.failed`).\n * - `failed` — worker threw and the queue marked the job dead. `errorSummary.fatal` carries the cause.\n * - `cancelled` — manual operator action via PR 8a (out of scope for PR 7).\n */\nexport const IMPORT_RUN_STATUSES = [\n 'pending',\n 'running',\n 'succeeded',\n 'failed',\n 'cancelled',\n] as const\nexport type ImportRunStatus = (typeof IMPORT_RUN_STATUSES)[number]\n\nexport const ImportRun = defineEntity({\n name: 'import_run',\n fields: {\n /** Operator-visible label, e.g. `\"MERCEDES — ME_20251027075918.txt\"`. Free-form. */\n label: field.text({ required: true, maxLength: 255 }),\n /** See {@link IMPORT_RUN_STATUSES}. */\n status: field.select({\n options: IMPORT_RUN_STATUSES,\n default: 'pending',\n indexed: true,\n }),\n /**\n * Path or storage key of the uploaded feed file. Generic string —\n * could be `/var/lumi/uploads/<id>.txt` for the local-disk PoC or an\n * S3 object key once storage adapter integration lands. The worker\n * reads this with the configured `readFeed` resolver.\n */\n filePath: field.text({ required: true, maxLength: 2048 }),\n /**\n * Name of the registered transform applied to each row. Resolved at\n * worker-dispatch time against the transform registry contributed by\n * the consumer. PoC: `'commerce:carmaker-feed'`.\n */\n transformName: field.text({ required: true, maxLength: 128, indexed: true }),\n /**\n * Opaque per-transform parameters. The carmaker transform expects\n * `{ brandId, supplierId, codePrefix? }`. Validation happens inside\n * the transform — the imports package never inspects this shape so\n * a new transform with different params doesn't require a schema\n * migration.\n */\n params: field.json(),\n /**\n * Row counters: `{ submitted, succeeded, failed, skipped, batches }`.\n * Updated by the worker as each batch completes. Final values are\n * what the operator reads; intermediate progress comes from\n * `toolkit_jobs.progress` via the queue UI.\n */\n totals: field.json(),\n /**\n * Output of `ErrorTracker.getTopPatterns(totalRows)` — the top-50\n * error signatures with up to 5 sample rows each. Empty `[]` until\n * the worker writes it on completion.\n */\n errorSummary: field.json(),\n /**\n * NON-FATAL warning patterns (commerce-demo PR4) — same\n * `ErrorTrackerSnapshot` shape as {@link errorSummary} but for advisories\n * on rows that STILL succeeded (their doc was indexed), e.g.\n * `no_purchase_price` (a part imported list-only, cost pending). Kept\n * separate from `errorSummary` so a warning never reads as a failed row.\n * Null/absent until a SUCCESSFUL run that produced warnings writes it — a\n * fatal failure (`markFailed`) abandons the run mid-stream and only sets\n * `errorSummary.fatal`, so the two summaries are not co-present.\n *\n * NOTE: because the warning aggregator reuses `ErrorTracker`, the persisted\n * JSON reuses its field names — the `totalErrors` key inside this blob is\n * really the WARNING count (and `patterns[].errorType` the warning type).\n */\n warningSummary: field.json(),\n /** Set when the worker picks up the job. */\n startedAt: field.date(),\n /** Set when the worker finishes (success OR fatal failure). */\n finishedAt: field.date(),\n /**\n * `toolkit_jobs.id` of the queue job processing this run — link\n * back so PR 8a can show live progress without a second lookup.\n */\n queueJobId: field.text({ maxLength: 64, indexed: true }),\n },\n behaviors: [\n behavior.auditable(),\n // Operator-facing search: by label (e.g. \"MERCEDES — ME_*.txt\"),\n // transform name, or queue job id. No FTS — labels are short\n // alphanumeric tags, not prose.\n searchable({\n fields: ['label', 'transformName', 'queueJobId', 'status', 'filePath'],\n projection: (row) => ({\n id: row.id as string,\n label: row.label as string,\n description: `${row.transformName} · ${row.status}`,\n }),\n }),\n ],\n scope: 'global',\n admin: {\n group: 'imports',\n label: 'Import runs',\n labelSingular: 'Import run',\n icon: 'upload',\n hideFromMenu: true,\n },\n})\n\nexport type ImportRunClient = AdminClient<typeof ImportRun.allFields>\n","/**\n * Save-time validation for `import_mapping` rows (commerce-demo phase 02 §3/§5).\n *\n * This is the R02 injection boundary realised as an entity behavior: a mapping's\n * `columnMap` may only point at fields the chosen `TargetSchema` whitelists, and a\n * mapping is incomplete unless every REQUIRED target of that schema is mapped. Both\n * checks run on EVERY write path (the PR3 wizard's generic-CRUD `POST`, a future\n * seed, a direct `AdminClient` call) — \"validate at the boundary\", not \"validate in\n * the one UI we happen to ship\".\n *\n * The transform compiler (`buildMappingTransform`) re-checks the whitelist at run\n * time, so this is defense-in-depth — but failing at SAVE gives the operator the\n * error in the wizard (where they can fix it) instead of as a per-row run failure.\n *\n * Lives next to the entity (same package) and imports the mapping engine + schema\n * registry one-directionally (engine/registry never import the entity), so no cycle.\n */\n\nimport type { Behavior } from '@murumets-ee/entity'\nimport { z } from 'zod'\nimport { mappingColumnSchema, numberFormatSchema } from '../mapping/engine.js'\nimport { getTargetSchema, listTargetSchemas } from '../mapping/schema-registry.js'\n\n/** Default applied by the `numberFormat` field — used when a create omits it (defaults run after hooks). */\nconst DEFAULT_NUMBER_FORMAT = { decimal: '.', thousands: '' } as const\n\n// Bounded, like every JSONB the entity accepts: a legitimate column map has at\n// most one entry per source column (same order of magnitude as the snapshot's\n// 256-column cap), so an arbitrarily long array is a bloat vector, not data.\nconst columnMapSchema = z.array(mappingColumnSchema).max(512)\n\n/**\n * File-less edit snapshot (#667) — header columns + a few sample rows, same\n * `{ columns, rows: [{column → cell}] }` shape as the preview endpoint's\n * response. Bounded on every axis (these are DISPLAY samples, not data):\n * a snapshot that exceeds the caps should have been trimmed/omitted by the\n * wizard, so an oversized one here is a bypass attempt or a bug — reject, and\n * additionally hard-cap the serialized size so 256-wide × max-cell rows can't\n * park ~600 KB of JSONB per mapping row.\n *\n * Strictness matters: the behavior hook persists the RAW input, so a\n * non-strict parse would let unknown top-level keys ride into JSONB unmeasured\n * (zod strips them from `snap.data`, but that stripped copy isn't what's\n * stored).\n */\nconst headerSnapshotSchema = z.strictObject({\n columns: z.array(z.string().min(1).max(256)).min(1).max(256),\n rows: z.array(z.record(z.string().max(500))).max(5),\n})\n\n/**\n * Serialized-size ceiling for a stored snapshot (defense-in-depth over the\n * per-axis caps). Measured in UTF-16 code units (`JSON.stringify(...).length`)\n * — multi-byte content can serialize up to ~3× larger on disk; the bound holds\n * either way. Mirrored client-side by `toHeaderSnapshot` in commerce-ui\n * (pinned by its round-trip test).\n */\nexport const HEADER_SNAPSHOT_MAX_CHARS = 100_000\n\n/**\n * Serialized-size ceiling for the RAW `columnMap` input. Same rationale + same\n * raw-not-parsed measurement as the snapshot ceiling: `mappingColumnSchema` is\n * shared with the RUN-TIME params parser (engine.ts), so it can't be made\n * `.strict()` without risking previously-persisted `import_run.params` — this\n * save-path-only ceiling bounds what unknown entry keys / unbounded per-entry\n * `transforms` arrays can park in JSONB without touching run-time parsing. A\n * legitimate 256-column map with transform chains serializes well under this.\n */\nexport const COLUMN_MAP_MAX_CHARS = 100_000\n\n/**\n * Throw a descriptive error unless the mapping is structurally valid AND honours its\n * schema's field whitelist + required targets. The thrown message is operator-facing\n * (surfaced verbatim by the admin CRUD route as a 4xx body).\n */\nexport function assertValidMapping(input: {\n targetSchema: unknown\n numberFormat: unknown\n columnMap: unknown\n headerSnapshot?: unknown\n}): void {\n // Snapshot is optional metadata — validate only when present (absent/null =\n // \"no file-less edit support for this row\", a valid state for pre-#667 rows).\n if (input.headerSnapshot !== undefined && input.headerSnapshot !== null) {\n const snap = headerSnapshotSchema.safeParse(input.headerSnapshot)\n if (!snap.success) {\n throw new Error(`Invalid headerSnapshot: ${snap.error.issues[0]?.message ?? 'bad shape'}`)\n }\n // Measure the RAW input, not `snap.data` — the raw value is what the write\n // path persists, and it's identical to the parse only because the schema\n // is `.strict()`. Measuring the raw keeps that true even if strictness is\n // ever relaxed.\n const serialized = JSON.stringify(input.headerSnapshot).length\n if (serialized > HEADER_SNAPSHOT_MAX_CHARS) {\n throw new Error(\n `headerSnapshot too large (${serialized} > ${HEADER_SNAPSHOT_MAX_CHARS} serialized chars) — trim sample rows/cells`,\n )\n }\n }\n const targetSchema = typeof input.targetSchema === 'string' ? input.targetSchema : ''\n const schema = getTargetSchema(targetSchema)\n if (!schema) {\n throw new Error(\n `Unknown target schema \"${targetSchema}\" (registered: ${listTargetSchemas().join(', ')})`,\n )\n }\n\n const nf = numberFormatSchema.safeParse(input.numberFormat)\n if (!nf.success) {\n throw new Error(`Invalid numberFormat: ${nf.error.issues[0]?.message ?? 'bad shape'}`)\n }\n\n const cm = columnMapSchema.safeParse(input.columnMap)\n if (!cm.success) {\n throw new Error(`Invalid columnMap: ${cm.error.issues[0]?.message ?? 'bad shape'}`)\n }\n const columnMapSerialized = JSON.stringify(input.columnMap).length\n if (columnMapSerialized > COLUMN_MAP_MAX_CHARS) {\n throw new Error(\n `columnMap too large (${columnMapSerialized} > ${COLUMN_MAP_MAX_CHARS} serialized chars)`,\n )\n }\n\n const allowed = new Set(schema.fields.map((f) => f.key))\n const seenTargets = new Set<string>()\n const dupeTargets = new Set<string>()\n for (const col of cm.data) {\n if (!allowed.has(col.target)) {\n throw new Error(\n `Mapping target \"${col.target}\" is not a field of schema \"${schema.name}\" ` +\n `(allowed: ${[...allowed].join(', ')})`,\n )\n }\n // Two source columns mapping to the SAME target collapse last-write-wins at run\n // time (`mapped[col.target] = …`), silently importing wrong data. The wizard\n // blocks this client-side; enforce it here too so a direct API call can't bypass\n // it (validate at the boundary).\n if (seenTargets.has(col.target)) dupeTargets.add(col.target)\n seenTargets.add(col.target)\n }\n if (dupeTargets.size > 0) {\n throw new Error(\n `Mapping for schema \"${schema.name}\" maps a target more than once: ${[...dupeTargets].join(', ')}`,\n )\n }\n\n const mappedTargets = new Set(cm.data.map((c) => c.target))\n const missing = schema.fields\n .filter((f) => f.required && !mappedTargets.has(f.key))\n .map((f) => f.key)\n if (missing.length > 0) {\n throw new Error(\n `Mapping for schema \"${schema.name}\" is missing required target(s): ${missing.join(', ')}`,\n )\n }\n}\n\n/**\n * Behavior wrapping {@link assertValidMapping}. Typed `Behavior<Record<never,\n * never>, …>` (NOT plain `Behavior`, whose wide `fields?` would collapse the\n * entity's inferred field types — the documented no-field-behavior gotcha).\n * Contributes a hook, no fields.\n */\nexport const importMappingValidation: Behavior<\n Record<never, never>,\n 'imports:import-mapping-validation'\n> = {\n name: 'imports:import-mapping-validation',\n hooks: {\n beforeCreate: async (data) => {\n // Field defaults run AFTER hooks, so an omitted numberFormat/columnMap is the\n // field default here — mirror those defaults so a minimal create validates\n // against the same effective config the row will hold.\n assertValidMapping({\n targetSchema: data.targetSchema,\n numberFormat: data.numberFormat ?? DEFAULT_NUMBER_FORMAT,\n columnMap: data.columnMap ?? [],\n headerSnapshot: data.headerSnapshot,\n })\n return data\n },\n beforeUpdate: async (_id, data, ctx) => {\n // A PATCH is partial — validate the EFFECTIVE config (patch overlaid on the\n // current row), so changing only `name` doesn't escape validation and changing\n // only `columnMap` is still checked against the persisted `targetSchema`.\n const current = (await ctx?.loadCurrent?.()) ?? {}\n assertValidMapping({\n targetSchema: 'targetSchema' in data ? data.targetSchema : current.targetSchema,\n numberFormat:\n 'numberFormat' in data\n ? data.numberFormat\n : (current.numberFormat ?? DEFAULT_NUMBER_FORMAT),\n columnMap: 'columnMap' in data ? data.columnMap : (current.columnMap ?? []),\n // Only the incoming value needs checking — the persisted snapshot was\n // validated when it was written.\n headerSnapshot: 'headerSnapshot' in data ? data.headerSnapshot : undefined,\n })\n return data\n },\n },\n}\n","/**\n * ImportMapping — a reusable, per-(supplier, brand) column-mapping profile that\n * drives the configurable feed-import engine (commerce-demo D005, phase 02).\n *\n * Replaces the hardcoded 14/15-column carmaker transform with operator-editable\n * mappings: which source column → which whitelisted target field, per-column\n * cleaning transforms, the file format (csv/tsv/xlsx), and the number format\n * (decimal + thousands separators — real supplier feeds vary, see R03).\n *\n * **Why this lives in `@murumets-ee/imports`, not `@murumets-ee/commerce`**\n * (D005 cycle constraint): the engine + the concrete target schemas need the\n * commerce `PartsDocument` shape, and `imports` already depends on\n * `@murumets-ee/search-elasticsearch` for it. Putting the mapping in commerce\n * would force `commerce → imports` and a forbidden import cycle. The entity\n * references the commerce `supplier` / `brand` entities **by name string**\n * (`field.reference({ entity: 'supplier' })`) — no Entity-object import, so the\n * leaf-package boundary holds.\n *\n * **Resolution** (most-specific, D002 ladder): for an upload tagged\n * `(supplier, brand)`, pick the `(supplier, brand)` mapping if present, else the\n * `(supplier, NULL)` supplier-default. See `resolveMapping()` in the engine.\n *\n * `hideFromMenu: true` — the mapping CRUD wizard lands in commerce-demo PR3. The\n * auto-EntityListPage at `/admin/import_mapping` still resolves for direct lookups\n * and for the admin-only AdminClient writes this engine relies on.\n */\n\nimport type { Behavior, SchemaFragment, SchemaTarget } from '@murumets-ee/entity'\nimport { behavior, defineEntity, field, searchable } from '@murumets-ee/entity'\nimport type { AdminClient } from '@murumets-ee/entity/admin'\nimport { sql } from 'drizzle-orm'\nimport type { PgColumn } from 'drizzle-orm/pg-core'\nimport { importMappingValidation } from './import-mapping-validation.js'\n\n/**\n * Registered target schemas a mapping may write into. This is the FIRST of two\n * whitelists (the second is each schema's target-field set) — a mapping whose\n * `targetSchema` is outside this list is rejected (deny-by-default, R02).\n */\nexport const IMPORT_MAPPING_TARGET_SCHEMAS = ['parts', 'discount_table'] as const\nexport type ImportMappingTargetSchema = (typeof IMPORT_MAPPING_TARGET_SCHEMAS)[number]\n\n/** File formats the reader dispatches on. `csv`/`tsv` → csv-parse; `xlsx` → exceljs. */\nexport const IMPORT_MAPPING_FORMATS = ['csv', 'tsv', 'xlsx'] as const\nexport type ImportMappingFormat = (typeof IMPORT_MAPPING_FORMATS)[number]\n\n/**\n * Declarative DDL: one mapping per `(supplier, brand, target_schema)`, enforced by\n * TWO PARTIAL unique indexes rather than one plain composite unique.\n *\n * Why partial: a plain `UNIQUE (supplierId, brandId, targetSchema)` treats NULL\n * `brandId` as distinct (Postgres NULL-distinct semantics), so it would allow MANY\n * supplier-default rows (`brandId IS NULL`) for the same `(supplier, schema)` —\n * ambiguous, since the engine resolves `brandId === null` as THE supplier default\n * (`resolveMapping`). Splitting it:\n * - brand-specific rows (`brandId IS NOT NULL`): unique on `(supplier, brand, schema)`;\n * - supplier-default rows (`brandId IS NULL`): unique on `(supplier, schema)`.\n *\n * The `where` predicates use raw `sql` with the live Drizzle column reference (so the\n * emitted identifier is correct regardless of casing) — `IS NULL` / `IS NOT NULL`,\n * NOT `eq(col, …)` which would serialize as an invalid bound `$1` in DDL.\n *\n * Typed `Behavior<Record<never, never>, …>` (NOT plain `Behavior`, whose wide\n * `fields?` would collapse the entity's inferred field types to `any` — the\n * documented no-field-behavior gotcha). Contributes constraints, no fields.\n */\nconst importMappingUnique: Behavior<Record<never, never>, 'imports:import-mapping-unique'> = {\n name: 'imports:import-mapping-unique',\n constraints: (table: Record<string, PgColumn>, target: SchemaTarget): SchemaFragment[] => {\n if (target !== 'main') return []\n const supplierId = table.supplierId\n const brandId = table.brandId\n const targetSchema = table.targetSchema\n // Defensive: only emit when all three columns exist.\n if (!supplierId || !brandId || !targetSchema) return []\n return [\n {\n kind: 'uniqueIndex',\n name: 'uq_import_mapping_supplier_brand_schema',\n on: [supplierId, brandId, targetSchema],\n where: sql`${brandId} IS NOT NULL`,\n },\n {\n kind: 'uniqueIndex',\n name: 'uq_import_mapping_supplier_schema_default',\n on: [supplierId, targetSchema],\n where: sql`${brandId} IS NULL`,\n },\n ]\n },\n}\n\nexport const ImportMapping = defineEntity({\n name: 'import_mapping',\n scope: 'global',\n fields: {\n /** FK to commerce `supplier` (by name string — no cross-package import). Required. */\n supplierId: field.reference({ entity: 'supplier', required: true, indexed: true }),\n /** FK to commerce `brand`. NULL = supplier default (applies to all that supplier's brands). */\n brandId: field.reference({ entity: 'brand', indexed: true }),\n /** Which registered target schema this mapping feeds — `parts` or `discount_table`. */\n targetSchema: field.select({\n options: IMPORT_MAPPING_TARGET_SCHEMAS,\n required: true,\n indexed: true,\n }),\n /** Operator-facing profile name (a supplier can have e.g. \"monthly full\" vs \"weekly delta\"). */\n name: field.text({ required: true, maxLength: 255 }),\n /** File format — drives reader dispatch + the default delimiter. */\n format: field.select({ options: IMPORT_MAPPING_FORMATS, required: true }),\n /**\n * Explicit single-char field delimiter override. When omitted, the reader\n * defaults from `format` (`csv` → `,`, `tsv` → `\\t`). Ignored for `xlsx`.\n */\n delimiter: field.text({ maxLength: 4 }),\n /** First row is the header. `false` → positional `{ \"0\": …, \"1\": … }` source keys. */\n hasHeader: field.boolean({ default: true }),\n /** 0-based header row index (header hygiene — R03 feeds have leading/trailing-space headers). */\n headerRowIndex: field.number({ integer: true, min: 0, default: 0 }),\n /** xlsx sheet name (or 1-based index as a string). Ignored for csv/tsv. */\n sheet: field.text({ maxLength: 128 }),\n /**\n * Number format for the `number` per-column transform:\n * `{ decimal: '.'|',', thousands: ''|'.'|','|' ' }`. Opaque json; validated\n * by the engine's Zod schema at consume time (matches `import_run.params`).\n */\n numberFormat: field.json({ default: { decimal: '.', thousands: '' } }),\n /**\n * Ordered column map: `[{ source, target, transforms? }]`. `source` is a\n * header name (or positional index string); `target` MUST be a field in the\n * chosen schema's whitelist (validated by the engine's Zod schema, R02\n * injection boundary); `transforms` is a bounded, enum-keyed list. Opaque\n * json on the entity; typed + validated by the engine.\n */\n columnMap: field.json({ default: [] }),\n /**\n * File-less edit snapshot (#667): `{ columns: string[], rows: [{column →\n * cell}] }` — the sample feed's header + a few sample rows, captured from\n * the wizard's preview at save time. Lets the editor re-open a saved\n * mapping WITHOUT re-uploading the (potentially huge) feed just to tweak a\n * transform. NULL on rows saved before this field existed (and when the\n * preview exceeded the size caps) — the editor falls back to the\n * upload-first flow. Staleness is benign: a source column that later\n * vanishes from the real feed is caught at run time by the 6b drift check\n * (409) / row errors, not by this snapshot. Size-capped by\n * `import-mapping-validation`.\n */\n headerSnapshot: field.json(),\n /** Inactive mappings are skipped by resolution. */\n active: field.boolean({ default: true, indexed: true }),\n },\n behaviors: [\n behavior.auditable(),\n // Operator-facing search (PR3 wizard): by profile name, schema, or format.\n // No `fts` → IlikeProvider, so this adds NO DDL (the 0036 migration is\n // unchanged). Names are short tags, not prose.\n searchable({\n fields: ['name', 'targetSchema', 'format'],\n projection: (row) => ({\n id: row.id as string,\n label: row.name as string,\n description: `${row.targetSchema} · ${row.format}`,\n }),\n }),\n importMappingUnique,\n // Save-time whitelist + required-target validation (R02 injection boundary).\n importMappingValidation,\n ],\n admin: {\n group: 'imports',\n label: 'Import mappings',\n labelSingular: 'Import mapping',\n icon: 'table',\n hideFromMenu: true,\n },\n})\n\nexport type ImportMappingClient = AdminClient<typeof ImportMapping.allFields>\n","/**\n * Per-feed transform plugin interface (PLAN-ECOMMERCE.md PR 7 / D14).\n *\n * The streaming reader reads raw rows out of the file (column key →\n * string). The transform turns one row into a typed `OutputDoc` ready\n * for the bulk-write surface (PoC: `PartsDocument` for the parts ES\n * index). The transform is what makes a generic feed-importer\n * commerce-aware — without it, the importer has no opinion on what a\n * row \"means\".\n *\n * Why an interface, not a function:\n * - The transform may need to reject a row (`RowSkip`) without that\n * counting as an error in `ErrorTracker` — e.g. blank lines, header\n * rows mistakenly retained, or \"this is a replacement-code marker\n * row, handled out-of-band\" decisions.\n * - The transform may need to fail a row (`RowError`) with a typed\n * error class so `ErrorTracker` collapses them by `errorType`.\n * - The transform may need access to the run-level params (brand /\n * supplier IDs, code prefix, batch ID) without those being\n * re-derived per row.\n *\n * PR 7 ships only the interface + a registry. PR 8 ships the first\n * concrete carmaker-feed transform.\n */\n\n/** Identifies which transform a registered handler implements. Stable across deploys. */\nexport type TransformName = string\n\n/** Per-run context provided to every row call. Kept narrow on purpose. */\nexport interface TransformContext {\n /** UUID of the `import_run` row. Forward to the output doc as `import_batch_id` for source attribution (D20). */\n importRunId: string\n /** Opaque per-run params copied from `import_run.params`. The transform validates the shape it expects. */\n params: Record<string, unknown>\n /** Operator-supplied label, useful for logging / debugging. */\n runLabel: string\n /** 1-based row number across the whole feed (header counts as row 0). Forward to `RowError.rowNumber`. */\n rowNumber: number\n}\n\n/**\n * A directional supersession pair harvested from a row as a SIDE-OUTPUT\n * (commerce-demo PR6, SD007). The edge ALWAYS runs `from` (older) → `to` (newer/current).\n *\n * Which end the emitting row occupies depends on which supersession column the mapping used,\n * so neither end can be described as \"the row's own code\": a `superseding_code` column names\n * the newer code and puts the row at `from` (the row is the obsolete one), while a\n * `replaces_code` column names the older code and puts the row at `to` (the row is current).\n * Both codes are already NORMALIZED (same normalization as the doc's `code_normalized`) so a\n * downstream equivalence-group merge collides codes correctly.\n *\n * This is PURE DATA. The runner buffers pairs in memory during the single ES pass\n * (an array append — it never blocks the `bulkUpsert` loop) and hands the buffer to\n * an INJECTED post-run sink (the commerce replacement-merge engine). The\n * `@murumets-ee/imports` leaf never writes the consumer's Postgres tables itself —\n * the boundary holds because the sink is injected, not imported.\n */\nexport interface DirectionalPair {\n /** The superseded (older) normalized code. */\n from: string\n /** The superseding (newer / current) normalized code. */\n to: string\n}\n\n/**\n * A partial-document PATCH emitted as a side-output (commerce-demo PR7): the row\n * itself produces no saleable doc, but it carries a partial update that must land\n * on a DIFFERENT doc — identified by `id` (the same `<code_normalized>__<supplier>`\n * key the parts sink uses). The runner buffers these during the single stream and,\n * AFTER the ES pass, applies them via a phase-2 `bulkPartialUpdate` (an ES `update`\n * merge, realtime by `_id` → order-independent). A patch whose target doesn't exist\n * surfaces as an `orphan_*` warning, never a phantom doc.\n *\n * Canonical use: a `<code>*S` core-fee row (D004) attaches its deposit to the parent\n * part's `core_charge_minor`. Kept GENERIC (`Partial<TDoc>`) so the runner never\n * imports a schema-specific doc type — the same leaf-clean shape as {@link DirectionalPair}.\n */\nexport interface DocPatch<TDoc> {\n /** The `_id` of the doc to merge into (a different row's doc than the one that emitted this). */\n id: string\n /** The partial fields to merge onto that doc. */\n doc: Partial<TDoc>\n /**\n * The warning to record if the target `id` isn't in the index (the phase-2\n * update 404s). The runner detects the orphan (it can't be known at buildRow\n * time — the parent may stream later) but the SCHEMA owns the bucket name +\n * message, so this rides on the patch. Absent → a generic `orphan_patch`\n * fallback. For `*S` core-fee rows the parts schema sets `orphan_core_charge`\n * (D004: warn, never silently drop).\n */\n orphanWarning?: RowWarning\n}\n\n/**\n * One of: a successful `OutputDoc` keyed by a stable `id`, an\n * intentional `skip` (counted in `totals.skipped`), or a row-level\n * error (counted in `totals.failed` AND aggregated by `ErrorTracker`).\n *\n * The `id` field on `success` is used as the bulk-upsert primary key\n * (`_id` in ES). Per D4 the parts index uses\n * `<code_normalized>__<supplier_id>` so a re-run of the same feed\n * idempotently overwrites instead of duplicating.\n *\n * **`warnings` on `success`** (commerce-demo PR4): a row can be a valid,\n * indexable doc AND still carry a non-fatal advisory — e.g. a part imported\n * with `list_price` but no resolvable purchase cost (`no_purchase_price`,\n * phase 03 §5). The runner indexes the doc (it's a success, NOT a failure)\n * and separately aggregates the warnings into a `warningSummary`, so the\n * operator sees \"120 rows cost-pending\" without those rows being dropped or\n * mis-counted as failures. Empty/absent `warnings` is the common case.\n *\n * **`pairs` on `success`** (commerce-demo PR6, SD007): a row can ALSO carry\n * directional supersession {@link DirectionalPair}s as a side-output — the `parts`\n * schema emits one when a row's `superseding_code` is present (a SPARSE column on a\n * full manufacturer catalogue, so most rows emit none). The runner buffers these\n * from ES-accepted rows and returns them for the injected `pairSink`. Empty/absent\n * `pairs` is the common case.\n *\n * **`patches` on `skip`** (commerce-demo PR7): a `<code>*S` core-fee row is NOT a\n * saleable doc (so it `skip`s the main index), but it carries a {@link DocPatch}\n * side-output that attaches its deposit to the PARENT part's `core_charge_minor`.\n * The runner buffers these (sparse — only reman parts carry `*S`) and applies them\n * in a phase-2 `bulkPartialUpdate` AFTER the stream; an unresolved parent becomes an\n * `orphan_core_charge` warning (D004). Empty/absent `patches` is the common case.\n */\nexport type RowResult<TDoc> =\n | {\n kind: 'success'\n id: string\n doc: TDoc\n warnings?: readonly RowWarning[]\n pairs?: readonly DirectionalPair[]\n }\n | { kind: 'skip'; reason: string; patches?: readonly DocPatch<TDoc>[] }\n | { kind: 'error'; error: RowError }\n\n/** Row-level error. The aggregator uses `(errorType, field, message)` as the dedup signature. */\nexport interface RowError {\n errorType: string\n message: string\n field?: string | undefined\n}\n\n/**\n * A non-fatal advisory attached to a SUCCESSFUL row (the doc is still\n * indexed). Same `(warningType, field, message)` dedup signature shape as\n * {@link RowError} so the runner can aggregate warnings through an\n * `ErrorTracker` instance into `import_run.warningSummary`.\n */\nexport interface RowWarning {\n warningType: string\n message: string\n field?: string | undefined\n}\n\n/**\n * The contract every per-feed transform implements. Pure function of\n * `(row, ctx) → RowResult` — no I/O. Async only because the future\n * carmaker variant might consult an in-memory taxonomy lookup.\n */\nexport type RowTransform<TDoc> = (\n rawRow: Record<string, string>,\n ctx: TransformContext,\n) => Promise<RowResult<TDoc>>\n\n/**\n * Holds the registered transforms for the current process. Each plugin\n * that ships a transform calls {@link TransformRegistry.register} from\n * its `init` hook.\n *\n * Per CLAUDE.md \"Whitelist, don't blacklist\": the worker dispatches\n * by `import_run.transformName` against this registry. An unregistered\n * name fails the run rather than running with a default transform.\n *\n * **Production callers MUST go through {@link getTransformRegistry}**, not\n * `new TransformRegistry()`. The constructor is exposed only so tests\n * (and rare custom-worker embeds) can build an isolated instance and\n * inject it into {@link createRunImportHandler} directly. Two `new`\n * instances do NOT share state — registering a transform on one will\n * NOT make it visible to a worker resolving against the other. The\n * package's queue handler (registered by `imports()` plugin) always\n * resolves against the singleton.\n */\nexport class TransformRegistry {\n private readonly entries = new Map<TransformName, RowTransform<unknown>>()\n\n /**\n * Register a transform under a stable name. Throws on duplicate\n * names so two plugins can't silently overwrite each other (same\n * fail-loud collision protection that `combineAdminRoutes` enforces\n * for duplicate `(prefix, method, path)` route registrations).\n */\n register<TDoc>(name: TransformName, transform: RowTransform<TDoc>): void {\n if (this.entries.has(name)) {\n throw new Error(\n `Transform \"${name}\" is already registered — two plugins cannot register the same transform name.`,\n )\n }\n this.entries.set(name, transform as RowTransform<unknown>)\n }\n\n /**\n * Last-wins registration — overwrites an existing entry instead of\n * throwing. The {@link registerImportTransform} consumer-app path uses\n * this (same semantics as `@murumets-ee/queue/client`'s `registerJob`):\n * under dev HMR a re-evaluated consumer module re-registers against the\n * surviving globalThis registry, and an overwrite picks up the edited\n * function where a skip-or-throw would keep dispatching the stale one\n * (the #186 staleness class). Plugin built-ins keep using\n * {@link register} behind `has()` guards so genuine cross-plugin name\n * collisions still fail loud.\n */\n set<TDoc>(name: TransformName, transform: RowTransform<TDoc>): void {\n this.entries.set(name, transform as RowTransform<unknown>)\n }\n\n /** Look up a transform by name. Returns `undefined` if no plugin has registered one. */\n get(name: TransformName): RowTransform<unknown> | undefined {\n return this.entries.get(name)\n }\n\n /** True iff a transform is registered for the given name. */\n has(name: TransformName): boolean {\n return this.entries.has(name)\n }\n\n /** All registered names. Useful for the admin Catalog tab and for tests. */\n list(): TransformName[] {\n return Array.from(this.entries.keys()).sort()\n }\n\n /** Drop all registrations. Tests only. */\n clear(): void {\n this.entries.clear()\n }\n}\n\n// ---------------------------------------------------------------------------\n// Process-global singleton (mirrors `@murumets-ee/queue`'s handler registry).\n// ---------------------------------------------------------------------------\n//\n// Why a singleton: registrations (the plugin's built-ins at init, plus\n// any consumer-app custom transforms) and the worker's `get` happen in\n// different modules that Next.js HMR / Turbopack can re-evaluate\n// independently. Holding the registry on `globalThis` via `Symbol.for`\n// means the same `Map` instance is shared across module evaluations —\n// `register` and `get` see each other regardless of evaluation order.\n// Same fix shape as the queue's handler-registry HMR bug (#186).\n\nconst REGISTRY_KEY = Symbol.for('@murumets-ee/imports:transforms')\n\ninterface GlobalThisWithTransforms {\n [REGISTRY_KEY]?: TransformRegistry\n}\n\n/** Returns the singleton registry, creating it on first access. */\nexport function getTransformRegistry(): TransformRegistry {\n const g = globalThis as GlobalThisWithTransforms\n let reg = g[REGISTRY_KEY]\n if (!reg) {\n reg = new TransformRegistry()\n g[REGISTRY_KEY] = reg\n }\n return reg\n}\n\n/**\n * Convenience wrapper around the singleton registry for CONSUMER apps that\n * ship their own custom transforms — same semantics as\n * `@murumets-ee/queue/client`'s `registerJob`: **last-wins overwrite, never\n * throws on a duplicate name.** Two reasons, both load-bearing:\n *\n * 1. **Upgrade safety.** The `imports()` plugin registers the built-in\n * transforms (`commerce:mapping`, `commerce:carmaker-feed`) during\n * `createApp`, and older hand-wired apps — following this module's\n * previous docs — call this helper UNGUARDED from their bootstrap right\n * after. A duplicate-name throw here would crash boot on the very\n * `pnpm up` upgrade that ships plugin-owned registration; re-registering\n * the identical stock function is a harmless overwrite instead.\n * 2. **HMR freshness.** The registry lives on globalThis and survives dev\n * module re-evaluation; an edited consumer transform re-registers on the\n * next eval and the overwrite picks up the edit — a skip would keep\n * dispatching the stale function forever (the #186 staleness class).\n *\n * Side effect worth knowing: an app can deliberately override a built-in\n * name (its bootstrap runs after plugin init, so last-wins = app wins).\n * Genuine collision protection (two plugins claiming one name) stays on the\n * raw `TransformRegistry.register`.\n */\nexport function registerImportTransform<TDoc>(\n name: TransformName,\n transform: RowTransform<TDoc>,\n): void {\n getTransformRegistry().set(name, transform)\n}\n"],"mappings":"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"}
@@ -1,5 +1,5 @@
1
- import { i as RowTransform } from "./transform-CulePAbJ.mjs";
2
- import { t as PartsParams } from "./parts-schema-D6mvqSKA.mjs";
1
+ import { i as RowTransform } from "./transform-CABBaYmF.mjs";
2
+ import { t as PartsParams } from "./parts-schema-x8_GRYWc.mjs";
3
3
  import { PartsDocument } from "@murumets-ee/search-elasticsearch";
4
4
 
5
5
  //#region src/transforms/carmaker-feed.d.ts
@@ -19,17 +19,17 @@ declare const carmakerFeedParamsSchema: import("zod").ZodObject<{
19
19
  supplierCodePrefix: import("zod").ZodOptional<import("zod").ZodString>;
20
20
  discountGroups: import("zod").ZodOptional<import("zod").ZodRecord<import("zod").ZodString, import("zod").ZodNumber>>;
21
21
  }, "strip", import("zod").ZodTypeAny, {
22
+ supplierId: string;
22
23
  brandId: string;
23
24
  brandSlug: string;
24
- supplierId: string;
25
25
  supplierDisplayName: string;
26
26
  importBatchId: string;
27
27
  supplierCodePrefix?: string | undefined;
28
28
  discountGroups?: Record<string, number> | undefined;
29
29
  }, {
30
+ supplierId: string;
30
31
  brandId: string;
31
32
  brandSlug: string;
32
- supplierId: string;
33
33
  supplierDisplayName: string;
34
34
  importBatchId: string;
35
35
  supplierCodePrefix?: string | undefined;
@@ -1 +1 @@
1
- import{n as e,r as t,t as n}from"./carmaker-feed-DtfAYS1P.mjs";export{n as CARMAKER_FEED_TRANSFORM_NAME,e as carmakerFeedParamsSchema,t as carmakerFeedTransform};
1
+ import{n as e,r as t,t as n}from"./carmaker-feed-CxhZBrkO.mjs";export{n as CARMAKER_FEED_TRANSFORM_NAME,e as carmakerFeedParamsSchema,t as carmakerFeedTransform};
@@ -1,4 +1,4 @@
1
- import { c as TransformRegistry, i as RowTransform, t as DirectionalPair } from "./transform-CulePAbJ.mjs";
1
+ import { c as TransformRegistry, i as RowTransform, t as DirectionalPair } from "./transform-CABBaYmF.mjs";
2
2
  import { Logger } from "@murumets-ee/core";
3
3
  import { z } from "zod";
4
4
  import { EsClientLike } from "@murumets-ee/search-elasticsearch";
@@ -736,4 +736,4 @@ interface ImportsRunDeadListenerConfig {
736
736
  declare function createImportsRunDeadListener(config: ImportsRunDeadListenerConfig): QueueTerminalNotifier;
737
737
  //#endregion
738
738
  export { StreamFeedOptions as A, ErrorTracker as C, feedReaderConfigSchema as D, ReadFeedOptions as E, ImportRun as F, ImportRunClient as I, ImportRunStatus as L, countDataRows as M, streamFeed as N, readFeed as O, IMPORT_RUN_STATUSES as P, ErrorSample as S, FeedReaderConfig as T, RunImportResult as _, ImportsRunJobContext as a, DEFAULT_MAX_SAMPLES_PER_PATTERN as b, PairSinkContext as c, createRunImportHandler as d, importsRunJob as f, RunImportOptions as g, ImportRunProgress as h, ImportsRunDeadListenerConfig as i, StreamFeedRow as j, readPersistedFeedConfig as k, RunImportHandlerConfig as l, DEFAULT_BATCH_SIZE as m, EsClientResolver as n, ImportsRunJobPayload as o, importsRunJobPayloadSchema as p, FilePathResolver as r, PairSink as s, EnsureIndexResolver as t, createImportsRunDeadListener as u, runImport as v, ErrorTrackerConfig as w, ErrorPattern as x, DEFAULT_MAX_PATTERNS as y };
739
- //# sourceMappingURL=worker-ZWkDs7gz.d.mts.map
739
+ //# sourceMappingURL=worker-DRTgbtT7.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"worker-ZWkDs7gz.d.mts","names":[],"sources":["../src/entities/import-run.ts","../src/streaming.ts","../src/read-feed.ts","../src/error-tracker.ts","../src/runner.ts","../src/worker.ts"],"mappings":"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"}
1
+ {"version":3,"file":"worker-DRTgbtT7.d.mts","names":[],"sources":["../src/entities/import-run.ts","../src/streaming.ts","../src/read-feed.ts","../src/error-tracker.ts","../src/runner.ts","../src/worker.ts"],"mappings":"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"}
package/dist/worker.d.mts CHANGED
@@ -1,2 +1,2 @@
1
- import { a as ImportsRunJobContext, c as PairSinkContext, d as createRunImportHandler, f as importsRunJob, i as ImportsRunDeadListenerConfig, l as RunImportHandlerConfig, n as EsClientResolver, o as ImportsRunJobPayload, p as importsRunJobPayloadSchema, r as FilePathResolver, s as PairSink, t as EnsureIndexResolver, u as createImportsRunDeadListener } from "./worker-ZWkDs7gz.mjs";
1
+ import { a as ImportsRunJobContext, c as PairSinkContext, d as createRunImportHandler, f as importsRunJob, i as ImportsRunDeadListenerConfig, l as RunImportHandlerConfig, n as EsClientResolver, o as ImportsRunJobPayload, p as importsRunJobPayloadSchema, r as FilePathResolver, s as PairSink, t as EnsureIndexResolver, u as createImportsRunDeadListener } from "./worker-DRTgbtT7.mjs";
2
2
  export { EnsureIndexResolver, EsClientResolver, FilePathResolver, ImportsRunDeadListenerConfig, ImportsRunJobContext, ImportsRunJobPayload, PairSink, PairSinkContext, RunImportHandlerConfig, createImportsRunDeadListener, createRunImportHandler, importsRunJob, importsRunJobPayloadSchema };
package/dist/worker.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import{c as e,n as t,u as n}from"./runner-C-s6SD7t.mjs";import{a as r,i,r as a}from"./publish-DCXIPwTE.mjs";import{runAsCli as o}from"@murumets-ee/core";import{z as s}from"zod";import{eq as c}from"drizzle-orm";import{defineJob as l}from"@murumets-ee/queue/client";const u=s.object({importRunId:s.string().uuid()}),d=l({name:`imports:run`,description:`Stream a feed file, transform rows, bulk-write to Elasticsearch.`,schema:u,defaultRetries:0});function f(a){let{importRuns:s,transforms:c,esClient:l,esIndex:u,resolveFilePath:d,ensureIndex:f,pairSink:m,logger:h}=a;return async a=>o(async()=>{let{importRunId:o}=a.payload,g=h?.child({jobId:a.id,importRunId:o,type:`imports:run`}),_=await s.findById(o);if(!_){g?.warn(`import_run row not found — skipping`);return}_.status===`running`&&g?.warn({previousQueueJobId:_.queueJobId,previousStartedAt:_.startedAt},`imports: re-claiming a row left in running by a prior worker — likely a crash recovered by queue lock-recovery`);let v=c.get(_.transformName);if(!v)throw await p(s,o,`Unknown transform "${_.transformName}"`),Error(`No transform registered for name "${_.transformName}"`);let y=_.params&&typeof _.params==`object`&&!Array.isArray(_.params)?_.params:{},b=e(y);if(!b.success){let e=b.error.issues[0]?.message??`invalid feed reader config`;throw await p(s,o,`Invalid params.feed: ${e}`),Error(`Invalid params.feed for import_run ${o}: ${e}`)}let x=b.data;await s.update(o,{status:`running`,queueJobId:a.id,startedAt:new Date}),i(o);let S;try{S=await l()}catch(e){throw await p(s,o,`Could not resolve ES client: ${e instanceof Error?e.message:String(e)}`),e}if(f)try{await f()}catch(e){throw await p(s,o,`Could not ensure ES index/alias: ${e instanceof Error?e.message:String(e)}`),e}let C,w;if(d)try{let e=await d(_.filePath);C=e.localPath,w=e.cleanup}catch(e){throw await p(s,o,`Could not resolve filePath: ${e instanceof Error?e.message:String(e)}`),e}else C=_.filePath;let T;if(x.format!==`xlsx`)try{T=await n(C,x.hasHeader===void 0?{}:{hasHeader:x.hasHeader}),g?.info({importRunId:o,totalRows:T},`imports: pre-pass row count`)}catch(e){g?.warn({err:e},`imports: pre-pass row count failed — continuing without total`)}let E;try{let e={filePath:C,...x.format!==void 0&&{format:x.format},...x.delimiter!==void 0&&{delimiter:x.delimiter},...x.hasHeader!==void 0&&{hasHeader:x.hasHeader},...x.headerRowIndex!==void 0&&{headerRowIndex:x.headerRowIndex},...x.sheet!==void 0&&{sheet:x.sheet}};E=await t({importRunId:o,runLabel:_.label,params:y,transform:v,feed:e,esClient:S,esIndex:u,onProgress:e=>a.updateProgress(T===void 0?e:{...e,totalRows:T}),...g!==void 0&&{logger:g}})}catch(e){throw await p(s,o,e instanceof Error?e.message:String(e)),w&&await w().catch(e=>{g?.warn({err:e},`filePath cleanup after failed run threw — ignoring`)}),e}if(w&&await w().catch(e=>{g?.warn({err:e},`filePath cleanup after successful run threw — ignoring`)}),await s.update(o,{status:`succeeded`,finishedAt:new Date,totals:{rowsRead:E.rowsRead,rowsSucceeded:E.rowsSucceeded,rowsFailed:E.rowsFailed,rowsSkipped:E.rowsSkipped,rowsWarned:E.rowsWarned,batchesCompleted:E.batchesCompleted,patchesApplied:E.patchesApplied,patchesOrphaned:E.patchesOrphaned},errorSummary:JSON.parse(JSON.stringify(E.errors)),warningSummary:JSON.parse(JSON.stringify(E.warnings))}),r(o,{rowsRead:E.rowsRead,rowsSucceeded:E.rowsSucceeded,rowsFailed:E.rowsFailed}),m&&E.pairs.length>0)try{await m(E.pairs,{importRunId:o,params:y,...g!==void 0&&{logger:g}}),g?.info({importRunId:o,pairs:E.pairs.length},`imports: pairSink invoked`)}catch(e){g?.warn({importRunId:o,pairs:E.pairs.length,err:e},`imports: pairSink threw — replacement ingestion skipped, catalogue import unaffected`)}g?.info({rowsRead:E.rowsRead,rowsSucceeded:E.rowsSucceeded,rowsFailed:E.rowsFailed,rowsSkipped:E.rowsSkipped,batches:E.batchesCompleted},`import_run completed`)})}async function p(e,t,n){try{await e.update(t,{status:`failed`,finishedAt:new Date,errorSummary:{fatal:n}}),a(t,n)}catch{}}function m(e){let{importRuns:t,logger:n}=e;return{onJobDead:async e=>{e.jobType===`imports:run`&&await o(async()=>{let r=n?.child({event:`imports.run.dead`,jobId:e.jobId,type:`imports:run`}),i=t.getTable(),a=(await t.findMany({where:c(i.queueJobId,e.jobId),limit:1}))[0];if(!a){r?.warn(`queue.job.dead with no matching import_run — skipping`);return}if(a.status!==`running`&&a.status!==`pending`){r?.info({importRunId:a.id,status:a.status},`queue.job.dead arrived for a row already in a terminal state — leaving as-is`);return}a.status===`pending`&&r?.warn({importRunId:a.id,attempts:e.attempts},`queue.job.dead arrived for an import_run still in pending — worker crashed BEFORE claim/lookup`),await p(t,a.id,e.error),r?.info({importRunId:a.id,attempts:e.attempts,priorStatus:a.status},`queue.job.dead → import_run marked failed`)})}}}export{m as createImportsRunDeadListener,f as createRunImportHandler,d as importsRunJob,u as importsRunJobPayloadSchema};
1
+ import{c as e,n as t,u as n}from"./runner-C-s6SD7t.mjs";import{a as r,i,r as a}from"./publish-DCXIPwTE.mjs";import{runAsCli as o}from"@murumets-ee/core";import{z as s}from"zod";import{eq as c}from"drizzle-orm";import{defineJob as l}from"@murumets-ee/queue/client";const u=s.object({importRunId:s.string().uuid()}),d=l({name:`imports:run`,description:`Stream a feed file, transform rows, bulk-write to Elasticsearch.`,schema:u,defaultRetries:0});function f(a){let{importRuns:s,transforms:c,esClient:l,esIndex:u,resolveFilePath:d,ensureIndex:f,pairSink:m,logger:h}=a;return async a=>o(async()=>{let{importRunId:o}=a.payload,g=h?.child({jobId:a.id,importRunId:o,type:`imports:run`}),_=await s.findById(o);if(!_){g?.warn(`import_run row not found — skipping`);return}_.status===`running`&&g?.warn({previousQueueJobId:_.queueJobId,previousStartedAt:_.startedAt},`imports: re-claiming a row left in running by a prior worker — likely a crash recovered by queue lock-recovery`);let v=c.get(_.transformName);if(!v)throw await p(s,o,`Unknown transform "${_.transformName}"`),Error(`No transform registered for name "${_.transformName}"`);let y=_.params&&typeof _.params==`object`&&!Array.isArray(_.params)?_.params:{},b=e(y);if(!b.success){let e=b.error.issues[0]?.message??`invalid feed reader config`;throw await p(s,o,`Invalid params.feed: ${e}`),Error(`Invalid params.feed for import_run ${o}: ${e}`)}let x=b.data;await s.update(o,{status:`running`,queueJobId:a.id,startedAt:new Date}),i(o);let S;try{S=await l()}catch(e){throw await p(s,o,`Could not resolve ES client: ${e instanceof Error?e.message:String(e)}`),e}if(f)try{await f()}catch(e){throw await p(s,o,`Could not ensure ES index/alias: ${e instanceof Error?e.message:String(e)}`),e}let C,w;if(d)try{let e=await d(_.filePath);C=e.localPath,w=e.cleanup}catch(e){throw await p(s,o,`Could not resolve filePath: ${e instanceof Error?e.message:String(e)}`),e}else C=_.filePath;let T;if(x.format!==`xlsx`)try{T=await n(C,x.hasHeader===void 0?{}:{hasHeader:x.hasHeader}),g?.info({importRunId:o,totalRows:T},`imports: pre-pass row count`)}catch(e){g?.warn({err:e},`imports: pre-pass row count failed — continuing without total`)}let E;try{let e={filePath:C,...x.format!==void 0&&{format:x.format},...x.delimiter!==void 0&&{delimiter:x.delimiter},...x.hasHeader!==void 0&&{hasHeader:x.hasHeader},...x.headerRowIndex!==void 0&&{headerRowIndex:x.headerRowIndex},...x.sheet!==void 0&&{sheet:x.sheet}};E=await t({importRunId:o,runLabel:_.label,params:y,transform:v,feed:e,esClient:S,esIndex:u,onProgress:e=>a.updateProgress(T===void 0?e:{...e,totalRows:T}),...g!==void 0&&{logger:g}})}catch(e){throw await p(s,o,e instanceof Error?e.message:String(e)),w&&await w().catch(e=>{g?.warn({err:e},`filePath cleanup after failed run threw — ignoring`)}),e}if(w&&await w().catch(e=>{g?.warn({err:e},`filePath cleanup after successful run threw — ignoring`)}),await s.update(o,{status:`succeeded`,finishedAt:new Date,totals:{rowsRead:E.rowsRead,rowsSucceeded:E.rowsSucceeded,rowsFailed:E.rowsFailed,rowsSkipped:E.rowsSkipped,rowsWarned:E.rowsWarned,batchesCompleted:E.batchesCompleted,patchesApplied:E.patchesApplied,patchesOrphaned:E.patchesOrphaned},errorSummary:JSON.parse(JSON.stringify(E.errors)),warningSummary:JSON.parse(JSON.stringify(E.warnings))}),r(o,{rowsRead:E.rowsRead,rowsSucceeded:E.rowsSucceeded,rowsFailed:E.rowsFailed}),m&&E.pairs.length>0)try{await m(E.pairs,{importRunId:o,params:y,...g!==void 0&&{logger:g}}),g?.info({importRunId:o,pairs:E.pairs.length},`imports: pairSink invoked`)}catch(e){g?.warn({importRunId:o,pairs:E.pairs.length,err:e},`imports: pairSink threw — replacement ingestion incomplete, catalogue import unaffected`)}g?.info({rowsRead:E.rowsRead,rowsSucceeded:E.rowsSucceeded,rowsFailed:E.rowsFailed,rowsSkipped:E.rowsSkipped,batches:E.batchesCompleted},`import_run completed`)})}async function p(e,t,n){try{await e.update(t,{status:`failed`,finishedAt:new Date,errorSummary:{fatal:n}}),a(t,n)}catch{}}function m(e){let{importRuns:t,logger:n}=e;return{onJobDead:async e=>{e.jobType===`imports:run`&&await o(async()=>{let r=n?.child({event:`imports.run.dead`,jobId:e.jobId,type:`imports:run`}),i=t.getTable(),a=(await t.findMany({where:c(i.queueJobId,e.jobId),limit:1}))[0];if(!a){r?.warn(`queue.job.dead with no matching import_run — skipping`);return}if(a.status!==`running`&&a.status!==`pending`){r?.info({importRunId:a.id,status:a.status},`queue.job.dead arrived for a row already in a terminal state — leaving as-is`);return}a.status===`pending`&&r?.warn({importRunId:a.id,attempts:e.attempts},`queue.job.dead arrived for an import_run still in pending — worker crashed BEFORE claim/lookup`),await p(t,a.id,e.error),r?.info({importRunId:a.id,attempts:e.attempts,priorStatus:a.status},`queue.job.dead → import_run marked failed`)})}}}export{m as createImportsRunDeadListener,f as createRunImportHandler,d as importsRunJob,u as importsRunJobPayloadSchema};
2
2
  //# sourceMappingURL=worker.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"worker.mjs","names":[],"sources":["../src/worker.ts"],"sourcesContent":["/**\n * Queue handler for `imports:run`. The route handler that creates the\n * `import_run` row enqueues this job carrying `{ importRunId }`; the\n * worker module here resolves the run, the transform, and the ES\n * client, then runs `runImport` and writes results back.\n *\n * Per PLAN-ECOMMERCE.md PR 7 (PoC scope): one job type, no resumability.\n * A failed handler attempt re-runs from row 1 on retry — fine for the\n * tens-of-MB feed sizes the PoC exercises.\n *\n * Per CLAUDE.md \"leaf packages\" boundary rule: this handler reads the\n * import_run row via an injected `AdminClient`, NOT a direct `db.select`.\n * Construction happens at plugin init in `plugin.ts`.\n */\n\nimport { type Logger, runAsCli } from '@murumets-ee/core'\nimport {\n defineJob,\n type JobDefinition,\n type QueueJobDeadEvent,\n type QueueTerminalNotifier,\n} from '@murumets-ee/queue/client'\nimport type { EsClientLike } from '@murumets-ee/search-elasticsearch'\nimport { eq } from 'drizzle-orm'\nimport type { PgColumn } from 'drizzle-orm/pg-core'\nimport { z } from 'zod'\nimport type { ImportRunClient, ImportRunStatus } from './entities/import-run.js'\nimport { type ReadFeedOptions, readPersistedFeedConfig } from './read-feed.js'\nimport {\n publishImportRunFailed,\n publishImportRunStarted,\n publishImportRunSucceeded,\n} from './realtime/publish.js'\nimport { type ImportRunProgress, runImport } from './runner.js'\nimport { countDataRows } from './streaming.js'\nimport type { DirectionalPair, TransformRegistry } from './transform.js'\n\n/**\n * Structural subset of `JobContext` the handler reads. Declared\n * locally so we don't depend on a `JobHandler` import — the queue\n * package doesn't re-export it from any subpath today (matches the\n * `SendEmailJobContext` pattern in `@murumets-ee/notifications`).\n *\n * Function-param contravariance lets a handler returned with this\n * narrower shape be assigned to the wider `JobHandler<TPayload>` that\n * `registerJob` expects.\n */\nexport interface ImportsRunJobContext {\n id: string\n payload: ImportsRunJobPayload\n updateProgress(data: ImportRunProgress): void\n}\n\n/**\n * Payload schema. `importRunId` is the `import_run.id` UUID — the worker\n * looks up everything else (filePath, transformName, params, …) from\n * that row. Keeping the payload tiny means the queue's progress JSON\n * column never bloats with feed metadata duplicated across `toolkit_jobs`.\n */\nexport const importsRunJobPayloadSchema = z.object({\n importRunId: z.string().uuid(),\n})\nexport type ImportsRunJobPayload = z.infer<typeof importsRunJobPayloadSchema>\n\n/**\n * Job definition. Consumers register their handler against this with\n * `registerJob(importsRunJob, createRunImportHandler({...}))`.\n *\n * `defaultRetries: 0` — re-running a multi-batch import against the\n * same `import_run.id` while the previous handler may still be writing\n * is a footgun (duplicate batches, double-counted progress). The\n * operator handles retries explicitly via PR 8a's \"retry\" button by\n * creating a new `import_run` row. When the resumable design lands\n * (post-PoC), retries become safe to enable.\n */\nexport const importsRunJob: JobDefinition<ImportsRunJobPayload> = defineJob({\n name: 'imports:run',\n description: 'Stream a feed file, transform rows, bulk-write to Elasticsearch.',\n schema: importsRunJobPayloadSchema,\n defaultRetries: 0,\n})\n\n/**\n * Resolves the ES client at handler-invocation time. A function (rather\n * than the bare `EsClientLike`) so the consumer can lazy-construct the\n * client — typical Next.js setups create the ES connection in a\n * route-handler initialiser, not at plugin-init time.\n */\nexport type EsClientResolver = () => EsClientLike | Promise<EsClientLike>\n\n/**\n * Ensure the destination index/alias exists before the first bulk-write.\n * Invoked once per job, BEFORE {@link runImport}. Idempotent: the canonical\n * implementation (`ensureAliasedIndex` in `@murumets-ee/search-elasticsearch`)\n * no-ops when the alias already resolves.\n *\n * **Why it lives here.** This is the home of the foot-gun it guards: a worker\n * that bulk-writes to alias `<x>` before the alias exists makes ES\n * auto-create a physical `<x>` index with default `text` mappings, breaking\n * aggregations forever. The bootstrap used to run at app boot, which turned\n * \"ES is down\" into a full-admin 500 (issue #610). Moving it onto the import\n * path keeps the guarantee while scoping ES availability to imports/search —\n * the rest of the admin boots and runs with ES down.\n */\nexport type EnsureIndexResolver = () => Promise<unknown>\n\n/**\n * Resolves the value of `import_run.filePath` (whatever the upload\n * route persisted there — typically a storage adapter key) to a\n * readable LOCAL filesystem path that {@link runImport} can hand to\n * `node:fs.createReadStream`.\n *\n * **Why this exists:** the streaming reader (`streamFeed`) reads from\n * local disk via `createReadStream`. The upload route may persist a\n * remote-storage object key (R2, S3, …) on `import_run.filePath`\n * because that's what `@murumets-ee/storage` returns. Without a\n * resolver, `createReadStream('uploads/2026/05/<uuid>/feed.txt')`\n * crashes with `ENOENT`. The resolver is the documented integration\n * point — typical wiring downloads the storage object to a tmpfile\n * and returns its path.\n *\n * **`cleanup`** runs after the run finishes (success OR failure). The\n * worker awaits it best-effort — a failed cleanup logs but does not\n * crash the run.\n *\n * **No-resolver fallback:** when `resolveFilePath` is unset, the\n * worker treats `import_run.filePath` as already a local FS path\n * (back-compat with the original PoC design where uploads landed on\n * local disk). This stays valid for fixture-driven tests + on-disk\n * deployments.\n */\nexport type FilePathResolver = (\n storageKey: string,\n) => Promise<{ localPath: string; cleanup?: () => Promise<void> }>\n\n/**\n * Context handed to a {@link PairSink} alongside the harvested pairs. Carries the\n * run identity so the sink can build `source` attribution\n * (`feed:<supplierId>:<importBatchId>`, D20) and scope its writes.\n */\nexport interface PairSinkContext {\n importRunId: string\n /** The `import_run.params` object — the sink reads `supplierId` / `importBatchId`. */\n params: Record<string, unknown>\n /**\n * The run's structured logger (child), forwarded so the sink's INTERNAL telemetry —\n * a params-parse miss that skips ingestion, an over-merge flag — is visible. Without it\n * a misconfigured run looks successful from the worker's vantage (a params-miss returns\n * normally, so the worker logs \"applied\"). Optional; the sink no-ops its logs if absent.\n */\n logger?: Logger\n}\n\n/**\n * Optional post-run consumer for the directional supersession pairs the runner\n * harvested as a side-output (commerce-demo PR6, SD007). Invoked ONCE after a\n * successful run with the full in-memory pair buffer (read-once / merge-after) so\n * the consumer (the commerce replacement-merge engine) can build equivalence groups\n * and bulk-write them to Postgres.\n *\n * **Why injected, not imported.** The merge writes the CONSUMER's Postgres tables,\n * but the runner + this worker live in the `@murumets-ee/imports` leaf, which must\n * not depend on commerce. The app composes the sink (commerce exports it, the app\n * passes it to `imports({ pairSink })`) — the same injection shape as `esClient` /\n * `resolveFilePath` / `ensureIndex`. The boundary holds.\n *\n * **Best-effort.** A sink throw is logged and SWALLOWED — the catalogue import\n * (the ES write) has already succeeded and must not be undone by a secondary\n * replacement-ingestion failure. The merge is idempotent, so a re-import recovers.\n */\nexport type PairSink = (pairs: readonly DirectionalPair[], ctx: PairSinkContext) => Promise<void>\n\nexport interface RunImportHandlerConfig {\n /** AdminClient over the `import_run` entity. */\n importRuns: ImportRunClient\n /** Transform registry to dispatch against. Defaults to the process-global singleton. */\n transforms: TransformRegistry\n /** Resolver for the ES client. */\n esClient: EsClientResolver\n /** ES alias / index to bulk-write into. Per D6 callers always pass an alias. */\n esIndex: string\n /**\n * Optional bootstrap for the destination index/alias, run once per job\n * before any bulk-write. See {@link EnsureIndexResolver}. Omit for\n * deployments that bootstrap the index out-of-band (e.g. a reindex job).\n */\n ensureIndex?: EnsureIndexResolver\n /**\n * Optional resolver for `import_run.filePath`. See {@link FilePathResolver}.\n * Required when uploads land in remote storage (R2/S3); optional for\n * on-disk PoC setups.\n */\n resolveFilePath?: FilePathResolver\n /**\n * Optional consumer for the directional supersession pairs harvested as a\n * side-output (commerce-demo PR6, SD007). See {@link PairSink}. Omit for\n * deployments that don't ingest replacement codes — the pairs are simply\n * discarded after the run.\n */\n pairSink?: PairSink\n /** Optional structured logger. Defaults to silent. */\n logger?: Logger\n}\n\n/**\n * Build the `JobHandler` for {@link importsRunJob}. The returned\n * handler is what gets passed to `registerJob`.\n *\n * The body is wrapped in `runAsCli` so AdminClient calls inside have a\n * synthetic `cli` admin context — `auditable()` records `updatedBy:\n * 'cli'` rather than NULL, and the firewall checker passes. This is\n * the documented worker entry-point pattern (see `runAsCli` JSDoc in\n * `@murumets-ee/core`).\n */\nexport function createRunImportHandler(\n config: RunImportHandlerConfig,\n): (job: ImportsRunJobContext) => Promise<void> {\n const {\n importRuns,\n transforms,\n esClient,\n esIndex,\n resolveFilePath,\n ensureIndex,\n pairSink,\n logger,\n } = config\n\n return async (job: ImportsRunJobContext): Promise<void> =>\n runAsCli(async () => {\n const { importRunId } = job.payload\n const log = logger?.child({ jobId: job.id, importRunId, type: 'imports:run' })\n\n const run = await importRuns.findById(importRunId)\n if (!run) {\n // Row was deleted between enqueue and dispatch — nothing to do.\n // Don't throw: a thrown handler retries with no payoff because\n // the row stays gone. Log + return so the queue marks the job\n // completed.\n log?.warn('import_run row not found — skipping')\n return\n }\n\n // Re-claim detection. The queue's `recoverStuckJobs` sweep flips a\n // crashed worker's `toolkit_jobs` row from `processing` back to\n // `pending` — but the queue layer has no knowledge of `import_run`,\n // so this row stays at `status='running'` from the prior attempt.\n // When the queue re-dispatches the job, we land here with a row\n // that the operator-facing Active panel has been showing as live\n // for however long lock-recovery took (default 30 min). The\n // existing `update` below overwrites `queueJobId` + `startedAt` so\n // there is no STATE corruption — but emit a structured warning so\n // ops can correlate this re-run with the worker crash that\n // produced it. The sibling `sweepStaleImportRuns` job below catches\n // the case where re-dispatch never happens (queue marks the job\n // dead, or the queue worker is offline long-term).\n if (run.status === 'running') {\n log?.warn(\n {\n previousQueueJobId: run.queueJobId,\n previousStartedAt: run.startedAt,\n },\n 'imports: re-claiming a row left in running by a prior worker — likely a crash recovered by queue lock-recovery',\n )\n }\n\n // `transformName`, `filePath`, `label` are declared `field.text({\n // required: true })` so the inferred DTO already types them as\n // `string` — no cast needed. `params` is `field.json()` which\n // infers as `JsonValue | undefined`; narrow to a record so the\n // transform receives an object.\n const transform = transforms.get(run.transformName)\n if (!transform) {\n await markFailed(importRuns, importRunId, `Unknown transform \"${run.transformName}\"`)\n throw new Error(`No transform registered for name \"${run.transformName}\"`)\n }\n const params: Record<string, unknown> =\n run.params && typeof run.params === 'object' && !Array.isArray(run.params)\n ? (run.params as Record<string, unknown>)\n : {}\n\n // Reader config (format/delimiter/header/sheet) travels in\n // `params.feed` for mapping-driven runs (commerce-demo PR3). Carmaker\n // runs omit it → empty config → tab-delimited default, so the existing\n // path is byte-for-byte unchanged. Re-validate here as defense-in-depth\n // even though the enqueuing route already validated the saved mapping.\n // Shared with the #842 audit, which has to rehydrate IDENTICALLY or it\n // re-reads a past run with options that run never used.\n const feedParsed = readPersistedFeedConfig(params)\n if (!feedParsed.success) {\n const reason = feedParsed.error.issues[0]?.message ?? 'invalid feed reader config'\n await markFailed(importRuns, importRunId, `Invalid params.feed: ${reason}`)\n throw new Error(`Invalid params.feed for import_run ${importRunId}: ${reason}`)\n }\n const feedConfig = feedParsed.data\n\n await importRuns.update(importRunId, {\n status: 'running' satisfies ImportRunStatus,\n queueJobId: job.id,\n startedAt: new Date(),\n })\n // Realtime: row transitioned pending → running. Subscribers\n // refetch to surface the change in the imports admin page.\n // Best-effort — a publish failure is logged inside `publishSafe`\n // and does NOT roll back the row update.\n publishImportRunStarted(importRunId)\n\n let resolvedClient: EsClientLike\n try {\n resolvedClient = await esClient()\n } catch (err) {\n const reason = err instanceof Error ? err.message : String(err)\n await markFailed(importRuns, importRunId, `Could not resolve ES client: ${reason}`)\n throw err\n }\n\n // Ensure the destination alias exists BEFORE any bulk-write. Moved here\n // from app boot (issue #610): the foot-gun is the worker auto-creating a\n // mis-mapped physical index, so the guard belongs on the import path —\n // not gating the whole app on ES. Idempotent + scoped: an ES outage (or\n // the `ensureAliasedIndex` physical-collision guard firing) fails THIS\n // job loudly while the rest of the admin keeps working.\n if (ensureIndex) {\n try {\n await ensureIndex()\n } catch (err) {\n const reason = err instanceof Error ? err.message : String(err)\n await markFailed(importRuns, importRunId, `Could not ensure ES index/alias: ${reason}`)\n throw err\n }\n }\n\n // Materialise the file locally if the consumer wired a resolver.\n // The `cleanup` callback (when present) runs in the `finally`\n // below regardless of run outcome so a tmpfile created here\n // doesn't outlive the handler.\n let localPath: string\n let fileCleanup: (() => Promise<void>) | undefined\n if (resolveFilePath) {\n try {\n const resolved = await resolveFilePath(run.filePath)\n localPath = resolved.localPath\n fileCleanup = resolved.cleanup\n } catch (err) {\n const reason = err instanceof Error ? err.message : String(err)\n await markFailed(importRuns, importRunId, `Could not resolve filePath: ${reason}`)\n throw err\n }\n } else {\n // Back-compat: no resolver wired → treat `filePath` as already\n // a local FS path. Fine for fixture-driven tests + the original\n // on-disk PoC design.\n localPath = run.filePath\n }\n\n // Pre-pass line count: a 16 MB tab-delimited feed counts in\n // ~50ms; a 500 MB feed in <2s. Cheap relative to the full\n // import. Gives the operator a denominator (`processed / total`)\n // so the imports-page panel can render an actual percent\n // instead of just \"rows so far\".\n // Pre-pass row count is a `\\n`-tally over a delimited file — meaningless\n // on an xlsx binary, so skip it there (the UI falls back to bare counts).\n // `hasHeader` from the feed config keeps the header-exclusion correct for\n // headerless mappings.\n let totalRows: number | undefined\n if (feedConfig.format !== 'xlsx') {\n try {\n totalRows = await countDataRows(\n localPath,\n feedConfig.hasHeader !== undefined ? { hasHeader: feedConfig.hasHeader } : {},\n )\n log?.info({ importRunId, totalRows }, 'imports: pre-pass row count')\n } catch (err) {\n // Don't fail the run on a count miss — the actual import is\n // unaffected; the operator just loses the percent denominator.\n log?.warn({ err }, 'imports: pre-pass row count failed — continuing without total')\n }\n }\n\n let result: Awaited<ReturnType<typeof runImport>>\n try {\n // Build feed options with the conditional-spread idiom (NOT\n // `...feedConfig`) so absent optionals stay ABSENT — spreading a\n // `T | undefined` value would set the key to `undefined`, which\n // `exactOptionalPropertyTypes` rejects against `ReadFeedOptions`.\n const feedOpts: ReadFeedOptions = {\n filePath: localPath,\n ...(feedConfig.format !== undefined && { format: feedConfig.format }),\n ...(feedConfig.delimiter !== undefined && { delimiter: feedConfig.delimiter }),\n ...(feedConfig.hasHeader !== undefined && { hasHeader: feedConfig.hasHeader }),\n ...(feedConfig.headerRowIndex !== undefined && {\n headerRowIndex: feedConfig.headerRowIndex,\n }),\n ...(feedConfig.sheet !== undefined && { sheet: feedConfig.sheet }),\n }\n result = await runImport({\n importRunId,\n runLabel: run.label,\n params,\n transform,\n feed: feedOpts,\n esClient: resolvedClient,\n esIndex,\n onProgress: (progress) =>\n job.updateProgress(totalRows !== undefined ? { ...progress, totalRows } : progress),\n ...(log !== undefined && { logger: log }),\n })\n } catch (err) {\n const reason = err instanceof Error ? err.message : String(err)\n await markFailed(importRuns, importRunId, reason)\n if (fileCleanup) {\n await fileCleanup().catch((cleanupErr: unknown) => {\n log?.warn({ err: cleanupErr }, 'filePath cleanup after failed run threw — ignoring')\n })\n }\n throw err\n }\n\n // Cleanup runs before the success-update so a cleanup throw\n // surfaces as a logged warning rather than a crashed handler at\n // the worst possible moment (just after the row was about to be\n // marked succeeded).\n if (fileCleanup) {\n await fileCleanup().catch((cleanupErr: unknown) => {\n log?.warn({ err: cleanupErr }, 'filePath cleanup after successful run threw — ignoring')\n })\n }\n\n await importRuns.update(importRunId, {\n status: 'succeeded' satisfies ImportRunStatus,\n finishedAt: new Date(),\n totals: {\n rowsRead: result.rowsRead,\n rowsSucceeded: result.rowsSucceeded,\n rowsFailed: result.rowsFailed,\n rowsSkipped: result.rowsSkipped,\n rowsWarned: result.rowsWarned,\n batchesCompleted: result.batchesCompleted,\n // Phase-2 core-charge attach (commerce-demo PR7) — durable on the run row so\n // an operator sees how many `*S` deposits attached vs orphaned, not just the\n // orphan warnings in `warningSummary`.\n patchesApplied: result.patchesApplied,\n patchesOrphaned: result.patchesOrphaned,\n },\n // The typed `ErrorTrackerSnapshot` is fully JSON-serialisable but\n // doesn't structurally satisfy entity's recursive `JsonValue`\n // (objects without an index signature aren't `Record<string,\n // JsonValue>`). Round-trip through `JSON.parse(JSON.stringify(...))`\n // to land as the recursive JSON shape — the runtime cost is one\n // serialise per import_run completion, the type-cast cost is zero.\n errorSummary: JSON.parse(JSON.stringify(result.errors)),\n // Non-fatal warnings (commerce-demo PR4) — kept separate from\n // `errorSummary` so a `no_purchase_price` advisory never reads as a\n // failed row. Same serialise-roundtrip rationale as `errorSummary`.\n warningSummary: JSON.parse(JSON.stringify(result.warnings)),\n })\n // Realtime: row transitioned to terminal-success. Subscribers\n // refetch to move the row from Active → Recent.\n publishImportRunSucceeded(importRunId, {\n rowsRead: result.rowsRead,\n rowsSucceeded: result.rowsSucceeded,\n rowsFailed: result.rowsFailed,\n })\n\n // Side-output: hand the harvested supersession pairs to the injected sink\n // (commerce-demo PR6, SD007) AFTER the catalogue run is marked succeeded — the\n // ES import is primary; replacement ingestion is secondary. Best-effort: a sink\n // throw is logged + swallowed so a Postgres hiccup can't undo a successful\n // catalogue import (the merge is idempotent → a re-import recovers).\n if (pairSink && result.pairs.length > 0) {\n try {\n await pairSink(result.pairs, {\n importRunId,\n params,\n ...(log !== undefined && { logger: log }),\n })\n // \"invoked\" not \"applied\": the sink may internally skip (e.g. missing run params);\n // it logs its own applied/skipped outcome via the forwarded logger.\n log?.info({ importRunId, pairs: result.pairs.length }, 'imports: pairSink invoked')\n } catch (err) {\n log?.warn(\n { importRunId, pairs: result.pairs.length, err },\n 'imports: pairSink threw — replacement ingestion skipped, catalogue import unaffected',\n )\n }\n }\n\n log?.info(\n {\n rowsRead: result.rowsRead,\n rowsSucceeded: result.rowsSucceeded,\n rowsFailed: result.rowsFailed,\n rowsSkipped: result.rowsSkipped,\n batches: result.batchesCompleted,\n },\n 'import_run completed',\n )\n })\n}\n\n/** Write a `failed` row with a fatal error reason. Best-effort — logs but does not throw on DB failure. */\nasync function markFailed(importRuns: ImportRunClient, id: string, reason: string): Promise<void> {\n try {\n await importRuns.update(id, {\n status: 'failed' satisfies ImportRunStatus,\n finishedAt: new Date(),\n errorSummary: { fatal: reason },\n })\n // Realtime: row transitioned to terminal-failure. Subscribers\n // refetch to surface the failure in the Recent panel. Publish\n // is best-effort and only fires when the persist succeeds —\n // skipping it on persist failure keeps subscribers consistent\n // with the DB (no event for a row that didn't transition).\n publishImportRunFailed(id, reason)\n } catch {\n // The handler already threw / will throw — the queue will surface\n // the original error. A second failure here just logs noise.\n }\n}\n\nexport interface ImportsRunDeadListenerConfig {\n importRuns: ImportRunClient\n /** Optional structured logger. */\n logger?: Logger\n}\n\n/**\n * Build a {@link QueueTerminalNotifier} that mirrors `imports:run`\n * dead-job events into `import_run.status='failed'`.\n *\n * **Why this exists.** The `imports:run` handler's normal failure path\n * already calls `markFailed` before re-throwing, so a synchronous\n * handler error reaches the operator-visible `failed` status without\n * help from anyone. The case THIS listener catches is the one\n * `markFailed` never gets to: the worker process is killed mid-handler\n * (OOM, container restart, kill -9), the queue's lock-recovery sweep\n * eventually reclaims the row, and the queue ultimately marks the job\n * `dead` (defaultRetries: 0 + repeated process death = dead-letter\n * with `import_run.status` still `running`). Without this listener\n * the row sits in the operator's Active panel forever.\n *\n * **Generic primitive.** This is a thin subscriber over the queue's\n * multi-subscriber `QueueTerminalNotifier` registry — the same shape\n * graphile-worker (`job:failed`) and BullMQ (`failed`) expose. Other\n * domain plugins that mirror queue state (ticketing-reply, future\n * exports/reports/...) can register their own listeners exactly the\n * same way without coordinating via a per-domain sweep job.\n *\n * **Filtering.** The listener is invoked for EVERY dead event in the\n * process. We early-return on any `jobType` other than `imports:run`\n * so the per-event cost on unrelated jobs is a single property read.\n *\n * **Idempotency.** A dead event delivered after the row has already\n * reached its own terminal state (succeeded / failed / cancelled) is\n * ignored — the only transition we make is `running → failed`. This\n * keeps a late replay or out-of-order delivery from clobbering a\n * row whose worker reached `markFailed` faster than the queue's\n * dead-letter dispatch.\n *\n * The handler is wrapped in `runAsCli` so AdminClient calls inside\n * have a synthetic CLI admin context — same pattern as\n * {@link createRunImportHandler}.\n */\nexport function createImportsRunDeadListener(\n config: ImportsRunDeadListenerConfig,\n): QueueTerminalNotifier {\n const { importRuns, logger } = config\n\n return {\n onJobDead: async (event: QueueJobDeadEvent): Promise<void> => {\n if (event.jobType !== 'imports:run') return\n\n await runAsCli(async () => {\n const log = logger?.child({\n event: 'imports.run.dead',\n jobId: event.jobId,\n type: 'imports:run',\n })\n\n // Look up the mirror row by `queueJobId`. Cap to 1 — the\n // upload route writes one `import_run` per enqueued job.\n //\n // `AdminClient.getTable()` returns `PgTableWithColumns<any>`\n // because entity tables have dynamic column shapes (see the\n // `getTable()` JSDoc in `packages/entity/src/admin/client.ts`\n // and the documented-`any`-cases list in CLAUDE.md). Cast\n // the table to a structural shape covering only the column\n // we touch, so both `eq()` and type-coverage see a typed\n // operand instead of an `any`-derived property access.\n const table = importRuns.getTable() as unknown as { queueJobId: PgColumn }\n const matches = await importRuns.findMany({\n where: eq(table.queueJobId, event.jobId),\n limit: 1,\n })\n const row = matches[0]\n if (!row) {\n // No mirror row for this dead job. Either it was deleted\n // by the operator, or the dead event is for a job\n // unrelated to imports despite the type filter\n // (shouldn't happen, but is cheap to log).\n log?.warn('queue.job.dead with no matching import_run — skipping')\n return\n }\n\n // Only transition rows that haven't already reached their\n // own terminal state. A late dead-event must not overwrite\n // a `succeeded` row that the worker reached via the normal\n // path before the queue's dead-letter dispatch fired.\n if (row.status !== 'running' && row.status !== 'pending') {\n log?.info(\n { importRunId: row.id, status: row.status },\n 'queue.job.dead arrived for a row already in a terminal state — leaving as-is',\n )\n return\n }\n\n // Dead-from-`pending` is the rarer + more interesting case:\n // the worker process died (or never claimed) BEFORE it could\n // flip the row to `running`. Log it at warn so ops can spot\n // a queue+worker-startup pathology that the standard `running\n // → failed` transition wouldn't surface. Dead-from-`running`\n // is the documented \"crash mid-import\" case — info is enough.\n if (row.status === 'pending') {\n log?.warn(\n { importRunId: row.id, attempts: event.attempts },\n 'queue.job.dead arrived for an import_run still in pending — worker crashed BEFORE claim/lookup',\n )\n }\n\n await markFailed(importRuns, row.id, event.error)\n log?.info(\n { importRunId: row.id, attempts: event.attempts, priorStatus: row.status },\n 'queue.job.dead → import_run marked failed',\n )\n })\n },\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"worker.mjs","names":[],"sources":["../src/worker.ts"],"sourcesContent":["/**\n * Queue handler for `imports:run`. The route handler that creates the\n * `import_run` row enqueues this job carrying `{ importRunId }`; the\n * worker module here resolves the run, the transform, and the ES\n * client, then runs `runImport` and writes results back.\n *\n * Per PLAN-ECOMMERCE.md PR 7 (PoC scope): one job type, no resumability.\n * A failed handler attempt re-runs from row 1 on retry — fine for the\n * tens-of-MB feed sizes the PoC exercises.\n *\n * Per CLAUDE.md \"leaf packages\" boundary rule: this handler reads the\n * import_run row via an injected `AdminClient`, NOT a direct `db.select`.\n * Construction happens at plugin init in `plugin.ts`.\n */\n\nimport { type Logger, runAsCli } from '@murumets-ee/core'\nimport {\n defineJob,\n type JobDefinition,\n type QueueJobDeadEvent,\n type QueueTerminalNotifier,\n} from '@murumets-ee/queue/client'\nimport type { EsClientLike } from '@murumets-ee/search-elasticsearch'\nimport { eq } from 'drizzle-orm'\nimport type { PgColumn } from 'drizzle-orm/pg-core'\nimport { z } from 'zod'\nimport type { ImportRunClient, ImportRunStatus } from './entities/import-run.js'\nimport { type ReadFeedOptions, readPersistedFeedConfig } from './read-feed.js'\nimport {\n publishImportRunFailed,\n publishImportRunStarted,\n publishImportRunSucceeded,\n} from './realtime/publish.js'\nimport { type ImportRunProgress, runImport } from './runner.js'\nimport { countDataRows } from './streaming.js'\nimport type { DirectionalPair, TransformRegistry } from './transform.js'\n\n/**\n * Structural subset of `JobContext` the handler reads. Declared\n * locally so we don't depend on a `JobHandler` import — the queue\n * package doesn't re-export it from any subpath today (matches the\n * `SendEmailJobContext` pattern in `@murumets-ee/notifications`).\n *\n * Function-param contravariance lets a handler returned with this\n * narrower shape be assigned to the wider `JobHandler<TPayload>` that\n * `registerJob` expects.\n */\nexport interface ImportsRunJobContext {\n id: string\n payload: ImportsRunJobPayload\n updateProgress(data: ImportRunProgress): void\n}\n\n/**\n * Payload schema. `importRunId` is the `import_run.id` UUID — the worker\n * looks up everything else (filePath, transformName, params, …) from\n * that row. Keeping the payload tiny means the queue's progress JSON\n * column never bloats with feed metadata duplicated across `toolkit_jobs`.\n */\nexport const importsRunJobPayloadSchema = z.object({\n importRunId: z.string().uuid(),\n})\nexport type ImportsRunJobPayload = z.infer<typeof importsRunJobPayloadSchema>\n\n/**\n * Job definition. Consumers register their handler against this with\n * `registerJob(importsRunJob, createRunImportHandler({...}))`.\n *\n * `defaultRetries: 0` — re-running a multi-batch import against the\n * same `import_run.id` while the previous handler may still be writing\n * is a footgun (duplicate batches, double-counted progress). The\n * operator handles retries explicitly via PR 8a's \"retry\" button by\n * creating a new `import_run` row. When the resumable design lands\n * (post-PoC), retries become safe to enable.\n */\nexport const importsRunJob: JobDefinition<ImportsRunJobPayload> = defineJob({\n name: 'imports:run',\n description: 'Stream a feed file, transform rows, bulk-write to Elasticsearch.',\n schema: importsRunJobPayloadSchema,\n defaultRetries: 0,\n})\n\n/**\n * Resolves the ES client at handler-invocation time. A function (rather\n * than the bare `EsClientLike`) so the consumer can lazy-construct the\n * client — typical Next.js setups create the ES connection in a\n * route-handler initialiser, not at plugin-init time.\n */\nexport type EsClientResolver = () => EsClientLike | Promise<EsClientLike>\n\n/**\n * Ensure the destination index/alias exists before the first bulk-write.\n * Invoked once per job, BEFORE {@link runImport}. Idempotent: the canonical\n * implementation (`ensureAliasedIndex` in `@murumets-ee/search-elasticsearch`)\n * no-ops when the alias already resolves.\n *\n * **Why it lives here.** This is the home of the foot-gun it guards: a worker\n * that bulk-writes to alias `<x>` before the alias exists makes ES\n * auto-create a physical `<x>` index with default `text` mappings, breaking\n * aggregations forever. The bootstrap used to run at app boot, which turned\n * \"ES is down\" into a full-admin 500 (issue #610). Moving it onto the import\n * path keeps the guarantee while scoping ES availability to imports/search —\n * the rest of the admin boots and runs with ES down.\n */\nexport type EnsureIndexResolver = () => Promise<unknown>\n\n/**\n * Resolves the value of `import_run.filePath` (whatever the upload\n * route persisted there — typically a storage adapter key) to a\n * readable LOCAL filesystem path that {@link runImport} can hand to\n * `node:fs.createReadStream`.\n *\n * **Why this exists:** the streaming reader (`streamFeed`) reads from\n * local disk via `createReadStream`. The upload route may persist a\n * remote-storage object key (R2, S3, …) on `import_run.filePath`\n * because that's what `@murumets-ee/storage` returns. Without a\n * resolver, `createReadStream('uploads/2026/05/<uuid>/feed.txt')`\n * crashes with `ENOENT`. The resolver is the documented integration\n * point — typical wiring downloads the storage object to a tmpfile\n * and returns its path.\n *\n * **`cleanup`** runs after the run finishes (success OR failure). The\n * worker awaits it best-effort — a failed cleanup logs but does not\n * crash the run.\n *\n * **No-resolver fallback:** when `resolveFilePath` is unset, the\n * worker treats `import_run.filePath` as already a local FS path\n * (back-compat with the original PoC design where uploads landed on\n * local disk). This stays valid for fixture-driven tests + on-disk\n * deployments.\n */\nexport type FilePathResolver = (\n storageKey: string,\n) => Promise<{ localPath: string; cleanup?: () => Promise<void> }>\n\n/**\n * Context handed to a {@link PairSink} alongside the harvested pairs. Carries the\n * run identity so the sink can build `source` attribution\n * (`feed:<supplierId>:<importBatchId>`, D20) and scope its writes.\n */\nexport interface PairSinkContext {\n importRunId: string\n /** The `import_run.params` object — the sink reads `supplierId` / `importBatchId`. */\n params: Record<string, unknown>\n /**\n * The run's structured logger (child), forwarded so the sink's INTERNAL telemetry —\n * a params-parse miss that skips ingestion, an over-merge flag — is visible. Without it\n * a misconfigured run looks successful from the worker's vantage (a params-miss returns\n * normally, so the worker logs \"applied\"). Optional; the sink no-ops its logs if absent.\n */\n logger?: Logger\n}\n\n/**\n * Optional post-run consumer for the directional supersession pairs the runner\n * harvested as a side-output (commerce-demo PR6, SD007). Invoked ONCE after a\n * successful run with the full in-memory pair buffer (read-once / merge-after) so\n * the consumer (the commerce replacement-merge engine) can build equivalence groups\n * and bulk-write them to Postgres.\n *\n * **Why injected, not imported.** The merge writes the CONSUMER's Postgres tables,\n * but the runner + this worker live in the `@murumets-ee/imports` leaf, which must\n * not depend on commerce. The app composes the sink (commerce exports it, the app\n * passes it to `imports({ pairSink })`) — the same injection shape as `esClient` /\n * `resolveFilePath` / `ensureIndex`. The boundary holds.\n *\n * **Best-effort.** A sink throw is logged and SWALLOWED — the catalogue import\n * (the ES write) has already succeeded and must not be undone by a secondary\n * replacement-ingestion failure. The merge is idempotent, so a re-import recovers.\n */\nexport type PairSink = (pairs: readonly DirectionalPair[], ctx: PairSinkContext) => Promise<void>\n\nexport interface RunImportHandlerConfig {\n /** AdminClient over the `import_run` entity. */\n importRuns: ImportRunClient\n /** Transform registry to dispatch against. Defaults to the process-global singleton. */\n transforms: TransformRegistry\n /** Resolver for the ES client. */\n esClient: EsClientResolver\n /** ES alias / index to bulk-write into. Per D6 callers always pass an alias. */\n esIndex: string\n /**\n * Optional bootstrap for the destination index/alias, run once per job\n * before any bulk-write. See {@link EnsureIndexResolver}. Omit for\n * deployments that bootstrap the index out-of-band (e.g. a reindex job).\n */\n ensureIndex?: EnsureIndexResolver\n /**\n * Optional resolver for `import_run.filePath`. See {@link FilePathResolver}.\n * Required when uploads land in remote storage (R2/S3); optional for\n * on-disk PoC setups.\n */\n resolveFilePath?: FilePathResolver\n /**\n * Optional consumer for the directional supersession pairs harvested as a\n * side-output (commerce-demo PR6, SD007). See {@link PairSink}. Omit for\n * deployments that don't ingest replacement codes — the pairs are simply\n * discarded after the run.\n */\n pairSink?: PairSink\n /** Optional structured logger. Defaults to silent. */\n logger?: Logger\n}\n\n/**\n * Build the `JobHandler` for {@link importsRunJob}. The returned\n * handler is what gets passed to `registerJob`.\n *\n * The body is wrapped in `runAsCli` so AdminClient calls inside have a\n * synthetic `cli` admin context — `auditable()` records `updatedBy:\n * 'cli'` rather than NULL, and the firewall checker passes. This is\n * the documented worker entry-point pattern (see `runAsCli` JSDoc in\n * `@murumets-ee/core`).\n */\nexport function createRunImportHandler(\n config: RunImportHandlerConfig,\n): (job: ImportsRunJobContext) => Promise<void> {\n const {\n importRuns,\n transforms,\n esClient,\n esIndex,\n resolveFilePath,\n ensureIndex,\n pairSink,\n logger,\n } = config\n\n return async (job: ImportsRunJobContext): Promise<void> =>\n runAsCli(async () => {\n const { importRunId } = job.payload\n const log = logger?.child({ jobId: job.id, importRunId, type: 'imports:run' })\n\n const run = await importRuns.findById(importRunId)\n if (!run) {\n // Row was deleted between enqueue and dispatch — nothing to do.\n // Don't throw: a thrown handler retries with no payoff because\n // the row stays gone. Log + return so the queue marks the job\n // completed.\n log?.warn('import_run row not found — skipping')\n return\n }\n\n // Re-claim detection. The queue's `recoverStuckJobs` sweep flips a\n // crashed worker's `toolkit_jobs` row from `processing` back to\n // `pending` — but the queue layer has no knowledge of `import_run`,\n // so this row stays at `status='running'` from the prior attempt.\n // When the queue re-dispatches the job, we land here with a row\n // that the operator-facing Active panel has been showing as live\n // for however long lock-recovery took (default 30 min). The\n // existing `update` below overwrites `queueJobId` + `startedAt` so\n // there is no STATE corruption — but emit a structured warning so\n // ops can correlate this re-run with the worker crash that\n // produced it. The sibling `sweepStaleImportRuns` job below catches\n // the case where re-dispatch never happens (queue marks the job\n // dead, or the queue worker is offline long-term).\n if (run.status === 'running') {\n log?.warn(\n {\n previousQueueJobId: run.queueJobId,\n previousStartedAt: run.startedAt,\n },\n 'imports: re-claiming a row left in running by a prior worker — likely a crash recovered by queue lock-recovery',\n )\n }\n\n // `transformName`, `filePath`, `label` are declared `field.text({\n // required: true })` so the inferred DTO already types them as\n // `string` — no cast needed. `params` is `field.json()` which\n // infers as `JsonValue | undefined`; narrow to a record so the\n // transform receives an object.\n const transform = transforms.get(run.transformName)\n if (!transform) {\n await markFailed(importRuns, importRunId, `Unknown transform \"${run.transformName}\"`)\n throw new Error(`No transform registered for name \"${run.transformName}\"`)\n }\n const params: Record<string, unknown> =\n run.params && typeof run.params === 'object' && !Array.isArray(run.params)\n ? (run.params as Record<string, unknown>)\n : {}\n\n // Reader config (format/delimiter/header/sheet) travels in\n // `params.feed` for mapping-driven runs (commerce-demo PR3). Carmaker\n // runs omit it → empty config → tab-delimited default, so the existing\n // path is byte-for-byte unchanged. Re-validate here as defense-in-depth\n // even though the enqueuing route already validated the saved mapping.\n // Shared with the #842 audit, which has to rehydrate IDENTICALLY or it\n // re-reads a past run with options that run never used.\n const feedParsed = readPersistedFeedConfig(params)\n if (!feedParsed.success) {\n const reason = feedParsed.error.issues[0]?.message ?? 'invalid feed reader config'\n await markFailed(importRuns, importRunId, `Invalid params.feed: ${reason}`)\n throw new Error(`Invalid params.feed for import_run ${importRunId}: ${reason}`)\n }\n const feedConfig = feedParsed.data\n\n await importRuns.update(importRunId, {\n status: 'running' satisfies ImportRunStatus,\n queueJobId: job.id,\n startedAt: new Date(),\n })\n // Realtime: row transitioned pending → running. Subscribers\n // refetch to surface the change in the imports admin page.\n // Best-effort — a publish failure is logged inside `publishSafe`\n // and does NOT roll back the row update.\n publishImportRunStarted(importRunId)\n\n let resolvedClient: EsClientLike\n try {\n resolvedClient = await esClient()\n } catch (err) {\n const reason = err instanceof Error ? err.message : String(err)\n await markFailed(importRuns, importRunId, `Could not resolve ES client: ${reason}`)\n throw err\n }\n\n // Ensure the destination alias exists BEFORE any bulk-write. Moved here\n // from app boot (issue #610): the foot-gun is the worker auto-creating a\n // mis-mapped physical index, so the guard belongs on the import path —\n // not gating the whole app on ES. Idempotent + scoped: an ES outage (or\n // the `ensureAliasedIndex` physical-collision guard firing) fails THIS\n // job loudly while the rest of the admin keeps working.\n if (ensureIndex) {\n try {\n await ensureIndex()\n } catch (err) {\n const reason = err instanceof Error ? err.message : String(err)\n await markFailed(importRuns, importRunId, `Could not ensure ES index/alias: ${reason}`)\n throw err\n }\n }\n\n // Materialise the file locally if the consumer wired a resolver.\n // The `cleanup` callback (when present) runs in the `finally`\n // below regardless of run outcome so a tmpfile created here\n // doesn't outlive the handler.\n let localPath: string\n let fileCleanup: (() => Promise<void>) | undefined\n if (resolveFilePath) {\n try {\n const resolved = await resolveFilePath(run.filePath)\n localPath = resolved.localPath\n fileCleanup = resolved.cleanup\n } catch (err) {\n const reason = err instanceof Error ? err.message : String(err)\n await markFailed(importRuns, importRunId, `Could not resolve filePath: ${reason}`)\n throw err\n }\n } else {\n // Back-compat: no resolver wired → treat `filePath` as already\n // a local FS path. Fine for fixture-driven tests + the original\n // on-disk PoC design.\n localPath = run.filePath\n }\n\n // Pre-pass line count: a 16 MB tab-delimited feed counts in\n // ~50ms; a 500 MB feed in <2s. Cheap relative to the full\n // import. Gives the operator a denominator (`processed / total`)\n // so the imports-page panel can render an actual percent\n // instead of just \"rows so far\".\n // Pre-pass row count is a `\\n`-tally over a delimited file — meaningless\n // on an xlsx binary, so skip it there (the UI falls back to bare counts).\n // `hasHeader` from the feed config keeps the header-exclusion correct for\n // headerless mappings.\n let totalRows: number | undefined\n if (feedConfig.format !== 'xlsx') {\n try {\n totalRows = await countDataRows(\n localPath,\n feedConfig.hasHeader !== undefined ? { hasHeader: feedConfig.hasHeader } : {},\n )\n log?.info({ importRunId, totalRows }, 'imports: pre-pass row count')\n } catch (err) {\n // Don't fail the run on a count miss — the actual import is\n // unaffected; the operator just loses the percent denominator.\n log?.warn({ err }, 'imports: pre-pass row count failed — continuing without total')\n }\n }\n\n let result: Awaited<ReturnType<typeof runImport>>\n try {\n // Build feed options with the conditional-spread idiom (NOT\n // `...feedConfig`) so absent optionals stay ABSENT — spreading a\n // `T | undefined` value would set the key to `undefined`, which\n // `exactOptionalPropertyTypes` rejects against `ReadFeedOptions`.\n const feedOpts: ReadFeedOptions = {\n filePath: localPath,\n ...(feedConfig.format !== undefined && { format: feedConfig.format }),\n ...(feedConfig.delimiter !== undefined && { delimiter: feedConfig.delimiter }),\n ...(feedConfig.hasHeader !== undefined && { hasHeader: feedConfig.hasHeader }),\n ...(feedConfig.headerRowIndex !== undefined && {\n headerRowIndex: feedConfig.headerRowIndex,\n }),\n ...(feedConfig.sheet !== undefined && { sheet: feedConfig.sheet }),\n }\n result = await runImport({\n importRunId,\n runLabel: run.label,\n params,\n transform,\n feed: feedOpts,\n esClient: resolvedClient,\n esIndex,\n onProgress: (progress) =>\n job.updateProgress(totalRows !== undefined ? { ...progress, totalRows } : progress),\n ...(log !== undefined && { logger: log }),\n })\n } catch (err) {\n const reason = err instanceof Error ? err.message : String(err)\n await markFailed(importRuns, importRunId, reason)\n if (fileCleanup) {\n await fileCleanup().catch((cleanupErr: unknown) => {\n log?.warn({ err: cleanupErr }, 'filePath cleanup after failed run threw — ignoring')\n })\n }\n throw err\n }\n\n // Cleanup runs before the success-update so a cleanup throw\n // surfaces as a logged warning rather than a crashed handler at\n // the worst possible moment (just after the row was about to be\n // marked succeeded).\n if (fileCleanup) {\n await fileCleanup().catch((cleanupErr: unknown) => {\n log?.warn({ err: cleanupErr }, 'filePath cleanup after successful run threw — ignoring')\n })\n }\n\n await importRuns.update(importRunId, {\n status: 'succeeded' satisfies ImportRunStatus,\n finishedAt: new Date(),\n totals: {\n rowsRead: result.rowsRead,\n rowsSucceeded: result.rowsSucceeded,\n rowsFailed: result.rowsFailed,\n rowsSkipped: result.rowsSkipped,\n rowsWarned: result.rowsWarned,\n batchesCompleted: result.batchesCompleted,\n // Phase-2 core-charge attach (commerce-demo PR7) — durable on the run row so\n // an operator sees how many `*S` deposits attached vs orphaned, not just the\n // orphan warnings in `warningSummary`.\n patchesApplied: result.patchesApplied,\n patchesOrphaned: result.patchesOrphaned,\n },\n // The typed `ErrorTrackerSnapshot` is fully JSON-serialisable but\n // doesn't structurally satisfy entity's recursive `JsonValue`\n // (objects without an index signature aren't `Record<string,\n // JsonValue>`). Round-trip through `JSON.parse(JSON.stringify(...))`\n // to land as the recursive JSON shape — the runtime cost is one\n // serialise per import_run completion, the type-cast cost is zero.\n errorSummary: JSON.parse(JSON.stringify(result.errors)),\n // Non-fatal warnings (commerce-demo PR4) — kept separate from\n // `errorSummary` so a `no_purchase_price` advisory never reads as a\n // failed row. Same serialise-roundtrip rationale as `errorSummary`.\n warningSummary: JSON.parse(JSON.stringify(result.warnings)),\n })\n // Realtime: row transitioned to terminal-success. Subscribers\n // refetch to move the row from Active → Recent.\n publishImportRunSucceeded(importRunId, {\n rowsRead: result.rowsRead,\n rowsSucceeded: result.rowsSucceeded,\n rowsFailed: result.rowsFailed,\n })\n\n // Side-output: hand the harvested supersession pairs to the injected sink\n // (commerce-demo PR6, SD007) AFTER the catalogue run is marked succeeded — the\n // ES import is primary; replacement ingestion is secondary. Best-effort: a sink\n // throw is logged + swallowed so a Postgres hiccup can't undo a successful\n // catalogue import (the merge is idempotent → a re-import recovers).\n if (pairSink && result.pairs.length > 0) {\n try {\n await pairSink(result.pairs, {\n importRunId,\n params,\n ...(log !== undefined && { logger: log }),\n })\n // \"invoked\" not \"applied\": the sink may internally skip (e.g. missing run params);\n // it logs its own applied/skipped outcome via the forwarded logger.\n log?.info({ importRunId, pairs: result.pairs.length }, 'imports: pairSink invoked')\n } catch (err) {\n log?.warn(\n { importRunId, pairs: result.pairs.length, err },\n // NOT \"skipped\": a sink may apply part of the batch before failing (the commerce\n // replacement sink commits per batch), so some pairs can already have landed.\n // \"Incomplete\" is the only claim this boundary can honestly make about a sink\n // whose internals it does not know.\n 'imports: pairSink threw — replacement ingestion incomplete, catalogue import unaffected',\n )\n }\n }\n\n log?.info(\n {\n rowsRead: result.rowsRead,\n rowsSucceeded: result.rowsSucceeded,\n rowsFailed: result.rowsFailed,\n rowsSkipped: result.rowsSkipped,\n batches: result.batchesCompleted,\n },\n 'import_run completed',\n )\n })\n}\n\n/** Write a `failed` row with a fatal error reason. Best-effort — logs but does not throw on DB failure. */\nasync function markFailed(importRuns: ImportRunClient, id: string, reason: string): Promise<void> {\n try {\n await importRuns.update(id, {\n status: 'failed' satisfies ImportRunStatus,\n finishedAt: new Date(),\n errorSummary: { fatal: reason },\n })\n // Realtime: row transitioned to terminal-failure. Subscribers\n // refetch to surface the failure in the Recent panel. Publish\n // is best-effort and only fires when the persist succeeds —\n // skipping it on persist failure keeps subscribers consistent\n // with the DB (no event for a row that didn't transition).\n publishImportRunFailed(id, reason)\n } catch {\n // The handler already threw / will throw — the queue will surface\n // the original error. A second failure here just logs noise.\n }\n}\n\nexport interface ImportsRunDeadListenerConfig {\n importRuns: ImportRunClient\n /** Optional structured logger. */\n logger?: Logger\n}\n\n/**\n * Build a {@link QueueTerminalNotifier} that mirrors `imports:run`\n * dead-job events into `import_run.status='failed'`.\n *\n * **Why this exists.** The `imports:run` handler's normal failure path\n * already calls `markFailed` before re-throwing, so a synchronous\n * handler error reaches the operator-visible `failed` status without\n * help from anyone. The case THIS listener catches is the one\n * `markFailed` never gets to: the worker process is killed mid-handler\n * (OOM, container restart, kill -9), the queue's lock-recovery sweep\n * eventually reclaims the row, and the queue ultimately marks the job\n * `dead` (defaultRetries: 0 + repeated process death = dead-letter\n * with `import_run.status` still `running`). Without this listener\n * the row sits in the operator's Active panel forever.\n *\n * **Generic primitive.** This is a thin subscriber over the queue's\n * multi-subscriber `QueueTerminalNotifier` registry — the same shape\n * graphile-worker (`job:failed`) and BullMQ (`failed`) expose. Other\n * domain plugins that mirror queue state (ticketing-reply, future\n * exports/reports/...) can register their own listeners exactly the\n * same way without coordinating via a per-domain sweep job.\n *\n * **Filtering.** The listener is invoked for EVERY dead event in the\n * process. We early-return on any `jobType` other than `imports:run`\n * so the per-event cost on unrelated jobs is a single property read.\n *\n * **Idempotency.** A dead event delivered after the row has already\n * reached its own terminal state (succeeded / failed / cancelled) is\n * ignored — the only transition we make is `running → failed`. This\n * keeps a late replay or out-of-order delivery from clobbering a\n * row whose worker reached `markFailed` faster than the queue's\n * dead-letter dispatch.\n *\n * The handler is wrapped in `runAsCli` so AdminClient calls inside\n * have a synthetic CLI admin context — same pattern as\n * {@link createRunImportHandler}.\n */\nexport function createImportsRunDeadListener(\n config: ImportsRunDeadListenerConfig,\n): QueueTerminalNotifier {\n const { importRuns, logger } = config\n\n return {\n onJobDead: async (event: QueueJobDeadEvent): Promise<void> => {\n if (event.jobType !== 'imports:run') return\n\n await runAsCli(async () => {\n const log = logger?.child({\n event: 'imports.run.dead',\n jobId: event.jobId,\n type: 'imports:run',\n })\n\n // Look up the mirror row by `queueJobId`. Cap to 1 — the\n // upload route writes one `import_run` per enqueued job.\n //\n // `AdminClient.getTable()` returns `PgTableWithColumns<any>`\n // because entity tables have dynamic column shapes (see the\n // `getTable()` JSDoc in `packages/entity/src/admin/client.ts`\n // and the documented-`any`-cases list in CLAUDE.md). Cast\n // the table to a structural shape covering only the column\n // we touch, so both `eq()` and type-coverage see a typed\n // operand instead of an `any`-derived property access.\n const table = importRuns.getTable() as unknown as { queueJobId: PgColumn }\n const matches = await importRuns.findMany({\n where: eq(table.queueJobId, event.jobId),\n limit: 1,\n })\n const row = matches[0]\n if (!row) {\n // No mirror row for this dead job. Either it was deleted\n // by the operator, or the dead event is for a job\n // unrelated to imports despite the type filter\n // (shouldn't happen, but is cheap to log).\n log?.warn('queue.job.dead with no matching import_run — skipping')\n return\n }\n\n // Only transition rows that haven't already reached their\n // own terminal state. A late dead-event must not overwrite\n // a `succeeded` row that the worker reached via the normal\n // path before the queue's dead-letter dispatch fired.\n if (row.status !== 'running' && row.status !== 'pending') {\n log?.info(\n { importRunId: row.id, status: row.status },\n 'queue.job.dead arrived for a row already in a terminal state — leaving as-is',\n )\n return\n }\n\n // Dead-from-`pending` is the rarer + more interesting case:\n // the worker process died (or never claimed) BEFORE it could\n // flip the row to `running`. Log it at warn so ops can spot\n // a queue+worker-startup pathology that the standard `running\n // → failed` transition wouldn't surface. Dead-from-`running`\n // is the documented \"crash mid-import\" case — info is enough.\n if (row.status === 'pending') {\n log?.warn(\n { importRunId: row.id, attempts: event.attempts },\n 'queue.job.dead arrived for an import_run still in pending — worker crashed BEFORE claim/lookup',\n )\n }\n\n await markFailed(importRuns, row.id, event.error)\n log?.info(\n { importRunId: row.id, attempts: event.attempts, priorStatus: row.status },\n 'queue.job.dead → import_run marked failed',\n )\n })\n },\n }\n}\n"],"mappings":"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"}