@murumets-ee/imports 0.18.0 → 0.20.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/cleanup-C069GTkO.mjs +2 -0
- package/dist/cleanup-C069GTkO.mjs.map +1 -0
- package/dist/import-run-CviKL4jq.mjs +2 -0
- package/dist/import-run-CviKL4jq.mjs.map +1 -0
- package/dist/index.d.mts +86 -3
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +1 -1
- package/dist/plugin.d.mts +48 -3
- package/dist/plugin.d.mts.map +1 -1
- package/dist/plugin.mjs +1 -1
- package/dist/plugin.mjs.map +1 -1
- package/dist/{publish-Dw2vnCoo.mjs → publish-D6PH6nu4.mjs} +1 -1
- package/dist/{publish-Dw2vnCoo.mjs.map → publish-D6PH6nu4.mjs.map} +1 -1
- package/dist/realtime/index.mjs +1 -1
- package/dist/rolldown-runtime-DR3Ue2cl.mjs +1 -0
- package/dist/{runner-D9FtnIBn.mjs → runner-D5to1Bt6.mjs} +1 -1
- package/dist/{runner-D9FtnIBn.mjs.map → runner-D5to1Bt6.mjs.map} +1 -1
- package/dist/storage-resolver.d.mts +1 -1
- package/dist/transform-B1KpU3lo.mjs +2 -0
- package/dist/transform-B1KpU3lo.mjs.map +1 -0
- package/dist/{transform-CqCKV88O.d.mts → transform-BkALrFhI.d.mts} +4 -3
- package/dist/{transform-CqCKV88O.d.mts.map → transform-BkALrFhI.d.mts.map} +1 -1
- package/dist/transforms.d.mts +1 -1
- package/dist/{worker-B7ADOFEV.d.mts → worker-DUUtPREz.d.mts} +16 -17
- package/dist/worker-DUUtPREz.d.mts.map +1 -0
- package/dist/worker.d.mts +1 -1
- package/dist/worker.mjs +1 -1
- package/package.json +10 -9
- package/dist/transform-CSTDgiGK.mjs +0 -2
- package/dist/transform-CSTDgiGK.mjs.map +0 -1
- package/dist/worker-B7ADOFEV.d.mts.map +0 -1
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import{t as e}from"./rolldown-runtime-DR3Ue2cl.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(`./import-run-CviKL4jq.mjs`).then(e=>e.r),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(`./import-run-CviKL4jq.mjs`).then(e=>e.r),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-C069GTkO.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cleanup-C069GTkO.mjs","names":[],"sources":["../src/admin/cleanup.ts"],"sourcesContent":["/**\n * Cleanup orphaned imports uploads.\n *\n * Finds rows in `toolkit_files` within the configured imports bucket\n * that:\n * - were created more than `ttlHours` ago, AND\n * - have no matching `import_run.filePath` reference\n *\n * For each match, calls `StorageClient.delete(key)` — which removes\n * BOTH the adapter bytes (local file / R2 object) AND the\n * `toolkit_files` metadata row in one transactional unit (see\n * `@murumets-ee/storage` `StorageClient.delete`).\n *\n * Why this exists: the Tus upload flow stores bytes + metadata BEFORE\n * the client's downstream `register` step inserts an `import_run` row.\n * If the register call fails (validation, MIME mismatch, network\n * blip, agent abandons the workflow), the storage row is left\n * dangling. The commerce-side register handler does opportunistic\n * cleanup on its OWN failure paths, but rejections from the Zod\n * schema (before the handler runs) and rejections where the agent\n * never calls register at all both leak.\n *\n * Scheduling: driven by the `defineJob({ schedule: '@every 1h' })`\n * declaration in `plugin.ts`. The queue worker bootstraps a\n * `toolkit_scheduled_jobs` row for this job at startup and its cron\n * loop (`processDueSchedules`) fires the registered handler on each\n * tick. The handler does NOT re-enqueue itself — the schedule is\n * driven by the worker, not the handler.\n */\n\nimport type { ToolkitApp } from '@murumets-ee/core'\nimport type { Logger } from '@murumets-ee/logging'\nimport { getImportsStorage } from '../storage.js'\n\nexport interface CleanupOrphanedImportsArgs {\n /** Toolkit app (provides db). */\n app: ToolkitApp\n /**\n * Bucket name the imports flow uses. Rows in `toolkit_files` are\n * filtered to this bucket so the cleanup doesn't touch media,\n * ticketing attachments, or any other consumer's uploads even if\n * they share the same Postgres instance.\n */\n bucket: string\n /**\n * Hours after which an unreferenced upload is considered orphaned.\n * Operator-grace window: should be long enough that a normal\n * upload-then-register round-trip never wipes a fresh upload, but\n * short enough that storage waste from failed uploads doesn't\n * accumulate. 2 hours is the default in the imports plugin\n * config — calibrated against the typical \"operator clicks\n * upload, walks away, comes back to check\" workflow.\n */\n ttlHours: number\n /**\n * Maximum rows to process per run. Bounded so a sudden flood of\n * orphans (e.g. after a brand-new supplier with unrecognised\n * filenames floods the queue with rejected uploads) doesn't\n * block the queue worker on a single multi-hour cleanup.\n */\n limit?: number\n /** Logger for diagnostics. */\n logger: Pick<Logger, 'debug' | 'info' | 'warn'>\n}\n\n/** Default per-run cap. Tuned against ticketing's `cleanupOrphanedUploads` (also 100). */\nconst DEFAULT_LIMIT = 100\n\n/** Hard cap on per-run limit. Protects against pathological caller input. */\nconst MAX_LIMIT = 1000\n\n/**\n * Validate the numeric inputs every cleanup helper accepts. Returns\n * the sanitised values OR throws — the queue worker logs thrown\n * errors against the scheduled-job row so a misconfigured TTL\n * doesn't silently delete every bucket file (which would happen if\n * a negative `ttlHours` reached the date arithmetic and pushed the\n * cutoff into the future).\n */\nfunction validateCleanupArgs(\n raw: { ttlOrRetention: number; ttlLabel: string; limit?: number },\n): { ttlOrRetention: number; limit: number } {\n const { ttlOrRetention, ttlLabel, limit = DEFAULT_LIMIT } = raw\n if (!Number.isFinite(ttlOrRetention) || ttlOrRetention < 0) {\n throw new Error(\n `imports cleanup: ${ttlLabel} must be a finite, non-negative number (got ${ttlOrRetention})`,\n )\n }\n if (!Number.isFinite(limit) || !Number.isInteger(limit) || limit < 1 || limit > MAX_LIMIT) {\n throw new Error(\n `imports cleanup: limit must be an integer in [1, ${MAX_LIMIT}] (got ${limit})`,\n )\n }\n return { ttlOrRetention, limit }\n}\n\n/**\n * Run one cleanup pass. Returns the count of rows actually deleted\n * (which may be less than the count of orphans found if some\n * `storage.delete` calls failed — see the `warn` log inside the loop).\n */\nexport async function cleanupOrphanedImports(args: CleanupOrphanedImportsArgs): Promise<number> {\n const { app, bucket, logger } = args\n const db = app.db.readWrite\n const { ttlOrRetention: ttlHours, limit } = validateCleanupArgs({\n ttlOrRetention: args.ttlHours,\n ttlLabel: 'ttlHours',\n // `exactOptionalPropertyTypes` rejects an explicit `undefined`\n // for the optional `limit?: number` slot. Spread the field only\n // when defined so the validator's default-via-destructure kicks in.\n ...(args.limit !== undefined && { limit: args.limit }),\n })\n\n // Cross-table query: `toolkit_files` LEFT JOIN `import_run` — same\n // documented exception for cross-entity JOINs that ticketing's\n // cleanup uses. Two-table joins can't go through a single-entity\n // AdminClient (per CLAUDE.md \"Database Queries\").\n const { and, asc, eq, isNull, lt, getTableColumns } = await import('drizzle-orm')\n const { toolkitFiles } = await import('@murumets-ee/storage')\n const { createAdminClient } = await import('@murumets-ee/core/clients')\n const { ImportRun } = await import('../entities/import-run.js')\n type AnyPgColumn = import('drizzle-orm/pg-core').AnyPgColumn\n\n const importRunsTable = createAdminClient(ImportRun).getTable()\n\n // Both `toolkitFiles` and the `importRunsTable` are exported as\n // generic PgTables so direct dot-access widens to `T | undefined`.\n // Pull the column refs through `getTableColumns` (same shape as\n // ticketing's cleanup + the entity-version helper).\n const fileCols = getTableColumns(toolkitFiles) as Record<string, AnyPgColumn>\n const fileKey = fileCols.key\n const fileBucket = fileCols.bucket\n const fileCreatedAt = fileCols.createdAt\n if (!fileKey || !fileBucket || !fileCreatedAt) {\n throw new Error(\n 'cleanupOrphanedImports: toolkit_files schema missing expected columns (key/bucket/createdAt)',\n )\n }\n const importRunCols = getTableColumns(importRunsTable) as Record<string, AnyPgColumn>\n const irFilePath = importRunCols.filePath\n const irId = importRunCols.id\n if (!irFilePath || !irId) {\n throw new Error(\n 'cleanupOrphanedImports: import_run schema missing expected columns (filePath/id)',\n )\n }\n\n const cutoff = new Date(Date.now() - ttlHours * 60 * 60 * 1000)\n\n // Find toolkit_files rows in the imports bucket, older than cutoff,\n // where the storage key is NOT referenced by any import_run row.\n // ORDER BY createdAt ASC delivers strict FIFO so a sustained\n // orphan stream makes deterministic progress on the oldest rows\n // first — without the orderBy, Postgres returns rows in heap\n // order and a LIMIT 100 sweep could keep recycling the same\n // young rows while ancient orphans never get touched.\n const orphans = await db\n .select({ key: fileKey })\n .from(toolkitFiles)\n .leftJoin(importRunsTable, eq(irFilePath, fileKey))\n .where(and(eq(fileBucket, bucket), lt(fileCreatedAt, cutoff), isNull(irId)))\n .orderBy(asc(fileCreatedAt))\n .limit(limit)\n\n if (orphans.length === 0) {\n logger.debug({ bucket, ttlHours }, 'No orphaned imports uploads found')\n return 0\n }\n\n const storage = await getImportsStorage()\n let cleaned = 0\n for (const row of orphans) {\n const key = row.key as unknown as string\n try {\n await storage.delete(key)\n cleaned++\n } catch (err) {\n logger.warn({ key, err }, 'Failed to delete orphaned imports upload')\n }\n }\n\n logger.info(\n { cleaned, total: orphans.length, bucket, ttlHours },\n 'Cleaned up orphaned imports uploads',\n )\n return cleaned\n}\n\n/** Statuses considered \"done\" for the post-success/failure cleanup. */\nconst TERMINAL_IMPORT_RUN_STATUSES = ['succeeded', 'failed', 'cancelled'] as const\n\nexport interface CleanupProcessedImportsArgs {\n /** Toolkit app (provides db). */\n app: ToolkitApp\n /** Bucket name to scope the cleanup. Same semantics as `cleanupOrphanedImports`. */\n bucket: string\n /**\n * Days after which a terminal-status `import_run`'s source file is\n * deleted. Operator-grace window: long enough that an operator can\n * inspect the source file if a transform bug surfaces shortly after\n * a successful import; short enough that storage doesn't accumulate\n * months of supplier feeds.\n *\n * The plugin default is 7. Set to 0 to delete the source file\n * immediately at the next cleanup tick after the import row\n * reaches terminal status — useful when storage cost matters more\n * than forensic reprocess capability.\n */\n retentionDays: number\n /** Max rows per run. Defaults to 100 (same cap as the orphan sweep). */\n limit?: number\n logger: Pick<Logger, 'debug' | 'info' | 'warn'>\n}\n\n/**\n * Cleanup source files for `import_run`s that finished (succeeded /\n * failed / cancelled) more than `retentionDays` days ago.\n *\n * Counterpart to `cleanupOrphanedImports`: that one targets uploads\n * with NO matching `import_run` (handshake never completed); this one\n * targets uploads whose `import_run` row IS present and terminal.\n * Together they cover every \"useless storage row\" case the imports\n * flow can produce.\n *\n * Deletes ONLY the storage row (via `StorageClient.delete`); the\n * `import_run` row is kept so the History panel + audit trail still\n * surface the past run. The `import_run.filePath` value points at a\n * now-deleted key after this runs — consumers that try to re-download\n * the source file (e.g. a future \"reprocess\" button) will get a\n * `getMetadata` → null and should show \"source file unavailable\" in\n * the UI rather than crashing.\n */\nexport async function cleanupProcessedImports(args: CleanupProcessedImportsArgs): Promise<number> {\n const { app, bucket, logger } = args\n const db = app.db.readWrite\n const { ttlOrRetention: retentionDays, limit } = validateCleanupArgs({\n ttlOrRetention: args.retentionDays,\n ttlLabel: 'retentionDays',\n // `exactOptionalPropertyTypes` rejects an explicit `undefined`\n // for the optional `limit?: number` slot. Spread the field only\n // when defined so the validator's default-via-destructure kicks in.\n ...(args.limit !== undefined && { limit: args.limit }),\n })\n\n const { and, asc, eq, getTableColumns, inArray, lt } = await import('drizzle-orm')\n const { toolkitFiles } = await import('@murumets-ee/storage')\n const { createAdminClient } = await import('@murumets-ee/core/clients')\n const { ImportRun } = await import('../entities/import-run.js')\n type AnyPgColumn = import('drizzle-orm/pg-core').AnyPgColumn\n\n const importRunsTable = createAdminClient(ImportRun).getTable()\n\n const fileCols = getTableColumns(toolkitFiles) as Record<string, AnyPgColumn>\n const fileKey = fileCols.key\n const fileBucket = fileCols.bucket\n if (!fileKey || !fileBucket) {\n throw new Error(\n 'cleanupProcessedImports: toolkit_files schema missing expected columns (key/bucket)',\n )\n }\n const importRunCols = getTableColumns(importRunsTable) as Record<string, AnyPgColumn>\n const irFilePath = importRunCols.filePath\n const irStatus = importRunCols.status\n const irFinishedAt = importRunCols.finishedAt\n if (!irFilePath || !irStatus || !irFinishedAt) {\n throw new Error(\n 'cleanupProcessedImports: import_run schema missing expected columns (filePath/status/finishedAt)',\n )\n }\n\n const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000)\n\n // INNER JOIN so we only consider keys that DO have a matching\n // import_run row — the inverse of the orphan-cleanup query. Filter\n // on terminal status + finishedAt cutoff so a long-running batch\n // doesn't get its still-being-processed file ripped out from under\n // the worker.\n //\n // `selectDistinctOn([fileKey])` is the right primitive here:\n //\n // (a) **Dedupe before LIMIT.** `toolkit_files.key` is UNIQUE\n // within its own table, but `import_run.filePath` has no\n // unique constraint — a future re-run / retry flow that\n // creates two terminal `import_run` rows pointing at the\n // same `storageKey` would make the INNER JOIN project the\n // key twice. Without dedupe in SQL, a pathological cluster\n // of 100 duplicates sharing 1 storageKey would burn the\n // LIMIT cap to clean 1 file.\n //\n // (b) **Deterministic ordering** (NOT strict global FIFO).\n // `DISTINCT ON` requires the ORDER BY to start with the\n // distinct columns (Postgres rule). So we sort by `fileKey`\n // first, then `irFinishedAt` within each key group. The\n // LIMIT then picks the first N keys in `fileKey` order —\n // i.e. alphabetical by storage path, not globally oldest by\n // finishedAt. In practice this is roughly chronological\n // anyway: storage keys are `uploads/YYYY/MM/<uuid>/...` so\n // earlier months sort first. Within a month, ordering is\n // UUID-random — but every key eventually gets cleaned\n // (no starvation), so over ~10 ticks the entire backlog\n // drains either way. A subquery-wrapped \"true global FIFO\"\n // (DISTINCT ON inner, ORDER BY finishedAt outer) is\n // possible but adds complexity for marginal benefit; the\n // practical-FIFO-via-key-prefix property is acceptable.\n const candidates = await db\n .selectDistinctOn([fileKey], { key: fileKey })\n .from(toolkitFiles)\n .innerJoin(importRunsTable, eq(irFilePath, fileKey))\n .where(\n and(\n eq(fileBucket, bucket),\n inArray(irStatus, [...TERMINAL_IMPORT_RUN_STATUSES]),\n lt(irFinishedAt, cutoff),\n ),\n )\n .orderBy(asc(fileKey), asc(irFinishedAt))\n .limit(limit)\n\n if (candidates.length === 0) {\n logger.debug({ bucket, retentionDays }, 'No processed-imports uploads ready for cleanup')\n return 0\n }\n\n const storage = await getImportsStorage()\n let cleaned = 0\n for (const row of candidates) {\n const key = row.key as unknown as string\n try {\n await storage.delete(key)\n cleaned++\n } catch (err) {\n logger.warn({ key, err }, 'Failed to delete processed-imports upload')\n }\n }\n\n logger.info(\n { cleaned, total: candidates.length, bucket, retentionDays },\n 'Cleaned up processed imports uploads',\n )\n return cleaned\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import{t as e}from"./rolldown-runtime-DR3Ue2cl.mjs";import{behavior as t,defineEntity as n,field as r,searchable as i}from"@murumets-ee/entity";var a=e({IMPORT_RUN_STATUSES:()=>o,ImportRun:()=>s});const o=[`pending`,`running`,`succeeded`,`failed`,`cancelled`],s=n({name:`import_run`,fields:{label:r.text({required:!0,maxLength:255}),status:r.select({options:o,default:`pending`,indexed:!0}),filePath:r.text({required:!0,maxLength:2048}),transformName:r.text({required:!0,maxLength:128,indexed:!0}),params:r.json(),totals:r.json(),errorSummary:r.json(),startedAt:r.date(),finishedAt:r.date(),queueJobId:r.text({maxLength:64,indexed:!0})},behaviors:[t.auditable(),i({fields:[`label`,`transformName`,`queueJobId`,`status`,`filePath`],projection:e=>({id:e.id,label:e.label,description:`${e.transformName} · ${e.status}`})})],scope:`global`,admin:{group:`imports`,label:`Import runs`,labelSingular:`Import run`,icon:`upload`,hideFromMenu:!0}});export{s as n,a as r,o as t};
|
|
2
|
+
//# sourceMappingURL=import-run-CviKL4jq.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"import-run-CviKL4jq.mjs","names":[],"sources":["../src/entities/import-run.ts"],"sourcesContent":["/**\n * ImportRun — one feed-import attempt. Tracks file, transform, status,\n * row totals, and the top error patterns surfaced by `ErrorTracker`.\n *\n * Per PLAN-ECOMMERCE.md PR 7 (PoC scope): generic plumbing entity. The\n * commerce-specific transform parameters (brandId, supplierId, supplier\n * code prefix) live in the opaque `params` JSONB so the imports package\n * stays free of commerce-domain dependencies. PR 8 wires a concrete\n * carmaker-feed transform that reads those params; PR 8a renders a\n * bespoke `/admin/commerce/imports` page over this entity's history.\n *\n * `hideFromMenu: true` because PR 7 ships only the entity + plumbing —\n * the operator-facing surface is PR 8a. The auto-EntityListPage at\n * `/admin/import_run` still resolves for direct lookups, but no sidebar\n * entry points to it.\n */\n\nimport { behavior, defineEntity, field, searchable } from '@murumets-ee/entity'\nimport type { AdminClient } from '@murumets-ee/entity/admin'\n\n/**\n * Lifecycle stages.\n *\n * - `pending` — row exists, queue job has been enqueued, worker hasn't picked it up yet.\n * - `running` — worker is streaming + bulk-writing.\n * - `succeeded` — worker finished cleanly (note: per-row failures still possible — see `totals.failed`).\n * - `failed` — worker threw and the queue marked the job dead. `errorSummary.fatal` carries the cause.\n * - `cancelled` — manual operator action via PR 8a (out of scope for PR 7).\n */\nexport const IMPORT_RUN_STATUSES = ['pending', 'running', 'succeeded', 'failed', 'cancelled'] as const\nexport type ImportRunStatus = (typeof IMPORT_RUN_STATUSES)[number]\n\nexport const ImportRun = defineEntity({\n name: 'import_run',\n fields: {\n /** Operator-visible label, e.g. `\"MERCEDES — ME_20251027075918.txt\"`. Free-form. */\n label: field.text({ required: true, maxLength: 255 }),\n /** See {@link IMPORT_RUN_STATUSES}. */\n status: field.select({\n options: IMPORT_RUN_STATUSES,\n default: 'pending',\n indexed: true,\n }),\n /**\n * Path or storage key of the uploaded feed file. Generic string —\n * could be `/var/lumi/uploads/<id>.txt` for the local-disk PoC or an\n * S3 object key once storage adapter integration lands. The worker\n * reads this with the configured `readFeed` resolver.\n */\n filePath: field.text({ required: true, maxLength: 2048 }),\n /**\n * Name of the registered transform applied to each row. Resolved at\n * worker-dispatch time against the transform registry contributed by\n * the consumer. PoC: `'commerce:carmaker-feed'`.\n */\n transformName: field.text({ required: true, maxLength: 128, indexed: true }),\n /**\n * Opaque per-transform parameters. The carmaker transform expects\n * `{ brandId, supplierId, codePrefix? }`. Validation happens inside\n * the transform — the imports package never inspects this shape so\n * a new transform with different params doesn't require a schema\n * migration.\n */\n params: field.json(),\n /**\n * Row counters: `{ submitted, succeeded, failed, skipped, batches }`.\n * Updated by the worker as each batch completes. Final values are\n * what the operator reads; intermediate progress comes from\n * `toolkit_jobs.progress` via the queue UI.\n */\n totals: field.json(),\n /**\n * Output of `ErrorTracker.getTopPatterns(totalRows)` — the top-50\n * error signatures with up to 5 sample rows each. Empty `[]` until\n * the worker writes it on completion.\n */\n errorSummary: field.json(),\n /** Set when the worker picks up the job. */\n startedAt: field.date(),\n /** Set when the worker finishes (success OR fatal failure). */\n finishedAt: field.date(),\n /**\n * `toolkit_jobs.id` of the queue job processing this run — link\n * back so PR 8a can show live progress without a second lookup.\n */\n queueJobId: field.text({ maxLength: 64, indexed: true }),\n },\n behaviors: [\n behavior.auditable(),\n // Operator-facing search: by label (e.g. \"MERCEDES — ME_*.txt\"),\n // transform name, or queue job id. No FTS — labels are short\n // alphanumeric tags, not prose.\n searchable({\n fields: ['label', 'transformName', 'queueJobId', 'status', 'filePath'],\n projection: (row) => ({\n id: row.id as string,\n label: row.label as string,\n description: `${row.transformName} · ${row.status}`,\n }),\n }),\n ],\n scope: 'global',\n admin: {\n group: 'imports',\n label: 'Import runs',\n labelSingular: 'Import run',\n icon: 'upload',\n hideFromMenu: true,\n },\n})\n\nexport type ImportRunClient = AdminClient<typeof ImportRun.allFields>\n"],"mappings":"qMA6BA,MAAa,EAAsB,CAAC,UAAW,UAAW,YAAa,SAAU,YAAY,CAGhF,EAAY,EAAa,CACpC,KAAM,aACN,OAAQ,CAEN,MAAO,EAAM,KAAK,CAAE,SAAU,GAAM,UAAW,IAAK,CAAC,CAErD,OAAQ,EAAM,OAAO,CACnB,QAAS,EACT,QAAS,UACT,QAAS,GACV,CAAC,CAOF,SAAU,EAAM,KAAK,CAAE,SAAU,GAAM,UAAW,KAAM,CAAC,CAMzD,cAAe,EAAM,KAAK,CAAE,SAAU,GAAM,UAAW,IAAK,QAAS,GAAM,CAAC,CAQ5E,OAAQ,EAAM,MAAM,CAOpB,OAAQ,EAAM,MAAM,CAMpB,aAAc,EAAM,MAAM,CAE1B,UAAW,EAAM,MAAM,CAEvB,WAAY,EAAM,MAAM,CAKxB,WAAY,EAAM,KAAK,CAAE,UAAW,GAAI,QAAS,GAAM,CAAC,CACzD,CACD,UAAW,CACT,EAAS,WAAW,CAIpB,EAAW,CACT,OAAQ,CAAC,QAAS,gBAAiB,aAAc,SAAU,WAAW,CACtE,WAAa,IAAS,CACpB,GAAI,EAAI,GACR,MAAO,EAAI,MACX,YAAa,GAAG,EAAI,cAAc,KAAK,EAAI,SAC5C,EACF,CAAC,CACH,CACD,MAAO,SACP,MAAO,CACL,MAAO,UACP,MAAO,cACP,cAAe,aACf,KAAM,SACN,aAAc,GACf,CACF,CAAC"}
|
package/dist/index.d.mts
CHANGED
|
@@ -1,3 +1,86 @@
|
|
|
1
|
-
import { C as ErrorSample, D as ImportRun, E as IMPORT_RUN_STATUSES, O as ImportRunClient, S as ErrorPattern, T as ErrorTrackerConfig, _ as StreamFeedRow, b as DEFAULT_MAX_PATTERNS, d as DEFAULT_BATCH_SIZE, f as ImportRunProgress, g as StreamFeedOptions, h as runImport, k as ImportRunStatus, m as RunImportResult, n as FilePathResolver, p as RunImportOptions, t as EsClientResolver, v as countDataRows, w as ErrorTracker, x as DEFAULT_MAX_SAMPLES_PER_PATTERN, y as streamFeed } from "./worker-
|
|
2
|
-
import { a as TransformName, c as registerImportTransform, i as TransformContext, n as RowResult, o as TransformRegistry, r as RowTransform, s as getTransformRegistry, t as RowError } from "./transform-
|
|
3
|
-
|
|
1
|
+
import { C as ErrorSample, D as ImportRun, E as IMPORT_RUN_STATUSES, O as ImportRunClient, S as ErrorPattern, T as ErrorTrackerConfig, _ as StreamFeedRow, b as DEFAULT_MAX_PATTERNS, d as DEFAULT_BATCH_SIZE, f as ImportRunProgress, g as StreamFeedOptions, h as runImport, k as ImportRunStatus, m as RunImportResult, n as FilePathResolver, p as RunImportOptions, t as EsClientResolver, v as countDataRows, w as ErrorTracker, x as DEFAULT_MAX_SAMPLES_PER_PATTERN, y as streamFeed } from "./worker-DUUtPREz.mjs";
|
|
2
|
+
import { a as TransformName, c as registerImportTransform, i as TransformContext, n as RowResult, o as TransformRegistry, r as RowTransform, s as getTransformRegistry, t as RowError } from "./transform-BkALrFhI.mjs";
|
|
3
|
+
import { ToolkitApp } from "@murumets-ee/core";
|
|
4
|
+
import { Logger as Logger$1 } from "@murumets-ee/logging";
|
|
5
|
+
|
|
6
|
+
//#region src/admin/cleanup.d.ts
|
|
7
|
+
interface CleanupOrphanedImportsArgs {
|
|
8
|
+
/** Toolkit app (provides db). */
|
|
9
|
+
app: ToolkitApp;
|
|
10
|
+
/**
|
|
11
|
+
* Bucket name the imports flow uses. Rows in `toolkit_files` are
|
|
12
|
+
* filtered to this bucket so the cleanup doesn't touch media,
|
|
13
|
+
* ticketing attachments, or any other consumer's uploads even if
|
|
14
|
+
* they share the same Postgres instance.
|
|
15
|
+
*/
|
|
16
|
+
bucket: string;
|
|
17
|
+
/**
|
|
18
|
+
* Hours after which an unreferenced upload is considered orphaned.
|
|
19
|
+
* Operator-grace window: should be long enough that a normal
|
|
20
|
+
* upload-then-register round-trip never wipes a fresh upload, but
|
|
21
|
+
* short enough that storage waste from failed uploads doesn't
|
|
22
|
+
* accumulate. 2 hours is the default in the imports plugin
|
|
23
|
+
* config — calibrated against the typical "operator clicks
|
|
24
|
+
* upload, walks away, comes back to check" workflow.
|
|
25
|
+
*/
|
|
26
|
+
ttlHours: number;
|
|
27
|
+
/**
|
|
28
|
+
* Maximum rows to process per run. Bounded so a sudden flood of
|
|
29
|
+
* orphans (e.g. after a brand-new supplier with unrecognised
|
|
30
|
+
* filenames floods the queue with rejected uploads) doesn't
|
|
31
|
+
* block the queue worker on a single multi-hour cleanup.
|
|
32
|
+
*/
|
|
33
|
+
limit?: number;
|
|
34
|
+
/** Logger for diagnostics. */
|
|
35
|
+
logger: Pick<Logger$1, 'debug' | 'info' | 'warn'>;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Run one cleanup pass. Returns the count of rows actually deleted
|
|
39
|
+
* (which may be less than the count of orphans found if some
|
|
40
|
+
* `storage.delete` calls failed — see the `warn` log inside the loop).
|
|
41
|
+
*/
|
|
42
|
+
declare function cleanupOrphanedImports(args: CleanupOrphanedImportsArgs): Promise<number>;
|
|
43
|
+
interface CleanupProcessedImportsArgs {
|
|
44
|
+
/** Toolkit app (provides db). */
|
|
45
|
+
app: ToolkitApp;
|
|
46
|
+
/** Bucket name to scope the cleanup. Same semantics as `cleanupOrphanedImports`. */
|
|
47
|
+
bucket: string;
|
|
48
|
+
/**
|
|
49
|
+
* Days after which a terminal-status `import_run`'s source file is
|
|
50
|
+
* deleted. Operator-grace window: long enough that an operator can
|
|
51
|
+
* inspect the source file if a transform bug surfaces shortly after
|
|
52
|
+
* a successful import; short enough that storage doesn't accumulate
|
|
53
|
+
* months of supplier feeds.
|
|
54
|
+
*
|
|
55
|
+
* The plugin default is 7. Set to 0 to delete the source file
|
|
56
|
+
* immediately at the next cleanup tick after the import row
|
|
57
|
+
* reaches terminal status — useful when storage cost matters more
|
|
58
|
+
* than forensic reprocess capability.
|
|
59
|
+
*/
|
|
60
|
+
retentionDays: number;
|
|
61
|
+
/** Max rows per run. Defaults to 100 (same cap as the orphan sweep). */
|
|
62
|
+
limit?: number;
|
|
63
|
+
logger: Pick<Logger$1, 'debug' | 'info' | 'warn'>;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Cleanup source files for `import_run`s that finished (succeeded /
|
|
67
|
+
* failed / cancelled) more than `retentionDays` days ago.
|
|
68
|
+
*
|
|
69
|
+
* Counterpart to `cleanupOrphanedImports`: that one targets uploads
|
|
70
|
+
* with NO matching `import_run` (handshake never completed); this one
|
|
71
|
+
* targets uploads whose `import_run` row IS present and terminal.
|
|
72
|
+
* Together they cover every "useless storage row" case the imports
|
|
73
|
+
* flow can produce.
|
|
74
|
+
*
|
|
75
|
+
* Deletes ONLY the storage row (via `StorageClient.delete`); the
|
|
76
|
+
* `import_run` row is kept so the History panel + audit trail still
|
|
77
|
+
* surface the past run. The `import_run.filePath` value points at a
|
|
78
|
+
* now-deleted key after this runs — consumers that try to re-download
|
|
79
|
+
* the source file (e.g. a future "reprocess" button) will get a
|
|
80
|
+
* `getMetadata` → null and should show "source file unavailable" in
|
|
81
|
+
* the UI rather than crashing.
|
|
82
|
+
*/
|
|
83
|
+
declare function cleanupProcessedImports(args: CleanupProcessedImportsArgs): Promise<number>;
|
|
84
|
+
//#endregion
|
|
85
|
+
export { type CleanupOrphanedImportsArgs, type CleanupProcessedImportsArgs, DEFAULT_BATCH_SIZE, DEFAULT_MAX_PATTERNS, DEFAULT_MAX_SAMPLES_PER_PATTERN, type ErrorPattern, type ErrorSample, ErrorTracker, type ErrorTrackerConfig, type EsClientResolver, type FilePathResolver, IMPORT_RUN_STATUSES, ImportRun, type ImportRunClient, type ImportRunProgress, type ImportRunStatus, type RowError, type RowResult, type RowTransform, type RunImportOptions, type RunImportResult, type StreamFeedOptions, type StreamFeedRow, type TransformContext, type TransformName, TransformRegistry, cleanupOrphanedImports, cleanupProcessedImports, countDataRows, getTransformRegistry, registerImportTransform, runImport, streamFeed };
|
|
86
|
+
//# sourceMappingURL=index.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../src/admin/cleanup.ts"],"mappings":";;;;;;UAkCiB,0BAAA;EAmEsE;EAjErF,GAAA,EAAK,UAAA;EA2JqC;;;;;;EApJ1C,MAAA;EAsJA;;;;;;;;;EA5IA,QAAA;EAmLoB;;;;;;EA5KpB,KAAA;EA4KuF;EA1KvF,MAAA,EAAQ,IAAA,CAAK,QAAA;AAAA;;;;;;iBAuCO,sBAAA,CAAuB,IAAA,EAAM,0BAAA,GAA6B,OAAA;AAAA,UA0F/D,2BAAA;;EAEf,GAAA,EAAK,UAAA;;EAEL,MAAA;;;;;;;;;;;;;EAaA,aAAA;;EAEA,KAAA;EACA,MAAA,EAAQ,IAAA,CAAK,QAAA;AAAA;;;;;;;;;;;;;;;;;;;iBAqBO,uBAAA,CAAwB,IAAA,EAAM,2BAAA,GAA8B,OAAA"}
|
package/dist/index.mjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{
|
|
1
|
+
import{n as e,t}from"./import-run-CviKL4jq.mjs";import{a as n,i as r,n as i,o as a,r as o,s,t as c}from"./runner-D5to1Bt6.mjs";import{n as l,r as u,t as d}from"./transform-B1KpU3lo.mjs";import{n as f,t as p}from"./cleanup-C069GTkO.mjs";export{c as DEFAULT_BATCH_SIZE,n as DEFAULT_MAX_PATTERNS,a as DEFAULT_MAX_SAMPLES_PER_PATTERN,s as ErrorTracker,t as IMPORT_RUN_STATUSES,e as ImportRun,d as TransformRegistry,p as cleanupOrphanedImports,f as cleanupProcessedImports,o as countDataRows,l as getTransformRegistry,u as registerImportTransform,i as runImport,r as streamFeed};
|
package/dist/plugin.d.mts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import { n as FilePathResolver, t as EsClientResolver } from "./worker-
|
|
1
|
+
import { n as FilePathResolver, t as EsClientResolver } from "./worker-DUUtPREz.mjs";
|
|
2
2
|
import { ImportsStorageFactory } from "./storage.mjs";
|
|
3
|
-
import { Plugin } from "@murumets-ee/core";
|
|
4
3
|
|
|
5
4
|
//#region src/plugin.d.ts
|
|
6
5
|
interface ImportsPluginOptions {
|
|
@@ -50,8 +49,54 @@ interface ImportsPluginOptions {
|
|
|
50
49
|
* the global default).
|
|
51
50
|
*/
|
|
52
51
|
storage?: ImportsStorageFactory;
|
|
52
|
+
/**
|
|
53
|
+
* Bucket name to scope the orphan-cleanup queue job. Rows in
|
|
54
|
+
* `toolkit_files` with this bucket value AND no matching
|
|
55
|
+
* `import_run.filePath` AND `createdAt < cutoff` get garbage-
|
|
56
|
+
* collected on each cleanup tick. Defaults to `'lumi-imports'` —
|
|
57
|
+
* the convention `apps/admin-playground/lib/imports-storage.ts`
|
|
58
|
+
* uses by default. Override if your deployment pins a different
|
|
59
|
+
* bucket name via `IMPORTS_STORAGE_BUCKET` or equivalent.
|
|
60
|
+
*
|
|
61
|
+
* Set to `null` to DISABLE the cleanup job entirely (e.g. for
|
|
62
|
+
* deployments that handle their own retention via S3 lifecycle
|
|
63
|
+
* rules). Disabling without an alternative will leak storage
|
|
64
|
+
* over time as failed uploads accumulate.
|
|
65
|
+
*/
|
|
66
|
+
storageBucket?: string | null;
|
|
67
|
+
/**
|
|
68
|
+
* Hours after which an unreferenced `toolkit_files` row in the
|
|
69
|
+
* imports bucket is considered orphaned and deleted by the
|
|
70
|
+
* cleanup job. Default 2h — long enough that a normal
|
|
71
|
+
* upload-then-register round-trip never wipes a fresh upload,
|
|
72
|
+
* short enough that storage waste from failed uploads doesn't
|
|
73
|
+
* accumulate.
|
|
74
|
+
*
|
|
75
|
+
* The upload→register flow happens client-side automatically (no
|
|
76
|
+
* "operator clicks register" step in the current UI), so the TTL
|
|
77
|
+
* just needs to cover network blips + retries. Bumping the TTL
|
|
78
|
+
* is safe if your operators commonly upload then wait before
|
|
79
|
+
* processing.
|
|
80
|
+
*/
|
|
81
|
+
orphanUploadTtlHours?: number;
|
|
82
|
+
/**
|
|
83
|
+
* Days after which a terminal-status `import_run`'s source file is
|
|
84
|
+
* deleted from `toolkit_files`. Default 7 days — gives operators a
|
|
85
|
+
* reprocess window if a transform bug surfaces post-success, while
|
|
86
|
+
* keeping storage costs bounded. Set to 0 for instant cleanup at
|
|
87
|
+
* the next cleanup tick after `import_run.status` becomes terminal
|
|
88
|
+
* (`succeeded` / `failed` / `cancelled`).
|
|
89
|
+
*
|
|
90
|
+
* The `import_run` row itself is preserved — the audit trail and
|
|
91
|
+
* History panel still show the past run; only the source feed
|
|
92
|
+
* file is removed.
|
|
93
|
+
*/
|
|
94
|
+
processedRetentionDays?: number;
|
|
53
95
|
}
|
|
54
|
-
declare function imports(options: ImportsPluginOptions): Plugin
|
|
96
|
+
declare function imports(options: ImportsPluginOptions): Pick<import("@murumets-ee/core").Plugin, "server" | "adminUi" | "postInstall"> & {
|
|
97
|
+
readonly name: "@murumets-ee/imports";
|
|
98
|
+
readonly shared?: undefined;
|
|
99
|
+
};
|
|
55
100
|
//#endregion
|
|
56
101
|
export { ImportsPluginOptions, imports };
|
|
57
102
|
//# sourceMappingURL=plugin.d.mts.map
|
package/dist/plugin.d.mts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"plugin.d.mts","names":[],"sources":["../src/plugin.ts"],"mappings":"
|
|
1
|
+
{"version":3,"file":"plugin.d.mts","names":[],"sources":["../src/plugin.ts"],"mappings":";;;;UA+DiB,oBAAA;EA4FoC;;;;;EAtFnD,QAAA,EAAU,gBAAA;;;;;;;EAOV,OAAA;;;;;;;;;;;;;;;EAeA,eAAA,GAAkB,gBAAA;;;;;;;;;;;;;;;;;;EAkBlB,OAAA,GAAU,qBAAA;;;;;;;;;;;;;;;EAeV,aAAA;;;;;;;;;;;;;;;EAeA,oBAAA;;;;;;;;;;;;;EAaA,sBAAA;AAAA;AAAA,iBAGc,OAAA,CAAQ,OAAA,EAAS,oBAAA,GAAoB,IAAA,6BAAA,MAAA;EAAA"}
|
package/dist/plugin.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import{
|
|
1
|
+
import{n as e}from"./import-run-CviKL4jq.mjs";import{n as t}from"./transform-B1KpU3lo.mjs";import{registerImportsStorageFactory as n}from"./storage.mjs";import{createImportsRunDeadListener as r,createRunImportHandler as i,importsRunJob as a}from"./worker.mjs";import{definePlugin as o}from"@murumets-ee/core";const s=Symbol.for(`@murumets-ee/imports:dead-listener-unsubscribe`);function c(c){let l=c.esIndex??`parts`,u=c.storageBucket===null?null:{bucket:c.storageBucket??`lumi-imports`,orphanTtlHours:c.orphanUploadTtlHours??2,processedRetentionDays:c.processedRetentionDays??7};return c.storage&&n(c.storage),o({name:`@murumets-ee/imports`,server:{entities:[e],init:async n=>{if(!n.plugins.all().some(e=>e.name===`@murumets-ee/queue`)){n.logger.warn(`imports: queue() plugin not in plugins array — imports:run jobs will not be processed`);return}let o=await import(`@murumets-ee/queue/client`),{createAdminClient:d}=await import(`@murumets-ee/core/clients`),f=d(e,n),p=t();o.registerJob(a,i({importRuns:f,transforms:p,esClient:c.esClient,esIndex:l,...c.resolveFilePath!==void 0&&{resolveFilePath:c.resolveFilePath},logger:n.logger.child({pkg:`imports`})}));let m=globalThis[s];m&&m();let h=o.addQueueTerminalListener(r({importRuns:f,logger:n.logger.child({pkg:`imports`})}));if(globalThis[s]=h,u!==null){let{cleanupOrphanedImports:e,cleanupProcessedImports:t}=await import(`./cleanup-C069GTkO.mjs`).then(e=>e.r),{runAsCli:r}=await import(`@murumets-ee/core`),i=n.logger.child({pkg:`imports`,job:`cleanup`});o.registerJob(o.defineJob({name:`imports:cleanup-orphaned-uploads`,description:`Delete toolkit_files rows in the imports bucket with no matching import_run reference and createdAt older than the configured TTL.`,schedule:`@every 1h`}),async()=>{await r(()=>e({app:n,bucket:u.bucket,ttlHours:u.orphanTtlHours,logger:i}))}),o.registerJob(o.defineJob({name:`imports:cleanup-processed-uploads`,description:`Delete toolkit_files rows for import_runs in terminal status (succeeded/failed/cancelled) whose finishedAt is older than the configured retention window. The import_run row itself is preserved.`,schedule:`@every 1h`}),async()=>{await r(()=>t({app:n,bucket:u.bucket,retentionDays:u.processedRetentionDays,logger:i}))})}n.logger.info({esIndex:l,cleanup:u===null?{enabled:!1}:{enabled:!0,...u}},`Imports plugin initialized`)}}})}export{c as imports};
|
|
2
2
|
//# sourceMappingURL=plugin.mjs.map
|
package/dist/plugin.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"plugin.mjs","names":[],"sources":["../src/plugin.ts"],"sourcesContent":["/**\n * Imports plugin — registers the `import_run` entity and (when ES + queue\n * are wired) registers the `imports:run` job handler.\n *\n * Per PLAN-ECOMMERCE.md PR 7 (PoC scope): plumbing only. The bespoke\n * admin pages (upload form, parts search) live in PR 8a. Per-feed\n * transforms (carmaker, …) live in PR 8 and self-register against the\n * process-global transform registry — this plugin's init does NOT\n * import any transform.\n *\n * @example\n * ```ts\n * import { defineLumiConfig } from '@murumets-ee/core'\n * import { imports } from '@murumets-ee/imports/plugin'\n * import { Client } from '@elastic/elasticsearch'\n *\n * const es = new Client({ node: process.env.ES_URL })\n *\n * export default defineLumiConfig({\n * plugins: [\n * // queue() and the carmaker-transform plugin must be present too.\n * queue(),\n * imports({ esClient: () => es, esIndex: 'parts' }),\n * ],\n * })\n * ```\n */\n\nimport type { Plugin } from '@murumets-ee/core'\n// `@murumets-ee/core/clients` is dynamic-imported inside `init` below —\n// its module begins with `import 'server-only'`, which throws when this\n// plugin module is loaded at `lumi.config.ts` evaluation time by the\n// CLI (`lumi migrate`/`seed`/`doctor` use jiti, which doesn't apply the\n// `react-server` export condition). Static-importing here would propagate\n// that throw all the way through `lumi.config.ts` → CLI startup, blocking\n// every CLI invocation. Same documented pattern as `getDb()` in\n// `@murumets-ee/queue/admin.ts`. The `init` hook only runs at\n// `createApp()` time (Next.js request init / worker boot), where the\n// `server-only` resolves to its empty stub.\nimport { ImportRun } from './entities/import-run.js'\nimport { type ImportsStorageFactory, registerImportsStorageFactory } from './storage.js'\nimport {\n type EsClientResolver,\n type FilePathResolver,\n createRunImportHandler,\n createImportsRunDeadListener,\n importsRunJob,\n} from './worker.js'\nimport { getTransformRegistry } from './transform.js'\n\n/**\n * `Symbol.for`-keyed slot for the active dead-listener unsubscribe\n * handle. Symbol.for so an HMR-replaced module instance still finds\n * the previous unsubscribe (`Symbol`-by-reference would skip the\n * lookup across module-evaluation boundaries). Same pattern as\n * `transform.ts`'s registry slot and `storage.ts`'s factory slot.\n */\nconst IMPORTS_DEAD_LISTENER_KEY = Symbol.for('@murumets-ee/imports:dead-listener-unsubscribe')\n\ninterface GlobalThisWithUnsubscribe {\n [IMPORTS_DEAD_LISTENER_KEY]?: () => void\n}\n\nexport interface ImportsPluginOptions {\n /**\n * Elasticsearch client resolver. Lazy so consumers can construct\n * the client inside a route initialiser instead of at plugin-init\n * time. Required — without it, the worker can't bulk-write.\n */\n esClient: EsClientResolver\n /**\n * ES index alias to write to. Defaults to `'parts'` (the\n * `PARTS_INDEX_ALIAS` re-exported from `@murumets-ee/search-elasticsearch`,\n * inlined here to avoid a runtime import for a one-line constant).\n * Per D6 always pass an alias, never a physical index.\n */\n esIndex?: string\n /**\n * Resolve `import_run.filePath` to a local FS path before streaming.\n * **Required when uploads land in remote storage** (R2/S3/etc.) —\n * without it, the worker hands the storage key to `createReadStream`\n * and crashes with `ENOENT`.\n *\n * Typical wiring downloads the storage object to a tmpfile and\n * returns its path; the optional `cleanup` callback runs after the\n * run finishes (success or failure).\n *\n * Omit this option for the original on-disk PoC setup where the\n * upload route writes directly to a local directory and persists\n * the absolute path on `import_run.filePath`.\n */\n resolveFilePath?: FilePathResolver\n /**\n * Optional dedicated `StorageClient` factory for imports. When\n * provided, the upload route (in `@murumets-ee/commerce`) and the\n * worker resolver consult this client instead of the global\n * `getStorageConfig()` default — so a deployment can pin imports\n * to a local-disk adapter (single-VPS / dev) while media,\n * ticketing attachments, and the rest of the app keep using the\n * default R2 (or S3) adapter.\n *\n * Lazy / async so consumers can defer adapter construction until\n * the first import — useful when the local-disk root is\n * environment-dependent or when the factory needs `getApp()` to\n * have resolved.\n *\n * Omit to keep the legacy single-storage behaviour (everything on\n * the global default).\n */\n storage?: ImportsStorageFactory\n}\n\nexport function imports(options: ImportsPluginOptions): Plugin {\n const esIndex = options.esIndex ?? 'parts'\n\n // Register the storage factory eagerly — runs at `imports()` factory\n // call time during `lumi.config.ts` evaluation, NOT inside the\n // `init` hook. The hook only fires at `createApp()` time (Next.js\n // request boot / worker boot), but the factory needs to be visible\n // to consumers that read it at module-load (e.g. the commerce\n // route's lazy `getStorageClient` thunk closes over a value that\n // resolves the moment a request comes in). Late registration in\n // `init` would race the first request.\n if (options.storage) {\n registerImportsStorageFactory(options.storage)\n }\n\n return {\n name: '@murumets-ee/imports',\n server: {\n entities: [ImportRun],\n init: async (app) => {\n // Queue PACKAGE being importable doesn't mean the queue() PLUGIN\n // is in the consumer's plugins array. Without it, registerJob()\n // succeeds but no worker ever picks up the job. Probe and warn.\n const queuePluginPresent = app.plugins\n .all()\n .some((p) => p.name === '@murumets-ee/queue')\n if (!queuePluginPresent) {\n app.logger.warn(\n 'imports: queue() plugin not in plugins array — imports:run jobs will not be processed',\n )\n return\n }\n\n // Queue client stays dynamic-imported: we already early-returned\n // above when the queue() plugin isn't in the consumer's plugins\n // array, so static-importing here would force-load the queue\n // client module in deployments that don't enable the worker.\n const queueClientModule = await import('@murumets-ee/queue/client')\n const { createAdminClient } = await import('@murumets-ee/core/clients')\n\n const importRuns = createAdminClient(ImportRun, app)\n const transforms = getTransformRegistry()\n\n queueClientModule.registerJob(\n importsRunJob,\n createRunImportHandler({\n importRuns,\n transforms,\n esClient: options.esClient,\n esIndex,\n ...(options.resolveFilePath !== undefined && {\n resolveFilePath: options.resolveFilePath,\n }),\n logger: app.logger.child({ pkg: 'imports' }),\n }),\n )\n\n // Subscribe to the queue's terminal-state event stream so a\n // worker that crashes mid-import — and whose `imports:run` job\n // ultimately reaches `dead` without the handler reaching its\n // own `markFailed` path — flips the mirror `import_run` row to\n // `failed`. This replaces what would otherwise be a per-domain\n // periodic sweep; the queue's terminal notifier is the generic\n // primitive (graphile-worker's `job:failed` event, BullMQ's\n // `failed` listener — same shape).\n //\n // The listener is multi-subscriber, so we coexist with the\n // notifications plugin's admin-email handler without either of\n // us clobbering the other. Filtering on `event.jobType` keeps\n // this listener cheap on jobs we don't own.\n //\n // **HMR**: each re-init of this `init` hook constructs a new\n // listener instance. The notifier registry stores listeners\n // by reference, so without explicit cleanup an HMR cycle would\n // STACK duplicate listeners — `findMany`+`update` per dead\n // event growing linearly with the number of HMR rounds. Stash\n // the unsubscribe on a globalThis Symbol-keyed slot (same\n // pattern as `transform.ts` / `storage.ts`) and call it before\n // re-subscribing so HMR is idempotent.\n const previousUnsubscribe = (globalThis as GlobalThisWithUnsubscribe)[\n IMPORTS_DEAD_LISTENER_KEY\n ]\n if (previousUnsubscribe) previousUnsubscribe()\n const unsubscribe = queueClientModule.addQueueTerminalListener(\n createImportsRunDeadListener({\n importRuns,\n logger: app.logger.child({ pkg: 'imports' }),\n }),\n )\n ;(globalThis as GlobalThisWithUnsubscribe)[IMPORTS_DEAD_LISTENER_KEY] = unsubscribe\n\n app.logger.info({ esIndex }, 'Imports plugin initialized')\n },\n },\n }\n}\n"],"mappings":"6NAyDA,MAAM,EAA4B,OAAO,IAAI,iDAAiD,CAuD9F,SAAgB,EAAQ,EAAuC,CAC7D,IAAM,EAAU,EAAQ,SAAW,QAcnC,OAJI,EAAQ,SACV,EAA8B,EAAQ,QAAQ,CAGzC,CACL,KAAM,uBACN,OAAQ,CACN,SAAU,CAAC,EAAU,CACrB,KAAM,KAAO,IAAQ,CAOnB,GAAI,CAHuB,EAAI,QAC5B,KAAK,CACL,KAAM,GAAM,EAAE,OAAS,qBACH,CAAE,CACvB,EAAI,OAAO,KACT,wFACD,CACD,OAOF,IAAM,EAAoB,MAAM,OAAO,6BACjC,CAAE,qBAAsB,MAAM,OAAO,6BAErC,EAAa,EAAkB,EAAW,EAAI,CAC9C,EAAa,GAAsB,CAEzC,EAAkB,YAChB,EACA,EAAuB,CACrB,aACA,aACA,SAAU,EAAQ,SAClB,UACA,GAAI,EAAQ,kBAAoB,IAAA,IAAa,CAC3C,gBAAiB,EAAQ,gBAC1B,CACD,OAAQ,EAAI,OAAO,MAAM,CAAE,IAAK,UAAW,CAAC,CAC7C,CAAC,CACH,CAwBD,IAAM,EAAuB,WAC3B,GAEE,GAAqB,GAAqB,CAC9C,IAAM,EAAc,EAAkB,yBACpC,EAA6B,CAC3B,aACA,OAAQ,EAAI,OAAO,MAAM,CAAE,IAAK,UAAW,CAAC,CAC7C,CAAC,CACH,CACC,WAAyC,GAA6B,EAExE,EAAI,OAAO,KAAK,CAAE,UAAS,CAAE,6BAA6B,EAE7D,CACF"}
|
|
1
|
+
{"version":3,"file":"plugin.mjs","names":[],"sources":["../src/plugin.ts"],"sourcesContent":["/**\n * Imports plugin — registers the `import_run` entity and (when ES + queue\n * are wired) registers the `imports:run` job handler.\n *\n * Per PLAN-ECOMMERCE.md PR 7 (PoC scope): plumbing only. The bespoke\n * admin pages (upload form, parts search) live in PR 8a. Per-feed\n * transforms (carmaker, …) live in PR 8 and self-register against the\n * process-global transform registry — this plugin's init does NOT\n * import any transform.\n *\n * @example\n * ```ts\n * import { defineLumiConfig } from '@murumets-ee/core'\n * import { imports } from '@murumets-ee/imports/plugin'\n * import { Client } from '@elastic/elasticsearch'\n *\n * const es = new Client({ node: process.env.ES_URL })\n *\n * export default defineLumiConfig({\n * plugins: [\n * // queue() and the carmaker-transform plugin must be present too.\n * queue(),\n * imports({ esClient: () => es, esIndex: 'parts' }),\n * ],\n * })\n * ```\n */\n\nimport { definePlugin } from '@murumets-ee/core'\n// `@murumets-ee/core/clients` is dynamic-imported inside `init` below —\n// its module begins with `import 'server-only'`, which throws when this\n// plugin module is loaded at `lumi.config.ts` evaluation time by the\n// CLI (`lumi migrate`/`seed`/`doctor` use jiti, which doesn't apply the\n// `react-server` export condition). Static-importing here would propagate\n// that throw all the way through `lumi.config.ts` → CLI startup, blocking\n// every CLI invocation. Same documented pattern as `getDb()` in\n// `@murumets-ee/queue/admin.ts`. The `init` hook only runs at\n// `createApp()` time (Next.js request init / worker boot), where the\n// `server-only` resolves to its empty stub.\nimport { ImportRun } from './entities/import-run.js'\nimport { type ImportsStorageFactory, registerImportsStorageFactory } from './storage.js'\nimport { getTransformRegistry } from './transform.js'\nimport {\n createImportsRunDeadListener,\n createRunImportHandler,\n type EsClientResolver,\n type FilePathResolver,\n importsRunJob,\n} from './worker.js'\n\n/**\n * `Symbol.for`-keyed slot for the active dead-listener unsubscribe\n * handle. Symbol.for so an HMR-replaced module instance still finds\n * the previous unsubscribe (`Symbol`-by-reference would skip the\n * lookup across module-evaluation boundaries). Same pattern as\n * `transform.ts`'s registry slot and `storage.ts`'s factory slot.\n */\nconst IMPORTS_DEAD_LISTENER_KEY = Symbol.for('@murumets-ee/imports:dead-listener-unsubscribe')\n\ninterface GlobalThisWithUnsubscribe {\n [IMPORTS_DEAD_LISTENER_KEY]?: () => void\n}\n\nexport interface ImportsPluginOptions {\n /**\n * Elasticsearch client resolver. Lazy so consumers can construct\n * the client inside a route initialiser instead of at plugin-init\n * time. Required — without it, the worker can't bulk-write.\n */\n esClient: EsClientResolver\n /**\n * ES index alias to write to. Defaults to `'parts'` (the\n * `PARTS_INDEX_ALIAS` re-exported from `@murumets-ee/search-elasticsearch`,\n * inlined here to avoid a runtime import for a one-line constant).\n * Per D6 always pass an alias, never a physical index.\n */\n esIndex?: string\n /**\n * Resolve `import_run.filePath` to a local FS path before streaming.\n * **Required when uploads land in remote storage** (R2/S3/etc.) —\n * without it, the worker hands the storage key to `createReadStream`\n * and crashes with `ENOENT`.\n *\n * Typical wiring downloads the storage object to a tmpfile and\n * returns its path; the optional `cleanup` callback runs after the\n * run finishes (success or failure).\n *\n * Omit this option for the original on-disk PoC setup where the\n * upload route writes directly to a local directory and persists\n * the absolute path on `import_run.filePath`.\n */\n resolveFilePath?: FilePathResolver\n /**\n * Optional dedicated `StorageClient` factory for imports. When\n * provided, the upload route (in `@murumets-ee/commerce`) and the\n * worker resolver consult this client instead of the global\n * `getStorageConfig()` default — so a deployment can pin imports\n * to a local-disk adapter (single-VPS / dev) while media,\n * ticketing attachments, and the rest of the app keep using the\n * default R2 (or S3) adapter.\n *\n * Lazy / async so consumers can defer adapter construction until\n * the first import — useful when the local-disk root is\n * environment-dependent or when the factory needs `getApp()` to\n * have resolved.\n *\n * Omit to keep the legacy single-storage behaviour (everything on\n * the global default).\n */\n storage?: ImportsStorageFactory\n /**\n * Bucket name to scope the orphan-cleanup queue job. Rows in\n * `toolkit_files` with this bucket value AND no matching\n * `import_run.filePath` AND `createdAt < cutoff` get garbage-\n * collected on each cleanup tick. Defaults to `'lumi-imports'` —\n * the convention `apps/admin-playground/lib/imports-storage.ts`\n * uses by default. Override if your deployment pins a different\n * bucket name via `IMPORTS_STORAGE_BUCKET` or equivalent.\n *\n * Set to `null` to DISABLE the cleanup job entirely (e.g. for\n * deployments that handle their own retention via S3 lifecycle\n * rules). Disabling without an alternative will leak storage\n * over time as failed uploads accumulate.\n */\n storageBucket?: string | null\n /**\n * Hours after which an unreferenced `toolkit_files` row in the\n * imports bucket is considered orphaned and deleted by the\n * cleanup job. Default 2h — long enough that a normal\n * upload-then-register round-trip never wipes a fresh upload,\n * short enough that storage waste from failed uploads doesn't\n * accumulate.\n *\n * The upload→register flow happens client-side automatically (no\n * \"operator clicks register\" step in the current UI), so the TTL\n * just needs to cover network blips + retries. Bumping the TTL\n * is safe if your operators commonly upload then wait before\n * processing.\n */\n orphanUploadTtlHours?: number\n /**\n * Days after which a terminal-status `import_run`'s source file is\n * deleted from `toolkit_files`. Default 7 days — gives operators a\n * reprocess window if a transform bug surfaces post-success, while\n * keeping storage costs bounded. Set to 0 for instant cleanup at\n * the next cleanup tick after `import_run.status` becomes terminal\n * (`succeeded` / `failed` / `cancelled`).\n *\n * The `import_run` row itself is preserved — the audit trail and\n * History panel still show the past run; only the source feed\n * file is removed.\n */\n processedRetentionDays?: number\n}\n\nexport function imports(options: ImportsPluginOptions) {\n const esIndex = options.esIndex ?? 'parts'\n // Resolve the cleanup config eagerly. `storageBucket === null` is the\n // explicit opt-out path; `undefined` falls back to the convention.\n // Two cleanup jobs share the same bucket but have independent TTLs:\n // orphans (no import_run ref) churn fast (2h); processed runs\n // (terminal status) get a longer reprocess window (7d).\n const cleanupConfig =\n options.storageBucket === null\n ? null\n : {\n bucket: options.storageBucket ?? 'lumi-imports',\n orphanTtlHours: options.orphanUploadTtlHours ?? 2,\n processedRetentionDays: options.processedRetentionDays ?? 7,\n }\n\n // Register the storage factory eagerly — runs at `imports()` factory\n // call time during `lumi.config.ts` evaluation, NOT inside the\n // `init` hook. The hook only fires at `createApp()` time (Next.js\n // request boot / worker boot), but the factory needs to be visible\n // to consumers that read it at module-load (e.g. the commerce\n // route's lazy `getStorageClient` thunk closes over a value that\n // resolves the moment a request comes in). Late registration in\n // `init` would race the first request.\n if (options.storage) {\n registerImportsStorageFactory(options.storage)\n }\n\n return definePlugin({\n name: '@murumets-ee/imports',\n server: {\n entities: [ImportRun],\n init: async (app) => {\n // Queue PACKAGE being importable doesn't mean the queue() PLUGIN\n // is in the consumer's plugins array. Without it, registerJob()\n // succeeds but no worker ever picks up the job. Probe and warn.\n const queuePluginPresent = app.plugins.all().some((p) => p.name === '@murumets-ee/queue')\n if (!queuePluginPresent) {\n app.logger.warn(\n 'imports: queue() plugin not in plugins array — imports:run jobs will not be processed',\n )\n return\n }\n\n // Queue client stays dynamic-imported: we already early-returned\n // above when the queue() plugin isn't in the consumer's plugins\n // array, so static-importing here would force-load the queue\n // client module in deployments that don't enable the worker.\n const queueClientModule = await import('@murumets-ee/queue/client')\n const { createAdminClient } = await import('@murumets-ee/core/clients')\n\n const importRuns = createAdminClient(ImportRun, app)\n const transforms = getTransformRegistry()\n\n queueClientModule.registerJob(\n importsRunJob,\n createRunImportHandler({\n importRuns,\n transforms,\n esClient: options.esClient,\n esIndex,\n ...(options.resolveFilePath !== undefined && {\n resolveFilePath: options.resolveFilePath,\n }),\n logger: app.logger.child({ pkg: 'imports' }),\n }),\n )\n\n // Subscribe to the queue's terminal-state event stream so a\n // worker that crashes mid-import — and whose `imports:run` job\n // ultimately reaches `dead` without the handler reaching its\n // own `markFailed` path — flips the mirror `import_run` row to\n // `failed`. This replaces what would otherwise be a per-domain\n // periodic sweep; the queue's terminal notifier is the generic\n // primitive (graphile-worker's `job:failed` event, BullMQ's\n // `failed` listener — same shape).\n //\n // The listener is multi-subscriber, so we coexist with the\n // notifications plugin's admin-email handler without either of\n // us clobbering the other. Filtering on `event.jobType` keeps\n // this listener cheap on jobs we don't own.\n //\n // **HMR**: each re-init of this `init` hook constructs a new\n // listener instance. The notifier registry stores listeners\n // by reference, so without explicit cleanup an HMR cycle would\n // STACK duplicate listeners — `findMany`+`update` per dead\n // event growing linearly with the number of HMR rounds. Stash\n // the unsubscribe on a globalThis Symbol-keyed slot (same\n // pattern as `transform.ts` / `storage.ts`) and call it before\n // re-subscribing so HMR is idempotent.\n const previousUnsubscribe = (globalThis as GlobalThisWithUnsubscribe)[\n IMPORTS_DEAD_LISTENER_KEY\n ]\n if (previousUnsubscribe) previousUnsubscribe()\n const unsubscribe = queueClientModule.addQueueTerminalListener(\n createImportsRunDeadListener({\n importRuns,\n logger: app.logger.child({ pkg: 'imports' }),\n }),\n )\n ;(globalThis as GlobalThisWithUnsubscribe)[IMPORTS_DEAD_LISTENER_KEY] = unsubscribe\n\n // Storage-cleanup jobs. Two passes, same bucket:\n // 1. Orphan sweep — uploads whose register handshake never\n // completed (no `import_run` reference). 2h TTL.\n // 2. Processed sweep — uploads whose `import_run` is terminal\n // (succeeded / failed / cancelled). 7-day reprocess\n // window then drop.\n // `defineJob({ schedule })` declares the cadence; the queue\n // worker reads the metadata at startup, materialises a\n // `toolkit_scheduled_jobs` row, and its cron loop fires the\n // registered handler each tick. No explicit\n // `client.schedule()` call needed — the worker owns the\n // schedule, not the handler. Same helper-signature shape as\n // ticketing's cleanup.\n if (cleanupConfig !== null) {\n const { cleanupOrphanedImports, cleanupProcessedImports } = await import(\n './admin/cleanup.js'\n )\n const { runAsCli } = await import('@murumets-ee/core')\n const cleanupLogger = app.logger.child({ pkg: 'imports', job: 'cleanup' })\n\n queueClientModule.registerJob(\n queueClientModule.defineJob({\n name: 'imports:cleanup-orphaned-uploads',\n description:\n 'Delete toolkit_files rows in the imports bucket with no matching import_run reference and createdAt older than the configured TTL.',\n schedule: '@every 1h',\n }),\n async () => {\n // `runAsCli` is load-bearing today: the queue worker's\n // handler context (see `packages/queue/src/handler-context.ts`)\n // tracks the job type but NOT a user / RequestContext.\n // Any AdminClient call reachable from the cleanup\n // helpers — currently `createAdminClient(ImportRun).getTable()`\n // for column-metadata resolution — runs without a user\n // unless we enter a CLI scope. The synthetic CLI user\n // is the right attribution for scheduled-job work\n // (matches ticketing's `cleanupOrphanedUploads` shape).\n await runAsCli(() =>\n cleanupOrphanedImports({\n app,\n bucket: cleanupConfig.bucket,\n ttlHours: cleanupConfig.orphanTtlHours,\n logger: cleanupLogger,\n }),\n )\n },\n )\n\n queueClientModule.registerJob(\n queueClientModule.defineJob({\n name: 'imports:cleanup-processed-uploads',\n description:\n 'Delete toolkit_files rows for import_runs in terminal status (succeeded/failed/cancelled) whose finishedAt is older than the configured retention window. The import_run row itself is preserved.',\n schedule: '@every 1h',\n }),\n async () => {\n await runAsCli(() =>\n cleanupProcessedImports({\n app,\n bucket: cleanupConfig.bucket,\n retentionDays: cleanupConfig.processedRetentionDays,\n logger: cleanupLogger,\n }),\n )\n },\n )\n }\n\n app.logger.info(\n {\n esIndex,\n // Stable shape regardless of enabled/disabled so structured-\n // log queries (`cleanup.enabled = false`) don't have to\n // handle a string fallback.\n cleanup:\n cleanupConfig === null ? { enabled: false } : { enabled: true, ...cleanupConfig },\n },\n 'Imports plugin initialized',\n )\n },\n },\n })\n}\n"],"mappings":"qTAyDA,MAAM,EAA4B,OAAO,IAAI,iDAAiD,CAkG9F,SAAgB,EAAQ,EAA+B,CACrD,IAAM,EAAU,EAAQ,SAAW,QAM7B,EACJ,EAAQ,gBAAkB,KACtB,KACA,CACE,OAAQ,EAAQ,eAAiB,eACjC,eAAgB,EAAQ,sBAAwB,EAChD,uBAAwB,EAAQ,wBAA0B,EAC3D,CAcP,OAJI,EAAQ,SACV,EAA8B,EAAQ,QAAQ,CAGzC,EAAa,CAClB,KAAM,uBACN,OAAQ,CACN,SAAU,CAAC,EAAU,CACrB,KAAM,KAAO,IAAQ,CAKnB,GAAI,CADuB,EAAI,QAAQ,KAAK,CAAC,KAAM,GAAM,EAAE,OAAS,qBAC7C,CAAE,CACvB,EAAI,OAAO,KACT,wFACD,CACD,OAOF,IAAM,EAAoB,MAAM,OAAO,6BACjC,CAAE,qBAAsB,MAAM,OAAO,6BAErC,EAAa,EAAkB,EAAW,EAAI,CAC9C,EAAa,GAAsB,CAEzC,EAAkB,YAChB,EACA,EAAuB,CACrB,aACA,aACA,SAAU,EAAQ,SAClB,UACA,GAAI,EAAQ,kBAAoB,IAAA,IAAa,CAC3C,gBAAiB,EAAQ,gBAC1B,CACD,OAAQ,EAAI,OAAO,MAAM,CAAE,IAAK,UAAW,CAAC,CAC7C,CAAC,CACH,CAwBD,IAAM,EAAuB,WAC3B,GAEE,GAAqB,GAAqB,CAC9C,IAAM,EAAc,EAAkB,yBACpC,EAA6B,CAC3B,aACA,OAAQ,EAAI,OAAO,MAAM,CAAE,IAAK,UAAW,CAAC,CAC7C,CAAC,CACH,CAgBD,GAfE,WAAyC,GAA6B,EAepE,IAAkB,KAAM,CAC1B,GAAM,CAAE,yBAAwB,2BAA4B,MAAM,OAChE,0BAAA,KAAA,GAAA,EAAA,EAAA,CAEI,CAAE,YAAa,MAAM,OAAO,qBAC5B,EAAgB,EAAI,OAAO,MAAM,CAAE,IAAK,UAAW,IAAK,UAAW,CAAC,CAE1E,EAAkB,YAChB,EAAkB,UAAU,CAC1B,KAAM,mCACN,YACE,qIACF,SAAU,YACX,CAAC,CACF,SAAY,CAUV,MAAM,MACJ,EAAuB,CACrB,MACA,OAAQ,EAAc,OACtB,SAAU,EAAc,eACxB,OAAQ,EACT,CAAC,CACH,EAEJ,CAED,EAAkB,YAChB,EAAkB,UAAU,CAC1B,KAAM,oCACN,YACE,oMACF,SAAU,YACX,CAAC,CACF,SAAY,CACV,MAAM,MACJ,EAAwB,CACtB,MACA,OAAQ,EAAc,OACtB,cAAe,EAAc,uBAC7B,OAAQ,EACT,CAAC,CACH,EAEJ,CAGH,EAAI,OAAO,KACT,CACE,UAIA,QACE,IAAkB,KAAO,CAAE,QAAS,GAAO,CAAG,CAAE,QAAS,GAAM,GAAG,EAAe,CACpF,CACD,6BACD,EAEJ,CACF,CAAC"}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
import{createLogger as e}from"@murumets-ee/logging";const t={runCreated:`imports.run.created`,runStarted:`imports.run.started`,runSucceeded:`imports.run.succeeded`,runFailed:`imports.run.failed`},n=e({name:`imports:realtime`}),r={admin:!0};let i=null;function a(e,t){e instanceof Error&&e.name===`RealtimeOversizeError`?n.error({err:e,topic:t},`realtime publish failed — payload oversize`):n.warn({err:e,topic:t},`realtime publish failed — event dropped`)}function o(e,t){if(i){try{i({topic:e,payload:t,scope:r})}catch(t){a(t,e)}return}import(`@murumets-ee/core/realtime`).then(n=>{i=n.publishEvent,i({topic:e,payload:t,scope:r})}).catch(t=>{a(t,e)})}async function s(){i||=(await import(`@murumets-ee/core/realtime`)).publishEvent}function c(e,n){o(t.runCreated,{importRunId:e,...n.brandSlug!==void 0&&{brandSlug:n.brandSlug},...n.supplierDisplayName!==void 0&&{supplierDisplayName:n.supplierDisplayName},filename:n.filename})}function l(e){o(t.runStarted,{importRunId:e})}function u(e,n){o(t.runSucceeded,{importRunId:e,rowsRead:n.rowsRead,rowsSucceeded:n.rowsSucceeded,rowsFailed:n.rowsFailed})}function d(e,n){o(t.runFailed,{importRunId:e,reason:n})}export{u as a,l as i,c as n,t as o,d as r,s as t};
|
|
2
|
-
//# sourceMappingURL=publish-
|
|
2
|
+
//# sourceMappingURL=publish-D6PH6nu4.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"publish-
|
|
1
|
+
{"version":3,"file":"publish-D6PH6nu4.mjs","names":[],"sources":["../src/realtime/topics.ts","../src/realtime/publish.ts"],"sourcesContent":["/**\n * Imports realtime topic registry.\n *\n * Topics use the `imports.run.<event>` namespace. Subscribers listen\n * via `imports.run.**` for everything or narrow per event.\n *\n * Payload shapes are intentionally untyped here — callers pass\n * `Record<string, unknown>` and consumers (the imports admin page)\n * read fields by name. The point of this layer is \"an import_run\n * row's status changed, refetch\" rather than diffing event payloads\n * against the page's snapshot.\n *\n * Why a separate namespace from `queue.job.*`: queue events are\n * job-shaped (id, type, attempts, …) and admin-only. Imports events\n * are row-shaped (importRunId, status, brand, supplier, …) and\n * track the entity lifecycle independent of the underlying queue\n * mechanics — a future refactor could swap the queue for a different\n * driver without changing the imports event surface.\n */\n\nexport const IMPORTS_TOPICS = {\n /**\n * The upload route inserted an `import_run` row + enqueued its\n * driving queue job. Fired AFTER the outbox txn commits, so a\n * subscriber's refetch sees the row.\n */\n runCreated: 'imports.run.created',\n /**\n * The worker claimed the row and transitioned its status to\n * `running` — the streaming reader is open, the first batch is\n * about to bulk-write. Distinct from `queue.job.claimed` because\n * imports' state machine flips on the row, not the job.\n */\n runStarted: 'imports.run.started',\n /**\n * The worker finished cleanly — `import_run.status = 'succeeded'`,\n * `errorSummary` populated, totals locked in. Subscribers refetch\n * the history slice to bring the row out of the Active panel and\n * into Recent.\n */\n runSucceeded: 'imports.run.succeeded',\n /**\n * The worker (or `markFailed`) wrote `import_run.status = 'failed'`\n * with an error in `errorSummary`. Same refetch trigger; the page\n * differentiates terminal-success vs terminal-failure by reading\n * the row.\n */\n runFailed: 'imports.run.failed',\n} as const\n\nexport type ImportsTopic = (typeof IMPORTS_TOPICS)[keyof typeof IMPORTS_TOPICS]\n","/**\n * Typed publisher wrappers around `@murumets-ee/core/realtime.publishEvent`\n * for the `imports.run.*` topic surface.\n *\n * Like queue events, every imports event is admin-only — a future\n * refactor that opens these to non-admin operators (e.g. an\n * `imports:view` permission scope) would update {@link ADMIN_SCOPE}\n * here, not the call sites.\n *\n * Publish failures are caught and logged, never thrown — these\n * wrappers are called from worker state-transition paths and the\n * upload route, and a missing realtime delivery must NEVER roll back\n * the database write that triggered it.\n *\n * Why dynamic import: `@murumets-ee/core/realtime` carries\n * `import 'server-only'`, but this module is reachable from the\n * imports package's entrypoints (e.g. `worker.ts`), which are loaded\n * by jiti/tsx in `lumi doctor` / `lumi migrate` / `pnpm worker` —\n * none of which set the `react-server` export condition that turns\n * `server-only` into the empty stub. A static value-import would\n * therefore throw at boot. Same dynamic-import pattern as\n * `packages/queue/src/realtime/publish.ts` and\n * `packages/ticketing/src/realtime/publish.ts`.\n *\n * The `cachedPublish` + `publishSafe` + `_warmRealtimePublisher`\n * scaffolding here is the third copy of the same shape (queue,\n * ticketing, imports). Tracked for extraction to a shared\n * `@murumets-ee/core/realtime/safe-publisher` helper in #201 —\n * reach for that when it lands instead of copy-pasting a fourth\n * time.\n */\n\nimport type { Scope } from '@murumets-ee/core/realtime'\nimport { createLogger } from '@murumets-ee/logging'\nimport { IMPORTS_TOPICS } from './topics.js'\n\nconst logger = createLogger({ name: 'imports:realtime' })\n\n/** All imports events fan out to admin connections only. */\nconst ADMIN_SCOPE: Scope = { admin: true }\n\n// Cached publisher — once the dynamic import has resolved, every\n// subsequent publish is fully synchronous. Only the very first call\n// after process start pays the import cost.\nlet cachedPublish: typeof import('@murumets-ee/core/realtime').publishEvent | null = null\n\nfunction logPublishError(err: unknown, topic: string): void {\n if (err instanceof Error && err.name === 'RealtimeOversizeError') {\n // Oversize is a code bug (payload above the 7900-byte pg_notify\n // cap). For imports row events the payload is tiny — id, status,\n // brand/supplier label fragments — well under the cap. If this\n // ever fires the deriver smuggled too much in.\n logger.error({ err, topic }, 'realtime publish failed — payload oversize')\n } else {\n logger.warn({ err, topic }, 'realtime publish failed — event dropped')\n }\n}\n\nfunction publishSafe(topic: string, payload: Record<string, unknown>): void {\n if (cachedPublish) {\n try {\n cachedPublish({ topic, payload, scope: ADMIN_SCOPE })\n } catch (err) {\n logPublishError(err, topic)\n }\n return\n }\n // First call: resolve the import then fan out. Subsequent calls in\n // the same microtask race the import — that's fine, they queue\n // behind the same promise and use the resolved cache when it lands.\n import('@murumets-ee/core/realtime')\n .then((mod) => {\n cachedPublish = mod.publishEvent\n cachedPublish({ topic, payload, scope: ADMIN_SCOPE })\n })\n .catch((err: unknown) => {\n logPublishError(err, topic)\n })\n}\n\n/**\n * @internal — tests pre-resolve the dynamic import so the first\n * publish is synchronous, matching the post-warmup behaviour in\n * production.\n */\nexport async function _warmRealtimePublisher(): Promise<void> {\n if (cachedPublish) return\n const mod = await import('@murumets-ee/core/realtime')\n cachedPublish = mod.publishEvent\n}\n\n/**\n * Fire after the upload route's outbox txn commits, NOT inside it.\n * The realtime fanout is best-effort and a failure must not roll\n * back the row insert.\n */\nexport function publishImportRunCreated(\n importRunId: string,\n fields: { brandSlug?: string; supplierDisplayName?: string; filename: string },\n): void {\n publishSafe(IMPORTS_TOPICS.runCreated, {\n importRunId,\n ...(fields.brandSlug !== undefined && { brandSlug: fields.brandSlug }),\n ...(fields.supplierDisplayName !== undefined && {\n supplierDisplayName: fields.supplierDisplayName,\n }),\n filename: fields.filename,\n })\n}\n\n/**\n * Fire after the worker writes `status: 'running'` on the row.\n * Subscribers refetch to move the row from \"queued\" to \"running\" in\n * the Active panel and update the History snapshot.\n */\nexport function publishImportRunStarted(importRunId: string): void {\n publishSafe(IMPORTS_TOPICS.runStarted, { importRunId })\n}\n\n/**\n * Fire after the worker writes `status: 'succeeded'` + final totals.\n * Subscribers refetch to move the row from Active → Recent.\n */\nexport function publishImportRunSucceeded(\n importRunId: string,\n totals: { rowsRead: number; rowsSucceeded: number; rowsFailed: number },\n): void {\n publishSafe(IMPORTS_TOPICS.runSucceeded, {\n importRunId,\n rowsRead: totals.rowsRead,\n rowsSucceeded: totals.rowsSucceeded,\n rowsFailed: totals.rowsFailed,\n })\n}\n\n/**\n * Fire after the worker writes `status: 'failed'` with an error in\n * `errorSummary`. Subscribers refetch to surface the failure in\n * Recent.\n */\nexport function publishImportRunFailed(importRunId: string, reason: string): void {\n publishSafe(IMPORTS_TOPICS.runFailed, { importRunId, reason })\n}\n"],"mappings":"oDAoBA,MAAa,EAAiB,CAM5B,WAAY,sBAOZ,WAAY,sBAOZ,aAAc,wBAOd,UAAW,qBACZ,CCZK,EAAS,EAAa,CAAE,KAAM,mBAAoB,CAAC,CAGnD,EAAqB,CAAE,MAAO,GAAM,CAK1C,IAAI,EAAiF,KAErF,SAAS,EAAgB,EAAc,EAAqB,CACtD,aAAe,OAAS,EAAI,OAAS,wBAKvC,EAAO,MAAM,CAAE,MAAK,QAAO,CAAE,6CAA6C,CAE1E,EAAO,KAAK,CAAE,MAAK,QAAO,CAAE,0CAA0C,CAI1E,SAAS,EAAY,EAAe,EAAwC,CAC1E,GAAI,EAAe,CACjB,GAAI,CACF,EAAc,CAAE,QAAO,UAAS,MAAO,EAAa,CAAC,OAC9C,EAAK,CACZ,EAAgB,EAAK,EAAM,CAE7B,OAKF,OAAO,8BACJ,KAAM,GAAQ,CACb,EAAgB,EAAI,aACpB,EAAc,CAAE,QAAO,UAAS,MAAO,EAAa,CAAC,EACrD,CACD,MAAO,GAAiB,CACvB,EAAgB,EAAK,EAAM,EAC3B,CAQN,eAAsB,GAAwC,CACxD,AAEJ,KAAgB,MADE,OAAO,+BACL,aAQtB,SAAgB,EACd,EACA,EACM,CACN,EAAY,EAAe,WAAY,CACrC,cACA,GAAI,EAAO,YAAc,IAAA,IAAa,CAAE,UAAW,EAAO,UAAW,CACrE,GAAI,EAAO,sBAAwB,IAAA,IAAa,CAC9C,oBAAqB,EAAO,oBAC7B,CACD,SAAU,EAAO,SAClB,CAAC,CAQJ,SAAgB,EAAwB,EAA2B,CACjE,EAAY,EAAe,WAAY,CAAE,cAAa,CAAC,CAOzD,SAAgB,EACd,EACA,EACM,CACN,EAAY,EAAe,aAAc,CACvC,cACA,SAAU,EAAO,SACjB,cAAe,EAAO,cACtB,WAAY,EAAO,WACpB,CAAC,CAQJ,SAAgB,EAAuB,EAAqB,EAAsB,CAChF,EAAY,EAAe,UAAW,CAAE,cAAa,SAAQ,CAAC"}
|
package/dist/realtime/index.mjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{a as e,i as t,n,o as r,r as i,t as a}from"../publish-
|
|
1
|
+
import{a as e,i as t,n,o as r,r as i,t as a}from"../publish-D6PH6nu4.mjs";export{r as IMPORTS_TOPICS,a as _warmRealtimePublisher,n as publishImportRunCreated,i as publishImportRunFailed,t as publishImportRunStarted,e as publishImportRunSucceeded};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
var e=Object.defineProperty,t=(t,n)=>{let r={};for(var i in t)e(r,i,{get:t[i],enumerable:!0});return n||e(r,Symbol.toStringTag,{value:`Module`}),r};export{t};
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
import{bulkUpsert as e}from"@murumets-ee/search-elasticsearch";import{createReadStream as t}from"node:fs";import{parse as n}from"csv-parse";const r=50,i=5;var a=class{patterns=new Map;maxPatterns;maxSamplesPerPattern;droppedSignatures=0;constructor(e={}){this.maxPatterns=e.maxPatterns??50,this.maxSamplesPerPattern=e.maxSamplesPerPattern??5}addError(e,t,n,r,i){let a=`${t}:${r??`GENERAL`}:${n}`,o=this.patterns.get(a);if(!o){if(this.patterns.size>=this.maxPatterns){this.droppedSignatures+=1;return}o={errorType:t,field:r??null,message:n,count:0,firstOccurrence:e,lastOccurrence:e,samples:[]},this.patterns.set(a,o)}o.count+=1,o.lastOccurrence=e,o.samples.length<this.maxSamplesPerPattern&&o.samples.push({rowNumber:e,rowData:i})}getTotalErrorCount(){let e=0;for(let t of this.patterns.values())e+=t.count;return e}getDistinctPatternCount(){return this.patterns.size}getDroppedSignatureCount(){return this.droppedSignatures}getTopPatterns(){let e=Array.from(this.patterns.values()).sort((e,t)=>t.count-e.count),t=e.reduce((e,t)=>e+t.count,0);return e.map(e=>({errorType:e.errorType,field:e.field,message:e.message,count:e.count,firstOccurrence:e.firstOccurrence,lastOccurrence:e.lastOccurrence,samples:e.samples.slice(),percentage:t>0?e.count/t*100:0}))}snapshot(){return{totalErrors:this.getTotalErrorCount(),distinctPatterns:this.getDistinctPatternCount(),droppedSignatures:this.droppedSignatures,patterns:this.getTopPatterns()}}};async function o(e,n={}){let{hasHeader:r=!0}=n,i=t(e),a=0,o=0;for await(let e of i){for(let t=0;t<e.length;t+=1)e[t]===10&&(a+=1);e.length>0&&(o=e[e.length-1]??0)}return o!==0&&o!==10&&(a+=1),r?Math.max(0,a-1):a}async function*s(e){let{filePath:r,delimiter:i=` `,hasHeader:a=!0,columns:o,relaxColumnCount:s=!1,quote:c=!1}=e,l=o?Array.from(o):a,u=t(r),d=u.pipe(n({delimiter:i,columns:l,bom:!0,skip_empty_lines:!0,relax_column_count:s,quote:c})),f=0;try{for await(let e of d){f+=1;let t={};if(Array.isArray(e)){let n=e;for(let e=0;e<n.length;e+=1)t[String(e)]=n[e]??``}else for(let[n,r]of Object.entries(e))t[n]=r??``;yield{rowNumber:f,row:t}}}finally{u.destroy()}}const c=1e3;async function l(t){let{importRunId:n,runLabel:r,params:i,transform:o,feed:l,esClient:u,esIndex:d,batchSize:f=c,onProgress:p,rowLimit:m,signal:h,errorTracker:g=new a,logger:_}=t;if(f<1)throw Error(`batchSize must be >= 1 (got ${f})`);let v=Date.now();_?.info({importRunId:n,runLabel:r,batchSize:f,esIndex:d},`imports.runner: started`);let y=0,b=0,x=0,S=0,C=0,w=[],T=async()=>{if(w.length===0)return;let t=w;w=[];let r=Date.now(),i;try{i=await e(u,{index:d,docs:t.map(({id:e,doc:t})=>({id:e,doc:t})),...h!==void 0&&{signal:h}})}catch(e){let n=h?.aborted??(e instanceof Error&&(e.name===`AbortError`||/abort/i.test(e.message)))?`aborted`:`bulk_request_failed`,r=e instanceof Error?e.message:String(e);for(let{rowNumber:e}of t)g.addError(e,n,r,void 0,null);throw x+=t.length,e}b+=i.succeeded,x+=i.failures.length,C+=1;let a=Date.now()-r,o=(Date.now()-v)/1e3;if(_?.info({importRunId:n,batch:C,flushMs:a,submitted:t.length,succeeded:i.succeeded,failed:i.failures.length,rowsRead:y,rowsPerSecond:o>0?Math.round(y/o):0},`imports.runner: batch flushed`),i.failures.length>0){let e=new Map(t.map(e=>[e.id,e.rowNumber]));for(let t of i.failures){let n=e.get(t.id)??-1;g.addError(n,t.type,t.reason,void 0,{id:t.id})}}if(p){let e=(Date.now()-v)/1e3;p({rowsRead:y,rowsSucceeded:b,rowsFailed:x,rowsSkipped:S,batchesCompleted:C,elapsedSeconds:e,rowsPerSecond:e>0?y/e:0,distinctErrorPatterns:g.getDistinctPatternCount()})}},E=f,D=new Map;for await(let{rowNumber:e,row:t}of s(l)){if(h?.aborted||m!==void 0&&y>=m)break;if(y+=1,y===1&&_?.info({importRunId:n,rowNumber:e,sampleKeys:Object.keys(t)},`imports.runner: STREAM_FIRST_ROW`),y%E===0){let e=(Date.now()-v)/1e3;_?.info({importRunId:n,rowsRead:y,rowsSucceeded:b,rowsFailed:x,rowsSkipped:S,pending:w.length,elapsedSec:Math.round(e*10)/10,rowsPerSecond:e>0?Math.round(y/e):0,distinctFirstErrorTypes:D.size},`imports.runner: STREAM_TICK`)}let a={importRunId:n,params:i,runLabel:r,rowNumber:e},s;try{s=await o(t,a)}catch(r){let i=r instanceof Error?r.message:String(r);g.addError(e,`transform_threw`,i,void 0,t),D.has(`transform_threw`)||(D.set(`transform_threw`,{rowNumber:e,message:i}),_?.warn({importRunId:n,rowNumber:e,errorType:`transform_threw`,message:i},`imports.runner: FIRST_ERROR transform_threw`)),x+=1;continue}if(s.kind===`skip`){S+=1;continue}if(s.kind===`error`){g.addError(e,s.error.errorType,s.error.message,s.error.field,t),D.has(s.error.errorType)||(D.set(s.error.errorType,{rowNumber:e,message:s.error.message}),_?.warn({importRunId:n,rowNumber:e,errorType:s.error.errorType,field:s.error.field,message:s.error.message},`imports.runner: FIRST_ERROR ${s.error.errorType}`)),x+=1;continue}w.push({id:s.id,doc:s.doc,rowNumber:e}),w.length>=f&&await T()}await T();let O=(Date.now()-v)/1e3;return _?.info({importRunId:n,rowsRead:y,rowsSucceeded:b,rowsFailed:x,rowsSkipped:S,batchesCompleted:C,totalSec:Math.round(O*10)/10,rowsPerSecond:O>0?Math.round(y/O):0},`imports.runner: finished`),{rowsRead:y,rowsSucceeded:b,rowsFailed:x,rowsSkipped:S,batchesCompleted:C,errors:g.snapshot()}}export{r as a,s as i,l as n,i as o,o as r,a as s,c as t};
|
|
2
|
-
//# sourceMappingURL=runner-
|
|
2
|
+
//# sourceMappingURL=runner-D5to1Bt6.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"runner-D9FtnIBn.mjs","names":[],"sources":["../src/error-tracker.ts","../src/streaming.ts","../src/runner.ts"],"sourcesContent":["/**\n * Aggregates per-row errors into top-N pattern buckets so a feed of 1M\n * malformed rows surfaces as a handful of actionable signatures rather\n * than a million identical strings.\n *\n * Lifted from giga-test (`backend/src/workers/csv-importer.ts`) and\n * generalized:\n * - Configurable caps so the importer can tune memory bounds per run.\n * - Pure data — no logging side effects, no I/O.\n * - JSON-serialisable output via {@link ErrorTracker.snapshot} for the\n * `import_run.errorSummary` column.\n *\n * Pattern signature shape: `${errorType}:${field || 'GENERAL'}:${message}`.\n * Same `errorType + field + message` collapses to one bucket; differing\n * messages stay separate. This is intentional: a parser error on column\n * `NetPrice/Discount` (\"invalid number 'NA,5'\") and the same on column\n * `GrossPrice` are operationally distinct even if the parser is the same.\n *\n * Memory bounds: the patterns map is capped at `maxPatterns`. Once full,\n * additional NEW signatures are dropped — known patterns keep accumulating\n * counts. This is the \"top-N most common\" model: rare-but-novel errors\n * past the cap are invisible, but the cap protects against a runaway\n * adversarial feed exploding the map. Sample arrays are independently\n * capped at `maxSamplesPerPattern`.\n */\n\n/**\n * Recursively-defined JSON-serialisable value. Mirrors the shape of\n * `JsonValue` in `@murumets-ee/entity` without taking a dependency on\n * that package — error-tracker is otherwise standalone, and the\n * `ImportRun.errorSummary` JSONB column accepts anything in this shape.\n */\nexport type ImportJsonValue =\n | string\n | number\n | boolean\n | null\n | ImportJsonValue[]\n | { [key: string]: ImportJsonValue }\n\n/** One sample row attached to a pattern. `rowData` is the originally-parsed row. */\nexport interface ErrorSample {\n rowNumber: number\n rowData: ImportJsonValue\n}\n\n/** Public shape of an aggregated pattern as returned by {@link ErrorTracker.getTopPatterns}. */\nexport interface ErrorPattern {\n errorType: string\n field: string | null\n message: string\n count: number\n firstOccurrence: number\n lastOccurrence: number\n samples: ReadonlyArray<ErrorSample>\n /** `count / totalErrors`, scaled 0..100. `0` when there are zero errors total. */\n percentage: number\n}\n\nexport interface ErrorTrackerConfig {\n /** Hard cap on distinct signatures. Defaults to 50. New signatures past the cap are dropped. */\n maxPatterns?: number\n /** Hard cap on samples retained per pattern. Defaults to 5. Excess samples are dropped. */\n maxSamplesPerPattern?: number\n}\n\n/** Default caps — match giga-test for compatibility. */\nexport const DEFAULT_MAX_PATTERNS = 50\nexport const DEFAULT_MAX_SAMPLES_PER_PATTERN = 5\n\ninterface InternalPattern {\n errorType: string\n field: string | null\n message: string\n count: number\n firstOccurrence: number\n lastOccurrence: number\n samples: ErrorSample[]\n}\n\nexport class ErrorTracker {\n private readonly patterns = new Map<string, InternalPattern>()\n private readonly maxPatterns: number\n private readonly maxSamplesPerPattern: number\n private droppedSignatures = 0\n\n constructor(config: ErrorTrackerConfig = {}) {\n this.maxPatterns = config.maxPatterns ?? DEFAULT_MAX_PATTERNS\n this.maxSamplesPerPattern = config.maxSamplesPerPattern ?? DEFAULT_MAX_SAMPLES_PER_PATTERN\n }\n\n /**\n * Record one error. Same `(errorType, field, message)` triple bumps the\n * existing bucket; a new triple opens a new one (subject to {@link maxPatterns}).\n *\n * `field` is optional — pass `undefined` for errors not tied to a single\n * column (e.g. parse errors at row level). Internally normalised to the\n * literal string `'GENERAL'` so it shares a bucket with other genericised\n * errors of the same type+message.\n */\n addError(\n rowNumber: number,\n errorType: string,\n message: string,\n field: string | undefined,\n rowData: ImportJsonValue,\n ): void {\n const fieldKey = field ?? 'GENERAL'\n const signature = `${errorType}:${fieldKey}:${message}`\n\n let pattern = this.patterns.get(signature)\n if (!pattern) {\n if (this.patterns.size >= this.maxPatterns) {\n this.droppedSignatures += 1\n return\n }\n pattern = {\n errorType,\n field: field ?? null,\n message,\n count: 0,\n firstOccurrence: rowNumber,\n lastOccurrence: rowNumber,\n samples: [],\n }\n this.patterns.set(signature, pattern)\n }\n\n pattern.count += 1\n pattern.lastOccurrence = rowNumber\n if (pattern.samples.length < this.maxSamplesPerPattern) {\n pattern.samples.push({ rowNumber, rowData })\n }\n }\n\n /** Total count across every pattern. Counts errors, not patterns. */\n getTotalErrorCount(): number {\n let sum = 0\n for (const p of this.patterns.values()) sum += p.count\n return sum\n }\n\n /** Number of distinct signatures retained in the map (≤ `maxPatterns`). */\n getDistinctPatternCount(): number {\n return this.patterns.size\n }\n\n /**\n * Number of NEW signatures dropped because the map was already at\n * capacity. Surfacing this in the import_run summary tells the operator\n * \"the top-N was saturated — there's a long tail you're not seeing\".\n */\n getDroppedSignatureCount(): number {\n return this.droppedSignatures\n }\n\n /**\n * Top patterns sorted by descending count, capped at `maxPatterns`.\n * Stable secondary order is insertion order (Map iteration order is\n * insertion order; Array.sort is stable in V8).\n */\n getTopPatterns(): ErrorPattern[] {\n const all = Array.from(this.patterns.values()).sort((a, b) => b.count - a.count)\n const totalErrors = all.reduce((sum, p) => sum + p.count, 0)\n return all.map((p) => ({\n errorType: p.errorType,\n field: p.field,\n message: p.message,\n count: p.count,\n firstOccurrence: p.firstOccurrence,\n lastOccurrence: p.lastOccurrence,\n samples: p.samples.slice(),\n percentage: totalErrors > 0 ? (p.count / totalErrors) * 100 : 0,\n }))\n }\n\n /**\n * Compact JSON-serialisable snapshot for `import_run.errorSummary`.\n * Aside from the patterns array, includes the totals so a reader of\n * just this column doesn't have to re-derive them.\n */\n snapshot(): ErrorTrackerSnapshot {\n return {\n totalErrors: this.getTotalErrorCount(),\n distinctPatterns: this.getDistinctPatternCount(),\n droppedSignatures: this.droppedSignatures,\n patterns: this.getTopPatterns(),\n }\n }\n}\n\nexport interface ErrorTrackerSnapshot {\n totalErrors: number\n distinctPatterns: number\n droppedSignatures: number\n patterns: ErrorPattern[]\n}\n","/**\n * Tab-delimited / CSV streaming reader. Generic over delimiter so the\n * same path handles `.txt` (tab), `.csv` (comma), and the rare `;`\n * European export dialect.\n *\n * Built on `csv-parse` per giga-test precedent — node-stream-based, low\n * memory, handles UTF-8 BOM (the carmaker feeds are Windows-exported\n * and ship with a BOM that breaks naive split-on-tab parsers).\n *\n * The reader yields `{ rowNumber, row }` pairs where:\n * - `rowNumber` is 1-based and counts the header as row 0.\n * - `row` is `Record<string, string>` keyed by header name. Empty cells\n * are the empty string, NOT `undefined` — feed transforms test with\n * `value === ''` consistently.\n *\n * Why this lives in a streaming reader and not inside the transform\n * itself: the transform sees one already-parsed row at a time, never\n * the file. That keeps transform implementations free of I/O concerns\n * and makes them trivially unit-testable with a fixture row map.\n */\n\nimport { createReadStream } from 'node:fs'\nimport { parse } from 'csv-parse'\n\n/**\n * Pre-pass: count data rows in a delimited file (excluding the header\n * row when `hasHeader` is true; default `true` to match `streamFeed`).\n *\n * Implementation: stream the file as raw bytes and tally `\\n`. NOT a\n * full parse — escaped newlines inside quoted fields would over-count.\n * That's acceptable for the supplier-feed use case because we default\n * `quote: false` (every `\\n` IS a row boundary). For genuine RFC-4180\n * CSV inputs with embedded newlines, treat the result as an upper\n * bound; the actual parsed row count from `streamFeed` is authoritative.\n *\n * Why bytes-only and not csv-parse: a 16 MB feed counts in ~50 ms with\n * `\\n` tally vs ~500 ms with csv-parse parsing. For 500 MB the gap\n * matters — pre-pass needs to be a small fraction of the actual import\n * or the operator perceives the run as \"stuck on pre-pass\" with no UI.\n *\n * Returns the number of DATA rows (header excluded when present).\n * Empty trailing lines (no `\\n` after the final row, or a single\n * trailing `\\n`) are handled identically to how csv-parse treats them\n * with `skip_empty_lines: true`.\n */\nexport async function countDataRows(\n filePath: string,\n options: { hasHeader?: boolean } = {},\n): Promise<number> {\n const { hasHeader = true } = options\n const stream = createReadStream(filePath)\n let count = 0\n let lastByte = 0\n for await (const chunk of stream as AsyncIterable<Buffer>) {\n for (let i = 0; i < chunk.length; i += 1) {\n if (chunk[i] === 0x0a /* \\n */) count += 1\n }\n if (chunk.length > 0) lastByte = chunk[chunk.length - 1] ?? 0\n }\n // If the file doesn't end with a newline, the final row didn't get\n // counted by the `\\n` tally — add one. Skipping when the file is\n // entirely empty (count=0 and lastByte=0).\n if (lastByte !== 0 && lastByte !== 0x0a) count += 1\n return hasHeader ? Math.max(0, count - 1) : count\n}\n\nexport interface StreamFeedOptions {\n /** Path to the file on disk. The PoC uploads land on local disk; S3-keyed reads come later. */\n filePath: string\n /**\n * Single-character field delimiter. Default `\\t` (the carmaker feed\n * format). Pass `,` for CSV, `;` for some European dialects.\n */\n delimiter?: string\n /**\n * `true` (default): the first row is the header and column names come\n * from it. `false`: rows are emitted as positional `{ \"0\": ..., \"1\": ... }`\n * and the transform reads by index — useful for headerless feeds that\n * commit to a documented column order.\n */\n hasHeader?: boolean\n /**\n * Optional explicit column-name list. When provided, takes precedence\n * over `hasHeader` (header row, if present, is skipped but its values\n * are ignored). Useful when the upstream header is unstable but the\n * positional shape isn't.\n */\n columns?: ReadonlyArray<string>\n /**\n * Forward to `csv-parse` `relax_column_count`. Default `false` —\n * a row whose column count doesn't match the header surfaces as a\n * parser error so the transform isn't silently fed truncated data.\n */\n relaxColumnCount?: boolean\n /**\n * Forward to `csv-parse` `quote`. The character (or `false`) that\n * encloses fields containing the delimiter. Default `false` — most\n * supplier feeds in this package's target use case are tab-delimited\n * exports that contain literal `\"` characters in description fields\n * (e.g. `LIVRE \"INVERSION D'IMAGE\"` from the Mercedes carmaker feed)\n * with NO quote-as-field-wrapper convention. csv-parse's library\n * default `'\"'` then misreads the literal `\"` as an opening quote\n * and rejects the row with `Invalid Opening Quote`.\n *\n * Pass `'\"'` (or any character) to opt back into RFC-4180-style\n * CSV parsing for genuine quoted-CSV inputs. Pass `'\\''` for\n * single-quoted dialects.\n */\n quote?: string | false\n}\n\nexport interface StreamFeedRow {\n /** 1-based row number. Header (when present) is row 0; first data row is row 1. */\n rowNumber: number\n /**\n * Cell values keyed by column name (or string-position when\n * `hasHeader: false` AND no `columns`).\n *\n * **Cell-value invariants:**\n * - Empty cells (`A\\t\\tC`) → `''` (empty string).\n * - Missing TRAILING cells in `relaxColumnCount: true` mode → the\n * key is **absent** from the object, not present-with-`''`. csv-parse\n * does not emit keys for short rows. Transforms reading those\n * columns get `undefined` from `row['col']` and must handle it\n * (`row['col'] ?? ''` is the canonical idiom).\n * - With the default `relaxColumnCount: false`, short rows reject at\n * the parser, so this case never reaches the transform.\n */\n row: Record<string, string>\n}\n\n/**\n * Async-iterable over the parsed rows of a delimited file. Use with\n * `for await (const { rowNumber, row } of streamFeed({ filePath, ... }))`.\n *\n * The iterator owns its file descriptor — the `for await` loop closes\n * the underlying stream when it returns or breaks. Aborting mid-stream\n * (`break`, `throw`, signal) is safe; csv-parse propagates the close.\n */\nexport async function* streamFeed(options: StreamFeedOptions): AsyncIterable<StreamFeedRow> {\n const {\n filePath,\n delimiter = '\\t',\n hasHeader = true,\n columns,\n relaxColumnCount = false,\n quote = false,\n } = options\n\n // When the caller provides explicit `columns`, prefer them. When the\n // file has a header but no explicit `columns`, csv-parse takes the\n // first row as the column source. When neither is true, rows are\n // emitted with string-position keys.\n //\n // Typed against csv-parse's actual `columns?: ColumnOption[] | boolean`\n // signature — `string[]` satisfies `ColumnOption[]` since\n // `ColumnOption = string | undefined | null | false | { name: string }`.\n const columnConfig: string[] | boolean = columns ? Array.from(columns) : hasHeader\n\n const stream = createReadStream(filePath)\n const parser = stream.pipe(\n parse({\n delimiter,\n columns: columnConfig,\n bom: true,\n skip_empty_lines: true,\n // csv-parse defaults to strict column count; opt-in relaxation only.\n relax_column_count: relaxColumnCount,\n // Default `quote: false` — see StreamFeedOptions.quote JSDoc.\n // Carmaker / parts-feed exports contain literal `\"` characters\n // in description fields and would otherwise reject as\n // `Invalid Opening Quote`. Genuine RFC-4180 CSV consumers opt\n // back in by passing `quote: '\"'`.\n quote,\n }),\n )\n\n let rowNumber = 0\n try {\n for await (const rawRow of parser as AsyncIterable<\n ReadonlyArray<string> | Record<string, string | undefined>\n >) {\n rowNumber += 1\n // csv-parse emits records with string keys when `columns` is\n // truthy, otherwise an Array. Normalise both shapes to\n // `Record<string, string>` so transforms can rely on `value === ''`\n // for missing cells (csv-parse leaves trailing missing cells as\n // `undefined` when `relax_column_count: true`; this collapses\n // them to `''` to keep the contract uniform).\n const row: Record<string, string> = {}\n if (Array.isArray(rawRow)) {\n const arr = rawRow as ReadonlyArray<string | undefined>\n for (let i = 0; i < arr.length; i += 1) {\n row[String(i)] = arr[i] ?? ''\n }\n } else {\n for (const [k, v] of Object.entries(rawRow)) {\n row[k] = v ?? ''\n }\n }\n yield { rowNumber, row }\n }\n } finally {\n // Safety: ensure the underlying file descriptor closes even if the\n // consumer breaks mid-iteration. Node closes streams on\n // garbage-collection but this makes it deterministic under tests.\n stream.destroy()\n }\n}\n","/**\n * One-shot importer: stream rows → transform → batched bulk-write to ES,\n * accumulating per-row errors into `ErrorTracker` and reporting progress\n * to the queue every batch.\n *\n * Per PLAN-ECOMMERCE.md PR 7 (PoC scope):\n * - **Batch size 1000.** Matches giga-test precedent. Configurable for\n * integration tests that don't want a 1k row floor.\n * - **No resumability and no automatic retries.** The `imports:run` queue\n * job is registered with `defaultRetries: 0` (see `worker.ts`) so a\n * failed handler does NOT re-enqueue itself — re-running a multi-batch\n * import against the same `import_run.id` while the previous attempt\n * may still be writing is a footgun (duplicate batches, double-counted\n * progress). Operator retries by creating a NEW `import_run` row.\n * - **No per-supplier transform plugin.** The runner takes a single\n * `RowTransform<TDoc>` from the registry and applies it to every row;\n * PR 8 may diverge but only by registering a different transform name.\n * - **Direct `bulkUpsert` into the live aliased index** (D6 alias is\n * set up by PR 4's `ensureAliasedIndex`; the importer doesn't reindex).\n *\n * Per D21 (sanctioned bulk path): this runner intentionally bypasses\n * AdminClient and entity hooks. Per-batch audit / observability lives\n * on the surrounding `import_run` row + queue progress, NOT per-row.\n */\n\nimport { bulkUpsert, type BulkIndexResult, type EsClientLike } from '@murumets-ee/search-elasticsearch'\nimport { ErrorTracker } from './error-tracker.js'\nimport { streamFeed, type StreamFeedOptions } from './streaming.js'\nimport type { RowTransform, TransformContext } from './transform.js'\n\n/** Soft default; chosen to match giga-test. ES bulk requests over ~5MB get split server-side anyway. */\nexport const DEFAULT_BATCH_SIZE = 1000\n\nexport interface RunImportOptions<TDoc> {\n /** UUID of the `import_run` row driving this run. Forwarded to every transform invocation. */\n importRunId: string\n /** Operator-supplied label for the run. Forwarded to the transform context. */\n runLabel: string\n /** Opaque per-run params copied from `import_run.params`. */\n params: Record<string, unknown>\n /** Transform applied to every parsed row. */\n transform: RowTransform<TDoc>\n /** Streaming reader options — file path, delimiter, header config. */\n feed: StreamFeedOptions\n /** ES client (low-level shape from `@murumets-ee/search-elasticsearch`). */\n esClient: EsClientLike\n /** Index alias to write to. Per D6, callers always pass an alias, never a physical index. */\n esIndex: string\n /** Rows per `bulkUpsert` call. Default {@link DEFAULT_BATCH_SIZE}. */\n batchSize?: number\n /**\n * Callback invoked after every batch. The handler in `worker.ts`\n * forwards this to `ctx.updateProgress` for the queue UI; tests\n * inspect it directly. Synchronous + cheap so a slow callback can't\n * back-pressure the importer.\n */\n onProgress?: (progress: ImportRunProgress) => void\n /** Optional: stop processing after this many rows. Tests use it; production passes `undefined`. */\n rowLimit?: number\n /** Abort signal threaded into the underlying ES client request — cooperative cancel. */\n signal?: AbortSignal\n /** Optional ErrorTracker config (caps). Default: top-50 patterns × 5 samples. */\n errorTracker?: ErrorTracker\n /**\n * Optional structural logger for per-batch telemetry. Receives\n * `{ batch, ms, succeeded, failed, rowsRead, rowsPerSecond }` after\n * every flush, plus a one-shot `started` and `finished` event. The\n * worker forwards `app.logger.child({ pkg: 'imports' })`; tests\n * leave it undefined.\n *\n * Why: without per-batch visibility, a stalled run looks identical\n * to \"first batch in progress\" forever — the only feedback is the\n * queue's `progress` jsonb (which writes after each batch) and the\n * eventual `succeeded`/`failed` job event. Adding this lets the\n * operator tail the worker log and immediately see whether ES is\n * rate-limiting, the transform is rejecting every row, or the\n * stream is actually progressing.\n */\n logger?: {\n info: (data: Record<string, unknown>, msg: string) => void\n warn: (data: Record<string, unknown>, msg: string) => void\n }\n}\n\n/**\n * Progress payload written to `toolkit_jobs.progress` after every batch.\n * Caps + flush rules live on the queue's `updateProgress` debounce —\n * callers don't need to throttle.\n */\nexport interface ImportRunProgress {\n rowsRead: number\n rowsSucceeded: number\n rowsFailed: number\n rowsSkipped: number\n batchesCompleted: number\n /** Wall-clock seconds since the runner started. */\n elapsedSeconds: number\n /** Rows / second, computed at every batch. */\n rowsPerSecond: number\n /** Distinct error patterns currently held by the tracker. Saturates at the cap. */\n distinctErrorPatterns: number\n /**\n * Total data-row count from a pre-pass scan, when the worker computed one.\n * Lets the UI render `processed / total` percent. Absent when the worker\n * skipped the count (file too large for the count budget, count failed,\n * etc.) — UI must treat it as optional.\n */\n totalRows?: number\n}\n\n/**\n * Final result returned by {@link runImport}. The handler writes these\n * onto the `import_run` row alongside the ErrorTracker snapshot.\n */\nexport interface RunImportResult {\n /** Total rows read from the file (excludes skipped empty lines). */\n rowsRead: number\n /** Rows the transform turned into a successful doc AND the ES cluster acknowledged. */\n rowsSucceeded: number\n /**\n * Rows that the transform rejected (`{ kind: 'error' }`) OR that ES\n * rejected on bulk-write (per-doc failure). Both are aggregated by\n * `errorTracker` for the import_run summary.\n */\n rowsFailed: number\n /** Rows that the transform skipped (`{ kind: 'skip' }`) — header noise, blank lines, intentional drop. */\n rowsSkipped: number\n /** Number of `bulkUpsert` calls made. */\n batchesCompleted: number\n /** Final value of {@link ErrorTracker.snapshot}. */\n errors: ReturnType<ErrorTracker['snapshot']>\n}\n\n/**\n * Apply the runner against a feed file. Stops on rowLimit OR end-of-file\n * OR if `signal` aborts. Throws if the streaming reader / ES client\n * throws — caller (the queue handler) catches that and writes\n * `import_run.status = 'failed'` with the error message in\n * `errorSummary.fatal`.\n */\nexport async function runImport<TDoc>(options: RunImportOptions<TDoc>): Promise<RunImportResult> {\n const {\n importRunId,\n runLabel,\n params,\n transform,\n feed,\n esClient,\n esIndex,\n batchSize = DEFAULT_BATCH_SIZE,\n onProgress,\n rowLimit,\n signal,\n errorTracker = new ErrorTracker(),\n logger,\n } = options\n\n if (batchSize < 1) {\n throw new Error(`batchSize must be >= 1 (got ${batchSize})`)\n }\n\n const startedAt = Date.now()\n logger?.info(\n { importRunId, runLabel, batchSize, esIndex },\n 'imports.runner: started',\n )\n let rowsRead = 0\n let rowsSucceeded = 0\n let rowsFailed = 0\n let rowsSkipped = 0\n let batchesCompleted = 0\n\n let pending: Array<{ id: string; doc: TDoc; rowNumber: number }> = []\n\n const flush = async (): Promise<void> => {\n if (pending.length === 0) return\n const batch = pending\n pending = []\n const flushStartedAt = Date.now()\n let result: BulkIndexResult\n try {\n result = await bulkUpsert<TDoc>(esClient, {\n index: esIndex,\n docs: batch.map(({ id, doc }) => ({ id, doc })),\n ...(signal !== undefined && { signal }),\n })\n } catch (err) {\n // Cluster- or transport-level failure — the whole batch is\n // unaccounted for. Distinguish abort (operator-driven cancel) from\n // a real cluster failure so the errorSummary doesn't mislabel a\n // cancelled run as broken cluster connectivity.\n const isAbort =\n signal?.aborted ??\n (err instanceof Error && (err.name === 'AbortError' || /abort/i.test(err.message)))\n const errorType = isAbort ? 'aborted' : 'bulk_request_failed'\n const reason = err instanceof Error ? err.message : String(err)\n for (const { rowNumber } of batch) {\n errorTracker.addError(rowNumber, errorType, reason, undefined, null)\n }\n rowsFailed += batch.length\n throw err\n }\n\n rowsSucceeded += result.succeeded\n rowsFailed += result.failures.length\n batchesCompleted += 1\n\n const flushMs = Date.now() - flushStartedAt\n const elapsedSec = (Date.now() - startedAt) / 1000\n logger?.info(\n {\n importRunId,\n batch: batchesCompleted,\n flushMs,\n submitted: batch.length,\n succeeded: result.succeeded,\n failed: result.failures.length,\n rowsRead,\n rowsPerSecond: elapsedSec > 0 ? Math.round(rowsRead / elapsedSec) : 0,\n },\n 'imports.runner: batch flushed',\n )\n\n if (result.failures.length > 0) {\n // Map each ES failure back to its source row via `id`. The bulk\n // response order matches the request order, but ES doesn't promise\n // that; matching by `id` is the safe path. PoC volume is small\n // enough that the O(failures × batch) cost is irrelevant.\n const byId = new Map(batch.map((b) => [b.id, b.rowNumber]))\n for (const fail of result.failures) {\n const rowNumber = byId.get(fail.id) ?? -1\n errorTracker.addError(rowNumber, fail.type, fail.reason, undefined, { id: fail.id })\n }\n }\n\n if (onProgress) {\n const elapsedSeconds = (Date.now() - startedAt) / 1000\n onProgress({\n rowsRead,\n rowsSucceeded,\n rowsFailed,\n rowsSkipped,\n batchesCompleted,\n elapsedSeconds,\n rowsPerSecond: elapsedSeconds > 0 ? rowsRead / elapsedSeconds : 0,\n distinctErrorPatterns: errorTracker.getDistinctPatternCount(),\n })\n }\n }\n\n // Streaming heartbeat — sample every Nth row so the operator sees the\n // streamer is alive even when a transform-rejected file would prevent\n // any flush from firing. Without this, a feed that's 100% rejected\n // looks identical to \"stuck\" until end-of-file. Sample frequency is\n // batchSize so the cadence matches the natural batch rhythm.\n const streamSampleEvery = batchSize\n\n // First-error capture per error type — surface what's actually rejecting\n // the rows when 100% of a feed is rejected. Without this, the only signal\n // is `errorTracker.snapshot()` which only writes to import_run.errorSummary\n // at the END of the run. Mid-run we want to know NOW.\n const firstErrorByType = new Map<string, { rowNumber: number; message: string }>()\n\n for await (const { rowNumber, row } of streamFeed(feed)) {\n if (signal?.aborted) break\n if (rowLimit !== undefined && rowsRead >= rowLimit) break\n rowsRead += 1\n\n if (rowsRead === 1) {\n logger?.info({ importRunId, rowNumber, sampleKeys: Object.keys(row) }, 'imports.runner: STREAM_FIRST_ROW')\n }\n if (rowsRead % streamSampleEvery === 0) {\n const elapsedSec = (Date.now() - startedAt) / 1000\n // Telemetry only — no row content, no error sample text.\n // PR #262 review (M-5): the prior `firstErrors:\n // Object.fromEntries(firstErrorByType)` payload included the\n // raw error message captured from the transform, which for the\n // carmaker feed embeds supplier codes + price fragments. The\n // detailed first-error breakdown is already persisted on\n // `import_run.errorSummary` via `errorTracker.snapshot()` —\n // operators inspect it there, not in the worker log.\n logger?.info(\n {\n importRunId,\n rowsRead,\n rowsSucceeded,\n rowsFailed,\n rowsSkipped,\n pending: pending.length,\n elapsedSec: Math.round(elapsedSec * 10) / 10,\n rowsPerSecond: elapsedSec > 0 ? Math.round(rowsRead / elapsedSec) : 0,\n distinctFirstErrorTypes: firstErrorByType.size,\n },\n 'imports.runner: STREAM_TICK',\n )\n }\n\n const ctx: TransformContext = { importRunId, params, runLabel, rowNumber }\n let result: Awaited<ReturnType<typeof transform>>\n try {\n result = await transform(row, ctx)\n } catch (err) {\n // A throw from the transform is a programmer error — surface it as\n // a row-level error so the run can continue. (If the bug is\n // catastrophic, the operator sees the same message repeated and\n // can stop the run.)\n const reason = err instanceof Error ? err.message : String(err)\n errorTracker.addError(rowNumber, 'transform_threw', reason, undefined, row)\n if (!firstErrorByType.has('transform_threw')) {\n firstErrorByType.set('transform_threw', { rowNumber, message: reason })\n // PR #262 review (M-5): drop the `row` payload from the\n // log line — supplier feeds carry pricing + internal codes.\n // The full row is on `import_run.errorSummary.samples` for\n // post-mortem review by operators with view permission.\n logger?.warn(\n { importRunId, rowNumber, errorType: 'transform_threw', message: reason },\n 'imports.runner: FIRST_ERROR transform_threw',\n )\n }\n rowsFailed += 1\n continue\n }\n\n if (result.kind === 'skip') {\n rowsSkipped += 1\n continue\n }\n if (result.kind === 'error') {\n errorTracker.addError(\n rowNumber,\n result.error.errorType,\n result.error.message,\n result.error.field,\n row,\n )\n if (!firstErrorByType.has(result.error.errorType)) {\n firstErrorByType.set(result.error.errorType, {\n rowNumber,\n message: result.error.message,\n })\n logger?.warn(\n {\n importRunId,\n rowNumber,\n errorType: result.error.errorType,\n field: result.error.field,\n message: result.error.message,\n },\n `imports.runner: FIRST_ERROR ${result.error.errorType}`,\n )\n }\n rowsFailed += 1\n continue\n }\n\n pending.push({ id: result.id, doc: result.doc, rowNumber })\n if (pending.length >= batchSize) {\n await flush()\n }\n }\n\n await flush()\n\n const totalSec = (Date.now() - startedAt) / 1000\n logger?.info(\n {\n importRunId,\n rowsRead,\n rowsSucceeded,\n rowsFailed,\n rowsSkipped,\n batchesCompleted,\n totalSec: Math.round(totalSec * 10) / 10,\n rowsPerSecond: totalSec > 0 ? Math.round(rowsRead / totalSec) : 0,\n },\n 'imports.runner: finished',\n )\n\n return {\n rowsRead,\n rowsSucceeded,\n rowsFailed,\n rowsSkipped,\n batchesCompleted,\n errors: errorTracker.snapshot(),\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"runner-D5to1Bt6.mjs","names":[],"sources":["../src/error-tracker.ts","../src/streaming.ts","../src/runner.ts"],"sourcesContent":["/**\n * Aggregates per-row errors into top-N pattern buckets so a feed of 1M\n * malformed rows surfaces as a handful of actionable signatures rather\n * than a million identical strings.\n *\n * Lifted from giga-test (`backend/src/workers/csv-importer.ts`) and\n * generalized:\n * - Configurable caps so the importer can tune memory bounds per run.\n * - Pure data — no logging side effects, no I/O.\n * - JSON-serialisable output via {@link ErrorTracker.snapshot} for the\n * `import_run.errorSummary` column.\n *\n * Pattern signature shape: `${errorType}:${field || 'GENERAL'}:${message}`.\n * Same `errorType + field + message` collapses to one bucket; differing\n * messages stay separate. This is intentional: a parser error on column\n * `NetPrice/Discount` (\"invalid number 'NA,5'\") and the same on column\n * `GrossPrice` are operationally distinct even if the parser is the same.\n *\n * Memory bounds: the patterns map is capped at `maxPatterns`. Once full,\n * additional NEW signatures are dropped — known patterns keep accumulating\n * counts. This is the \"top-N most common\" model: rare-but-novel errors\n * past the cap are invisible, but the cap protects against a runaway\n * adversarial feed exploding the map. Sample arrays are independently\n * capped at `maxSamplesPerPattern`.\n */\n\n/**\n * Recursively-defined JSON-serialisable value. Mirrors the shape of\n * `JsonValue` in `@murumets-ee/entity` without taking a dependency on\n * that package — error-tracker is otherwise standalone, and the\n * `ImportRun.errorSummary` JSONB column accepts anything in this shape.\n */\nexport type ImportJsonValue =\n | string\n | number\n | boolean\n | null\n | ImportJsonValue[]\n | { [key: string]: ImportJsonValue }\n\n/** One sample row attached to a pattern. `rowData` is the originally-parsed row. */\nexport interface ErrorSample {\n rowNumber: number\n rowData: ImportJsonValue\n}\n\n/** Public shape of an aggregated pattern as returned by {@link ErrorTracker.getTopPatterns}. */\nexport interface ErrorPattern {\n errorType: string\n field: string | null\n message: string\n count: number\n firstOccurrence: number\n lastOccurrence: number\n samples: ReadonlyArray<ErrorSample>\n /** `count / totalErrors`, scaled 0..100. `0` when there are zero errors total. */\n percentage: number\n}\n\nexport interface ErrorTrackerConfig {\n /** Hard cap on distinct signatures. Defaults to 50. New signatures past the cap are dropped. */\n maxPatterns?: number\n /** Hard cap on samples retained per pattern. Defaults to 5. Excess samples are dropped. */\n maxSamplesPerPattern?: number\n}\n\n/** Default caps — match giga-test for compatibility. */\nexport const DEFAULT_MAX_PATTERNS = 50\nexport const DEFAULT_MAX_SAMPLES_PER_PATTERN = 5\n\ninterface InternalPattern {\n errorType: string\n field: string | null\n message: string\n count: number\n firstOccurrence: number\n lastOccurrence: number\n samples: ErrorSample[]\n}\n\nexport class ErrorTracker {\n private readonly patterns = new Map<string, InternalPattern>()\n private readonly maxPatterns: number\n private readonly maxSamplesPerPattern: number\n private droppedSignatures = 0\n\n constructor(config: ErrorTrackerConfig = {}) {\n this.maxPatterns = config.maxPatterns ?? DEFAULT_MAX_PATTERNS\n this.maxSamplesPerPattern = config.maxSamplesPerPattern ?? DEFAULT_MAX_SAMPLES_PER_PATTERN\n }\n\n /**\n * Record one error. Same `(errorType, field, message)` triple bumps the\n * existing bucket; a new triple opens a new one (subject to {@link maxPatterns}).\n *\n * `field` is optional — pass `undefined` for errors not tied to a single\n * column (e.g. parse errors at row level). Internally normalised to the\n * literal string `'GENERAL'` so it shares a bucket with other genericised\n * errors of the same type+message.\n */\n addError(\n rowNumber: number,\n errorType: string,\n message: string,\n field: string | undefined,\n rowData: ImportJsonValue,\n ): void {\n const fieldKey = field ?? 'GENERAL'\n const signature = `${errorType}:${fieldKey}:${message}`\n\n let pattern = this.patterns.get(signature)\n if (!pattern) {\n if (this.patterns.size >= this.maxPatterns) {\n this.droppedSignatures += 1\n return\n }\n pattern = {\n errorType,\n field: field ?? null,\n message,\n count: 0,\n firstOccurrence: rowNumber,\n lastOccurrence: rowNumber,\n samples: [],\n }\n this.patterns.set(signature, pattern)\n }\n\n pattern.count += 1\n pattern.lastOccurrence = rowNumber\n if (pattern.samples.length < this.maxSamplesPerPattern) {\n pattern.samples.push({ rowNumber, rowData })\n }\n }\n\n /** Total count across every pattern. Counts errors, not patterns. */\n getTotalErrorCount(): number {\n let sum = 0\n for (const p of this.patterns.values()) sum += p.count\n return sum\n }\n\n /** Number of distinct signatures retained in the map (≤ `maxPatterns`). */\n getDistinctPatternCount(): number {\n return this.patterns.size\n }\n\n /**\n * Number of NEW signatures dropped because the map was already at\n * capacity. Surfacing this in the import_run summary tells the operator\n * \"the top-N was saturated — there's a long tail you're not seeing\".\n */\n getDroppedSignatureCount(): number {\n return this.droppedSignatures\n }\n\n /**\n * Top patterns sorted by descending count, capped at `maxPatterns`.\n * Stable secondary order is insertion order (Map iteration order is\n * insertion order; Array.sort is stable in V8).\n */\n getTopPatterns(): ErrorPattern[] {\n const all = Array.from(this.patterns.values()).sort((a, b) => b.count - a.count)\n const totalErrors = all.reduce((sum, p) => sum + p.count, 0)\n return all.map((p) => ({\n errorType: p.errorType,\n field: p.field,\n message: p.message,\n count: p.count,\n firstOccurrence: p.firstOccurrence,\n lastOccurrence: p.lastOccurrence,\n samples: p.samples.slice(),\n percentage: totalErrors > 0 ? (p.count / totalErrors) * 100 : 0,\n }))\n }\n\n /**\n * Compact JSON-serialisable snapshot for `import_run.errorSummary`.\n * Aside from the patterns array, includes the totals so a reader of\n * just this column doesn't have to re-derive them.\n */\n snapshot(): ErrorTrackerSnapshot {\n return {\n totalErrors: this.getTotalErrorCount(),\n distinctPatterns: this.getDistinctPatternCount(),\n droppedSignatures: this.droppedSignatures,\n patterns: this.getTopPatterns(),\n }\n }\n}\n\nexport interface ErrorTrackerSnapshot {\n totalErrors: number\n distinctPatterns: number\n droppedSignatures: number\n patterns: ErrorPattern[]\n}\n","/**\n * Tab-delimited / CSV streaming reader. Generic over delimiter so the\n * same path handles `.txt` (tab), `.csv` (comma), and the rare `;`\n * European export dialect.\n *\n * Built on `csv-parse` per giga-test precedent — node-stream-based, low\n * memory, handles UTF-8 BOM (the carmaker feeds are Windows-exported\n * and ship with a BOM that breaks naive split-on-tab parsers).\n *\n * The reader yields `{ rowNumber, row }` pairs where:\n * - `rowNumber` is 1-based and counts the header as row 0.\n * - `row` is `Record<string, string>` keyed by header name. Empty cells\n * are the empty string, NOT `undefined` — feed transforms test with\n * `value === ''` consistently.\n *\n * Why this lives in a streaming reader and not inside the transform\n * itself: the transform sees one already-parsed row at a time, never\n * the file. That keeps transform implementations free of I/O concerns\n * and makes them trivially unit-testable with a fixture row map.\n */\n\nimport { createReadStream } from 'node:fs'\nimport { parse } from 'csv-parse'\n\n/**\n * Pre-pass: count data rows in a delimited file (excluding the header\n * row when `hasHeader` is true; default `true` to match `streamFeed`).\n *\n * Implementation: stream the file as raw bytes and tally `\\n`. NOT a\n * full parse — escaped newlines inside quoted fields would over-count.\n * That's acceptable for the supplier-feed use case because we default\n * `quote: false` (every `\\n` IS a row boundary). For genuine RFC-4180\n * CSV inputs with embedded newlines, treat the result as an upper\n * bound; the actual parsed row count from `streamFeed` is authoritative.\n *\n * Why bytes-only and not csv-parse: a 16 MB feed counts in ~50 ms with\n * `\\n` tally vs ~500 ms with csv-parse parsing. For 500 MB the gap\n * matters — pre-pass needs to be a small fraction of the actual import\n * or the operator perceives the run as \"stuck on pre-pass\" with no UI.\n *\n * Returns the number of DATA rows (header excluded when present).\n * Empty trailing lines (no `\\n` after the final row, or a single\n * trailing `\\n`) are handled identically to how csv-parse treats them\n * with `skip_empty_lines: true`.\n */\nexport async function countDataRows(\n filePath: string,\n options: { hasHeader?: boolean } = {},\n): Promise<number> {\n const { hasHeader = true } = options\n const stream = createReadStream(filePath)\n let count = 0\n let lastByte = 0\n for await (const chunk of stream as AsyncIterable<Buffer>) {\n for (let i = 0; i < chunk.length; i += 1) {\n if (chunk[i] === 0x0a /* \\n */) count += 1\n }\n if (chunk.length > 0) lastByte = chunk[chunk.length - 1] ?? 0\n }\n // If the file doesn't end with a newline, the final row didn't get\n // counted by the `\\n` tally — add one. Skipping when the file is\n // entirely empty (count=0 and lastByte=0).\n if (lastByte !== 0 && lastByte !== 0x0a) count += 1\n return hasHeader ? Math.max(0, count - 1) : count\n}\n\nexport interface StreamFeedOptions {\n /** Path to the file on disk. The PoC uploads land on local disk; S3-keyed reads come later. */\n filePath: string\n /**\n * Single-character field delimiter. Default `\\t` (the carmaker feed\n * format). Pass `,` for CSV, `;` for some European dialects.\n */\n delimiter?: string\n /**\n * `true` (default): the first row is the header and column names come\n * from it. `false`: rows are emitted as positional `{ \"0\": ..., \"1\": ... }`\n * and the transform reads by index — useful for headerless feeds that\n * commit to a documented column order.\n */\n hasHeader?: boolean\n /**\n * Optional explicit column-name list. When provided, takes precedence\n * over `hasHeader` (header row, if present, is skipped but its values\n * are ignored). Useful when the upstream header is unstable but the\n * positional shape isn't.\n */\n columns?: ReadonlyArray<string>\n /**\n * Forward to `csv-parse` `relax_column_count`. Default `false` —\n * a row whose column count doesn't match the header surfaces as a\n * parser error so the transform isn't silently fed truncated data.\n */\n relaxColumnCount?: boolean\n /**\n * Forward to `csv-parse` `quote`. The character (or `false`) that\n * encloses fields containing the delimiter. Default `false` — most\n * supplier feeds in this package's target use case are tab-delimited\n * exports that contain literal `\"` characters in description fields\n * (e.g. `LIVRE \"INVERSION D'IMAGE\"` from the Mercedes carmaker feed)\n * with NO quote-as-field-wrapper convention. csv-parse's library\n * default `'\"'` then misreads the literal `\"` as an opening quote\n * and rejects the row with `Invalid Opening Quote`.\n *\n * Pass `'\"'` (or any character) to opt back into RFC-4180-style\n * CSV parsing for genuine quoted-CSV inputs. Pass `'\\''` for\n * single-quoted dialects.\n */\n quote?: string | false\n}\n\nexport interface StreamFeedRow {\n /** 1-based row number. Header (when present) is row 0; first data row is row 1. */\n rowNumber: number\n /**\n * Cell values keyed by column name (or string-position when\n * `hasHeader: false` AND no `columns`).\n *\n * **Cell-value invariants:**\n * - Empty cells (`A\\t\\tC`) → `''` (empty string).\n * - Missing TRAILING cells in `relaxColumnCount: true` mode → the\n * key is **absent** from the object, not present-with-`''`. csv-parse\n * does not emit keys for short rows. Transforms reading those\n * columns get `undefined` from `row['col']` and must handle it\n * (`row['col'] ?? ''` is the canonical idiom).\n * - With the default `relaxColumnCount: false`, short rows reject at\n * the parser, so this case never reaches the transform.\n */\n row: Record<string, string>\n}\n\n/**\n * Async-iterable over the parsed rows of a delimited file. Use with\n * `for await (const { rowNumber, row } of streamFeed({ filePath, ... }))`.\n *\n * The iterator owns its file descriptor — the `for await` loop closes\n * the underlying stream when it returns or breaks. Aborting mid-stream\n * (`break`, `throw`, signal) is safe; csv-parse propagates the close.\n */\nexport async function* streamFeed(options: StreamFeedOptions): AsyncIterable<StreamFeedRow> {\n const {\n filePath,\n delimiter = '\\t',\n hasHeader = true,\n columns,\n relaxColumnCount = false,\n quote = false,\n } = options\n\n // When the caller provides explicit `columns`, prefer them. When the\n // file has a header but no explicit `columns`, csv-parse takes the\n // first row as the column source. When neither is true, rows are\n // emitted with string-position keys.\n //\n // Typed against csv-parse's actual `columns?: ColumnOption[] | boolean`\n // signature — `string[]` satisfies `ColumnOption[]` since\n // `ColumnOption = string | undefined | null | false | { name: string }`.\n const columnConfig: string[] | boolean = columns ? Array.from(columns) : hasHeader\n\n const stream = createReadStream(filePath)\n const parser = stream.pipe(\n parse({\n delimiter,\n columns: columnConfig,\n bom: true,\n skip_empty_lines: true,\n // csv-parse defaults to strict column count; opt-in relaxation only.\n relax_column_count: relaxColumnCount,\n // Default `quote: false` — see StreamFeedOptions.quote JSDoc.\n // Carmaker / parts-feed exports contain literal `\"` characters\n // in description fields and would otherwise reject as\n // `Invalid Opening Quote`. Genuine RFC-4180 CSV consumers opt\n // back in by passing `quote: '\"'`.\n quote,\n }),\n )\n\n let rowNumber = 0\n try {\n for await (const rawRow of parser as AsyncIterable<\n ReadonlyArray<string> | Record<string, string | undefined>\n >) {\n rowNumber += 1\n // csv-parse emits records with string keys when `columns` is\n // truthy, otherwise an Array. Normalise both shapes to\n // `Record<string, string>` so transforms can rely on `value === ''`\n // for missing cells (csv-parse leaves trailing missing cells as\n // `undefined` when `relax_column_count: true`; this collapses\n // them to `''` to keep the contract uniform).\n const row: Record<string, string> = {}\n if (Array.isArray(rawRow)) {\n const arr = rawRow as ReadonlyArray<string | undefined>\n for (let i = 0; i < arr.length; i += 1) {\n row[String(i)] = arr[i] ?? ''\n }\n } else {\n for (const [k, v] of Object.entries(rawRow)) {\n row[k] = v ?? ''\n }\n }\n yield { rowNumber, row }\n }\n } finally {\n // Safety: ensure the underlying file descriptor closes even if the\n // consumer breaks mid-iteration. Node closes streams on\n // garbage-collection but this makes it deterministic under tests.\n stream.destroy()\n }\n}\n","/**\n * One-shot importer: stream rows → transform → batched bulk-write to ES,\n * accumulating per-row errors into `ErrorTracker` and reporting progress\n * to the queue every batch.\n *\n * Per PLAN-ECOMMERCE.md PR 7 (PoC scope):\n * - **Batch size 1000.** Matches giga-test precedent. Configurable for\n * integration tests that don't want a 1k row floor.\n * - **No resumability and no automatic retries.** The `imports:run` queue\n * job is registered with `defaultRetries: 0` (see `worker.ts`) so a\n * failed handler does NOT re-enqueue itself — re-running a multi-batch\n * import against the same `import_run.id` while the previous attempt\n * may still be writing is a footgun (duplicate batches, double-counted\n * progress). Operator retries by creating a NEW `import_run` row.\n * - **No per-supplier transform plugin.** The runner takes a single\n * `RowTransform<TDoc>` from the registry and applies it to every row;\n * PR 8 may diverge but only by registering a different transform name.\n * - **Direct `bulkUpsert` into the live aliased index** (D6 alias is\n * set up by PR 4's `ensureAliasedIndex`; the importer doesn't reindex).\n *\n * Per D21 (sanctioned bulk path): this runner intentionally bypasses\n * AdminClient and entity hooks. Per-batch audit / observability lives\n * on the surrounding `import_run` row + queue progress, NOT per-row.\n */\n\nimport { bulkUpsert, type BulkIndexResult, type EsClientLike } from '@murumets-ee/search-elasticsearch'\nimport { ErrorTracker } from './error-tracker.js'\nimport { streamFeed, type StreamFeedOptions } from './streaming.js'\nimport type { RowTransform, TransformContext } from './transform.js'\n\n/** Soft default; chosen to match giga-test. ES bulk requests over ~5MB get split server-side anyway. */\nexport const DEFAULT_BATCH_SIZE = 1000\n\nexport interface RunImportOptions<TDoc> {\n /** UUID of the `import_run` row driving this run. Forwarded to every transform invocation. */\n importRunId: string\n /** Operator-supplied label for the run. Forwarded to the transform context. */\n runLabel: string\n /** Opaque per-run params copied from `import_run.params`. */\n params: Record<string, unknown>\n /** Transform applied to every parsed row. */\n transform: RowTransform<TDoc>\n /** Streaming reader options — file path, delimiter, header config. */\n feed: StreamFeedOptions\n /** ES client (low-level shape from `@murumets-ee/search-elasticsearch`). */\n esClient: EsClientLike\n /** Index alias to write to. Per D6, callers always pass an alias, never a physical index. */\n esIndex: string\n /** Rows per `bulkUpsert` call. Default {@link DEFAULT_BATCH_SIZE}. */\n batchSize?: number\n /**\n * Callback invoked after every batch. The handler in `worker.ts`\n * forwards this to `ctx.updateProgress` for the queue UI; tests\n * inspect it directly. Synchronous + cheap so a slow callback can't\n * back-pressure the importer.\n */\n onProgress?: (progress: ImportRunProgress) => void\n /** Optional: stop processing after this many rows. Tests use it; production passes `undefined`. */\n rowLimit?: number\n /** Abort signal threaded into the underlying ES client request — cooperative cancel. */\n signal?: AbortSignal\n /** Optional ErrorTracker config (caps). Default: top-50 patterns × 5 samples. */\n errorTracker?: ErrorTracker\n /**\n * Optional structural logger for per-batch telemetry. Receives\n * `{ batch, ms, succeeded, failed, rowsRead, rowsPerSecond }` after\n * every flush, plus a one-shot `started` and `finished` event. The\n * worker forwards `app.logger.child({ pkg: 'imports' })`; tests\n * leave it undefined.\n *\n * Why: without per-batch visibility, a stalled run looks identical\n * to \"first batch in progress\" forever — the only feedback is the\n * queue's `progress` jsonb (which writes after each batch) and the\n * eventual `succeeded`/`failed` job event. Adding this lets the\n * operator tail the worker log and immediately see whether ES is\n * rate-limiting, the transform is rejecting every row, or the\n * stream is actually progressing.\n */\n logger?: {\n info: (data: Record<string, unknown>, msg: string) => void\n warn: (data: Record<string, unknown>, msg: string) => void\n }\n}\n\n/**\n * Progress payload written to `toolkit_jobs.progress` after every batch.\n * Caps + flush rules live on the queue's `updateProgress` debounce —\n * callers don't need to throttle.\n */\nexport interface ImportRunProgress {\n rowsRead: number\n rowsSucceeded: number\n rowsFailed: number\n rowsSkipped: number\n batchesCompleted: number\n /** Wall-clock seconds since the runner started. */\n elapsedSeconds: number\n /** Rows / second, computed at every batch. */\n rowsPerSecond: number\n /** Distinct error patterns currently held by the tracker. Saturates at the cap. */\n distinctErrorPatterns: number\n /**\n * Total data-row count from a pre-pass scan, when the worker computed one.\n * Lets the UI render `processed / total` percent. Absent when the worker\n * skipped the count (file too large for the count budget, count failed,\n * etc.) — UI must treat it as optional.\n */\n totalRows?: number\n}\n\n/**\n * Final result returned by {@link runImport}. The handler writes these\n * onto the `import_run` row alongside the ErrorTracker snapshot.\n */\nexport interface RunImportResult {\n /** Total rows read from the file (excludes skipped empty lines). */\n rowsRead: number\n /** Rows the transform turned into a successful doc AND the ES cluster acknowledged. */\n rowsSucceeded: number\n /**\n * Rows that the transform rejected (`{ kind: 'error' }`) OR that ES\n * rejected on bulk-write (per-doc failure). Both are aggregated by\n * `errorTracker` for the import_run summary.\n */\n rowsFailed: number\n /** Rows that the transform skipped (`{ kind: 'skip' }`) — header noise, blank lines, intentional drop. */\n rowsSkipped: number\n /** Number of `bulkUpsert` calls made. */\n batchesCompleted: number\n /** Final value of {@link ErrorTracker.snapshot}. */\n errors: ReturnType<ErrorTracker['snapshot']>\n}\n\n/**\n * Apply the runner against a feed file. Stops on rowLimit OR end-of-file\n * OR if `signal` aborts. Throws if the streaming reader / ES client\n * throws — caller (the queue handler) catches that and writes\n * `import_run.status = 'failed'` with the error message in\n * `errorSummary.fatal`.\n */\nexport async function runImport<TDoc>(options: RunImportOptions<TDoc>): Promise<RunImportResult> {\n const {\n importRunId,\n runLabel,\n params,\n transform,\n feed,\n esClient,\n esIndex,\n batchSize = DEFAULT_BATCH_SIZE,\n onProgress,\n rowLimit,\n signal,\n errorTracker = new ErrorTracker(),\n logger,\n } = options\n\n if (batchSize < 1) {\n throw new Error(`batchSize must be >= 1 (got ${batchSize})`)\n }\n\n const startedAt = Date.now()\n logger?.info(\n { importRunId, runLabel, batchSize, esIndex },\n 'imports.runner: started',\n )\n let rowsRead = 0\n let rowsSucceeded = 0\n let rowsFailed = 0\n let rowsSkipped = 0\n let batchesCompleted = 0\n\n let pending: Array<{ id: string; doc: TDoc; rowNumber: number }> = []\n\n const flush = async (): Promise<void> => {\n if (pending.length === 0) return\n const batch = pending\n pending = []\n const flushStartedAt = Date.now()\n let result: BulkIndexResult\n try {\n result = await bulkUpsert<TDoc>(esClient, {\n index: esIndex,\n docs: batch.map(({ id, doc }) => ({ id, doc })),\n ...(signal !== undefined && { signal }),\n })\n } catch (err) {\n // Cluster- or transport-level failure — the whole batch is\n // unaccounted for. Distinguish abort (operator-driven cancel) from\n // a real cluster failure so the errorSummary doesn't mislabel a\n // cancelled run as broken cluster connectivity.\n const isAbort =\n signal?.aborted ??\n (err instanceof Error && (err.name === 'AbortError' || /abort/i.test(err.message)))\n const errorType = isAbort ? 'aborted' : 'bulk_request_failed'\n const reason = err instanceof Error ? err.message : String(err)\n for (const { rowNumber } of batch) {\n errorTracker.addError(rowNumber, errorType, reason, undefined, null)\n }\n rowsFailed += batch.length\n throw err\n }\n\n rowsSucceeded += result.succeeded\n rowsFailed += result.failures.length\n batchesCompleted += 1\n\n const flushMs = Date.now() - flushStartedAt\n const elapsedSec = (Date.now() - startedAt) / 1000\n logger?.info(\n {\n importRunId,\n batch: batchesCompleted,\n flushMs,\n submitted: batch.length,\n succeeded: result.succeeded,\n failed: result.failures.length,\n rowsRead,\n rowsPerSecond: elapsedSec > 0 ? Math.round(rowsRead / elapsedSec) : 0,\n },\n 'imports.runner: batch flushed',\n )\n\n if (result.failures.length > 0) {\n // Map each ES failure back to its source row via `id`. The bulk\n // response order matches the request order, but ES doesn't promise\n // that; matching by `id` is the safe path. PoC volume is small\n // enough that the O(failures × batch) cost is irrelevant.\n const byId = new Map(batch.map((b) => [b.id, b.rowNumber]))\n for (const fail of result.failures) {\n const rowNumber = byId.get(fail.id) ?? -1\n errorTracker.addError(rowNumber, fail.type, fail.reason, undefined, { id: fail.id })\n }\n }\n\n if (onProgress) {\n const elapsedSeconds = (Date.now() - startedAt) / 1000\n onProgress({\n rowsRead,\n rowsSucceeded,\n rowsFailed,\n rowsSkipped,\n batchesCompleted,\n elapsedSeconds,\n rowsPerSecond: elapsedSeconds > 0 ? rowsRead / elapsedSeconds : 0,\n distinctErrorPatterns: errorTracker.getDistinctPatternCount(),\n })\n }\n }\n\n // Streaming heartbeat — sample every Nth row so the operator sees the\n // streamer is alive even when a transform-rejected file would prevent\n // any flush from firing. Without this, a feed that's 100% rejected\n // looks identical to \"stuck\" until end-of-file. Sample frequency is\n // batchSize so the cadence matches the natural batch rhythm.\n const streamSampleEvery = batchSize\n\n // First-error capture per error type — surface what's actually rejecting\n // the rows when 100% of a feed is rejected. Without this, the only signal\n // is `errorTracker.snapshot()` which only writes to import_run.errorSummary\n // at the END of the run. Mid-run we want to know NOW.\n const firstErrorByType = new Map<string, { rowNumber: number; message: string }>()\n\n for await (const { rowNumber, row } of streamFeed(feed)) {\n if (signal?.aborted) break\n if (rowLimit !== undefined && rowsRead >= rowLimit) break\n rowsRead += 1\n\n if (rowsRead === 1) {\n logger?.info({ importRunId, rowNumber, sampleKeys: Object.keys(row) }, 'imports.runner: STREAM_FIRST_ROW')\n }\n if (rowsRead % streamSampleEvery === 0) {\n const elapsedSec = (Date.now() - startedAt) / 1000\n // Telemetry only — no row content, no error sample text.\n // PR #262 review (M-5): the prior `firstErrors:\n // Object.fromEntries(firstErrorByType)` payload included the\n // raw error message captured from the transform, which for the\n // carmaker feed embeds supplier codes + price fragments. The\n // detailed first-error breakdown is already persisted on\n // `import_run.errorSummary` via `errorTracker.snapshot()` —\n // operators inspect it there, not in the worker log.\n logger?.info(\n {\n importRunId,\n rowsRead,\n rowsSucceeded,\n rowsFailed,\n rowsSkipped,\n pending: pending.length,\n elapsedSec: Math.round(elapsedSec * 10) / 10,\n rowsPerSecond: elapsedSec > 0 ? Math.round(rowsRead / elapsedSec) : 0,\n distinctFirstErrorTypes: firstErrorByType.size,\n },\n 'imports.runner: STREAM_TICK',\n )\n }\n\n const ctx: TransformContext = { importRunId, params, runLabel, rowNumber }\n let result: Awaited<ReturnType<typeof transform>>\n try {\n result = await transform(row, ctx)\n } catch (err) {\n // A throw from the transform is a programmer error — surface it as\n // a row-level error so the run can continue. (If the bug is\n // catastrophic, the operator sees the same message repeated and\n // can stop the run.)\n const reason = err instanceof Error ? err.message : String(err)\n errorTracker.addError(rowNumber, 'transform_threw', reason, undefined, row)\n if (!firstErrorByType.has('transform_threw')) {\n firstErrorByType.set('transform_threw', { rowNumber, message: reason })\n // PR #262 review (M-5): drop the `row` payload from the\n // log line — supplier feeds carry pricing + internal codes.\n // The full row is on `import_run.errorSummary.samples` for\n // post-mortem review by operators with view permission.\n logger?.warn(\n { importRunId, rowNumber, errorType: 'transform_threw', message: reason },\n 'imports.runner: FIRST_ERROR transform_threw',\n )\n }\n rowsFailed += 1\n continue\n }\n\n if (result.kind === 'skip') {\n rowsSkipped += 1\n continue\n }\n if (result.kind === 'error') {\n errorTracker.addError(\n rowNumber,\n result.error.errorType,\n result.error.message,\n result.error.field,\n row,\n )\n if (!firstErrorByType.has(result.error.errorType)) {\n firstErrorByType.set(result.error.errorType, {\n rowNumber,\n message: result.error.message,\n })\n logger?.warn(\n {\n importRunId,\n rowNumber,\n errorType: result.error.errorType,\n field: result.error.field,\n message: result.error.message,\n },\n `imports.runner: FIRST_ERROR ${result.error.errorType}`,\n )\n }\n rowsFailed += 1\n continue\n }\n\n pending.push({ id: result.id, doc: result.doc, rowNumber })\n if (pending.length >= batchSize) {\n await flush()\n }\n }\n\n await flush()\n\n const totalSec = (Date.now() - startedAt) / 1000\n logger?.info(\n {\n importRunId,\n rowsRead,\n rowsSucceeded,\n rowsFailed,\n rowsSkipped,\n batchesCompleted,\n totalSec: Math.round(totalSec * 10) / 10,\n rowsPerSecond: totalSec > 0 ? Math.round(rowsRead / totalSec) : 0,\n },\n 'imports.runner: finished',\n )\n\n return {\n rowsRead,\n rowsSucceeded,\n rowsFailed,\n rowsSkipped,\n batchesCompleted,\n errors: errorTracker.snapshot(),\n }\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
var e=class{entries=new Map;register(e,t){if(this.entries.has(e))throw Error(`Transform "${e}" is already registered — two plugins cannot register the same transform name.`);this.entries.set(e,t)}get(e){return this.entries.get(e)}has(e){return this.entries.has(e)}list(){return Array.from(this.entries.keys()).sort()}clear(){this.entries.clear()}};const t=Symbol.for(`@murumets-ee/imports:transforms`);function n(){let n=globalThis,r=n[t];return r||(r=new e,n[t]=r),r}function r(e,t){n().register(e,t)}export{n,r,e as t};
|
|
2
|
+
//# sourceMappingURL=transform-B1KpU3lo.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"transform-B1KpU3lo.mjs","names":[],"sources":["../src/transform.ts"],"sourcesContent":["/**\n * Per-feed transform plugin interface (PLAN-ECOMMERCE.md PR 7 / D14).\n *\n * The streaming reader reads raw rows out of the file (column key →\n * string). The transform turns one row into a typed `OutputDoc` ready\n * for the bulk-write surface (PoC: `PartsDocument` for the parts ES\n * index). The transform is what makes a generic feed-importer\n * commerce-aware — without it, the importer has no opinion on what a\n * row \"means\".\n *\n * Why an interface, not a function:\n * - The transform may need to reject a row (`RowSkip`) without that\n * counting as an error in `ErrorTracker` — e.g. blank lines, header\n * rows mistakenly retained, or \"this is a replacement-code marker\n * row, handled out-of-band\" decisions.\n * - The transform may need to fail a row (`RowError`) with a typed\n * error class so `ErrorTracker` collapses them by `errorType`.\n * - The transform may need access to the run-level params (brand /\n * supplier IDs, code prefix, batch ID) without those being\n * re-derived per row.\n *\n * PR 7 ships only the interface + a registry. PR 8 ships the first\n * concrete carmaker-feed transform.\n */\n\n/** Identifies which transform a registered handler implements. Stable across deploys. */\nexport type TransformName = string\n\n/** Per-run context provided to every row call. Kept narrow on purpose. */\nexport interface TransformContext {\n /** UUID of the `import_run` row. Forward to the output doc as `import_batch_id` for source attribution (D20). */\n importRunId: string\n /** Opaque per-run params copied from `import_run.params`. The transform validates the shape it expects. */\n params: Record<string, unknown>\n /** Operator-supplied label, useful for logging / debugging. */\n runLabel: string\n /** 1-based row number across the whole feed (header counts as row 0). Forward to `RowError.rowNumber`. */\n rowNumber: number\n}\n\n/**\n * One of: a successful `OutputDoc` keyed by a stable `id`, an\n * intentional `skip` (counted in `totals.skipped`), or a row-level\n * error (counted in `totals.failed` AND aggregated by `ErrorTracker`).\n *\n * The `id` field on `success` is used as the bulk-upsert primary key\n * (`_id` in ES). Per D4 the parts index uses\n * `<code_normalized>__<supplier_id>` so a re-run of the same feed\n * idempotently overwrites instead of duplicating.\n */\nexport type RowResult<TDoc> =\n | { kind: 'success'; id: string; doc: TDoc }\n | { kind: 'skip'; reason: string }\n | { kind: 'error'; error: RowError }\n\n/** Row-level error. The aggregator uses `(errorType, field, message)` as the dedup signature. */\nexport interface RowError {\n errorType: string\n message: string\n field?: string | undefined\n}\n\n/**\n * The contract every per-feed transform implements. Pure function of\n * `(row, ctx) → RowResult` — no I/O. Async only because the future\n * carmaker variant might consult an in-memory taxonomy lookup.\n */\nexport interface RowTransform<TDoc> {\n (rawRow: Record<string, string>, ctx: TransformContext): Promise<RowResult<TDoc>>\n}\n\n/**\n * Holds the registered transforms for the current process. Each plugin\n * that ships a transform calls {@link TransformRegistry.register} from\n * its `init` hook.\n *\n * Per CLAUDE.md \"Whitelist, don't blacklist\": the worker dispatches\n * by `import_run.transformName` against this registry. An unregistered\n * name fails the run rather than running with a default transform.\n *\n * **Production callers MUST go through {@link getTransformRegistry}**, not\n * `new TransformRegistry()`. The constructor is exposed only so tests\n * (and rare custom-worker embeds) can build an isolated instance and\n * inject it into {@link createRunImportHandler} directly. Two `new`\n * instances do NOT share state — registering a transform on one will\n * NOT make it visible to a worker resolving against the other. The\n * package's queue handler (registered by `imports()` plugin) always\n * resolves against the singleton.\n */\nexport class TransformRegistry {\n private readonly entries = new Map<TransformName, RowTransform<unknown>>()\n\n /**\n * Register a transform under a stable name. Throws on duplicate\n * names so two plugins can't silently overwrite each other (same\n * fail-loud collision protection that `combineAdminRoutes` enforces\n * for duplicate `(prefix, method, path)` route registrations).\n */\n register<TDoc>(name: TransformName, transform: RowTransform<TDoc>): void {\n if (this.entries.has(name)) {\n throw new Error(\n `Transform \"${name}\" is already registered — two plugins cannot register the same transform name.`,\n )\n }\n this.entries.set(name, transform as RowTransform<unknown>)\n }\n\n /** Look up a transform by name. Returns `undefined` if no plugin has registered one. */\n get(name: TransformName): RowTransform<unknown> | undefined {\n return this.entries.get(name)\n }\n\n /** True iff a transform is registered for the given name. */\n has(name: TransformName): boolean {\n return this.entries.has(name)\n }\n\n /** All registered names. Useful for the admin Catalog tab and for tests. */\n list(): TransformName[] {\n return Array.from(this.entries.keys()).sort()\n }\n\n /** Drop all registrations. Tests only. */\n clear(): void {\n this.entries.clear()\n }\n}\n\n// ---------------------------------------------------------------------------\n// Process-global singleton (mirrors `@murumets-ee/queue`'s handler registry).\n// ---------------------------------------------------------------------------\n//\n// Why a singleton: PR 8's carmaker-feed transform lives in a different\n// plugin from `@murumets-ee/imports`. Under Next.js HMR, ONE of those\n// plugins might re-evaluate while the other does not. Holding the\n// registry on `globalThis` via `Symbol.for` means the same `Map`\n// instance is shared across module evaluations — the carmaker plugin's\n// `register` and the imports worker's `get` see each other regardless\n// of evaluation order. Same fix shape as the queue's handler-registry\n// HMR bug (#186).\n\nconst REGISTRY_KEY = Symbol.for('@murumets-ee/imports:transforms')\n\ninterface GlobalThisWithTransforms {\n [REGISTRY_KEY]?: TransformRegistry\n}\n\n/** Returns the singleton registry, creating it on first access. */\nexport function getTransformRegistry(): TransformRegistry {\n const g = globalThis as GlobalThisWithTransforms\n let reg = g[REGISTRY_KEY]\n if (!reg) {\n reg = new TransformRegistry()\n g[REGISTRY_KEY] = reg\n }\n return reg\n}\n\n/**\n * Convenience wrapper around `getTransformRegistry().register(name, fn)`.\n * Plugins that ship a transform call this from their `init` hook — same\n * shape as `@murumets-ee/queue/client`'s `registerJob`.\n */\nexport function registerImportTransform<TDoc>(\n name: TransformName,\n transform: RowTransform<TDoc>,\n): void {\n getTransformRegistry().register(name, transform)\n}\n"],"mappings":"AAyFA,IAAa,EAAb,KAA+B,CAC7B,QAA2B,IAAI,IAQ/B,SAAe,EAAqB,EAAqC,CACvE,GAAI,KAAK,QAAQ,IAAI,EAAK,CACxB,MAAU,MACR,cAAc,EAAK,gFACpB,CAEH,KAAK,QAAQ,IAAI,EAAM,EAAmC,CAI5D,IAAI,EAAwD,CAC1D,OAAO,KAAK,QAAQ,IAAI,EAAK,CAI/B,IAAI,EAA8B,CAChC,OAAO,KAAK,QAAQ,IAAI,EAAK,CAI/B,MAAwB,CACtB,OAAO,MAAM,KAAK,KAAK,QAAQ,MAAM,CAAC,CAAC,MAAM,CAI/C,OAAc,CACZ,KAAK,QAAQ,OAAO,GAiBxB,MAAM,EAAe,OAAO,IAAI,kCAAkC,CAOlE,SAAgB,GAA0C,CACxD,IAAM,EAAI,WACN,EAAM,EAAE,GAKZ,OAJK,IACH,EAAM,IAAI,EACV,EAAE,GAAgB,GAEb,EAQT,SAAgB,EACd,EACA,EACM,CACN,GAAsB,CAAC,SAAS,EAAM,EAAU"}
|
|
@@ -93,8 +93,9 @@ declare class TransformRegistry {
|
|
|
93
93
|
private readonly entries;
|
|
94
94
|
/**
|
|
95
95
|
* Register a transform under a stable name. Throws on duplicate
|
|
96
|
-
* names so two plugins can't silently overwrite each other (
|
|
97
|
-
*
|
|
96
|
+
* names so two plugins can't silently overwrite each other (same
|
|
97
|
+
* fail-loud collision protection that `combineAdminRoutes` enforces
|
|
98
|
+
* for duplicate `(prefix, method, path)` route registrations).
|
|
98
99
|
*/
|
|
99
100
|
register<TDoc>(name: TransformName, transform: RowTransform<TDoc>): void;
|
|
100
101
|
/** Look up a transform by name. Returns `undefined` if no plugin has registered one. */
|
|
@@ -116,4 +117,4 @@ declare function getTransformRegistry(): TransformRegistry;
|
|
|
116
117
|
declare function registerImportTransform<TDoc>(name: TransformName, transform: RowTransform<TDoc>): void;
|
|
117
118
|
//#endregion
|
|
118
119
|
export { TransformName as a, registerImportTransform as c, TransformContext as i, RowResult as n, TransformRegistry as o, RowTransform as r, getTransformRegistry as s, RowError as t };
|
|
119
|
-
//# sourceMappingURL=transform-
|
|
120
|
+
//# sourceMappingURL=transform-BkALrFhI.d.mts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"transform-
|
|
1
|
+
{"version":3,"file":"transform-BkALrFhI.d.mts","names":[],"sources":["../src/transform.ts"],"mappings":";;AA0BA;;;;;AAGA;;;;;;;;;;;AAqBA;;;;;;;;KAxBY,aAAA;;UAGK,gBAAA;EAwBX;EAtBJ,WAAA;EAsB0B;EApB1B,MAAA,EAAQ,MAAA;EAoB0B;EAlBlC,QAAA;EAqBuB;EAnBvB,SAAA;AAAA;;;;;;AA8BF;;;;;KAjBY,SAAA;EACN,IAAA;EAAiB,EAAA;EAAY,GAAA,EAAK,IAAA;AAAA;EAClC,IAAA;EAAc,MAAA;AAAA;EACd,IAAA;EAAe,KAAA,EAAO,QAAA;AAAA;;UAGX,QAAA;EACf,SAAA;EACA,OAAA;EACA,KAAA;AAAA;;;;;;UAQe,YAAA;EAAA,CACd,MAAA,EAAQ,MAAA,kBAAwB,GAAA,EAAK,gBAAA,GAAmB,OAAA,CAAQ,SAAA,CAAU,IAAA;AAAA;;;;;;;;;;;;;;;;;;;cAqBhE,iBAAA;EAAA,iBACM,OAAA;EAiCZ;;AAyBP;;;;EAlDE,QAAA,MAAA,CAAe,IAAA,EAAM,aAAA,EAAe,SAAA,EAAW,YAAA,CAAa,IAAA;EAiE9C;EAvDd,GAAA,CAAI,IAAA,EAAM,aAAA,GAAgB,YAAA;EAuDW;EAlDrC,GAAA,CAAI,IAAA,EAAM,aAAA;EAoDc;EA/CxB,IAAA,CAAA,GAAQ,aAAA;EA+Ce;EA1CvB,KAAA,CAAA;AAAA;;iBAyBc,oBAAA,CAAA,GAAwB,iBAAA;;;;;;iBAexB,uBAAA,MAAA,CACd,IAAA,EAAM,aAAA,EACN,SAAA,EAAW,YAAA,CAAa,IAAA"}
|
package/dist/transforms.d.mts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import { o as TransformRegistry, r as RowTransform } from "./transform-
|
|
2
|
-
import * as _$_murumets_ee_entity0 from "@murumets-ee/entity";
|
|
1
|
+
import { o as TransformRegistry, r as RowTransform } from "./transform-BkALrFhI.mjs";
|
|
3
2
|
import { EsClientLike } from "@murumets-ee/search-elasticsearch";
|
|
4
3
|
import { Logger } from "@murumets-ee/core";
|
|
5
4
|
import { JobDefinition, QueueTerminalNotifier } from "@murumets-ee/queue/client";
|
|
@@ -18,14 +17,14 @@ import { AdminClient } from "@murumets-ee/entity/admin";
|
|
|
18
17
|
*/
|
|
19
18
|
declare const IMPORT_RUN_STATUSES: readonly ["pending", "running", "succeeded", "failed", "cancelled"];
|
|
20
19
|
type ImportRunStatus = (typeof IMPORT_RUN_STATUSES)[number];
|
|
21
|
-
declare const ImportRun:
|
|
22
|
-
id:
|
|
23
|
-
} &
|
|
24
|
-
/** Operator-visible label, e.g. `"MERCEDES — ME_20251027075918.txt"`. Free-form. */label:
|
|
20
|
+
declare const ImportRun: import("@murumets-ee/entity").Entity<{
|
|
21
|
+
id: import("@murumets-ee/entity").IdField;
|
|
22
|
+
} & import("@murumets-ee/entity").AuditableFields & {
|
|
23
|
+
/** Operator-visible label, e.g. `"MERCEDES — ME_20251027075918.txt"`. Free-form. */label: import("@murumets-ee/entity").TextField & {
|
|
25
24
|
readonly required: true;
|
|
26
25
|
readonly maxLength: 255;
|
|
27
26
|
}; /** See {@link IMPORT_RUN_STATUSES}. */
|
|
28
|
-
status:
|
|
27
|
+
status: import("@murumets-ee/entity").SelectField & {
|
|
29
28
|
options: readonly ["pending", "running", "succeeded", "failed", "cancelled"];
|
|
30
29
|
} & {
|
|
31
30
|
readonly options: readonly ["pending", "running", "succeeded", "failed", "cancelled"];
|
|
@@ -38,7 +37,7 @@ declare const ImportRun: _$_murumets_ee_entity0.Entity<{
|
|
|
38
37
|
* S3 object key once storage adapter integration lands. The worker
|
|
39
38
|
* reads this with the configured `readFeed` resolver.
|
|
40
39
|
*/
|
|
41
|
-
filePath:
|
|
40
|
+
filePath: import("@murumets-ee/entity").TextField & {
|
|
42
41
|
readonly required: true;
|
|
43
42
|
readonly maxLength: 2048;
|
|
44
43
|
};
|
|
@@ -47,7 +46,7 @@ declare const ImportRun: _$_murumets_ee_entity0.Entity<{
|
|
|
47
46
|
* worker-dispatch time against the transform registry contributed by
|
|
48
47
|
* the consumer. PoC: `'commerce:carmaker-feed'`.
|
|
49
48
|
*/
|
|
50
|
-
transformName:
|
|
49
|
+
transformName: import("@murumets-ee/entity").TextField & {
|
|
51
50
|
readonly required: true;
|
|
52
51
|
readonly maxLength: 128;
|
|
53
52
|
readonly indexed: true;
|
|
@@ -59,31 +58,31 @@ declare const ImportRun: _$_murumets_ee_entity0.Entity<{
|
|
|
59
58
|
* a new transform with different params doesn't require a schema
|
|
60
59
|
* migration.
|
|
61
60
|
*/
|
|
62
|
-
params:
|
|
61
|
+
params: import("@murumets-ee/entity").JsonField & Partial<import("@murumets-ee/entity").JsonField>;
|
|
63
62
|
/**
|
|
64
63
|
* Row counters: `{ submitted, succeeded, failed, skipped, batches }`.
|
|
65
64
|
* Updated by the worker as each batch completes. Final values are
|
|
66
65
|
* what the operator reads; intermediate progress comes from
|
|
67
66
|
* `toolkit_jobs.progress` via the queue UI.
|
|
68
67
|
*/
|
|
69
|
-
totals:
|
|
68
|
+
totals: import("@murumets-ee/entity").JsonField & Partial<import("@murumets-ee/entity").JsonField>;
|
|
70
69
|
/**
|
|
71
70
|
* Output of `ErrorTracker.getTopPatterns(totalRows)` — the top-50
|
|
72
71
|
* error signatures with up to 5 sample rows each. Empty `[]` until
|
|
73
72
|
* the worker writes it on completion.
|
|
74
73
|
*/
|
|
75
|
-
errorSummary:
|
|
76
|
-
startedAt:
|
|
77
|
-
finishedAt:
|
|
74
|
+
errorSummary: import("@murumets-ee/entity").JsonField & Partial<import("@murumets-ee/entity").JsonField>; /** Set when the worker picks up the job. */
|
|
75
|
+
startedAt: import("@murumets-ee/entity").DateField & Partial<import("@murumets-ee/entity").DateField>; /** Set when the worker finishes (success OR fatal failure). */
|
|
76
|
+
finishedAt: import("@murumets-ee/entity").DateField & Partial<import("@murumets-ee/entity").DateField>;
|
|
78
77
|
/**
|
|
79
78
|
* `toolkit_jobs.id` of the queue job processing this run — link
|
|
80
79
|
* back so PR 8a can show live progress without a second lookup.
|
|
81
80
|
*/
|
|
82
|
-
queueJobId:
|
|
81
|
+
queueJobId: import("@murumets-ee/entity").TextField & {
|
|
83
82
|
readonly maxLength: 64;
|
|
84
83
|
readonly indexed: true;
|
|
85
84
|
};
|
|
86
|
-
}>;
|
|
85
|
+
}, "import_run", [import("@murumets-ee/entity").Behavior<import("@murumets-ee/entity").AuditableFields, string>, import("@murumets-ee/entity").SearchableBehavior]>;
|
|
87
86
|
type ImportRunClient = AdminClient<typeof ImportRun.allFields>;
|
|
88
87
|
//#endregion
|
|
89
88
|
//#region src/error-tracker.d.ts
|
|
@@ -572,4 +571,4 @@ interface ImportsRunDeadListenerConfig {
|
|
|
572
571
|
declare function createImportsRunDeadListener(config: ImportsRunDeadListenerConfig): QueueTerminalNotifier;
|
|
573
572
|
//#endregion
|
|
574
573
|
export { ErrorSample as C, ImportRun as D, IMPORT_RUN_STATUSES as E, ImportRunClient as O, ErrorPattern as S, ErrorTrackerConfig as T, StreamFeedRow as _, ImportsRunJobPayload as a, DEFAULT_MAX_PATTERNS as b, createRunImportHandler as c, DEFAULT_BATCH_SIZE as d, ImportRunProgress as f, StreamFeedOptions as g, runImport as h, ImportsRunJobContext as i, ImportRunStatus as k, importsRunJob as l, RunImportResult as m, FilePathResolver as n, RunImportHandlerConfig as o, RunImportOptions as p, ImportsRunDeadListenerConfig as r, createImportsRunDeadListener as s, EsClientResolver as t, importsRunJobPayloadSchema as u, countDataRows as v, ErrorTracker as w, DEFAULT_MAX_SAMPLES_PER_PATTERN as x, streamFeed as y };
|
|
575
|
-
//# sourceMappingURL=worker-
|
|
574
|
+
//# sourceMappingURL=worker-DUUtPREz.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"worker-DUUtPREz.d.mts","names":[],"sources":["../src/entities/import-run.ts","../src/error-tracker.ts","../src/streaming.ts","../src/runner.ts","../src/worker.ts"],"mappings":"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"}
|
package/dist/worker.d.mts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { a as ImportsRunJobPayload, c as createRunImportHandler, i as ImportsRunJobContext, l as importsRunJob, n as FilePathResolver, o as RunImportHandlerConfig, r as ImportsRunDeadListenerConfig, s as createImportsRunDeadListener, t as EsClientResolver, u as importsRunJobPayloadSchema } from "./worker-
|
|
1
|
+
import { a as ImportsRunJobPayload, c as createRunImportHandler, i as ImportsRunJobContext, l as importsRunJob, n as FilePathResolver, o as RunImportHandlerConfig, r as ImportsRunDeadListenerConfig, s as createImportsRunDeadListener, t as EsClientResolver, u as importsRunJobPayloadSchema } from "./worker-DUUtPREz.mjs";
|
|
2
2
|
export { EsClientResolver, FilePathResolver, ImportsRunDeadListenerConfig, ImportsRunJobContext, ImportsRunJobPayload, RunImportHandlerConfig, createImportsRunDeadListener, createRunImportHandler, importsRunJob, importsRunJobPayloadSchema };
|
package/dist/worker.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import{n as e,r as t}from"./runner-
|
|
1
|
+
import{n as e,r as t}from"./runner-D5to1Bt6.mjs";import{a as n,i as r,r as i}from"./publish-D6PH6nu4.mjs";import{runAsCli as a}from"@murumets-ee/core";import{defineJob as o}from"@murumets-ee/queue/client";import{eq as s}from"drizzle-orm";import{z as c}from"zod";const l=c.object({importRunId:c.string().uuid()}),u=o({name:`imports:run`,description:`Stream a feed file, transform rows, bulk-write to Elasticsearch.`,schema:l,defaultRetries:0});function d(i){let{importRuns:o,transforms:s,esClient:c,esIndex:l,resolveFilePath:u,logger:d}=i;return async i=>a(async()=>{let{importRunId:a}=i.payload,p=d?.child({jobId:i.id,importRunId:a,type:`imports:run`}),m=await o.findById(a);if(!m){p?.warn(`import_run row not found — skipping`);return}m.status===`running`&&p?.warn({previousQueueJobId:m.queueJobId,previousStartedAt:m.startedAt},`imports: re-claiming a row left in running by a prior worker — likely a crash recovered by queue lock-recovery`);let h=s.get(m.transformName);if(!h)throw await f(o,a,`Unknown transform "${m.transformName}"`),Error(`No transform registered for name "${m.transformName}"`);let g=m.params&&typeof m.params==`object`&&!Array.isArray(m.params)?m.params:{};await o.update(a,{status:`running`,queueJobId:i.id,startedAt:new Date}),r(a);let _;try{_=await c()}catch(e){throw await f(o,a,`Could not resolve ES client: ${e instanceof Error?e.message:String(e)}`),e}let v,y;if(u)try{let e=await u(m.filePath);v=e.localPath,y=e.cleanup}catch(e){throw await f(o,a,`Could not resolve filePath: ${e instanceof Error?e.message:String(e)}`),e}else v=m.filePath;let b;try{b=await t(v),p?.info({importRunId:a,totalRows:b},`imports: pre-pass row count`)}catch(e){p?.warn({err:e},`imports: pre-pass row count failed — continuing without total`)}let x;try{x=await e({importRunId:a,runLabel:m.label,params:g,transform:h,feed:{filePath:v},esClient:_,esIndex:l,onProgress:e=>i.updateProgress(b===void 0?e:{...e,totalRows:b}),...p!==void 0&&{logger:p}})}catch(e){throw await f(o,a,e instanceof Error?e.message:String(e)),y&&await y().catch(e=>{p?.warn({err:e},`filePath cleanup after failed run threw — ignoring`)}),e}y&&await y().catch(e=>{p?.warn({err:e},`filePath cleanup after successful run threw — ignoring`)}),await o.update(a,{status:`succeeded`,finishedAt:new Date,totals:{rowsRead:x.rowsRead,rowsSucceeded:x.rowsSucceeded,rowsFailed:x.rowsFailed,rowsSkipped:x.rowsSkipped,batchesCompleted:x.batchesCompleted},errorSummary:JSON.parse(JSON.stringify(x.errors))}),n(a,{rowsRead:x.rowsRead,rowsSucceeded:x.rowsSucceeded,rowsFailed:x.rowsFailed}),p?.info({rowsRead:x.rowsRead,rowsSucceeded:x.rowsSucceeded,rowsFailed:x.rowsFailed,rowsSkipped:x.rowsSkipped,batches:x.batchesCompleted},`import_run completed`)})}async function f(e,t,n){try{await e.update(t,{status:`failed`,finishedAt:new Date,errorSummary:{fatal:n}}),i(t,n)}catch{}}function p(e){let{importRuns:t,logger:n}=e;return{onJobDead:async e=>{e.jobType===`imports:run`&&await a(async()=>{let r=n?.child({event:`imports.run.dead`,jobId:e.jobId,type:`imports:run`}),i=t.getTable(),a=(await t.findMany({where:s(i.queueJobId,e.jobId),limit:1}))[0];if(!a){r?.warn(`queue.job.dead with no matching import_run — skipping`);return}if(a.status!==`running`&&a.status!==`pending`){r?.info({importRunId:a.id,status:a.status},`queue.job.dead arrived for a row already in a terminal state — leaving as-is`);return}a.status===`pending`&&r?.warn({importRunId:a.id,attempts:e.attempts},`queue.job.dead arrived for an import_run still in pending — worker crashed BEFORE claim/lookup`),await f(t,a.id,e.error),r?.info({importRunId:a.id,attempts:e.attempts,priorStatus:a.status},`queue.job.dead → import_run marked failed`)})}}}export{p as createImportsRunDeadListener,d as createRunImportHandler,u as importsRunJob,l as importsRunJobPayloadSchema};
|
|
2
2
|
//# sourceMappingURL=worker.mjs.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@murumets-ee/imports",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.0",
|
|
4
4
|
"license": "Elastic-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -40,13 +40,13 @@
|
|
|
40
40
|
"csv-parse": "^5.5.3",
|
|
41
41
|
"drizzle-orm": "^0.45.2",
|
|
42
42
|
"zod": "^3.24.1",
|
|
43
|
-
"@murumets-ee/core": "0.
|
|
44
|
-
"@murumets-ee/db": "0.
|
|
45
|
-
"@murumets-ee/entity": "0.
|
|
46
|
-
"@murumets-ee/logging": "0.
|
|
47
|
-
"@murumets-ee/queue": "0.
|
|
48
|
-
"@murumets-ee/search-elasticsearch": "0.
|
|
49
|
-
"@murumets-ee/storage": "0.
|
|
43
|
+
"@murumets-ee/core": "0.20.0",
|
|
44
|
+
"@murumets-ee/db": "0.20.0",
|
|
45
|
+
"@murumets-ee/entity": "0.20.0",
|
|
46
|
+
"@murumets-ee/logging": "0.20.0",
|
|
47
|
+
"@murumets-ee/queue": "0.20.0",
|
|
48
|
+
"@murumets-ee/search-elasticsearch": "0.20.0",
|
|
49
|
+
"@murumets-ee/storage": "0.20.0"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
52
|
"@types/node": "^20.19.40",
|
|
@@ -60,7 +60,8 @@
|
|
|
60
60
|
"scripts": {
|
|
61
61
|
"build": "tsdown",
|
|
62
62
|
"dev": "tsdown --watch",
|
|
63
|
-
"test": "vitest",
|
|
63
|
+
"test": "vitest run",
|
|
64
|
+
"test:watch": "vitest",
|
|
64
65
|
"test:integration": "vitest run --config vitest.integration.config.ts"
|
|
65
66
|
}
|
|
66
67
|
}
|
|
@@ -1,2 +0,0 @@
|
|
|
1
|
-
import{behavior as e,defineEntity as t,field as n,searchable as r}from"@murumets-ee/entity";const i=[`pending`,`running`,`succeeded`,`failed`,`cancelled`],a=t({name:`import_run`,fields:{label:n.text({required:!0,maxLength:255}),status:n.select({options:i,default:`pending`,indexed:!0}),filePath:n.text({required:!0,maxLength:2048}),transformName:n.text({required:!0,maxLength:128,indexed:!0}),params:n.json(),totals:n.json(),errorSummary:n.json(),startedAt:n.date(),finishedAt:n.date(),queueJobId:n.text({maxLength:64,indexed:!0})},behaviors:[e.auditable(),r({fields:[`label`,`transformName`,`queueJobId`,`status`,`filePath`],projection:e=>({id:e.id,label:e.label,description:`${e.transformName} · ${e.status}`})})],scope:`global`,admin:{group:`imports`,label:`Import runs`,labelSingular:`Import run`,icon:`upload`,hideFromMenu:!0}});var o=class{entries=new Map;register(e,t){if(this.entries.has(e))throw Error(`Transform "${e}" is already registered — two plugins cannot register the same transform name.`);this.entries.set(e,t)}get(e){return this.entries.get(e)}has(e){return this.entries.has(e)}list(){return Array.from(this.entries.keys()).sort()}clear(){this.entries.clear()}};const s=Symbol.for(`@murumets-ee/imports:transforms`);function c(){let e=globalThis,t=e[s];return t||(t=new o,e[s]=t),t}function l(e,t){c().register(e,t)}export{a,i,c as n,l as r,o as t};
|
|
2
|
-
//# sourceMappingURL=transform-CSTDgiGK.mjs.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"transform-CSTDgiGK.mjs","names":[],"sources":["../src/entities/import-run.ts","../src/transform.ts"],"sourcesContent":["/**\n * ImportRun — one feed-import attempt. Tracks file, transform, status,\n * row totals, and the top error patterns surfaced by `ErrorTracker`.\n *\n * Per PLAN-ECOMMERCE.md PR 7 (PoC scope): generic plumbing entity. The\n * commerce-specific transform parameters (brandId, supplierId, supplier\n * code prefix) live in the opaque `params` JSONB so the imports package\n * stays free of commerce-domain dependencies. PR 8 wires a concrete\n * carmaker-feed transform that reads those params; PR 8a renders a\n * bespoke `/admin/commerce/imports` page over this entity's history.\n *\n * `hideFromMenu: true` because PR 7 ships only the entity + plumbing —\n * the operator-facing surface is PR 8a. The auto-EntityListPage at\n * `/admin/import_run` still resolves for direct lookups, but no sidebar\n * entry points to it.\n */\n\nimport { behavior, defineEntity, field, searchable } from '@murumets-ee/entity'\nimport type { AdminClient } from '@murumets-ee/entity/admin'\n\n/**\n * Lifecycle stages.\n *\n * - `pending` — row exists, queue job has been enqueued, worker hasn't picked it up yet.\n * - `running` — worker is streaming + bulk-writing.\n * - `succeeded` — worker finished cleanly (note: per-row failures still possible — see `totals.failed`).\n * - `failed` — worker threw and the queue marked the job dead. `errorSummary.fatal` carries the cause.\n * - `cancelled` — manual operator action via PR 8a (out of scope for PR 7).\n */\nexport const IMPORT_RUN_STATUSES = ['pending', 'running', 'succeeded', 'failed', 'cancelled'] as const\nexport type ImportRunStatus = (typeof IMPORT_RUN_STATUSES)[number]\n\nexport const ImportRun = defineEntity({\n name: 'import_run',\n fields: {\n /** Operator-visible label, e.g. `\"MERCEDES — ME_20251027075918.txt\"`. Free-form. */\n label: field.text({ required: true, maxLength: 255 }),\n /** See {@link IMPORT_RUN_STATUSES}. */\n status: field.select({\n options: IMPORT_RUN_STATUSES,\n default: 'pending',\n indexed: true,\n }),\n /**\n * Path or storage key of the uploaded feed file. Generic string —\n * could be `/var/lumi/uploads/<id>.txt` for the local-disk PoC or an\n * S3 object key once storage adapter integration lands. The worker\n * reads this with the configured `readFeed` resolver.\n */\n filePath: field.text({ required: true, maxLength: 2048 }),\n /**\n * Name of the registered transform applied to each row. Resolved at\n * worker-dispatch time against the transform registry contributed by\n * the consumer. PoC: `'commerce:carmaker-feed'`.\n */\n transformName: field.text({ required: true, maxLength: 128, indexed: true }),\n /**\n * Opaque per-transform parameters. The carmaker transform expects\n * `{ brandId, supplierId, codePrefix? }`. Validation happens inside\n * the transform — the imports package never inspects this shape so\n * a new transform with different params doesn't require a schema\n * migration.\n */\n params: field.json(),\n /**\n * Row counters: `{ submitted, succeeded, failed, skipped, batches }`.\n * Updated by the worker as each batch completes. Final values are\n * what the operator reads; intermediate progress comes from\n * `toolkit_jobs.progress` via the queue UI.\n */\n totals: field.json(),\n /**\n * Output of `ErrorTracker.getTopPatterns(totalRows)` — the top-50\n * error signatures with up to 5 sample rows each. Empty `[]` until\n * the worker writes it on completion.\n */\n errorSummary: field.json(),\n /** Set when the worker picks up the job. */\n startedAt: field.date(),\n /** Set when the worker finishes (success OR fatal failure). */\n finishedAt: field.date(),\n /**\n * `toolkit_jobs.id` of the queue job processing this run — link\n * back so PR 8a can show live progress without a second lookup.\n */\n queueJobId: field.text({ maxLength: 64, indexed: true }),\n },\n behaviors: [\n behavior.auditable(),\n // Operator-facing search: by label (e.g. \"MERCEDES — ME_*.txt\"),\n // transform name, or queue job id. No FTS — labels are short\n // alphanumeric tags, not prose.\n searchable({\n fields: ['label', 'transformName', 'queueJobId', 'status', 'filePath'],\n projection: (row) => ({\n id: row.id as string,\n label: row.label as string,\n description: `${row.transformName} · ${row.status}`,\n }),\n }),\n ],\n scope: 'global',\n admin: {\n group: 'imports',\n label: 'Import runs',\n labelSingular: 'Import run',\n icon: 'upload',\n hideFromMenu: true,\n },\n})\n\nexport type ImportRunClient = AdminClient<typeof ImportRun.allFields>\n","/**\n * Per-feed transform plugin interface (PLAN-ECOMMERCE.md PR 7 / D14).\n *\n * The streaming reader reads raw rows out of the file (column key →\n * string). The transform turns one row into a typed `OutputDoc` ready\n * for the bulk-write surface (PoC: `PartsDocument` for the parts ES\n * index). The transform is what makes a generic feed-importer\n * commerce-aware — without it, the importer has no opinion on what a\n * row \"means\".\n *\n * Why an interface, not a function:\n * - The transform may need to reject a row (`RowSkip`) without that\n * counting as an error in `ErrorTracker` — e.g. blank lines, header\n * rows mistakenly retained, or \"this is a replacement-code marker\n * row, handled out-of-band\" decisions.\n * - The transform may need to fail a row (`RowError`) with a typed\n * error class so `ErrorTracker` collapses them by `errorType`.\n * - The transform may need access to the run-level params (brand /\n * supplier IDs, code prefix, batch ID) without those being\n * re-derived per row.\n *\n * PR 7 ships only the interface + a registry. PR 8 ships the first\n * concrete carmaker-feed transform.\n */\n\n/** Identifies which transform a registered handler implements. Stable across deploys. */\nexport type TransformName = string\n\n/** Per-run context provided to every row call. Kept narrow on purpose. */\nexport interface TransformContext {\n /** UUID of the `import_run` row. Forward to the output doc as `import_batch_id` for source attribution (D20). */\n importRunId: string\n /** Opaque per-run params copied from `import_run.params`. The transform validates the shape it expects. */\n params: Record<string, unknown>\n /** Operator-supplied label, useful for logging / debugging. */\n runLabel: string\n /** 1-based row number across the whole feed (header counts as row 0). Forward to `RowError.rowNumber`. */\n rowNumber: number\n}\n\n/**\n * One of: a successful `OutputDoc` keyed by a stable `id`, an\n * intentional `skip` (counted in `totals.skipped`), or a row-level\n * error (counted in `totals.failed` AND aggregated by `ErrorTracker`).\n *\n * The `id` field on `success` is used as the bulk-upsert primary key\n * (`_id` in ES). Per D4 the parts index uses\n * `<code_normalized>__<supplier_id>` so a re-run of the same feed\n * idempotently overwrites instead of duplicating.\n */\nexport type RowResult<TDoc> =\n | { kind: 'success'; id: string; doc: TDoc }\n | { kind: 'skip'; reason: string }\n | { kind: 'error'; error: RowError }\n\n/** Row-level error. The aggregator uses `(errorType, field, message)` as the dedup signature. */\nexport interface RowError {\n errorType: string\n message: string\n field?: string | undefined\n}\n\n/**\n * The contract every per-feed transform implements. Pure function of\n * `(row, ctx) → RowResult` — no I/O. Async only because the future\n * carmaker variant might consult an in-memory taxonomy lookup.\n */\nexport interface RowTransform<TDoc> {\n (rawRow: Record<string, string>, ctx: TransformContext): Promise<RowResult<TDoc>>\n}\n\n/**\n * Holds the registered transforms for the current process. Each plugin\n * that ships a transform calls {@link TransformRegistry.register} from\n * its `init` hook.\n *\n * Per CLAUDE.md \"Whitelist, don't blacklist\": the worker dispatches\n * by `import_run.transformName` against this registry. An unregistered\n * name fails the run rather than running with a default transform.\n *\n * **Production callers MUST go through {@link getTransformRegistry}**, not\n * `new TransformRegistry()`. The constructor is exposed only so tests\n * (and rare custom-worker embeds) can build an isolated instance and\n * inject it into {@link createRunImportHandler} directly. Two `new`\n * instances do NOT share state — registering a transform on one will\n * NOT make it visible to a worker resolving against the other. The\n * package's queue handler (registered by `imports()` plugin) always\n * resolves against the singleton.\n */\nexport class TransformRegistry {\n private readonly entries = new Map<TransformName, RowTransform<unknown>>()\n\n /**\n * Register a transform under a stable name. Throws on duplicate\n * names so two plugins can't silently overwrite each other (matches\n * the `SearchRegistry` pattern in `@murumets-ee/search`).\n */\n register<TDoc>(name: TransformName, transform: RowTransform<TDoc>): void {\n if (this.entries.has(name)) {\n throw new Error(\n `Transform \"${name}\" is already registered — two plugins cannot register the same transform name.`,\n )\n }\n this.entries.set(name, transform as RowTransform<unknown>)\n }\n\n /** Look up a transform by name. Returns `undefined` if no plugin has registered one. */\n get(name: TransformName): RowTransform<unknown> | undefined {\n return this.entries.get(name)\n }\n\n /** True iff a transform is registered for the given name. */\n has(name: TransformName): boolean {\n return this.entries.has(name)\n }\n\n /** All registered names. Useful for the admin Catalog tab and for tests. */\n list(): TransformName[] {\n return Array.from(this.entries.keys()).sort()\n }\n\n /** Drop all registrations. Tests only. */\n clear(): void {\n this.entries.clear()\n }\n}\n\n// ---------------------------------------------------------------------------\n// Process-global singleton (mirrors `@murumets-ee/queue`'s handler registry).\n// ---------------------------------------------------------------------------\n//\n// Why a singleton: PR 8's carmaker-feed transform lives in a different\n// plugin from `@murumets-ee/imports`. Under Next.js HMR, ONE of those\n// plugins might re-evaluate while the other does not. Holding the\n// registry on `globalThis` via `Symbol.for` means the same `Map`\n// instance is shared across module evaluations — the carmaker plugin's\n// `register` and the imports worker's `get` see each other regardless\n// of evaluation order. Same fix shape as the queue's handler-registry\n// HMR bug (#186).\n\nconst REGISTRY_KEY = Symbol.for('@murumets-ee/imports:transforms')\n\ninterface GlobalThisWithTransforms {\n [REGISTRY_KEY]?: TransformRegistry\n}\n\n/** Returns the singleton registry, creating it on first access. */\nexport function getTransformRegistry(): TransformRegistry {\n const g = globalThis as GlobalThisWithTransforms\n let reg = g[REGISTRY_KEY]\n if (!reg) {\n reg = new TransformRegistry()\n g[REGISTRY_KEY] = reg\n }\n return reg\n}\n\n/**\n * Convenience wrapper around `getTransformRegistry().register(name, fn)`.\n * Plugins that ship a transform call this from their `init` hook — same\n * shape as `@murumets-ee/queue/client`'s `registerJob`.\n */\nexport function registerImportTransform<TDoc>(\n name: TransformName,\n transform: RowTransform<TDoc>,\n): void {\n getTransformRegistry().register(name, transform)\n}\n"],"mappings":"4FA6BA,MAAa,EAAsB,CAAC,UAAW,UAAW,YAAa,SAAU,YAAY,CAGhF,EAAY,EAAa,CACpC,KAAM,aACN,OAAQ,CAEN,MAAO,EAAM,KAAK,CAAE,SAAU,GAAM,UAAW,IAAK,CAAC,CAErD,OAAQ,EAAM,OAAO,CACnB,QAAS,EACT,QAAS,UACT,QAAS,GACV,CAAC,CAOF,SAAU,EAAM,KAAK,CAAE,SAAU,GAAM,UAAW,KAAM,CAAC,CAMzD,cAAe,EAAM,KAAK,CAAE,SAAU,GAAM,UAAW,IAAK,QAAS,GAAM,CAAC,CAQ5E,OAAQ,EAAM,MAAM,CAOpB,OAAQ,EAAM,MAAM,CAMpB,aAAc,EAAM,MAAM,CAE1B,UAAW,EAAM,MAAM,CAEvB,WAAY,EAAM,MAAM,CAKxB,WAAY,EAAM,KAAK,CAAE,UAAW,GAAI,QAAS,GAAM,CAAC,CACzD,CACD,UAAW,CACT,EAAS,WAAW,CAIpB,EAAW,CACT,OAAQ,CAAC,QAAS,gBAAiB,aAAc,SAAU,WAAW,CACtE,WAAa,IAAS,CACpB,GAAI,EAAI,GACR,MAAO,EAAI,MACX,YAAa,GAAG,EAAI,cAAc,KAAK,EAAI,SAC5C,EACF,CAAC,CACH,CACD,MAAO,SACP,MAAO,CACL,MAAO,UACP,MAAO,cACP,cAAe,aACf,KAAM,SACN,aAAc,GACf,CACF,CAAC,CCpBF,IAAa,EAAb,KAA+B,CAC7B,QAA2B,IAAI,IAO/B,SAAe,EAAqB,EAAqC,CACvE,GAAI,KAAK,QAAQ,IAAI,EAAK,CACxB,MAAU,MACR,cAAc,EAAK,gFACpB,CAEH,KAAK,QAAQ,IAAI,EAAM,EAAmC,CAI5D,IAAI,EAAwD,CAC1D,OAAO,KAAK,QAAQ,IAAI,EAAK,CAI/B,IAAI,EAA8B,CAChC,OAAO,KAAK,QAAQ,IAAI,EAAK,CAI/B,MAAwB,CACtB,OAAO,MAAM,KAAK,KAAK,QAAQ,MAAM,CAAC,CAAC,MAAM,CAI/C,OAAc,CACZ,KAAK,QAAQ,OAAO,GAiBxB,MAAM,EAAe,OAAO,IAAI,kCAAkC,CAOlE,SAAgB,GAA0C,CACxD,IAAM,EAAI,WACN,EAAM,EAAE,GAKZ,OAJK,IACH,EAAM,IAAI,EACV,EAAE,GAAgB,GAEb,EAQT,SAAgB,EACd,EACA,EACM,CACN,GAAsB,CAAC,SAAS,EAAM,EAAU"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"worker-B7ADOFEV.d.mts","names":[],"sources":["../src/entities/import-run.ts","../src/error-tracker.ts","../src/streaming.ts","../src/runner.ts","../src/worker.ts"],"mappings":"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"}
|