@murumets-ee/imports 0.49.0 → 0.51.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/carmaker-feed-C_MnDaOC.mjs +2 -0
- package/dist/{carmaker-feed-CxhZBrkO.mjs.map → carmaker-feed-C_MnDaOC.mjs.map} +1 -1
- package/dist/carmaker-feed-DlD2xPs0.d.mts +58 -0
- package/dist/carmaker-feed-DlD2xPs0.d.mts.map +1 -0
- package/dist/{cleanup-CHjDgvKT.mjs → cleanup-DdILFoAP.mjs} +2 -2
- package/dist/{cleanup-CHjDgvKT.mjs.map → cleanup-DdILFoAP.mjs.map} +1 -1
- package/dist/index.d.mts +7 -5
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/index.mjs.map +1 -1
- package/dist/mapping/index.d.mts +2 -359
- package/dist/mapping/index.d.mts.map +1 -1
- package/dist/mapping/index.mjs +1 -1
- package/dist/{mapping-transform-D76b-P-p.mjs → mapping-transform-AMFEg1rS.mjs} +2 -2
- package/dist/{mapping-transform-D76b-P-p.mjs.map → mapping-transform-AMFEg1rS.mjs.map} +1 -1
- package/dist/{parts-schema-D0gMhNWj.mjs → parts-schema-BBUINSdN.mjs} +2 -2
- package/dist/{parts-schema-D0gMhNWj.mjs.map → parts-schema-BBUINSdN.mjs.map} +1 -1
- package/dist/{parts-schema-x8_GRYWc.d.mts → parts-schema-u5kr5zFg.d.mts} +359 -3
- package/dist/parts-schema-u5kr5zFg.d.mts.map +1 -0
- package/dist/plugin.d.mts +1 -1
- package/dist/plugin.mjs +1 -1
- package/dist/plugin.mjs.map +1 -1
- package/dist/runner-wPnCZlB4.mjs +2 -0
- package/dist/runner-wPnCZlB4.mjs.map +1 -0
- package/dist/{schema-registry-C_lQje-K.mjs → schema-registry-B_7AweTa.mjs} +2 -2
- package/dist/{schema-registry-C_lQje-K.mjs.map → schema-registry-B_7AweTa.mjs.map} +1 -1
- package/dist/storage-resolver.d.mts +1 -1
- package/dist/transform-DwAQ4b-j.mjs +2 -0
- package/dist/transform-DwAQ4b-j.mjs.map +1 -0
- package/dist/{transform-CABBaYmF.d.mts → transform-WeiiLdYP.d.mts} +12 -1
- package/dist/transform-WeiiLdYP.d.mts.map +1 -0
- package/dist/transforms.d.mts +2 -46
- package/dist/transforms.mjs +1 -1
- package/dist/{worker-DRTgbtT7.d.mts → worker-DH3SN-91.d.mts} +189 -4
- package/dist/worker-DH3SN-91.d.mts.map +1 -0
- package/dist/worker.d.mts +1 -1
- package/dist/worker.mjs +1 -1
- package/dist/worker.mjs.map +1 -1
- package/dist/{xlsx-reader-Ccesszbc.mjs → xlsx-reader-Dsq_ERiB.mjs} +2 -2
- package/dist/{xlsx-reader-Ccesszbc.mjs.map → xlsx-reader-Dsq_ERiB.mjs.map} +1 -1
- package/package.json +8 -8
- package/dist/carmaker-feed-CxhZBrkO.mjs +0 -2
- package/dist/parts-schema-x8_GRYWc.d.mts.map +0 -1
- package/dist/runner-C-s6SD7t.mjs +0 -2
- package/dist/runner-C-s6SD7t.mjs.map +0 -1
- package/dist/transform-CABBaYmF.d.mts.map +0 -1
- package/dist/transform-DIzWppgJ.mjs +0 -2
- package/dist/transform-DIzWppgJ.mjs.map +0 -1
- package/dist/transforms.d.mts.map +0 -1
- package/dist/worker-DRTgbtT7.d.mts.map +0 -1
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import{n as e,r as t,s as n}from"./parts-schema-BBUINSdN.mjs";const r=`commerce:carmaker-feed`,i=e,a={targetSchema:`parts`,numberFormat:{decimal:`,`,thousands:`.`},columnMap:[{source:`ArticleName`,target:`code`,transforms:[{op:`trim`}]},{source:`NetPrice/Discount`,target:`net_price`,transforms:[{op:`europeanNumber`}]},{source:`GrossPrice`,target:`gross_price`,transforms:[{op:`europeanNumber`}]},{source:`CurrencyCd`,target:`currency`,transforms:[{op:`trim`}]},{source:`Barcode`,target:`barcode`,transforms:[{op:`trim`}]},{source:`Description1`,target:`description1`,transforms:[{op:`trim`}]},{source:`Description2`,target:`description2`,transforms:[{op:`trim`}]},{source:`Description_DE`,target:`name_de`,transforms:[{op:`trim`}]},{source:`Description_EN`,target:`name_en`,transforms:[{op:`trim`}]},{source:`Description_ES`,target:`name_es`,transforms:[{op:`trim`}]},{source:`Description_FR`,target:`name_fr`,transforms:[{op:`trim`}]},{source:`Description_IT`,target:`name_it`,transforms:[{op:`trim`}]},{source:`Description_NL`,target:`name_nl`,transforms:[{op:`trim`}]},{source:`Description_PT`,target:`name_pt`,transforms:[{op:`trim`}]},{source:`Description_RO`,target:`name_ro`,transforms:[{op:`trim`}]}]},o=n(a,t);export{o as i,a as n,i as r,r as t};
|
|
2
|
+
//# sourceMappingURL=carmaker-feed-C_MnDaOC.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"carmaker-feed-
|
|
1
|
+
{"version":3,"file":"carmaker-feed-C_MnDaOC.mjs","names":[],"sources":["../src/transforms/carmaker-feed.ts"],"sourcesContent":["/**\n * Carmaker-feed transform — now a THIN WRAPPER over the configurable mapping\n * engine (commerce-demo PR2). The hardcoded 14/15-column parsing logic is RETIRED:\n * this declares the carmaker layout as a built-in `parts` `MappingConfig` and\n * delegates to `buildMappingTransform`. The shared `partsSchema` does code\n * normalization (stripping the per-run `supplierCodePrefix`), the EUR currency\n * gate, price validation, and `PartsDocument` assembly — so the carmaker feed and\n * every operator-built `parts` mapping run through one row taxonomy.\n *\n * **Behaviour**: the emitted `PartsDocument` is equivalent to the old hardcoded\n * transform for in-spec rows, with deliberate differences worth knowing:\n * - It now populates `name_ro` from the 15-column feed's `Description_RO` — the\n * old 14-column transform always left it null (PR1 added the field).\n * - Number/currency cells are more lenient: surrounding whitespace is trimmed,\n * empty / lowercase `na` count as absent, and a blank/unmapped currency\n * defaults to EUR (the old transform rejected a blank `CurrencyCd`).\n * - The error TAXONOMY is uniform target-field naming (`missing_code`/`code`,\n * `unsupported_currency`/`currency`, empty required price → `missing_price`)\n * rather than source-column-specific (`missing_article_name`/`ArticleName`),\n * since the engine is source-column-agnostic.\n *\n * The carmaker doc-shape suite (27 tests) plus the leniency tests below it are the\n * regression net.\n *\n * **Decisions still enforced (now via the shared parts schema):**\n * - **D2** Code normalized (supplier prefix stripped, uppercased, dash/space-stripped).\n * - **D4** ES `_id` = `<code_normalized>__<supplierId>`.\n * - **D9** EUR-only (non-EUR rows rejected).\n * - **D21** Bulk-write path bypasses entity hooks (audit on the `import_run`).\n *\n * **`*S` rows** are core-fee lines (commerce-demo SD002 / D004), handled by the shared\n * parts schema (PR7): a `<code>*S` row is NOT a saleable doc — it skips the index and\n * attaches its price as the PARENT part's `core_charge_minor` in the runner's phase-2\n * pass. So the carmaker feed's `*S` rows now route the core charge instead of passing\n * through as standalone docs. Replacement codes remain the dealer superseding-code\n * column (D003), never `*S`.\n *\n * Numbers use the `europeanNumber` op (comma decimal, dot thousands, bare-dot\n * rejected) to match the carmaker feed's documented format exactly; the\n * config's `numberFormat` is unused by that op but required by the schema.\n */\n\nimport type { PartsDocument } from '@murumets-ee/search-elasticsearch'\nimport { buildMappingTransform, type MappingConfig } from '../mapping/engine.js'\nimport { type PartsParams, partsParamsSchema, partsSchema } from '../mapping/parts-schema.js'\nimport type { RowTransform } from '../transform.js'\n\n/** Stable name under which this transform is registered. */\nexport const CARMAKER_FEED_TRANSFORM_NAME = 'commerce:carmaker-feed'\n\n/**\n * Per-job params — the per-(supplier, brand) identity resolved at enqueue time.\n * This IS the parts schema's params (re-exported), so brand/supplier injection +\n * the optional `supplierCodePrefix` strip validate identically everywhere.\n */\nexport const carmakerFeedParamsSchema = partsParamsSchema\nexport type CarmakerFeedParams = PartsParams\n\n/**\n * The built-in carmaker `parts` mapping (15 columns incl. `Description_RO`). Every\n * column carries a `trim` op; the two price columns use `europeanNumber`. The code\n * column is mapped RAW — the parts schema strips the supplier prefix (from params)\n * and normalizes, so both `doc.code` (raw) and `doc.code_normalized` are produced.\n *\n * Exported (not module-local) so a consumer building a real `import_mapping` row\n * for a GIGA brand — which needs a `stripPrefix` transform baked into the `code`\n * column instead of the legacy path's `params.supplierCodePrefix` — can start from\n * this exact layout rather than re-deriving it (see commerce's `seed.ts`).\n */\nexport const CARMAKER_MAPPING: MappingConfig = {\n targetSchema: 'parts',\n numberFormat: { decimal: ',', thousands: '.' },\n columnMap: [\n { source: 'ArticleName', target: 'code', transforms: [{ op: 'trim' }] },\n { source: 'NetPrice/Discount', target: 'net_price', transforms: [{ op: 'europeanNumber' }] },\n { source: 'GrossPrice', target: 'gross_price', transforms: [{ op: 'europeanNumber' }] },\n { source: 'CurrencyCd', target: 'currency', transforms: [{ op: 'trim' }] },\n { source: 'Barcode', target: 'barcode', transforms: [{ op: 'trim' }] },\n { source: 'Description1', target: 'description1', transforms: [{ op: 'trim' }] },\n { source: 'Description2', target: 'description2', transforms: [{ op: 'trim' }] },\n { source: 'Description_DE', target: 'name_de', transforms: [{ op: 'trim' }] },\n { source: 'Description_EN', target: 'name_en', transforms: [{ op: 'trim' }] },\n { source: 'Description_ES', target: 'name_es', transforms: [{ op: 'trim' }] },\n { source: 'Description_FR', target: 'name_fr', transforms: [{ op: 'trim' }] },\n { source: 'Description_IT', target: 'name_it', transforms: [{ op: 'trim' }] },\n { source: 'Description_NL', target: 'name_nl', transforms: [{ op: 'trim' }] },\n { source: 'Description_PT', target: 'name_pt', transforms: [{ op: 'trim' }] },\n { source: 'Description_RO', target: 'name_ro', transforms: [{ op: 'trim' }] },\n ],\n}\n\n/**\n * The carmaker transform — built once at module load from the static mapping. The\n * build validates every target against the `parts` whitelist (throws on a typo).\n */\nexport const carmakerFeedTransform: RowTransform<PartsDocument> = buildMappingTransform(\n CARMAKER_MAPPING,\n partsSchema,\n)\n"],"mappings":"8DAgDA,MAAa,EAA+B,yBAO/B,EAA2B,EAc3B,EAAkC,CAC7C,aAAc,QACd,aAAc,CAAE,QAAS,IAAK,UAAW,GAAI,EAC7C,UAAW,CACT,CAAE,OAAQ,cAAe,OAAQ,OAAQ,WAAY,CAAC,CAAE,GAAI,MAAO,CAAC,CAAE,EACtE,CAAE,OAAQ,oBAAqB,OAAQ,YAAa,WAAY,CAAC,CAAE,GAAI,gBAAiB,CAAC,CAAE,EAC3F,CAAE,OAAQ,aAAc,OAAQ,cAAe,WAAY,CAAC,CAAE,GAAI,gBAAiB,CAAC,CAAE,EACtF,CAAE,OAAQ,aAAc,OAAQ,WAAY,WAAY,CAAC,CAAE,GAAI,MAAO,CAAC,CAAE,EACzE,CAAE,OAAQ,UAAW,OAAQ,UAAW,WAAY,CAAC,CAAE,GAAI,MAAO,CAAC,CAAE,EACrE,CAAE,OAAQ,eAAgB,OAAQ,eAAgB,WAAY,CAAC,CAAE,GAAI,MAAO,CAAC,CAAE,EAC/E,CAAE,OAAQ,eAAgB,OAAQ,eAAgB,WAAY,CAAC,CAAE,GAAI,MAAO,CAAC,CAAE,EAC/E,CAAE,OAAQ,iBAAkB,OAAQ,UAAW,WAAY,CAAC,CAAE,GAAI,MAAO,CAAC,CAAE,EAC5E,CAAE,OAAQ,iBAAkB,OAAQ,UAAW,WAAY,CAAC,CAAE,GAAI,MAAO,CAAC,CAAE,EAC5E,CAAE,OAAQ,iBAAkB,OAAQ,UAAW,WAAY,CAAC,CAAE,GAAI,MAAO,CAAC,CAAE,EAC5E,CAAE,OAAQ,iBAAkB,OAAQ,UAAW,WAAY,CAAC,CAAE,GAAI,MAAO,CAAC,CAAE,EAC5E,CAAE,OAAQ,iBAAkB,OAAQ,UAAW,WAAY,CAAC,CAAE,GAAI,MAAO,CAAC,CAAE,EAC5E,CAAE,OAAQ,iBAAkB,OAAQ,UAAW,WAAY,CAAC,CAAE,GAAI,MAAO,CAAC,CAAE,EAC5E,CAAE,OAAQ,iBAAkB,OAAQ,UAAW,WAAY,CAAC,CAAE,GAAI,MAAO,CAAC,CAAE,EAC5E,CAAE,OAAQ,iBAAkB,OAAQ,UAAW,WAAY,CAAC,CAAE,GAAI,MAAO,CAAC,CAAE,CAC9E,CACF,EAMa,EAAqD,EAChE,EACA,CACF"}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { i as RowTransform } from "./transform-WeiiLdYP.mjs";
|
|
2
|
+
import { o as MappingConfig, t as PartsParams } from "./parts-schema-u5kr5zFg.mjs";
|
|
3
|
+
import { PartsDocument } from "@murumets-ee/search-elasticsearch";
|
|
4
|
+
|
|
5
|
+
//#region src/transforms/carmaker-feed.d.ts
|
|
6
|
+
/** Stable name under which this transform is registered. */
|
|
7
|
+
declare const CARMAKER_FEED_TRANSFORM_NAME = "commerce:carmaker-feed";
|
|
8
|
+
/**
|
|
9
|
+
* Per-job params — the per-(supplier, brand) identity resolved at enqueue time.
|
|
10
|
+
* This IS the parts schema's params (re-exported), so brand/supplier injection +
|
|
11
|
+
* the optional `supplierCodePrefix` strip validate identically everywhere.
|
|
12
|
+
*/
|
|
13
|
+
declare const carmakerFeedParamsSchema: import("zod").ZodObject<{
|
|
14
|
+
brandId: import("zod").ZodString;
|
|
15
|
+
brandSlug: import("zod").ZodString;
|
|
16
|
+
supplierId: import("zod").ZodString;
|
|
17
|
+
supplierDisplayName: import("zod").ZodString;
|
|
18
|
+
importBatchId: import("zod").ZodString;
|
|
19
|
+
supplierCodePrefix: import("zod").ZodOptional<import("zod").ZodString>;
|
|
20
|
+
discountGroups: import("zod").ZodOptional<import("zod").ZodRecord<import("zod").ZodString, import("zod").ZodNumber>>;
|
|
21
|
+
}, "strip", import("zod").ZodTypeAny, {
|
|
22
|
+
supplierId: string;
|
|
23
|
+
brandId: string;
|
|
24
|
+
brandSlug: string;
|
|
25
|
+
supplierDisplayName: string;
|
|
26
|
+
importBatchId: string;
|
|
27
|
+
supplierCodePrefix?: string | undefined;
|
|
28
|
+
discountGroups?: Record<string, number> | undefined;
|
|
29
|
+
}, {
|
|
30
|
+
supplierId: string;
|
|
31
|
+
brandId: string;
|
|
32
|
+
brandSlug: string;
|
|
33
|
+
supplierDisplayName: string;
|
|
34
|
+
importBatchId: string;
|
|
35
|
+
supplierCodePrefix?: string | undefined;
|
|
36
|
+
discountGroups?: Record<string, number> | undefined;
|
|
37
|
+
}>;
|
|
38
|
+
type CarmakerFeedParams = PartsParams;
|
|
39
|
+
/**
|
|
40
|
+
* The built-in carmaker `parts` mapping (15 columns incl. `Description_RO`). Every
|
|
41
|
+
* column carries a `trim` op; the two price columns use `europeanNumber`. The code
|
|
42
|
+
* column is mapped RAW — the parts schema strips the supplier prefix (from params)
|
|
43
|
+
* and normalizes, so both `doc.code` (raw) and `doc.code_normalized` are produced.
|
|
44
|
+
*
|
|
45
|
+
* Exported (not module-local) so a consumer building a real `import_mapping` row
|
|
46
|
+
* for a GIGA brand — which needs a `stripPrefix` transform baked into the `code`
|
|
47
|
+
* column instead of the legacy path's `params.supplierCodePrefix` — can start from
|
|
48
|
+
* this exact layout rather than re-deriving it (see commerce's `seed.ts`).
|
|
49
|
+
*/
|
|
50
|
+
declare const CARMAKER_MAPPING: MappingConfig;
|
|
51
|
+
/**
|
|
52
|
+
* The carmaker transform — built once at module load from the static mapping. The
|
|
53
|
+
* build validates every target against the `parts` whitelist (throws on a typo).
|
|
54
|
+
*/
|
|
55
|
+
declare const carmakerFeedTransform: RowTransform<PartsDocument>;
|
|
56
|
+
//#endregion
|
|
57
|
+
export { carmakerFeedTransform as a, carmakerFeedParamsSchema as i, CARMAKER_MAPPING as n, CarmakerFeedParams as r, CARMAKER_FEED_TRANSFORM_NAME as t };
|
|
58
|
+
//# sourceMappingURL=carmaker-feed-DlD2xPs0.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"carmaker-feed-DlD2xPs0.d.mts","names":[],"sources":["../src/transforms/carmaker-feed.ts"],"mappings":";;;;;;cAgDa,4BAAA;;;;;;cAOA,wBAAA,gBAAwB,SAAA;;;;;;;;;;;;;;;mBAAoB,MAAA;AAAA;;;;;;;mBAAA,MAAA;AAAA;AAAA,KAC7C,kBAAA,GAAqB,WAAW;;;;;;;;;;;;cAa/B,gBAAA,EAAkB,aAoB9B;;;;;cAMY,qBAAA,EAAuB,YAAY,CAAC,aAAA"}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import{t as e}from"./rolldown-runtime-DK3Fl9T5.mjs";import{getImportsStorage as t}from"./storage.mjs";var n=e({cleanupOrphanedImports:()=>a,cleanupProcessedImports:()=>s});const r=1e3;function i(e){let{ttlOrRetention:t,ttlLabel:n,limit:i=100}=e;if(!Number.isFinite(t)||t<0)throw Error(`imports cleanup: ${n} must be a finite, non-negative number (got ${t})`);if(!Number.isFinite(i)||!Number.isInteger(i)||i<1||i>r)throw Error(`imports cleanup: limit must be an integer in [1, ${r}] (got ${i})`);return{ttlOrRetention:t,limit:i}}async function a(e){let{app:n,bucket:r,logger:a}=e,o=n.db.readWrite,{ttlOrRetention:s,limit:c}=i({ttlOrRetention:e.ttlHours,ttlLabel:`ttlHours`,...e.limit!==void 0&&{limit:e.limit}}),{and:l,asc:u,eq:d,isNull:f,lt:p,getTableColumns:m}=await import(`drizzle-orm`),{toolkitFiles:h}=await import(`@murumets-ee/storage`),{createAdminClient:g}=await import(`@murumets-ee/core/clients`),{ImportRun:_}=await import(`./transform-
|
|
2
|
-
//# sourceMappingURL=cleanup-
|
|
1
|
+
import{t as e}from"./rolldown-runtime-DK3Fl9T5.mjs";import{getImportsStorage as t}from"./storage.mjs";var n=e({cleanupOrphanedImports:()=>a,cleanupProcessedImports:()=>s});const r=1e3;function i(e){let{ttlOrRetention:t,ttlLabel:n,limit:i=100}=e;if(!Number.isFinite(t)||t<0)throw Error(`imports cleanup: ${n} must be a finite, non-negative number (got ${t})`);if(!Number.isFinite(i)||!Number.isInteger(i)||i<1||i>r)throw Error(`imports cleanup: limit must be an integer in [1, ${r}] (got ${i})`);return{ttlOrRetention:t,limit:i}}async function a(e){let{app:n,bucket:r,logger:a}=e,o=n.db.readWrite,{ttlOrRetention:s,limit:c}=i({ttlOrRetention:e.ttlHours,ttlLabel:`ttlHours`,...e.limit!==void 0&&{limit:e.limit}}),{and:l,asc:u,eq:d,isNull:f,lt:p,getTableColumns:m}=await import(`drizzle-orm`),{toolkitFiles:h}=await import(`@murumets-ee/storage`),{createAdminClient:g}=await import(`@murumets-ee/core/clients`),{ImportRun:_}=await import(`./transform-DwAQ4b-j.mjs`).then(e=>e.d),v=g(_).getTable(),y=m(h),b=y.key,x=y.bucket,S=y.createdAt;if(!b||!x||!S)throw Error(`cleanupOrphanedImports: toolkit_files schema missing expected columns (key/bucket/createdAt)`);let C=m(v),w=C.filePath,T=C.id;if(!w||!T)throw Error(`cleanupOrphanedImports: import_run schema missing expected columns (filePath/id)`);let E=new Date(Date.now()-s*60*60*1e3),D=await o.select({key:b}).from(h).leftJoin(v,d(w,b)).where(l(d(x,r),p(S,E),f(T))).orderBy(u(S)).limit(c);if(D.length===0)return a.debug({bucket:r,ttlHours:s},`No orphaned imports uploads found`),0;let O=await t(),k=0;for(let e of D){let t=e.key;try{await O.delete(t),k++}catch(e){a.warn({key:t,err:e},`Failed to delete orphaned imports upload`)}}return a.info({cleaned:k,total:D.length,bucket:r,ttlHours:s},`Cleaned up orphaned imports uploads`),k}const o=[`succeeded`,`failed`,`cancelled`];async function s(e){let{app:n,bucket:r,logger:a}=e,s=n.db.readWrite,{ttlOrRetention:c,limit:l}=i({ttlOrRetention:e.retentionDays,ttlLabel:`retentionDays`,...e.limit!==void 0&&{limit:e.limit}}),{and:u,asc:d,eq:f,getTableColumns:p,inArray:m,lt:h}=await import(`drizzle-orm`),{toolkitFiles:g}=await import(`@murumets-ee/storage`),{createAdminClient:_}=await import(`@murumets-ee/core/clients`),{ImportRun:v}=await import(`./transform-DwAQ4b-j.mjs`).then(e=>e.d),y=_(v).getTable(),b=p(g),x=b.key,S=b.bucket;if(!x||!S)throw Error(`cleanupProcessedImports: toolkit_files schema missing expected columns (key/bucket)`);let C=p(y),w=C.filePath,T=C.status,E=C.finishedAt;if(!w||!T||!E)throw Error(`cleanupProcessedImports: import_run schema missing expected columns (filePath/status/finishedAt)`);let D=new Date(Date.now()-c*24*60*60*1e3),O=await s.selectDistinctOn([x],{key:x}).from(g).innerJoin(y,f(w,x)).where(u(f(S,r),m(T,[...o]),h(E,D))).orderBy(d(x),d(E)).limit(l);if(O.length===0)return a.debug({bucket:r,retentionDays:c},`No processed-imports uploads ready for cleanup`),0;let k=await t(),A=0;for(let e of O){let t=e.key;try{await k.delete(t),A++}catch(e){a.warn({key:t,err:e},`Failed to delete processed-imports upload`)}}return a.info({cleaned:A,total:O.length,bucket:r,retentionDays:c},`Cleaned up processed imports uploads`),A}export{s as n,n as r,a as t};
|
|
2
|
+
//# sourceMappingURL=cleanup-DdILFoAP.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cleanup-CHjDgvKT.mjs","names":[],"sources":["../src/admin/cleanup.ts"],"sourcesContent":["/**\n * Cleanup orphaned imports uploads.\n *\n * Finds rows in `toolkit_files` within the configured imports bucket\n * that:\n * - were created more than `ttlHours` ago, AND\n * - have no matching `import_run.filePath` reference\n *\n * For each match, calls `StorageClient.delete(key)` — which removes\n * BOTH the adapter bytes (local file / R2 object) AND the\n * `toolkit_files` metadata row in one transactional unit (see\n * `@murumets-ee/storage` `StorageClient.delete`).\n *\n * Why this exists: the Tus upload flow stores bytes + metadata BEFORE\n * the client's downstream `register` step inserts an `import_run` row.\n * If the register call fails (validation, MIME mismatch, network\n * blip, agent abandons the workflow), the storage row is left\n * dangling. The commerce-side register handler does opportunistic\n * cleanup on its OWN failure paths, but rejections from the Zod\n * schema (before the handler runs), rejections where the agent\n * never calls register at all, AND the handler's operator-fixable\n * mapping rejections (mapping not-found / no-mapping 422 / structure\n * drift 409 — which deliberately KEEP the valid upload for a re-run)\n * all rely on this sweep.\n *\n * Scheduling: driven by the `defineJob({ schedule: '@every 1h' })`\n * declaration in `plugin.ts`. The queue worker bootstraps a\n * `toolkit_scheduled_jobs` row for this job at startup and its cron\n * loop (`processDueSchedules`) fires the registered handler on each\n * tick. The handler does NOT re-enqueue itself — the schedule is\n * driven by the worker, not the handler.\n */\n\nimport type { ToolkitApp } from '@murumets-ee/core'\nimport type { Logger } from '@murumets-ee/logging'\nimport { getImportsStorage } from '../storage.js'\n\nexport interface CleanupOrphanedImportsArgs {\n /** Toolkit app (provides db). */\n app: ToolkitApp\n /**\n * Bucket name the imports flow uses. Rows in `toolkit_files` are\n * filtered to this bucket so the cleanup doesn't touch media,\n * ticketing attachments, or any other consumer's uploads even if\n * they share the same Postgres instance.\n */\n bucket: string\n /**\n * Hours after which an unreferenced upload is considered orphaned.\n * Operator-grace window: should be long enough that a normal\n * upload-then-register round-trip never wipes a fresh upload, but\n * short enough that storage waste from failed uploads doesn't\n * accumulate. 2 hours is the default in the imports plugin\n * config — calibrated against the typical \"operator clicks\n * upload, walks away, comes back to check\" workflow.\n */\n ttlHours: number\n /**\n * Maximum rows to process per run. Bounded so a sudden flood of\n * orphans (e.g. after a brand-new supplier with unrecognised\n * filenames floods the queue with rejected uploads) doesn't\n * block the queue worker on a single multi-hour cleanup.\n */\n limit?: number\n /** Logger for diagnostics. */\n logger: Pick<Logger, 'debug' | 'info' | 'warn'>\n}\n\n/** Default per-run cap. Tuned against ticketing's `cleanupOrphanedUploads` (also 100). */\nconst DEFAULT_LIMIT = 100\n\n/** Hard cap on per-run limit. Protects against pathological caller input. */\nconst MAX_LIMIT = 1000\n\n/**\n * Validate the numeric inputs every cleanup helper accepts. Returns\n * the sanitised values OR throws — the queue worker logs thrown\n * errors against the scheduled-job row so a misconfigured TTL\n * doesn't silently delete every bucket file (which would happen if\n * a negative `ttlHours` reached the date arithmetic and pushed the\n * cutoff into the future).\n */\nfunction validateCleanupArgs(\n raw: { ttlOrRetention: number; ttlLabel: string; limit?: number },\n): { ttlOrRetention: number; limit: number } {\n const { ttlOrRetention, ttlLabel, limit = DEFAULT_LIMIT } = raw\n if (!Number.isFinite(ttlOrRetention) || ttlOrRetention < 0) {\n throw new Error(\n `imports cleanup: ${ttlLabel} must be a finite, non-negative number (got ${ttlOrRetention})`,\n )\n }\n if (!Number.isFinite(limit) || !Number.isInteger(limit) || limit < 1 || limit > MAX_LIMIT) {\n throw new Error(\n `imports cleanup: limit must be an integer in [1, ${MAX_LIMIT}] (got ${limit})`,\n )\n }\n return { ttlOrRetention, limit }\n}\n\n/**\n * Run one cleanup pass. Returns the count of rows actually deleted\n * (which may be less than the count of orphans found if some\n * `storage.delete` calls failed — see the `warn` log inside the loop).\n */\nexport async function cleanupOrphanedImports(args: CleanupOrphanedImportsArgs): Promise<number> {\n const { app, bucket, logger } = args\n const db = app.db.readWrite\n const { ttlOrRetention: ttlHours, limit } = validateCleanupArgs({\n ttlOrRetention: args.ttlHours,\n ttlLabel: 'ttlHours',\n // `exactOptionalPropertyTypes` rejects an explicit `undefined`\n // for the optional `limit?: number` slot. Spread the field only\n // when defined so the validator's default-via-destructure kicks in.\n ...(args.limit !== undefined && { limit: args.limit }),\n })\n\n // Cross-table query: `toolkit_files` LEFT JOIN `import_run` — same\n // documented exception for cross-entity JOINs that ticketing's\n // cleanup uses. Two-table joins can't go through a single-entity\n // AdminClient (per CLAUDE.md \"Database Queries\").\n const { and, asc, eq, isNull, lt, getTableColumns } = await import('drizzle-orm')\n const { toolkitFiles } = await import('@murumets-ee/storage')\n const { createAdminClient } = await import('@murumets-ee/core/clients')\n const { ImportRun } = await import('../entities/import-run.js')\n type AnyPgColumn = import('drizzle-orm/pg-core').AnyPgColumn\n\n const importRunsTable = createAdminClient(ImportRun).getTable()\n\n // Both `toolkitFiles` and the `importRunsTable` are exported as\n // generic PgTables so direct dot-access widens to `T | undefined`.\n // Pull the column refs through `getTableColumns` (same shape as\n // ticketing's cleanup + the entity-version helper).\n const fileCols = getTableColumns(toolkitFiles) as Record<string, AnyPgColumn>\n const fileKey = fileCols.key\n const fileBucket = fileCols.bucket\n const fileCreatedAt = fileCols.createdAt\n if (!fileKey || !fileBucket || !fileCreatedAt) {\n throw new Error(\n 'cleanupOrphanedImports: toolkit_files schema missing expected columns (key/bucket/createdAt)',\n )\n }\n const importRunCols = getTableColumns(importRunsTable) as Record<string, AnyPgColumn>\n const irFilePath = importRunCols.filePath\n const irId = importRunCols.id\n if (!irFilePath || !irId) {\n throw new Error(\n 'cleanupOrphanedImports: import_run schema missing expected columns (filePath/id)',\n )\n }\n\n const cutoff = new Date(Date.now() - ttlHours * 60 * 60 * 1000)\n\n // Find toolkit_files rows in the imports bucket, older than cutoff,\n // where the storage key is NOT referenced by any import_run row.\n // ORDER BY createdAt ASC delivers strict FIFO so a sustained\n // orphan stream makes deterministic progress on the oldest rows\n // first — without the orderBy, Postgres returns rows in heap\n // order and a LIMIT 100 sweep could keep recycling the same\n // young rows while ancient orphans never get touched.\n const orphans = await db\n .select({ key: fileKey })\n .from(toolkitFiles)\n .leftJoin(importRunsTable, eq(irFilePath, fileKey))\n .where(and(eq(fileBucket, bucket), lt(fileCreatedAt, cutoff), isNull(irId)))\n .orderBy(asc(fileCreatedAt))\n .limit(limit)\n\n if (orphans.length === 0) {\n logger.debug({ bucket, ttlHours }, 'No orphaned imports uploads found')\n return 0\n }\n\n const storage = await getImportsStorage()\n let cleaned = 0\n for (const row of orphans) {\n const key = row.key as unknown as string\n try {\n await storage.delete(key)\n cleaned++\n } catch (err) {\n logger.warn({ key, err }, 'Failed to delete orphaned imports upload')\n }\n }\n\n logger.info(\n { cleaned, total: orphans.length, bucket, ttlHours },\n 'Cleaned up orphaned imports uploads',\n )\n return cleaned\n}\n\n/** Statuses considered \"done\" for the post-success/failure cleanup. */\nconst TERMINAL_IMPORT_RUN_STATUSES = ['succeeded', 'failed', 'cancelled'] as const\n\nexport interface CleanupProcessedImportsArgs {\n /** Toolkit app (provides db). */\n app: ToolkitApp\n /** Bucket name to scope the cleanup. Same semantics as `cleanupOrphanedImports`. */\n bucket: string\n /**\n * Days after which a terminal-status `import_run`'s source file is\n * deleted. Operator-grace window: long enough that an operator can\n * inspect the source file if a transform bug surfaces shortly after\n * a successful import; short enough that storage doesn't accumulate\n * months of supplier feeds.\n *\n * The plugin default is 7. Set to 0 to delete the source file\n * immediately at the next cleanup tick after the import row\n * reaches terminal status — useful when storage cost matters more\n * than forensic reprocess capability.\n */\n retentionDays: number\n /** Max rows per run. Defaults to 100 (same cap as the orphan sweep). */\n limit?: number\n logger: Pick<Logger, 'debug' | 'info' | 'warn'>\n}\n\n/**\n * Cleanup source files for `import_run`s that finished (succeeded /\n * failed / cancelled) more than `retentionDays` days ago.\n *\n * Counterpart to `cleanupOrphanedImports`: that one targets uploads\n * with NO matching `import_run` (handshake never completed); this one\n * targets uploads whose `import_run` row IS present and terminal.\n * Together they cover every \"useless storage row\" case the imports\n * flow can produce.\n *\n * Deletes ONLY the storage row (via `StorageClient.delete`); the\n * `import_run` row is kept so the History panel + audit trail still\n * surface the past run. The `import_run.filePath` value points at a\n * now-deleted key after this runs — consumers that try to re-download\n * the source file (e.g. a future \"reprocess\" button) will get a\n * `getMetadata` → null and should show \"source file unavailable\" in\n * the UI rather than crashing.\n */\nexport async function cleanupProcessedImports(args: CleanupProcessedImportsArgs): Promise<number> {\n const { app, bucket, logger } = args\n const db = app.db.readWrite\n const { ttlOrRetention: retentionDays, limit } = validateCleanupArgs({\n ttlOrRetention: args.retentionDays,\n ttlLabel: 'retentionDays',\n // `exactOptionalPropertyTypes` rejects an explicit `undefined`\n // for the optional `limit?: number` slot. Spread the field only\n // when defined so the validator's default-via-destructure kicks in.\n ...(args.limit !== undefined && { limit: args.limit }),\n })\n\n const { and, asc, eq, getTableColumns, inArray, lt } = await import('drizzle-orm')\n const { toolkitFiles } = await import('@murumets-ee/storage')\n const { createAdminClient } = await import('@murumets-ee/core/clients')\n const { ImportRun } = await import('../entities/import-run.js')\n type AnyPgColumn = import('drizzle-orm/pg-core').AnyPgColumn\n\n const importRunsTable = createAdminClient(ImportRun).getTable()\n\n const fileCols = getTableColumns(toolkitFiles) as Record<string, AnyPgColumn>\n const fileKey = fileCols.key\n const fileBucket = fileCols.bucket\n if (!fileKey || !fileBucket) {\n throw new Error(\n 'cleanupProcessedImports: toolkit_files schema missing expected columns (key/bucket)',\n )\n }\n const importRunCols = getTableColumns(importRunsTable) as Record<string, AnyPgColumn>\n const irFilePath = importRunCols.filePath\n const irStatus = importRunCols.status\n const irFinishedAt = importRunCols.finishedAt\n if (!irFilePath || !irStatus || !irFinishedAt) {\n throw new Error(\n 'cleanupProcessedImports: import_run schema missing expected columns (filePath/status/finishedAt)',\n )\n }\n\n const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000)\n\n // INNER JOIN so we only consider keys that DO have a matching\n // import_run row — the inverse of the orphan-cleanup query. Filter\n // on terminal status + finishedAt cutoff so a long-running batch\n // doesn't get its still-being-processed file ripped out from under\n // the worker.\n //\n // `selectDistinctOn([fileKey])` is the right primitive here:\n //\n // (a) **Dedupe before LIMIT.** `toolkit_files.key` is UNIQUE\n // within its own table, but `import_run.filePath` has no\n // unique constraint — a future re-run / retry flow that\n // creates two terminal `import_run` rows pointing at the\n // same `storageKey` would make the INNER JOIN project the\n // key twice. Without dedupe in SQL, a pathological cluster\n // of 100 duplicates sharing 1 storageKey would burn the\n // LIMIT cap to clean 1 file.\n //\n // (b) **Deterministic ordering** (NOT strict global FIFO).\n // `DISTINCT ON` requires the ORDER BY to start with the\n // distinct columns (Postgres rule). So we sort by `fileKey`\n // first, then `irFinishedAt` within each key group. The\n // LIMIT then picks the first N keys in `fileKey` order —\n // i.e. alphabetical by storage path, not globally oldest by\n // finishedAt. In practice this is roughly chronological\n // anyway: storage keys are `uploads/YYYY/MM/<uuid>/...` so\n // earlier months sort first. Within a month, ordering is\n // UUID-random — but every key eventually gets cleaned\n // (no starvation), so over ~10 ticks the entire backlog\n // drains either way. A subquery-wrapped \"true global FIFO\"\n // (DISTINCT ON inner, ORDER BY finishedAt outer) is\n // possible but adds complexity for marginal benefit; the\n // practical-FIFO-via-key-prefix property is acceptable.\n const candidates = await db\n .selectDistinctOn([fileKey], { key: fileKey })\n .from(toolkitFiles)\n .innerJoin(importRunsTable, eq(irFilePath, fileKey))\n .where(\n and(\n eq(fileBucket, bucket),\n inArray(irStatus, [...TERMINAL_IMPORT_RUN_STATUSES]),\n lt(irFinishedAt, cutoff),\n ),\n )\n .orderBy(asc(fileKey), asc(irFinishedAt))\n .limit(limit)\n\n if (candidates.length === 0) {\n logger.debug({ bucket, retentionDays }, 'No processed-imports uploads ready for cleanup')\n return 0\n }\n\n const storage = await getImportsStorage()\n let cleaned = 0\n for (const row of candidates) {\n const key = row.key as unknown as string\n try {\n await storage.delete(key)\n cleaned++\n } catch (err) {\n logger.warn({ key, err }, 'Failed to delete processed-imports upload')\n }\n }\n\n logger.info(\n { cleaned, total: candidates.length, bucket, retentionDays },\n 'Cleaned up processed imports uploads',\n )\n return cleaned\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"cleanup-DdILFoAP.mjs","names":[],"sources":["../src/admin/cleanup.ts"],"sourcesContent":["/**\n * Cleanup orphaned imports uploads.\n *\n * Finds rows in `toolkit_files` within the configured imports bucket\n * that:\n * - were created more than `ttlHours` ago, AND\n * - have no matching `import_run.filePath` reference\n *\n * For each match, calls `StorageClient.delete(key)` — which removes\n * BOTH the adapter bytes (local file / R2 object) AND the\n * `toolkit_files` metadata row in one transactional unit (see\n * `@murumets-ee/storage` `StorageClient.delete`).\n *\n * Why this exists: the Tus upload flow stores bytes + metadata BEFORE\n * the client's downstream `register` step inserts an `import_run` row.\n * If the register call fails (validation, MIME mismatch, network\n * blip, agent abandons the workflow), the storage row is left\n * dangling. The commerce-side register handler does opportunistic\n * cleanup on its OWN failure paths, but rejections from the Zod\n * schema (before the handler runs), rejections where the agent\n * never calls register at all, AND the handler's operator-fixable\n * mapping rejections (mapping not-found / no-mapping 422 / structure\n * drift 409 — which deliberately KEEP the valid upload for a re-run)\n * all rely on this sweep.\n *\n * Scheduling: driven by the `defineJob({ schedule: '@every 1h' })`\n * declaration in `plugin.ts`. The queue worker bootstraps a\n * `toolkit_scheduled_jobs` row for this job at startup and its cron\n * loop (`processDueSchedules`) fires the registered handler on each\n * tick. The handler does NOT re-enqueue itself — the schedule is\n * driven by the worker, not the handler.\n */\n\nimport type { ToolkitApp } from '@murumets-ee/core'\nimport type { Logger } from '@murumets-ee/logging'\nimport { getImportsStorage } from '../storage.js'\n\nexport interface CleanupOrphanedImportsArgs {\n /** Toolkit app (provides db). */\n app: ToolkitApp\n /**\n * Bucket name the imports flow uses. Rows in `toolkit_files` are\n * filtered to this bucket so the cleanup doesn't touch media,\n * ticketing attachments, or any other consumer's uploads even if\n * they share the same Postgres instance.\n */\n bucket: string\n /**\n * Hours after which an unreferenced upload is considered orphaned.\n * Operator-grace window: should be long enough that a normal\n * upload-then-register round-trip never wipes a fresh upload, but\n * short enough that storage waste from failed uploads doesn't\n * accumulate. 2 hours is the default in the imports plugin\n * config — calibrated against the typical \"operator clicks\n * upload, walks away, comes back to check\" workflow.\n */\n ttlHours: number\n /**\n * Maximum rows to process per run. Bounded so a sudden flood of\n * orphans (e.g. after a brand-new supplier with unrecognised\n * filenames floods the queue with rejected uploads) doesn't\n * block the queue worker on a single multi-hour cleanup.\n */\n limit?: number\n /** Logger for diagnostics. */\n logger: Pick<Logger, 'debug' | 'info' | 'warn'>\n}\n\n/** Default per-run cap. Tuned against ticketing's `cleanupOrphanedUploads` (also 100). */\nconst DEFAULT_LIMIT = 100\n\n/** Hard cap on per-run limit. Protects against pathological caller input. */\nconst MAX_LIMIT = 1000\n\n/**\n * Validate the numeric inputs every cleanup helper accepts. Returns\n * the sanitised values OR throws — the queue worker logs thrown\n * errors against the scheduled-job row so a misconfigured TTL\n * doesn't silently delete every bucket file (which would happen if\n * a negative `ttlHours` reached the date arithmetic and pushed the\n * cutoff into the future).\n */\nfunction validateCleanupArgs(\n raw: { ttlOrRetention: number; ttlLabel: string; limit?: number },\n): { ttlOrRetention: number; limit: number } {\n const { ttlOrRetention, ttlLabel, limit = DEFAULT_LIMIT } = raw\n if (!Number.isFinite(ttlOrRetention) || ttlOrRetention < 0) {\n throw new Error(\n `imports cleanup: ${ttlLabel} must be a finite, non-negative number (got ${ttlOrRetention})`,\n )\n }\n if (!Number.isFinite(limit) || !Number.isInteger(limit) || limit < 1 || limit > MAX_LIMIT) {\n throw new Error(\n `imports cleanup: limit must be an integer in [1, ${MAX_LIMIT}] (got ${limit})`,\n )\n }\n return { ttlOrRetention, limit }\n}\n\n/**\n * Run one cleanup pass. Returns the count of rows actually deleted\n * (which may be less than the count of orphans found if some\n * `storage.delete` calls failed — see the `warn` log inside the loop).\n */\nexport async function cleanupOrphanedImports(args: CleanupOrphanedImportsArgs): Promise<number> {\n const { app, bucket, logger } = args\n const db = app.db.readWrite\n const { ttlOrRetention: ttlHours, limit } = validateCleanupArgs({\n ttlOrRetention: args.ttlHours,\n ttlLabel: 'ttlHours',\n // `exactOptionalPropertyTypes` rejects an explicit `undefined`\n // for the optional `limit?: number` slot. Spread the field only\n // when defined so the validator's default-via-destructure kicks in.\n ...(args.limit !== undefined && { limit: args.limit }),\n })\n\n // Cross-table query: `toolkit_files` LEFT JOIN `import_run` — same\n // documented exception for cross-entity JOINs that ticketing's\n // cleanup uses. Two-table joins can't go through a single-entity\n // AdminClient (per CLAUDE.md \"Database Queries\").\n const { and, asc, eq, isNull, lt, getTableColumns } = await import('drizzle-orm')\n const { toolkitFiles } = await import('@murumets-ee/storage')\n const { createAdminClient } = await import('@murumets-ee/core/clients')\n const { ImportRun } = await import('../entities/import-run.js')\n type AnyPgColumn = import('drizzle-orm/pg-core').AnyPgColumn\n\n const importRunsTable = createAdminClient(ImportRun).getTable()\n\n // Both `toolkitFiles` and the `importRunsTable` are exported as\n // generic PgTables so direct dot-access widens to `T | undefined`.\n // Pull the column refs through `getTableColumns` (same shape as\n // ticketing's cleanup + the entity-version helper).\n const fileCols = getTableColumns(toolkitFiles) as Record<string, AnyPgColumn>\n const fileKey = fileCols.key\n const fileBucket = fileCols.bucket\n const fileCreatedAt = fileCols.createdAt\n if (!fileKey || !fileBucket || !fileCreatedAt) {\n throw new Error(\n 'cleanupOrphanedImports: toolkit_files schema missing expected columns (key/bucket/createdAt)',\n )\n }\n const importRunCols = getTableColumns(importRunsTable) as Record<string, AnyPgColumn>\n const irFilePath = importRunCols.filePath\n const irId = importRunCols.id\n if (!irFilePath || !irId) {\n throw new Error(\n 'cleanupOrphanedImports: import_run schema missing expected columns (filePath/id)',\n )\n }\n\n const cutoff = new Date(Date.now() - ttlHours * 60 * 60 * 1000)\n\n // Find toolkit_files rows in the imports bucket, older than cutoff,\n // where the storage key is NOT referenced by any import_run row.\n // ORDER BY createdAt ASC delivers strict FIFO so a sustained\n // orphan stream makes deterministic progress on the oldest rows\n // first — without the orderBy, Postgres returns rows in heap\n // order and a LIMIT 100 sweep could keep recycling the same\n // young rows while ancient orphans never get touched.\n const orphans = await db\n .select({ key: fileKey })\n .from(toolkitFiles)\n .leftJoin(importRunsTable, eq(irFilePath, fileKey))\n .where(and(eq(fileBucket, bucket), lt(fileCreatedAt, cutoff), isNull(irId)))\n .orderBy(asc(fileCreatedAt))\n .limit(limit)\n\n if (orphans.length === 0) {\n logger.debug({ bucket, ttlHours }, 'No orphaned imports uploads found')\n return 0\n }\n\n const storage = await getImportsStorage()\n let cleaned = 0\n for (const row of orphans) {\n const key = row.key as unknown as string\n try {\n await storage.delete(key)\n cleaned++\n } catch (err) {\n logger.warn({ key, err }, 'Failed to delete orphaned imports upload')\n }\n }\n\n logger.info(\n { cleaned, total: orphans.length, bucket, ttlHours },\n 'Cleaned up orphaned imports uploads',\n )\n return cleaned\n}\n\n/** Statuses considered \"done\" for the post-success/failure cleanup. */\nconst TERMINAL_IMPORT_RUN_STATUSES = ['succeeded', 'failed', 'cancelled'] as const\n\nexport interface CleanupProcessedImportsArgs {\n /** Toolkit app (provides db). */\n app: ToolkitApp\n /** Bucket name to scope the cleanup. Same semantics as `cleanupOrphanedImports`. */\n bucket: string\n /**\n * Days after which a terminal-status `import_run`'s source file is\n * deleted. Operator-grace window: long enough that an operator can\n * inspect the source file if a transform bug surfaces shortly after\n * a successful import; short enough that storage doesn't accumulate\n * months of supplier feeds.\n *\n * The plugin default is 7. Set to 0 to delete the source file\n * immediately at the next cleanup tick after the import row\n * reaches terminal status — useful when storage cost matters more\n * than forensic reprocess capability.\n */\n retentionDays: number\n /** Max rows per run. Defaults to 100 (same cap as the orphan sweep). */\n limit?: number\n logger: Pick<Logger, 'debug' | 'info' | 'warn'>\n}\n\n/**\n * Cleanup source files for `import_run`s that finished (succeeded /\n * failed / cancelled) more than `retentionDays` days ago.\n *\n * Counterpart to `cleanupOrphanedImports`: that one targets uploads\n * with NO matching `import_run` (handshake never completed); this one\n * targets uploads whose `import_run` row IS present and terminal.\n * Together they cover every \"useless storage row\" case the imports\n * flow can produce.\n *\n * Deletes ONLY the storage row (via `StorageClient.delete`); the\n * `import_run` row is kept so the History panel + audit trail still\n * surface the past run. The `import_run.filePath` value points at a\n * now-deleted key after this runs — consumers that try to re-download\n * the source file (e.g. a future \"reprocess\" button) will get a\n * `getMetadata` → null and should show \"source file unavailable\" in\n * the UI rather than crashing.\n */\nexport async function cleanupProcessedImports(args: CleanupProcessedImportsArgs): Promise<number> {\n const { app, bucket, logger } = args\n const db = app.db.readWrite\n const { ttlOrRetention: retentionDays, limit } = validateCleanupArgs({\n ttlOrRetention: args.retentionDays,\n ttlLabel: 'retentionDays',\n // `exactOptionalPropertyTypes` rejects an explicit `undefined`\n // for the optional `limit?: number` slot. Spread the field only\n // when defined so the validator's default-via-destructure kicks in.\n ...(args.limit !== undefined && { limit: args.limit }),\n })\n\n const { and, asc, eq, getTableColumns, inArray, lt } = await import('drizzle-orm')\n const { toolkitFiles } = await import('@murumets-ee/storage')\n const { createAdminClient } = await import('@murumets-ee/core/clients')\n const { ImportRun } = await import('../entities/import-run.js')\n type AnyPgColumn = import('drizzle-orm/pg-core').AnyPgColumn\n\n const importRunsTable = createAdminClient(ImportRun).getTable()\n\n const fileCols = getTableColumns(toolkitFiles) as Record<string, AnyPgColumn>\n const fileKey = fileCols.key\n const fileBucket = fileCols.bucket\n if (!fileKey || !fileBucket) {\n throw new Error(\n 'cleanupProcessedImports: toolkit_files schema missing expected columns (key/bucket)',\n )\n }\n const importRunCols = getTableColumns(importRunsTable) as Record<string, AnyPgColumn>\n const irFilePath = importRunCols.filePath\n const irStatus = importRunCols.status\n const irFinishedAt = importRunCols.finishedAt\n if (!irFilePath || !irStatus || !irFinishedAt) {\n throw new Error(\n 'cleanupProcessedImports: import_run schema missing expected columns (filePath/status/finishedAt)',\n )\n }\n\n const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000)\n\n // INNER JOIN so we only consider keys that DO have a matching\n // import_run row — the inverse of the orphan-cleanup query. Filter\n // on terminal status + finishedAt cutoff so a long-running batch\n // doesn't get its still-being-processed file ripped out from under\n // the worker.\n //\n // `selectDistinctOn([fileKey])` is the right primitive here:\n //\n // (a) **Dedupe before LIMIT.** `toolkit_files.key` is UNIQUE\n // within its own table, but `import_run.filePath` has no\n // unique constraint — a future re-run / retry flow that\n // creates two terminal `import_run` rows pointing at the\n // same `storageKey` would make the INNER JOIN project the\n // key twice. Without dedupe in SQL, a pathological cluster\n // of 100 duplicates sharing 1 storageKey would burn the\n // LIMIT cap to clean 1 file.\n //\n // (b) **Deterministic ordering** (NOT strict global FIFO).\n // `DISTINCT ON` requires the ORDER BY to start with the\n // distinct columns (Postgres rule). So we sort by `fileKey`\n // first, then `irFinishedAt` within each key group. The\n // LIMIT then picks the first N keys in `fileKey` order —\n // i.e. alphabetical by storage path, not globally oldest by\n // finishedAt. In practice this is roughly chronological\n // anyway: storage keys are `uploads/YYYY/MM/<uuid>/...` so\n // earlier months sort first. Within a month, ordering is\n // UUID-random — but every key eventually gets cleaned\n // (no starvation), so over ~10 ticks the entire backlog\n // drains either way. A subquery-wrapped \"true global FIFO\"\n // (DISTINCT ON inner, ORDER BY finishedAt outer) is\n // possible but adds complexity for marginal benefit; the\n // practical-FIFO-via-key-prefix property is acceptable.\n const candidates = await db\n .selectDistinctOn([fileKey], { key: fileKey })\n .from(toolkitFiles)\n .innerJoin(importRunsTable, eq(irFilePath, fileKey))\n .where(\n and(\n eq(fileBucket, bucket),\n inArray(irStatus, [...TERMINAL_IMPORT_RUN_STATUSES]),\n lt(irFinishedAt, cutoff),\n ),\n )\n .orderBy(asc(fileKey), asc(irFinishedAt))\n .limit(limit)\n\n if (candidates.length === 0) {\n logger.debug({ bucket, retentionDays }, 'No processed-imports uploads ready for cleanup')\n return 0\n }\n\n const storage = await getImportsStorage()\n let cleaned = 0\n for (const row of candidates) {\n const key = row.key as unknown as string\n try {\n await storage.delete(key)\n cleaned++\n } catch (err) {\n logger.warn({ key, err }, 'Failed to delete processed-imports upload')\n }\n }\n\n logger.info(\n { cleaned, total: candidates.length, bucket, retentionDays },\n 'Cleaned up processed imports uploads',\n )\n return cleaned\n}\n"],"mappings":"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"}
|
package/dist/index.d.mts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import { A as StreamFeedOptions, C as
|
|
2
|
-
import { a as RowWarning, c as TransformRegistry, i as RowTransform, l as getTransformRegistry, n as RowError, o as TransformContext, r as RowResult, s as TransformName, t as DirectionalPair, u as registerImportTransform } from "./transform-
|
|
1
|
+
import { A as ErrorSample, B as StreamFeedOptions, C as PriceStats, D as DEFAULT_MAX_PATTERNS, E as parsePriceStats, F as FeedReaderConfig, G as ImportRun, H as countDataRows, I as ReadFeedOptions, K as ImportRunClient, L as feedReaderConfigSchema, M as ErrorTrackerConfig, N as ErrorTrackerSnapshot, O as DEFAULT_MAX_SAMPLES_PER_PATTERN, P as mergeErrorTrackerSnapshots, R as readFeed, S as PriceAnomaly, T as detectPriceAnomaly, U as streamFeed, V as StreamFeedRow, W as IMPORT_RUN_STATUSES, _ as RunImportResult, b as PRICE_ANOMALY_THRESHOLD_MULTIPLIER, c as PairSinkContext, g as RunImportOptions, h as ImportRunProgress, j as ErrorTracker, k as ErrorPattern, m as DEFAULT_BATCH_SIZE, n as EsClientResolver, q as ImportRunStatus, r as FilePathResolver, s as PairSink, t as EnsureIndexResolver, v as runImport, w as PriceStatsCollector, x as PRICE_SAMPLE_CAP, y as EMPTY_PRICE_STATS, z as readPersistedFeedConfig } from "./worker-DH3SN-91.mjs";
|
|
2
|
+
import { a as RowWarning, c as TransformRegistry, i as RowTransform, l as getTransformRegistry, n as RowError, o as TransformContext, r as RowResult, s as TransformName, t as DirectionalPair, u as registerImportTransform } from "./transform-WeiiLdYP.mjs";
|
|
3
|
+
import { a as MappingColumn, l as mappingColumnSchema, o as MappingConfig, u as mappingConfigSchema } from "./parts-schema-u5kr5zFg.mjs";
|
|
4
|
+
import { n as CARMAKER_MAPPING } from "./carmaker-feed-DlD2xPs0.mjs";
|
|
3
5
|
import { ToolkitApp } from "@murumets-ee/core";
|
|
4
6
|
import { Behavior } from "@murumets-ee/entity";
|
|
5
7
|
import { Logger as Logger$1 } from "@murumets-ee/logging";
|
|
@@ -228,14 +230,14 @@ declare const ImportMapping: import("@murumets-ee/entity").Entity<{
|
|
|
228
230
|
id: import("@murumets-ee/entity").IdField;
|
|
229
231
|
} & import("@murumets-ee/entity").AuditableFields & Record<never, never> & {
|
|
230
232
|
/** FK to commerce `supplier` (by name string — no cross-package import). Required. */supplierId: import("@murumets-ee/entity").ReferenceField & {
|
|
231
|
-
cardinality: "
|
|
233
|
+
cardinality: "one" | "many";
|
|
232
234
|
} & {
|
|
233
235
|
readonly entity: "supplier";
|
|
234
236
|
readonly required: true;
|
|
235
237
|
readonly indexed: true;
|
|
236
238
|
}; /** FK to commerce `brand`. NULL = supplier default (applies to all that supplier's brands). */
|
|
237
239
|
brandId: import("@murumets-ee/entity").ReferenceField & {
|
|
238
|
-
cardinality: "
|
|
240
|
+
cardinality: "one" | "many";
|
|
239
241
|
} & {
|
|
240
242
|
readonly entity: "brand";
|
|
241
243
|
readonly indexed: true;
|
|
@@ -362,5 +364,5 @@ interface FeedPreview {
|
|
|
362
364
|
*/
|
|
363
365
|
declare function previewFeed(options: PreviewFeedOptions): Promise<FeedPreview>;
|
|
364
366
|
//#endregion
|
|
365
|
-
export { type AuditEmptyHeaderRunsArgs, type AuditedImportRun, type CleanupOrphanedImportsArgs, type CleanupProcessedImportsArgs, DEFAULT_BATCH_SIZE, DEFAULT_MAX_PATTERNS, DEFAULT_MAX_SAMPLES_PER_PATTERN, DEFAULT_PREVIEW_ROWS, type DirectionalPair, EMPTY_HEADER_VERDICTS, type EmptyHeaderAuditReport, type EmptyHeaderVerdict, type EnsureIndexResolver, type ErrorPattern, type ErrorSample, ErrorTracker, type ErrorTrackerConfig, type EsClientResolver, type FeedPreview, type FeedReaderConfig, type FilePathResolver, IMPORT_MAPPING_FORMATS, IMPORT_MAPPING_TARGET_SCHEMAS, IMPORT_RUN_STATUSES, ImportMapping, type ImportMappingClient, type ImportMappingFormat, type ImportMappingTargetSchema, ImportRun, type ImportRunClient, type ImportRunProgress, type ImportRunStatus, type InspectImportRunSourceArgs, type InspectImportRunSourceResult, MAX_PREVIEW_ROWS, type PairSink, type PairSinkContext, type PreviewFeedOptions, type ReadFeedOptions, type RecordedTotals, type RowError, type RowResult, type RowTransform, type RowWarning, type RunImportOptions, type RunImportResult, type StreamFeedOptions, type StreamFeedRow, type TransformContext, type TransformName, TransformRegistry, assertValidMapping, auditEmptyHeaderRuns, cleanupOrphanedImports, cleanupProcessedImports, countDataRows, feedReaderConfigSchema, getTransformRegistry, importMappingValidation, inspectImportRunSource, previewFeed, readFeed, readPersistedFeedConfig, readRecordedTotals, registerImportTransform, runImport, streamFeed };
|
|
367
|
+
export { type AuditEmptyHeaderRunsArgs, type AuditedImportRun, CARMAKER_MAPPING, type CleanupOrphanedImportsArgs, type CleanupProcessedImportsArgs, DEFAULT_BATCH_SIZE, DEFAULT_MAX_PATTERNS, DEFAULT_MAX_SAMPLES_PER_PATTERN, DEFAULT_PREVIEW_ROWS, type DirectionalPair, EMPTY_HEADER_VERDICTS, EMPTY_PRICE_STATS, type EmptyHeaderAuditReport, type EmptyHeaderVerdict, type EnsureIndexResolver, type ErrorPattern, type ErrorSample, ErrorTracker, type ErrorTrackerConfig, type ErrorTrackerSnapshot, type EsClientResolver, type FeedPreview, type FeedReaderConfig, type FilePathResolver, IMPORT_MAPPING_FORMATS, IMPORT_MAPPING_TARGET_SCHEMAS, IMPORT_RUN_STATUSES, ImportMapping, type ImportMappingClient, type ImportMappingFormat, type ImportMappingTargetSchema, ImportRun, type ImportRunClient, type ImportRunProgress, type ImportRunStatus, type InspectImportRunSourceArgs, type InspectImportRunSourceResult, MAX_PREVIEW_ROWS, type MappingColumn, type MappingConfig, PRICE_ANOMALY_THRESHOLD_MULTIPLIER, PRICE_SAMPLE_CAP, type PairSink, type PairSinkContext, type PreviewFeedOptions, type PriceAnomaly, type PriceStats, PriceStatsCollector, type ReadFeedOptions, type RecordedTotals, type RowError, type RowResult, type RowTransform, type RowWarning, type RunImportOptions, type RunImportResult, type StreamFeedOptions, type StreamFeedRow, type TransformContext, type TransformName, TransformRegistry, assertValidMapping, auditEmptyHeaderRuns, cleanupOrphanedImports, cleanupProcessedImports, countDataRows, detectPriceAnomaly, feedReaderConfigSchema, getTransformRegistry, importMappingValidation, inspectImportRunSource, mappingColumnSchema, mappingConfigSchema, mergeErrorTrackerSnapshots, parsePriceStats, previewFeed, readFeed, readPersistedFeedConfig, readRecordedTotals, registerImportTransform, runImport, streamFeed };
|
|
366
368
|
//# sourceMappingURL=index.d.mts.map
|
package/dist/index.d.mts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.mts","names":[],"sources":["../src/admin/audit-empty-header.ts","../src/admin/cleanup.ts","../src/entities/import-mapping.ts","../src/entities/import-mapping-validation.ts","../src/preview-feed.ts"],"mappings":"
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../src/admin/audit-empty-header.ts","../src/admin/cleanup.ts","../src/entities/import-mapping.ts","../src/entities/import-mapping-validation.ts","../src/preview-feed.ts"],"mappings":"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"}
|
package/dist/index.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import{a as e,c as t,i as n,l as r,n as i,o as a,r as o,s,t as c,u as l}from"./transform-
|
|
1
|
+
import{a as e,c as t,i as n,l as r,n as i,o as a,r as o,s,t as c,u as l}from"./transform-DwAQ4b-j.mjs";import{_ as u,a as d,c as f,d as p,f as m,h,i as g,l as _,m as v,n as y,o as b,p as x,r as S,s as C,t as w,u as T,v as E}from"./runner-wPnCZlB4.mjs";import{n as D,t as O}from"./cleanup-DdILFoAP.mjs";import{c as k,l as A}from"./parts-schema-BBUINSdN.mjs";import{n as j}from"./carmaker-feed-C_MnDaOC.mjs";import{DEFAULT_BOUNDED_CONCURRENCY as M,runBounded as N}from"@murumets-ee/core";const P=[`corrupt`,`clean`,`source_unavailable`,`undetectable`,`error`],F=[`succeeded`],I=5e3;function L(e){return e instanceof Error?e.message:String(e)}const R=new Set([`ENOENT`,`EACCES`,`EPERM`,`EISDIR`,`ENOTDIR`]),z=/FILE_ENDED/;function B(e,t){if(typeof e!=`object`||!e)return!1;if(e instanceof t)return!0;if(`code`in e){let{code:t}=e;if(typeof t==`string`&&R.has(t))return!0}return e instanceof Error&&z.test(e.message)}async function V(e){let{filePath:t,feed:n,resolveFilePath:r}=e;if(n.format!==`xlsx`)return{verdict:`undetectable`,detail:`not an xlsx run (format: ${n.format??`tsv (default)`}) — the defect is exceljs-specific`};if(n.hasHeader===!1)return{verdict:`undetectable`,detail:`run used hasHeader:false — no header row for the guard to test`};let{streamXlsx:i,XlsxEmptyHeaderError:a,XlsxNotAFileError:o}=await import(`./xlsx-reader-Dsq_ERiB.mjs`),s;try{s=r?await r(t):{localPath:t}}catch(e){return{verdict:`source_unavailable`,detail:L(e)}}try{let e=!1;for await(let t of i({filePath:s.localPath,...n.headerRowIndex!==void 0&&{headerRowIndex:n.headerRowIndex},...n.sheet!==void 0&&{sheet:n.sheet}})){e=!0;break}return e?{verdict:`clean`}:{verdict:`undetectable`,detail:`file yielded no data rows — the header guard never ran`}}catch(e){return e instanceof a?{verdict:`corrupt`,detail:e.message}:B(e,o)?{verdict:`source_unavailable`,detail:L(e)}:{verdict:`error`,detail:L(e)}}finally{await s.cleanup?.().catch(()=>{})}}function H(e,t){let n=e[t];return typeof n==`number`&&Number.isFinite(n)?n:void 0}function U(e){if(typeof e!=`object`||!e||Array.isArray(e))return{};let t=e;return{rowsRead:H(t,`rowsRead`),rowsSucceeded:H(t,`rowsSucceeded`),rowsFailed:H(t,`rowsFailed`),rowsSkipped:H(t,`rowsSkipped`)}}function W(){return{corrupt:0,clean:0,source_unavailable:0,undetectable:0,error:0}}function G(e){return e.rowsRead===void 0||e.rowsSucceeded===void 0?`unknown`:e.rowsRead>0&&e.rowsSucceeded===0}function K(e){if(e instanceof Date)return e;if(typeof e==`string`){let t=new Date(e);return Number.isNaN(t.getTime())?null:t}return null}async function q(e){let{app:t,resolveFilePath:n,logger:r}=e,i=e.statuses??F;if(i.length===0)throw Error(`auditEmptyHeaderRuns: statuses must name at least one import_run status`);let a=e.limit??200;if(!Number.isInteger(a)||a<1||a>I)throw Error(`auditEmptyHeaderRuns: limit must be an integer in [1, ${I}] (got ${a})`);let o=e.concurrency??M;if(!Number.isInteger(o)||o<1||o>16)throw Error(`auditEmptyHeaderRuns: concurrency must be an integer in [1, 16] (got ${o})`);let s=t.getClient(l),{and:c,desc:u,getTableColumns:d,inArray:f,sql:p}=await import(`drizzle-orm`),m=d(s.getTable()),g=m.status,_=m.params,v=m.createdAt;if(!g||!_||!v)throw Error(`auditEmptyHeaderRuns: import_run schema missing expected columns (status/params/createdAt)`);let y=c(f(g,[...i]),p`${_}->'feed'->>'format' = ${`xlsx`}`),b=await s.count({where:y}),x=await s.findMany({where:y,orderBy:u(v),limit:a}),S=Math.max(0,b-x.length);if(S>0&&r.warn({candidates:b,examined:x.length,notExamined:S,limit:a},`imports audit: capped at the most recent runs — re-run with a higher limit to cover the rest`),x.length===0)return r.info({statuses:i},`imports audit: no xlsx runs to audit`),{candidates:b,examined:0,notExamined:S,counts:W(),runs:[]};r.info({candidates:b,examining:x.length,concurrency:o},`imports audit: re-reading source files for the empty-header defect`);let C=await N(x,async e=>{let t=e.filePath,r=U(e.totals),i={id:e.id,label:e.label,filePath:t,transformName:e.transformName,createdAt:K(e.createdAt),finishedAt:K(e.finishedAt),totals:r,zeroRowsIndexed:G(r)};try{let r=h(e.params);if(!r.success){let e=r.error.issues[0]?.message??`invalid feed reader config`;return{...i,verdict:`error`,detail:`invalid params.feed: ${e}`}}let a=await V({filePath:t,feed:r.data,...n!==void 0&&{resolveFilePath:n}});return{...i,verdict:a.verdict,...a.detail!==void 0&&{detail:a.detail}}}catch(e){return{...i,verdict:`error`,detail:L(e)}}},o),w=W();for(let e of C)w[e.verdict]+=1,e.verdict===`corrupt`&&r.warn({importRunId:e.id,label:e.label,filePath:e.filePath,transformName:e.transformName,createdAt:e.createdAt,totals:e.totals},`imports audit: SILENTLY CORRUPTED run — its source file has no readable header`);let T=C.filter(e=>e.zeroRowsIndexed===!0).length,E=C.filter(e=>e.zeroRowsIndexed===`unknown`).length;return n===void 0&&w.source_unavailable===C.length&&r.warn({examined:C.length},`imports audit: every source was unreadable and no resolveFilePath was given — if filePath holds storage keys rather than local paths, pass storageResolveFilePath`),r.info({candidates:b,examined:C.length,notExamined:S,...w,zeroRowsIndexed:T,totalsUnknown:E},`imports audit: complete`),{candidates:b,examined:C.length,notExamined:S,counts:w,runs:C}}const J=100,Y=20;async function X(e){let{maxRows:t,...n}=e,r=t??20,i=Math.min(100,Math.max(1,Number.isFinite(r)?Math.floor(r):20)),a=[],o=new Map;for await(let{row:e}of v({...n,relaxColumnCount:!0})){for(let t of Object.keys(e))o.set(t,!0);if(a.push(e),a.length>=i)break}return{columns:[...o.keys()],rows:a}}export{j as CARMAKER_MAPPING,w as DEFAULT_BATCH_SIZE,_ as DEFAULT_MAX_PATTERNS,T as DEFAULT_MAX_SAMPLES_PER_PATTERN,Y as DEFAULT_PREVIEW_ROWS,P as EMPTY_HEADER_VERDICTS,S as EMPTY_PRICE_STATS,p as ErrorTracker,n as IMPORT_MAPPING_FORMATS,e as IMPORT_MAPPING_TARGET_SCHEMAS,r as IMPORT_RUN_STATUSES,a as ImportMapping,l as ImportRun,J as MAX_PREVIEW_ROWS,g as PRICE_ANOMALY_THRESHOLD_MULTIPLIER,d as PRICE_SAMPLE_CAP,b as PriceStatsCollector,c as TransformRegistry,s as assertValidMapping,q as auditEmptyHeaderRuns,O as cleanupOrphanedImports,D as cleanupProcessedImports,u as countDataRows,C as detectPriceAnomaly,x as feedReaderConfigSchema,i as getTransformRegistry,t as importMappingValidation,V as inspectImportRunSource,k as mappingColumnSchema,A as mappingConfigSchema,m as mergeErrorTrackerSnapshots,f as parsePriceStats,X as previewFeed,v as readFeed,h as readPersistedFeedConfig,U as readRecordedTotals,o as registerImportTransform,y as runImport,E as streamFeed};
|
|
2
2
|
//# sourceMappingURL=index.mjs.map
|
package/dist/index.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.mjs","names":[],"sources":["../src/admin/audit-empty-header.ts","../src/preview-feed.ts"],"sourcesContent":["/**\n * Retroactive audit for the silent empty-header corruption fixed in #839\n * (issue #842).\n *\n * **What went wrong.** exceljs assigns `this.sharedStrings = []` at the top of\n * `_parseSharedStrings`, before parsing a single entry — and an empty array is\n * truthy, which is what its inline-worksheet guard tests. A shared-strings\n * entry that yields nothing therefore leaves every string cell resolving to\n * `undefined`: the header parses to zero columns, every data row maps to `{}`,\n * and the import reports SUCCESS with every value dropped. #839 made\n * `streamXlsx` refuse that file. It cannot see backwards, and a past occurrence\n * is indistinguishable from success in the `import_run` row alone — both\n * `status: 'succeeded'`, both no fatal error.\n *\n * **What this does.** Re-reads the retained source file of every past xlsx run\n * with the PATCHED reader and classifies each one. `import_run.filePath` is\n * `required: true`, so every run has a source to point at.\n *\n * **What a verdict is worth — read this before acting on a report.**\n *\n * - `corrupt` has no false positives worth worrying about: the guard fires on\n * precisely the condition that produced the empty parse, so a file that\n * throws today would have thrown then. There is no threshold to tune, unlike\n * inferring corruption from suspiciously-low row counts.\n * - `clean` is NOT proof the run was fine. The upstream defect has two causes:\n * a file that genuinely carries an empty/unparseable shared-strings part\n * (deterministic — caught here), and a truncated or starved read at import\n * time (transient — the same file re-reads perfectly today). The file re-read\n * is blind to the second. That is why every audited run also carries\n * {@link AuditedImportRun.zeroRowsIndexed}, which is derived from the\n * recorded totals and needs no file at all: a run that finished `succeeded`\n * having indexed zero rows is the observable signature of the corruption\n * whichever cause produced it. It is corroborating, not conclusive — an\n * operator who uploaded a feed with the wrong column mapping gets the same\n * shape — so it is reported as a separate flag and never folded into the\n * verdict.\n * - `source_unavailable` is the honest answer for a run whose evidence is gone,\n * and there will be some: `cleanupProcessedImports` deletes the source file\n * of any terminal run older than `processedRetentionDays` (plugin default 7),\n * on an hourly schedule. The retained-file window is therefore about a week\n * on a deployment running the queue worker with the default config — run this\n * audit sooner rather than later, and read `zeroRowsIndexed` for the runs\n * whose files have already been reaped.\n *\n * Read-only by construction: it reports, it never re-imports. Deciding what to\n * do about a corrupted run needs the operator.\n *\n * Bounded per CLAUDE.md's fan-out rule — it is `O(runs)` reads against storage,\n * so it caps both the item count AND the peak concurrency, and logs whatever it\n * drops rather than silently truncating. The per-run cost is one file: the\n * resolver downloads the object, and the reader parses to the header of the\n * target sheet plus whatever follows it. Neither cap substitutes for the other,\n * and neither substitutes for running the audit while the answer is still\n * there.\n */\n\nimport { DEFAULT_BOUNDED_CONCURRENCY, runBounded, type ToolkitApp } from '@murumets-ee/core'\nimport type { Logger } from '@murumets-ee/logging'\nimport { ImportRun, type ImportRunStatus } from '../entities/import-run.js'\nimport { type FeedReaderConfig, readPersistedFeedConfig } from '../read-feed.js'\nimport type { FilePathResolver } from '../worker.js'\n\n/**\n * Per-run outcome of the file re-read.\n *\n * - `corrupt` — the patched reader refuses this file's header. The run imported\n * empty rows and reported success.\n * - `clean` — the header re-reads with at least one column today. See the\n * false-negative caveat in the module docblock.\n * - `source_unavailable` — the source file could not be read at all (reaped by\n * the retention sweep, deleted, or storage refused). Undecidable from the\n * file; `zeroRowsIndexed` is the only signal left.\n * - `undetectable` — the run is outside what this detector can decide: a\n * `hasHeader: false` run has no header for the guard to test, a non-xlsx run\n * cannot have the defect, and a file that yields no data rows never reached\n * the guard at all.\n * - `error` — reading failed for some other reason; `detail` carries it.\n */\nexport const EMPTY_HEADER_VERDICTS = [\n 'corrupt',\n 'clean',\n 'source_unavailable',\n 'undetectable',\n 'error',\n] as const\nexport type EmptyHeaderVerdict = (typeof EMPTY_HEADER_VERDICTS)[number]\n\n/** Statuses audited when the caller doesn't name any. */\nconst DEFAULT_STATUSES: readonly ImportRunStatus[] = ['succeeded']\n\n/** Default cap on runs examined per invocation. */\nconst DEFAULT_LIMIT = 200\n\n/** Hard ceiling on the per-invocation cap. Protects storage from a fat-fingered `--limit`. */\nconst MAX_LIMIT = 5000\n\n/** Hard ceiling on peak concurrent reads. Bounds the burst the item cap alone does not. */\nconst MAX_CONCURRENCY = 16\n\nexport interface InspectImportRunSourceArgs {\n /** Local path OR storage key, verbatim from `import_run.filePath`. */\n filePath: string\n /** The run's persisted reader config (`import_run.params.feed`). */\n feed: FeedReaderConfig\n /**\n * Resolves `filePath` to a readable local path, exactly as the worker does.\n * Omit when `filePath` is already a local FS path (the on-disk deployments\n * the worker's no-resolver fallback covers).\n */\n resolveFilePath?: FilePathResolver | undefined\n}\n\nexport interface InspectImportRunSourceResult {\n verdict: EmptyHeaderVerdict\n /** Human-readable reason. Always set for everything except `clean`. */\n detail?: string\n}\n\n/** Best-effort message for an unknown thrown value. */\nfunction describeError(err: unknown): string {\n if (err instanceof Error) return err.message\n return String(err)\n}\n\n/**\n * Filesystem / object-store errors that mean \"the evidence is not readable\",\n * as distinct from \"the file is readable and its header is bad\".\n *\n * ENOENT is the retention sweep having got there first — the expected outcome\n * for anything older than `processedRetentionDays`. The rest are real\n * deployment conditions rather than theory: the imports directory has had uid\n * drift before (#661's `imports-init` chown), and this audit is run from an\n * operator shell, not under the worker's uid — so EACCES against a file the\n * worker wrote is exactly the shape to expect. All of them are \"cannot decide\",\n * never \"corrupt\".\n */\nconst UNREADABLE_SOURCE_CODES = new Set(['ENOENT', 'EACCES', 'EPERM', 'EISDIR', 'ENOTDIR'])\n\n/**\n * unzipper's error for a zip that ends mid-entry. A half-written retained\n * upload is evidence we cannot read, not a verdict about the run — checked:\n * truncating a workbook at 95% / 60% / 20% all reject cleanly with this rather\n * than crashing or hanging.\n */\nconst TRUNCATED_ARCHIVE_MESSAGE = /FILE_ENDED/\n\nfunction isUnreadableSource(err: unknown, notAFile: new (...args: never[]) => Error): boolean {\n if (typeof err !== 'object' || err === null) return false\n // A directory (or socket, or fifo) where a workbook should be. Matched by\n // TYPE — the reader exports the class precisely so this is not a regex.\n if (err instanceof notAFile) return true\n if ('code' in err) {\n const { code } = err as { code?: unknown }\n if (typeof code === 'string' && UNREADABLE_SOURCE_CODES.has(code)) return true\n }\n return err instanceof Error && TRUNCATED_ARCHIVE_MESSAGE.test(err.message)\n}\n\n/**\n * Re-read ONE run's source file with the patched reader and classify it.\n *\n * Stops pulling rows once the header has been decided — the guard fires while\n * the header is captured, before any data row is yielded, so the first row\n * settles it.\n *\n * That bounds the rows parsed OF THE TARGET SHEET, and nothing else. The\n * resolver has already downloaded the whole object, and `streamXlsx` parses the\n * worksheets after the target on its way out — it has to, or exceljs strands a\n * temp file and an fd per deferred worksheet. So the per-run cost is one file,\n * plus its trailing sheets; what bounds the SWEEP is the item cap and the\n * concurrency cap in `auditEmptyHeaderRuns`.\n */\nexport async function inspectImportRunSource(\n args: InspectImportRunSourceArgs,\n): Promise<InspectImportRunSourceResult> {\n const { filePath, feed, resolveFilePath } = args\n\n // The defect is exceljs-specific: csv/tsv go through csv-parse, which has no\n // shared-strings table to lose. A run with no persisted `format` defaulted to\n // tab-delimited in the worker, so absence is a real answer, not a gap.\n if (feed.format !== 'xlsx') {\n return {\n verdict: 'undetectable',\n detail: `not an xlsx run (format: ${feed.format ?? 'tsv (default)'}) — the defect is exceljs-specific`,\n }\n }\n // With `hasHeader: false` the reader keys rows positionally and never captures\n // a header, so the guard has nothing to fire on. Say so rather than returning\n // a `clean` that means \"not checked\".\n if (feed.hasHeader === false) {\n return {\n verdict: 'undetectable',\n detail: 'run used hasHeader:false — no header row for the guard to test',\n }\n }\n\n // Dynamic import, matching `read-feed.ts`: exceljs is heavy and Node-only,\n // and this module is reachable from the package's main entry. Bound BEFORE\n // the resolver runs, so a module-resolution failure cannot strand a tmpfile\n // the resolver just downloaded — and outside the try below, because `catch`\n // cannot see a `const` block-scoped to the `try`.\n const { streamXlsx, XlsxEmptyHeaderError, XlsxNotAFileError } = await import('../xlsx-reader.js')\n\n let resolved: { localPath: string; cleanup?: (() => Promise<void>) | undefined }\n try {\n resolved = resolveFilePath ? await resolveFilePath(filePath) : { localPath: filePath }\n } catch (err) {\n // A resolver throw is a storage miss (reaped / deleted / permissions) or a\n // transport failure. `detail` carries which — a 404 and a connection reset\n // read very differently to an operator.\n return { verdict: 'source_unavailable', detail: describeError(err) }\n }\n\n try {\n let sawRow = false\n for await (const _first of streamXlsx({\n filePath: resolved.localPath,\n ...(feed.headerRowIndex !== undefined && { headerRowIndex: feed.headerRowIndex }),\n ...(feed.sheet !== undefined && { sheet: feed.sheet }),\n })) {\n void _first\n sawRow = true\n break\n }\n if (!sawRow) {\n // The reader completed without yielding, which means the guard never ran:\n // an empty worksheet, or a `headerRowIndex` past the last row. Reporting\n // `clean` here would be the comforting-but-false signal this whole audit\n // exists to avoid — nothing was verified.\n return {\n verdict: 'undetectable',\n detail: 'file yielded no data rows — the header guard never ran',\n }\n }\n return { verdict: 'clean' }\n } catch (err) {\n if (err instanceof XlsxEmptyHeaderError) {\n return { verdict: 'corrupt', detail: err.message }\n }\n if (isUnreadableSource(err, XlsxNotAFileError)) {\n return { verdict: 'source_unavailable', detail: describeError(err) }\n }\n return { verdict: 'error', detail: describeError(err) }\n } finally {\n await resolved.cleanup?.().catch(() => {\n // Best-effort tmpfile removal, same posture as the worker: a failed\n // cleanup must not turn a read-only audit into a failure. The path is a\n // /tmp copy the OS reclaims anyway.\n })\n }\n}\n\n/**\n * The counters this audit reads out of `import_run.totals`.\n *\n * A LOSSY view, deliberately: `rowsWarned` / `batchesCompleted` /\n * `patchesApplied` / `patchesOrphaned` are dropped rather than passed through,\n * because a report is easier to read for what it needs than for everything the\n * run recorded. Read the `import_run` row itself if you want the rest.\n */\nexport interface RecordedTotals {\n rowsRead?: number | undefined\n rowsSucceeded?: number | undefined\n rowsFailed?: number | undefined\n rowsSkipped?: number | undefined\n}\n\nfunction readCount(source: Record<string, unknown>, key: string): number | undefined {\n const value = source[key]\n return typeof value === 'number' && Number.isFinite(value) ? value : undefined\n}\n\n/** Narrow the opaque `totals` JSONB to the counters this audit reports. */\nexport function readRecordedTotals(raw: unknown): RecordedTotals {\n if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return {}\n const source = raw as Record<string, unknown>\n return {\n rowsRead: readCount(source, 'rowsRead'),\n rowsSucceeded: readCount(source, 'rowsSucceeded'),\n rowsFailed: readCount(source, 'rowsFailed'),\n rowsSkipped: readCount(source, 'rowsSkipped'),\n }\n}\n\nexport interface AuditedImportRun {\n id: string\n label: string\n /** Verbatim `import_run.filePath` — the storage key, so an operator can go look. */\n filePath: string\n transformName: string\n createdAt: Date | null\n finishedAt: Date | null\n /** Row counters the run recorded. A corrupted run that recorded a plausible count is the most misleading artefact of all. */\n totals: RecordedTotals\n verdict: EmptyHeaderVerdict\n detail?: string\n /**\n * The run finished having read rows but indexed none. Corroborating evidence\n * that survives the source file being reaped, and the ONLY signal available\n * for the transient variant of the defect — but it is also what a wrong\n * column mapping looks like, so it is never folded into the verdict.\n *\n * THREE-VALUED on purpose. `'unknown'` means the run recorded no usable\n * counters (older rows; any run audited with a non-`succeeded` status, since\n * `markFailed` never writes `totals`). Collapsing that to `false` would render\n * absence of evidence as evidence of absence — in the one signal that is\n * supposed to outlive the file.\n */\n zeroRowsIndexed: boolean | 'unknown'\n}\n\nexport interface EmptyHeaderAuditReport {\n /** Total xlsx runs matching the audited statuses. */\n candidates: number\n /** How many of them this invocation actually read. */\n examined: number\n /** `candidates - examined` — dropped by the cap, newest kept. Never silent: also logged. */\n notExamined: number\n counts: Record<EmptyHeaderVerdict, number>\n /** Every examined run, verdict-annotated, newest first. */\n runs: AuditedImportRun[]\n}\n\nexport interface AuditEmptyHeaderRunsArgs {\n /** Toolkit app — provides the app-bound `AdminClient` for `import_run`. */\n app: ToolkitApp\n /**\n * Same resolver the imports plugin hands the worker (typically\n * `storageResolveFilePath`). Omit only when `filePath` is a local FS path.\n */\n resolveFilePath?: FilePathResolver | undefined\n /** Statuses to audit. Default `['succeeded']` — a failed run announced itself. */\n statuses?: readonly ImportRunStatus[] | undefined\n /** Max runs examined, newest first. Default 200, hard ceiling 5000. */\n limit?: number | undefined\n /** Peak concurrent storage reads. Default 4, hard ceiling 16. */\n concurrency?: number | undefined\n logger: Pick<Logger, 'info' | 'warn'>\n}\n\nfunction emptyCounts(): Record<EmptyHeaderVerdict, number> {\n return { corrupt: 0, clean: 0, source_unavailable: 0, undetectable: 0, error: 0 }\n}\n\n/**\n * Derive the corroborating flag from whatever counters the run recorded.\n * `rowsRead` is what the reader yielded; `rowsSucceeded` is what the cluster\n * acknowledged. Read-but-none-indexed is the shape a `{}`-row stream leaves\n * behind however the transform rejected it (error OR skip), which is why the\n * test is on succeeded rather than failed.\n */\nfunction deriveZeroRowsIndexed(totals: RecordedTotals): boolean | 'unknown' {\n if (totals.rowsRead === undefined || totals.rowsSucceeded === undefined) return 'unknown'\n return totals.rowsRead > 0 && totals.rowsSucceeded === 0\n}\n\n/** Coerce a DTO date field (Date | string | null | undefined) to `Date | null`. */\nfunction toDate(value: unknown): Date | null {\n if (value instanceof Date) return value\n if (typeof value === 'string') {\n const parsed = new Date(value)\n return Number.isNaN(parsed.getTime()) ? null : parsed\n }\n return null\n}\n\n/**\n * Audit past import runs for the silent empty-header corruption.\n *\n * Newest-first, because the answerable window is the recent one — the retention\n * sweep reaps source files ~7 days after a run reaches terminal status by\n * default, so the oldest runs are the ones whose evidence is already gone.\n */\nexport async function auditEmptyHeaderRuns(\n args: AuditEmptyHeaderRunsArgs,\n): Promise<EmptyHeaderAuditReport> {\n const { app, resolveFilePath, logger } = args\n const statuses = args.statuses ?? DEFAULT_STATUSES\n if (statuses.length === 0) {\n throw new Error('auditEmptyHeaderRuns: statuses must name at least one import_run status')\n }\n const limit = args.limit ?? DEFAULT_LIMIT\n if (!Number.isInteger(limit) || limit < 1 || limit > MAX_LIMIT) {\n throw new Error(\n `auditEmptyHeaderRuns: limit must be an integer in [1, ${MAX_LIMIT}] (got ${limit})`,\n )\n }\n // Concurrency needs its own ceiling, not just `runBounded`'s\n // positive-integer check: `{ limit: 5000, concurrency: 5000 }` type-checks and\n // is a 5000-wide burst at object storage, which is the amplification the item\n // cap alone does not prevent.\n const concurrency = args.concurrency ?? DEFAULT_BOUNDED_CONCURRENCY\n if (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > MAX_CONCURRENCY) {\n throw new Error(\n `auditEmptyHeaderRuns: concurrency must be an integer in [1, ${MAX_CONCURRENCY}] (got ${concurrency})`,\n )\n }\n\n const client = app.getClient(ImportRun)\n\n const { and, desc, getTableColumns, inArray, sql } = await import('drizzle-orm')\n type AnyPgColumn = import('drizzle-orm/pg-core').AnyPgColumn\n\n // `getTableColumns` rather than dot-access: the entity table is a generic\n // PgTable, so direct access widens every column to `T | undefined` (same\n // shape the cleanup helpers use).\n const cols = getTableColumns(client.getTable()) as Record<string, AnyPgColumn>\n const statusCol = cols.status\n const paramsCol = cols.params\n const createdAtCol = cols.createdAt\n if (!statusCol || !paramsCol || !createdAtCol) {\n throw new Error(\n 'auditEmptyHeaderRuns: import_run schema missing expected columns (status/params/createdAt)',\n )\n }\n\n // Narrow to xlsx runs IN SQL rather than paging every run and filtering in\n // JS — otherwise the `limit` cap would be spent on tsv rows and a deployment\n // with a long carmaker history would examine nothing. `params` is jsonb, and\n // a JSONB operator inside Drizzle's typed builder is the sanctioned use of\n // the `sql` template (CLAUDE.md \"Database Queries\" rule 5): the column ref is\n // a typed Drizzle object and the compared value is bound as a parameter. The\n // key path mirrors the worker's `params.feed` rehydration exactly.\n const where = and(\n inArray(statusCol, [...statuses]),\n sql`${paramsCol}->'feed'->>'format' = ${'xlsx'}`,\n )\n\n const candidates = await client.count({ where })\n const rows = await client.findMany({ where, orderBy: desc(createdAtCol), limit })\n\n const notExamined = Math.max(0, candidates - rows.length)\n if (notExamined > 0) {\n logger.warn(\n { candidates, examined: rows.length, notExamined, limit },\n 'imports audit: capped at the most recent runs — re-run with a higher limit to cover the rest',\n )\n }\n if (rows.length === 0) {\n logger.info({ statuses }, 'imports audit: no xlsx runs to audit')\n return { candidates, examined: 0, notExamined, counts: emptyCounts(), runs: [] }\n }\n\n logger.info(\n { candidates, examining: rows.length, concurrency },\n 'imports audit: re-reading source files for the empty-header defect',\n )\n\n const audited = await runBounded(\n rows,\n async (row): Promise<AuditedImportRun> => {\n // `filePath` / `label` / `transformName` are `required: true`, so the\n // inferred DTO already types them as `string`. `totals` is `field.json()`.\n const filePath = row.filePath\n const totals = readRecordedTotals(row.totals)\n const base = {\n id: row.id,\n label: row.label,\n filePath,\n transformName: row.transformName,\n createdAt: toDate(row.createdAt),\n finishedAt: toDate(row.finishedAt),\n totals,\n zeroRowsIndexed: deriveZeroRowsIndexed(totals),\n }\n\n // EVERY per-item failure degrades THIS run to `error` and no further.\n // `runBounded` rejects on the first worker rejection, so an unhandled\n // throw here would discard all N results — and the report is the entire\n // product of this function.\n try {\n // Rehydrated through the SAME helper the worker uses, so the audit\n // cannot re-read a run with options that run never used.\n const feedParsed = readPersistedFeedConfig(row.params)\n if (!feedParsed.success) {\n const reason = feedParsed.error.issues[0]?.message ?? 'invalid feed reader config'\n return { ...base, verdict: 'error', detail: `invalid params.feed: ${reason}` }\n }\n\n const result = await inspectImportRunSource({\n filePath,\n feed: feedParsed.data,\n ...(resolveFilePath !== undefined && { resolveFilePath }),\n })\n return {\n ...base,\n verdict: result.verdict,\n ...(result.detail !== undefined && { detail: result.detail }),\n }\n } catch (err) {\n return { ...base, verdict: 'error', detail: describeError(err) }\n }\n },\n concurrency,\n )\n\n const counts = emptyCounts()\n for (const run of audited) {\n counts[run.verdict] += 1\n if (run.verdict === 'corrupt') {\n logger.warn(\n {\n importRunId: run.id,\n label: run.label,\n filePath: run.filePath,\n transformName: run.transformName,\n createdAt: run.createdAt,\n totals: run.totals,\n },\n 'imports audit: SILENTLY CORRUPTED run — its source file has no readable header',\n )\n }\n }\n\n const zeroRowsIndexed = audited.filter((run) => run.zeroRowsIndexed === true).length\n const totalsUnknown = audited.filter((run) => run.zeroRowsIndexed === 'unknown').length\n\n // A run of nothing but `source_unavailable` usually means the audit was\n // pointed at storage keys with no resolver, not that a week of evidence\n // evaporated. Those read identically in the report, so say which is likelier.\n if (resolveFilePath === undefined && counts.source_unavailable === audited.length) {\n logger.warn(\n { examined: audited.length },\n 'imports audit: every source was unreadable and no resolveFilePath was given — ' +\n 'if filePath holds storage keys rather than local paths, pass storageResolveFilePath',\n )\n }\n\n logger.info(\n {\n candidates,\n examined: audited.length,\n notExamined,\n ...counts,\n zeroRowsIndexed,\n totalsUnknown,\n },\n 'imports audit: complete',\n )\n\n return { candidates, examined: audited.length, notExamined, counts, runs: audited }\n}\n","/**\n * `previewFeed` — read the first N data rows of a feed for the PR3 import wizard\n * (commerce-demo phase 02 §4). Reuses {@link readFeed} so the preview is parsed by\n * the EXACT same reader (csv-parse / exceljs), with the same delimiter / header /\n * sheet config, that the run will use — the operator maps against what they'll get,\n * not a re-implemented best-guess parser (R02: drift between preview + run is a\n * mapping foot-gun).\n *\n * Bounded: it consumes only `maxRows` rows then stops iterating, so on a delimited\n * feed it streams a tiny prefix rather than the whole file. (The caller materialises\n * the storage object to a local path first — see the commerce preview route — so for\n * a very large upload the materialise cost dominates; that's a demo-depth trade-off,\n * noted on the route.)\n */\n\nimport { type ReadFeedOptions, readFeed } from './read-feed.js'\n\n/** Hard ceiling on preview rows regardless of caller request — bounds work + payload. */\nexport const MAX_PREVIEW_ROWS = 100\n/** Default sample size when the caller doesn't specify. */\nexport const DEFAULT_PREVIEW_ROWS = 20\n\nexport interface PreviewFeedOptions extends ReadFeedOptions {\n /** Rows to sample. Defaults to {@link DEFAULT_PREVIEW_ROWS}; clamped to [1, {@link MAX_PREVIEW_ROWS}]. */\n maxRows?: number\n}\n\nexport interface FeedPreview {\n /**\n * Source column names in first-seen order. With a header these are the (trimmed)\n * header names; headerless feeds yield positional `\"0\"`, `\"1\"`, … keys. The union\n * across sampled rows covers feeds whose first row omits trailing empty cells.\n */\n columns: string[]\n /** Up to `maxRows` sampled data rows, each a `{ column → cell }` map (cells are strings). */\n rows: Array<Record<string, string>>\n}\n\n/**\n * Read up to `maxRows` data rows from a feed file and return the detected columns +\n * sample rows. Throws whatever the underlying reader throws (bad sheet index,\n * duplicate header, unreadable file) — the caller maps that to a 4xx.\n */\nexport async function previewFeed(options: PreviewFeedOptions): Promise<FeedPreview> {\n const { maxRows, ...feedOptions } = options\n // Coerce to a finite integer BEFORE clamping: `previewFeed` is public API, so a\n // caller passing `NaN`/`Infinity`/fractional must not slip through. `NaN` in\n // particular would make `rows.length >= limit` always false → read the whole file.\n const requested = maxRows ?? DEFAULT_PREVIEW_ROWS\n const safeRows = Number.isFinite(requested) ? Math.floor(requested) : DEFAULT_PREVIEW_ROWS\n const limit = Math.min(MAX_PREVIEW_ROWS, Math.max(1, safeRows))\n\n const rows: Array<Record<string, string>> = []\n // First-seen-order column set: a Map preserves insertion order and dedupes.\n const columns = new Map<string, true>()\n\n // Preview is EXPLORATORY — force lenient column counts (csv/tsv). With the wrong\n // delimiter a feed parses as a single odd column, OR a row's field count differs\n // from the header; strict parsing would abort the whole preview with a cryptic\n // \"Invalid Record Length\" instead of letting the operator SEE the bad parse and\n // fix the delimiter/format, then re-preview. The actual run keeps its own\n // (stricter) settings.\n for await (const { row } of readFeed({ ...feedOptions, relaxColumnCount: true })) {\n for (const key of Object.keys(row)) columns.set(key, true)\n rows.push(row)\n if (rows.length >= limit) break\n }\n\n return { columns: [...columns.keys()], rows }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../src/admin/audit-empty-header.ts","../src/preview-feed.ts"],"sourcesContent":["/**\n * Retroactive audit for the silent empty-header corruption fixed in #839\n * (issue #842).\n *\n * **What went wrong.** exceljs assigns `this.sharedStrings = []` at the top of\n * `_parseSharedStrings`, before parsing a single entry — and an empty array is\n * truthy, which is what its inline-worksheet guard tests. A shared-strings\n * entry that yields nothing therefore leaves every string cell resolving to\n * `undefined`: the header parses to zero columns, every data row maps to `{}`,\n * and the import reports SUCCESS with every value dropped. #839 made\n * `streamXlsx` refuse that file. It cannot see backwards, and a past occurrence\n * is indistinguishable from success in the `import_run` row alone — both\n * `status: 'succeeded'`, both no fatal error.\n *\n * **What this does.** Re-reads the retained source file of every past xlsx run\n * with the PATCHED reader and classifies each one. `import_run.filePath` is\n * `required: true`, so every run has a source to point at.\n *\n * **What a verdict is worth — read this before acting on a report.**\n *\n * - `corrupt` has no false positives worth worrying about: the guard fires on\n * precisely the condition that produced the empty parse, so a file that\n * throws today would have thrown then. There is no threshold to tune, unlike\n * inferring corruption from suspiciously-low row counts.\n * - `clean` is NOT proof the run was fine. The upstream defect has two causes:\n * a file that genuinely carries an empty/unparseable shared-strings part\n * (deterministic — caught here), and a truncated or starved read at import\n * time (transient — the same file re-reads perfectly today). The file re-read\n * is blind to the second. That is why every audited run also carries\n * {@link AuditedImportRun.zeroRowsIndexed}, which is derived from the\n * recorded totals and needs no file at all: a run that finished `succeeded`\n * having indexed zero rows is the observable signature of the corruption\n * whichever cause produced it. It is corroborating, not conclusive — an\n * operator who uploaded a feed with the wrong column mapping gets the same\n * shape — so it is reported as a separate flag and never folded into the\n * verdict.\n * - `source_unavailable` is the honest answer for a run whose evidence is gone,\n * and there will be some: `cleanupProcessedImports` deletes the source file\n * of any terminal run older than `processedRetentionDays` (plugin default 7),\n * on an hourly schedule. The retained-file window is therefore about a week\n * on a deployment running the queue worker with the default config — run this\n * audit sooner rather than later, and read `zeroRowsIndexed` for the runs\n * whose files have already been reaped.\n *\n * Read-only by construction: it reports, it never re-imports. Deciding what to\n * do about a corrupted run needs the operator.\n *\n * Bounded per CLAUDE.md's fan-out rule — it is `O(runs)` reads against storage,\n * so it caps both the item count AND the peak concurrency, and logs whatever it\n * drops rather than silently truncating. The per-run cost is one file: the\n * resolver downloads the object, and the reader parses to the header of the\n * target sheet plus whatever follows it. Neither cap substitutes for the other,\n * and neither substitutes for running the audit while the answer is still\n * there.\n */\n\nimport { DEFAULT_BOUNDED_CONCURRENCY, runBounded, type ToolkitApp } from '@murumets-ee/core'\nimport type { Logger } from '@murumets-ee/logging'\nimport { ImportRun, type ImportRunStatus } from '../entities/import-run.js'\nimport { type FeedReaderConfig, readPersistedFeedConfig } from '../read-feed.js'\nimport type { FilePathResolver } from '../worker.js'\n\n/**\n * Per-run outcome of the file re-read.\n *\n * - `corrupt` — the patched reader refuses this file's header. The run imported\n * empty rows and reported success.\n * - `clean` — the header re-reads with at least one column today. See the\n * false-negative caveat in the module docblock.\n * - `source_unavailable` — the source file could not be read at all (reaped by\n * the retention sweep, deleted, or storage refused). Undecidable from the\n * file; `zeroRowsIndexed` is the only signal left.\n * - `undetectable` — the run is outside what this detector can decide: a\n * `hasHeader: false` run has no header for the guard to test, a non-xlsx run\n * cannot have the defect, and a file that yields no data rows never reached\n * the guard at all.\n * - `error` — reading failed for some other reason; `detail` carries it.\n */\nexport const EMPTY_HEADER_VERDICTS = [\n 'corrupt',\n 'clean',\n 'source_unavailable',\n 'undetectable',\n 'error',\n] as const\nexport type EmptyHeaderVerdict = (typeof EMPTY_HEADER_VERDICTS)[number]\n\n/** Statuses audited when the caller doesn't name any. */\nconst DEFAULT_STATUSES: readonly ImportRunStatus[] = ['succeeded']\n\n/** Default cap on runs examined per invocation. */\nconst DEFAULT_LIMIT = 200\n\n/** Hard ceiling on the per-invocation cap. Protects storage from a fat-fingered `--limit`. */\nconst MAX_LIMIT = 5000\n\n/** Hard ceiling on peak concurrent reads. Bounds the burst the item cap alone does not. */\nconst MAX_CONCURRENCY = 16\n\nexport interface InspectImportRunSourceArgs {\n /** Local path OR storage key, verbatim from `import_run.filePath`. */\n filePath: string\n /** The run's persisted reader config (`import_run.params.feed`). */\n feed: FeedReaderConfig\n /**\n * Resolves `filePath` to a readable local path, exactly as the worker does.\n * Omit when `filePath` is already a local FS path (the on-disk deployments\n * the worker's no-resolver fallback covers).\n */\n resolveFilePath?: FilePathResolver | undefined\n}\n\nexport interface InspectImportRunSourceResult {\n verdict: EmptyHeaderVerdict\n /** Human-readable reason. Always set for everything except `clean`. */\n detail?: string\n}\n\n/** Best-effort message for an unknown thrown value. */\nfunction describeError(err: unknown): string {\n if (err instanceof Error) return err.message\n return String(err)\n}\n\n/**\n * Filesystem / object-store errors that mean \"the evidence is not readable\",\n * as distinct from \"the file is readable and its header is bad\".\n *\n * ENOENT is the retention sweep having got there first — the expected outcome\n * for anything older than `processedRetentionDays`. The rest are real\n * deployment conditions rather than theory: the imports directory has had uid\n * drift before (#661's `imports-init` chown), and this audit is run from an\n * operator shell, not under the worker's uid — so EACCES against a file the\n * worker wrote is exactly the shape to expect. All of them are \"cannot decide\",\n * never \"corrupt\".\n */\nconst UNREADABLE_SOURCE_CODES = new Set(['ENOENT', 'EACCES', 'EPERM', 'EISDIR', 'ENOTDIR'])\n\n/**\n * unzipper's error for a zip that ends mid-entry. A half-written retained\n * upload is evidence we cannot read, not a verdict about the run — checked:\n * truncating a workbook at 95% / 60% / 20% all reject cleanly with this rather\n * than crashing or hanging.\n */\nconst TRUNCATED_ARCHIVE_MESSAGE = /FILE_ENDED/\n\nfunction isUnreadableSource(err: unknown, notAFile: new (...args: never[]) => Error): boolean {\n if (typeof err !== 'object' || err === null) return false\n // A directory (or socket, or fifo) where a workbook should be. Matched by\n // TYPE — the reader exports the class precisely so this is not a regex.\n if (err instanceof notAFile) return true\n if ('code' in err) {\n const { code } = err as { code?: unknown }\n if (typeof code === 'string' && UNREADABLE_SOURCE_CODES.has(code)) return true\n }\n return err instanceof Error && TRUNCATED_ARCHIVE_MESSAGE.test(err.message)\n}\n\n/**\n * Re-read ONE run's source file with the patched reader and classify it.\n *\n * Stops pulling rows once the header has been decided — the guard fires while\n * the header is captured, before any data row is yielded, so the first row\n * settles it.\n *\n * That bounds the rows parsed OF THE TARGET SHEET, and nothing else. The\n * resolver has already downloaded the whole object, and `streamXlsx` parses the\n * worksheets after the target on its way out — it has to, or exceljs strands a\n * temp file and an fd per deferred worksheet. So the per-run cost is one file,\n * plus its trailing sheets; what bounds the SWEEP is the item cap and the\n * concurrency cap in `auditEmptyHeaderRuns`.\n */\nexport async function inspectImportRunSource(\n args: InspectImportRunSourceArgs,\n): Promise<InspectImportRunSourceResult> {\n const { filePath, feed, resolveFilePath } = args\n\n // The defect is exceljs-specific: csv/tsv go through csv-parse, which has no\n // shared-strings table to lose. A run with no persisted `format` defaulted to\n // tab-delimited in the worker, so absence is a real answer, not a gap.\n if (feed.format !== 'xlsx') {\n return {\n verdict: 'undetectable',\n detail: `not an xlsx run (format: ${feed.format ?? 'tsv (default)'}) — the defect is exceljs-specific`,\n }\n }\n // With `hasHeader: false` the reader keys rows positionally and never captures\n // a header, so the guard has nothing to fire on. Say so rather than returning\n // a `clean` that means \"not checked\".\n if (feed.hasHeader === false) {\n return {\n verdict: 'undetectable',\n detail: 'run used hasHeader:false — no header row for the guard to test',\n }\n }\n\n // Dynamic import, matching `read-feed.ts`: exceljs is heavy and Node-only,\n // and this module is reachable from the package's main entry. Bound BEFORE\n // the resolver runs, so a module-resolution failure cannot strand a tmpfile\n // the resolver just downloaded — and outside the try below, because `catch`\n // cannot see a `const` block-scoped to the `try`.\n const { streamXlsx, XlsxEmptyHeaderError, XlsxNotAFileError } = await import('../xlsx-reader.js')\n\n let resolved: { localPath: string; cleanup?: (() => Promise<void>) | undefined }\n try {\n resolved = resolveFilePath ? await resolveFilePath(filePath) : { localPath: filePath }\n } catch (err) {\n // A resolver throw is a storage miss (reaped / deleted / permissions) or a\n // transport failure. `detail` carries which — a 404 and a connection reset\n // read very differently to an operator.\n return { verdict: 'source_unavailable', detail: describeError(err) }\n }\n\n try {\n let sawRow = false\n for await (const _first of streamXlsx({\n filePath: resolved.localPath,\n ...(feed.headerRowIndex !== undefined && { headerRowIndex: feed.headerRowIndex }),\n ...(feed.sheet !== undefined && { sheet: feed.sheet }),\n })) {\n void _first\n sawRow = true\n break\n }\n if (!sawRow) {\n // The reader completed without yielding, which means the guard never ran:\n // an empty worksheet, or a `headerRowIndex` past the last row. Reporting\n // `clean` here would be the comforting-but-false signal this whole audit\n // exists to avoid — nothing was verified.\n return {\n verdict: 'undetectable',\n detail: 'file yielded no data rows — the header guard never ran',\n }\n }\n return { verdict: 'clean' }\n } catch (err) {\n if (err instanceof XlsxEmptyHeaderError) {\n return { verdict: 'corrupt', detail: err.message }\n }\n if (isUnreadableSource(err, XlsxNotAFileError)) {\n return { verdict: 'source_unavailable', detail: describeError(err) }\n }\n return { verdict: 'error', detail: describeError(err) }\n } finally {\n await resolved.cleanup?.().catch(() => {\n // Best-effort tmpfile removal, same posture as the worker: a failed\n // cleanup must not turn a read-only audit into a failure. The path is a\n // /tmp copy the OS reclaims anyway.\n })\n }\n}\n\n/**\n * The counters this audit reads out of `import_run.totals`.\n *\n * A LOSSY view, deliberately: `rowsWarned` / `batchesCompleted` /\n * `patchesApplied` / `patchesOrphaned` are dropped rather than passed through,\n * because a report is easier to read for what it needs than for everything the\n * run recorded. Read the `import_run` row itself if you want the rest.\n */\nexport interface RecordedTotals {\n rowsRead?: number | undefined\n rowsSucceeded?: number | undefined\n rowsFailed?: number | undefined\n rowsSkipped?: number | undefined\n}\n\nfunction readCount(source: Record<string, unknown>, key: string): number | undefined {\n const value = source[key]\n return typeof value === 'number' && Number.isFinite(value) ? value : undefined\n}\n\n/** Narrow the opaque `totals` JSONB to the counters this audit reports. */\nexport function readRecordedTotals(raw: unknown): RecordedTotals {\n if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return {}\n const source = raw as Record<string, unknown>\n return {\n rowsRead: readCount(source, 'rowsRead'),\n rowsSucceeded: readCount(source, 'rowsSucceeded'),\n rowsFailed: readCount(source, 'rowsFailed'),\n rowsSkipped: readCount(source, 'rowsSkipped'),\n }\n}\n\nexport interface AuditedImportRun {\n id: string\n label: string\n /** Verbatim `import_run.filePath` — the storage key, so an operator can go look. */\n filePath: string\n transformName: string\n createdAt: Date | null\n finishedAt: Date | null\n /** Row counters the run recorded. A corrupted run that recorded a plausible count is the most misleading artefact of all. */\n totals: RecordedTotals\n verdict: EmptyHeaderVerdict\n detail?: string\n /**\n * The run finished having read rows but indexed none. Corroborating evidence\n * that survives the source file being reaped, and the ONLY signal available\n * for the transient variant of the defect — but it is also what a wrong\n * column mapping looks like, so it is never folded into the verdict.\n *\n * THREE-VALUED on purpose. `'unknown'` means the run recorded no usable\n * counters (older rows; any run audited with a non-`succeeded` status, since\n * `markFailed` never writes `totals`). Collapsing that to `false` would render\n * absence of evidence as evidence of absence — in the one signal that is\n * supposed to outlive the file.\n */\n zeroRowsIndexed: boolean | 'unknown'\n}\n\nexport interface EmptyHeaderAuditReport {\n /** Total xlsx runs matching the audited statuses. */\n candidates: number\n /** How many of them this invocation actually read. */\n examined: number\n /** `candidates - examined` — dropped by the cap, newest kept. Never silent: also logged. */\n notExamined: number\n counts: Record<EmptyHeaderVerdict, number>\n /** Every examined run, verdict-annotated, newest first. */\n runs: AuditedImportRun[]\n}\n\nexport interface AuditEmptyHeaderRunsArgs {\n /** Toolkit app — provides the app-bound `AdminClient` for `import_run`. */\n app: ToolkitApp\n /**\n * Same resolver the imports plugin hands the worker (typically\n * `storageResolveFilePath`). Omit only when `filePath` is a local FS path.\n */\n resolveFilePath?: FilePathResolver | undefined\n /** Statuses to audit. Default `['succeeded']` — a failed run announced itself. */\n statuses?: readonly ImportRunStatus[] | undefined\n /** Max runs examined, newest first. Default 200, hard ceiling 5000. */\n limit?: number | undefined\n /** Peak concurrent storage reads. Default 4, hard ceiling 16. */\n concurrency?: number | undefined\n logger: Pick<Logger, 'info' | 'warn'>\n}\n\nfunction emptyCounts(): Record<EmptyHeaderVerdict, number> {\n return { corrupt: 0, clean: 0, source_unavailable: 0, undetectable: 0, error: 0 }\n}\n\n/**\n * Derive the corroborating flag from whatever counters the run recorded.\n * `rowsRead` is what the reader yielded; `rowsSucceeded` is what the cluster\n * acknowledged. Read-but-none-indexed is the shape a `{}`-row stream leaves\n * behind however the transform rejected it (error OR skip), which is why the\n * test is on succeeded rather than failed.\n */\nfunction deriveZeroRowsIndexed(totals: RecordedTotals): boolean | 'unknown' {\n if (totals.rowsRead === undefined || totals.rowsSucceeded === undefined) return 'unknown'\n return totals.rowsRead > 0 && totals.rowsSucceeded === 0\n}\n\n/** Coerce a DTO date field (Date | string | null | undefined) to `Date | null`. */\nfunction toDate(value: unknown): Date | null {\n if (value instanceof Date) return value\n if (typeof value === 'string') {\n const parsed = new Date(value)\n return Number.isNaN(parsed.getTime()) ? null : parsed\n }\n return null\n}\n\n/**\n * Audit past import runs for the silent empty-header corruption.\n *\n * Newest-first, because the answerable window is the recent one — the retention\n * sweep reaps source files ~7 days after a run reaches terminal status by\n * default, so the oldest runs are the ones whose evidence is already gone.\n */\nexport async function auditEmptyHeaderRuns(\n args: AuditEmptyHeaderRunsArgs,\n): Promise<EmptyHeaderAuditReport> {\n const { app, resolveFilePath, logger } = args\n const statuses = args.statuses ?? DEFAULT_STATUSES\n if (statuses.length === 0) {\n throw new Error('auditEmptyHeaderRuns: statuses must name at least one import_run status')\n }\n const limit = args.limit ?? DEFAULT_LIMIT\n if (!Number.isInteger(limit) || limit < 1 || limit > MAX_LIMIT) {\n throw new Error(\n `auditEmptyHeaderRuns: limit must be an integer in [1, ${MAX_LIMIT}] (got ${limit})`,\n )\n }\n // Concurrency needs its own ceiling, not just `runBounded`'s\n // positive-integer check: `{ limit: 5000, concurrency: 5000 }` type-checks and\n // is a 5000-wide burst at object storage, which is the amplification the item\n // cap alone does not prevent.\n const concurrency = args.concurrency ?? DEFAULT_BOUNDED_CONCURRENCY\n if (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > MAX_CONCURRENCY) {\n throw new Error(\n `auditEmptyHeaderRuns: concurrency must be an integer in [1, ${MAX_CONCURRENCY}] (got ${concurrency})`,\n )\n }\n\n const client = app.getClient(ImportRun)\n\n const { and, desc, getTableColumns, inArray, sql } = await import('drizzle-orm')\n type AnyPgColumn = import('drizzle-orm/pg-core').AnyPgColumn\n\n // `getTableColumns` rather than dot-access: the entity table is a generic\n // PgTable, so direct access widens every column to `T | undefined` (same\n // shape the cleanup helpers use).\n const cols = getTableColumns(client.getTable()) as Record<string, AnyPgColumn>\n const statusCol = cols.status\n const paramsCol = cols.params\n const createdAtCol = cols.createdAt\n if (!statusCol || !paramsCol || !createdAtCol) {\n throw new Error(\n 'auditEmptyHeaderRuns: import_run schema missing expected columns (status/params/createdAt)',\n )\n }\n\n // Narrow to xlsx runs IN SQL rather than paging every run and filtering in\n // JS — otherwise the `limit` cap would be spent on tsv rows and a deployment\n // with a long carmaker history would examine nothing. `params` is jsonb, and\n // a JSONB operator inside Drizzle's typed builder is the sanctioned use of\n // the `sql` template (CLAUDE.md \"Database Queries\" rule 5): the column ref is\n // a typed Drizzle object and the compared value is bound as a parameter. The\n // key path mirrors the worker's `params.feed` rehydration exactly.\n const where = and(\n inArray(statusCol, [...statuses]),\n sql`${paramsCol}->'feed'->>'format' = ${'xlsx'}`,\n )\n\n const candidates = await client.count({ where })\n const rows = await client.findMany({ where, orderBy: desc(createdAtCol), limit })\n\n const notExamined = Math.max(0, candidates - rows.length)\n if (notExamined > 0) {\n logger.warn(\n { candidates, examined: rows.length, notExamined, limit },\n 'imports audit: capped at the most recent runs — re-run with a higher limit to cover the rest',\n )\n }\n if (rows.length === 0) {\n logger.info({ statuses }, 'imports audit: no xlsx runs to audit')\n return { candidates, examined: 0, notExamined, counts: emptyCounts(), runs: [] }\n }\n\n logger.info(\n { candidates, examining: rows.length, concurrency },\n 'imports audit: re-reading source files for the empty-header defect',\n )\n\n const audited = await runBounded(\n rows,\n async (row): Promise<AuditedImportRun> => {\n // `filePath` / `label` / `transformName` are `required: true`, so the\n // inferred DTO already types them as `string`. `totals` is `field.json()`.\n const filePath = row.filePath\n const totals = readRecordedTotals(row.totals)\n const base = {\n id: row.id,\n label: row.label,\n filePath,\n transformName: row.transformName,\n createdAt: toDate(row.createdAt),\n finishedAt: toDate(row.finishedAt),\n totals,\n zeroRowsIndexed: deriveZeroRowsIndexed(totals),\n }\n\n // EVERY per-item failure degrades THIS run to `error` and no further.\n // `runBounded` rejects on the first worker rejection, so an unhandled\n // throw here would discard all N results — and the report is the entire\n // product of this function.\n try {\n // Rehydrated through the SAME helper the worker uses, so the audit\n // cannot re-read a run with options that run never used.\n const feedParsed = readPersistedFeedConfig(row.params)\n if (!feedParsed.success) {\n const reason = feedParsed.error.issues[0]?.message ?? 'invalid feed reader config'\n return { ...base, verdict: 'error', detail: `invalid params.feed: ${reason}` }\n }\n\n const result = await inspectImportRunSource({\n filePath,\n feed: feedParsed.data,\n ...(resolveFilePath !== undefined && { resolveFilePath }),\n })\n return {\n ...base,\n verdict: result.verdict,\n ...(result.detail !== undefined && { detail: result.detail }),\n }\n } catch (err) {\n return { ...base, verdict: 'error', detail: describeError(err) }\n }\n },\n concurrency,\n )\n\n const counts = emptyCounts()\n for (const run of audited) {\n counts[run.verdict] += 1\n if (run.verdict === 'corrupt') {\n logger.warn(\n {\n importRunId: run.id,\n label: run.label,\n filePath: run.filePath,\n transformName: run.transformName,\n createdAt: run.createdAt,\n totals: run.totals,\n },\n 'imports audit: SILENTLY CORRUPTED run — its source file has no readable header',\n )\n }\n }\n\n const zeroRowsIndexed = audited.filter((run) => run.zeroRowsIndexed === true).length\n const totalsUnknown = audited.filter((run) => run.zeroRowsIndexed === 'unknown').length\n\n // A run of nothing but `source_unavailable` usually means the audit was\n // pointed at storage keys with no resolver, not that a week of evidence\n // evaporated. Those read identically in the report, so say which is likelier.\n if (resolveFilePath === undefined && counts.source_unavailable === audited.length) {\n logger.warn(\n { examined: audited.length },\n 'imports audit: every source was unreadable and no resolveFilePath was given — ' +\n 'if filePath holds storage keys rather than local paths, pass storageResolveFilePath',\n )\n }\n\n logger.info(\n {\n candidates,\n examined: audited.length,\n notExamined,\n ...counts,\n zeroRowsIndexed,\n totalsUnknown,\n },\n 'imports audit: complete',\n )\n\n return { candidates, examined: audited.length, notExamined, counts, runs: audited }\n}\n","/**\n * `previewFeed` — read the first N data rows of a feed for the PR3 import wizard\n * (commerce-demo phase 02 §4). Reuses {@link readFeed} so the preview is parsed by\n * the EXACT same reader (csv-parse / exceljs), with the same delimiter / header /\n * sheet config, that the run will use — the operator maps against what they'll get,\n * not a re-implemented best-guess parser (R02: drift between preview + run is a\n * mapping foot-gun).\n *\n * Bounded: it consumes only `maxRows` rows then stops iterating, so on a delimited\n * feed it streams a tiny prefix rather than the whole file. (The caller materialises\n * the storage object to a local path first — see the commerce preview route — so for\n * a very large upload the materialise cost dominates; that's a demo-depth trade-off,\n * noted on the route.)\n */\n\nimport { type ReadFeedOptions, readFeed } from './read-feed.js'\n\n/** Hard ceiling on preview rows regardless of caller request — bounds work + payload. */\nexport const MAX_PREVIEW_ROWS = 100\n/** Default sample size when the caller doesn't specify. */\nexport const DEFAULT_PREVIEW_ROWS = 20\n\nexport interface PreviewFeedOptions extends ReadFeedOptions {\n /** Rows to sample. Defaults to {@link DEFAULT_PREVIEW_ROWS}; clamped to [1, {@link MAX_PREVIEW_ROWS}]. */\n maxRows?: number\n}\n\nexport interface FeedPreview {\n /**\n * Source column names in first-seen order. With a header these are the (trimmed)\n * header names; headerless feeds yield positional `\"0\"`, `\"1\"`, … keys. The union\n * across sampled rows covers feeds whose first row omits trailing empty cells.\n */\n columns: string[]\n /** Up to `maxRows` sampled data rows, each a `{ column → cell }` map (cells are strings). */\n rows: Array<Record<string, string>>\n}\n\n/**\n * Read up to `maxRows` data rows from a feed file and return the detected columns +\n * sample rows. Throws whatever the underlying reader throws (bad sheet index,\n * duplicate header, unreadable file) — the caller maps that to a 4xx.\n */\nexport async function previewFeed(options: PreviewFeedOptions): Promise<FeedPreview> {\n const { maxRows, ...feedOptions } = options\n // Coerce to a finite integer BEFORE clamping: `previewFeed` is public API, so a\n // caller passing `NaN`/`Infinity`/fractional must not slip through. `NaN` in\n // particular would make `rows.length >= limit` always false → read the whole file.\n const requested = maxRows ?? DEFAULT_PREVIEW_ROWS\n const safeRows = Number.isFinite(requested) ? Math.floor(requested) : DEFAULT_PREVIEW_ROWS\n const limit = Math.min(MAX_PREVIEW_ROWS, Math.max(1, safeRows))\n\n const rows: Array<Record<string, string>> = []\n // First-seen-order column set: a Map preserves insertion order and dedupes.\n const columns = new Map<string, true>()\n\n // Preview is EXPLORATORY — force lenient column counts (csv/tsv). With the wrong\n // delimiter a feed parses as a single odd column, OR a row's field count differs\n // from the header; strict parsing would abort the whole preview with a cryptic\n // \"Invalid Record Length\" instead of letting the operator SEE the bad parse and\n // fix the delimiter/format, then re-preview. The actual run keeps its own\n // (stricter) settings.\n for await (const { row } of readFeed({ ...feedOptions, relaxColumnCount: true })) {\n for (const key of Object.keys(row)) columns.set(key, true)\n rows.push(row)\n if (rows.length >= limit) break\n }\n\n return { columns: [...columns.keys()], rows }\n}\n"],"mappings":"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"}
|