@murumets-ee/imports 0.32.2 → 0.32.3

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/index.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { A as ImportRunClient, C as DEFAULT_MAX_SAMPLES_PER_PATTERN, D as ErrorTrackerConfig, E as ErrorTracker, O as IMPORT_RUN_STATUSES, S as DEFAULT_MAX_PATTERNS, T as ErrorSample, _ as readFeed, b as countDataRows, d as DEFAULT_BATCH_SIZE, f as ImportRunProgress, g as ReadFeedOptions, h as runImport, j as ImportRunStatus, k as ImportRun, m as RunImportResult, n as FilePathResolver, p as RunImportOptions, t as EsClientResolver, v as StreamFeedOptions, w as ErrorPattern, x as streamFeed, y as StreamFeedRow } from "./worker-ByPOP6Qe.mjs";
1
+ import { A as ImportRun, C as DEFAULT_MAX_PATTERNS, D as ErrorTracker, E as ErrorSample, M as ImportRunStatus, O as ErrorTrackerConfig, S as streamFeed, T as ErrorPattern, _ as ReadFeedOptions, b as StreamFeedRow, f as DEFAULT_BATCH_SIZE, g as runImport, h as RunImportResult, j as ImportRunClient, k as IMPORT_RUN_STATUSES, m as RunImportOptions, n as EsClientResolver, p as ImportRunProgress, r as FilePathResolver, t as EnsureIndexResolver, v as readFeed, w as DEFAULT_MAX_SAMPLES_PER_PATTERN, x as countDataRows, y as StreamFeedOptions } from "./worker-C-5Xqf8_.mjs";
2
2
  import { a as TransformName, c as registerImportTransform, i as TransformContext, n as RowResult, o as TransformRegistry, r as RowTransform, s as getTransformRegistry, t as RowError } from "./transform-dO4z0Ze6.mjs";
3
3
  import { Behavior } from "@murumets-ee/entity";
4
4
  import { ToolkitApp } from "@murumets-ee/core";
@@ -20,14 +20,14 @@ declare const ImportMapping: import("@murumets-ee/entity").Entity<{
20
20
  id: import("@murumets-ee/entity").IdField;
21
21
  } & import("@murumets-ee/entity").AuditableFields & Record<never, never> & {
22
22
  /** FK to commerce `supplier` (by name string — no cross-package import). Required. */supplierId: import("@murumets-ee/entity").ReferenceField & {
23
- cardinality: "one" | "many";
23
+ cardinality: "many" | "one";
24
24
  } & {
25
25
  readonly entity: "supplier";
26
26
  readonly required: true;
27
27
  readonly indexed: true;
28
28
  }; /** FK to commerce `brand`. NULL = supplier default (applies to all that supplier's brands). */
29
29
  brandId: import("@murumets-ee/entity").ReferenceField & {
30
- cardinality: "one" | "many";
30
+ cardinality: "many" | "one";
31
31
  } & {
32
32
  readonly entity: "brand";
33
33
  readonly indexed: true;
@@ -174,5 +174,5 @@ interface CleanupProcessedImportsArgs {
174
174
  */
175
175
  declare function cleanupProcessedImports(args: CleanupProcessedImportsArgs): Promise<number>;
176
176
  //#endregion
177
- export { type CleanupOrphanedImportsArgs, type CleanupProcessedImportsArgs, DEFAULT_BATCH_SIZE, DEFAULT_MAX_PATTERNS, DEFAULT_MAX_SAMPLES_PER_PATTERN, type ErrorPattern, type ErrorSample, ErrorTracker, type ErrorTrackerConfig, type EsClientResolver, type FilePathResolver, IMPORT_MAPPING_FORMATS, IMPORT_MAPPING_TARGET_SCHEMAS, IMPORT_RUN_STATUSES, ImportMapping, type ImportMappingClient, type ImportMappingFormat, type ImportMappingTargetSchema, ImportRun, type ImportRunClient, type ImportRunProgress, type ImportRunStatus, type ReadFeedOptions, type RowError, type RowResult, type RowTransform, type RunImportOptions, type RunImportResult, type StreamFeedOptions, type StreamFeedRow, type TransformContext, type TransformName, TransformRegistry, cleanupOrphanedImports, cleanupProcessedImports, countDataRows, getTransformRegistry, readFeed, registerImportTransform, runImport, streamFeed };
177
+ export { type CleanupOrphanedImportsArgs, type CleanupProcessedImportsArgs, DEFAULT_BATCH_SIZE, DEFAULT_MAX_PATTERNS, DEFAULT_MAX_SAMPLES_PER_PATTERN, type EnsureIndexResolver, type ErrorPattern, type ErrorSample, ErrorTracker, type ErrorTrackerConfig, type EsClientResolver, type FilePathResolver, IMPORT_MAPPING_FORMATS, IMPORT_MAPPING_TARGET_SCHEMAS, IMPORT_RUN_STATUSES, ImportMapping, type ImportMappingClient, type ImportMappingFormat, type ImportMappingTargetSchema, ImportRun, type ImportRunClient, type ImportRunProgress, type ImportRunStatus, type ReadFeedOptions, type RowError, type RowResult, type RowTransform, type RunImportOptions, type RunImportResult, type StreamFeedOptions, type StreamFeedRow, type TransformContext, type TransformName, TransformRegistry, cleanupOrphanedImports, cleanupProcessedImports, countDataRows, getTransformRegistry, readFeed, registerImportTransform, runImport, streamFeed };
178
178
  //# sourceMappingURL=index.d.mts.map
package/dist/plugin.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { n as FilePathResolver, t as EsClientResolver } from "./worker-ByPOP6Qe.mjs";
1
+ import { n as EsClientResolver, r as FilePathResolver, t as EnsureIndexResolver } from "./worker-C-5Xqf8_.mjs";
2
2
  import { ImportsStorageFactory } from "./storage.mjs";
3
3
 
4
4
  //#region src/plugin.d.ts
@@ -16,6 +16,17 @@ interface ImportsPluginOptions {
16
16
  * Per D6 always pass an alias, never a physical index.
17
17
  */
18
18
  esIndex?: string;
19
+ /**
20
+ * Optional bootstrap for the destination index/alias, run once per import
21
+ * job before any bulk-write (see {@link EnsureIndexResolver}). Wire your
22
+ * app's `ensureAliasedIndex`-backed helper here so the worker can never
23
+ * auto-create a mis-mapped physical index — WITHOUT gating app boot on ES.
24
+ *
25
+ * Omit only when the index is bootstrapped out-of-band; without it a
26
+ * first-ever import against an unbootstrapped alias lets ES auto-create a
27
+ * `text`-mapped physical index, breaking aggregations (issue #610).
28
+ */
29
+ ensureIndex?: EnsureIndexResolver;
19
30
  /**
20
31
  * Resolve `import_run.filePath` to a local FS path before streaming.
21
32
  * **Required when uploads land in remote storage** (R2/S3/etc.) —
@@ -1 +1 @@
1
- {"version":3,"file":"plugin.d.mts","names":[],"sources":["../src/plugin.ts"],"mappings":";;;;UAsEiB,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"}
1
+ {"version":3,"file":"plugin.d.mts","names":[],"sources":["../src/plugin.ts"],"mappings":";;;;UAuEiB,oBAAA;EAuGoC;;;;;EAjGnD,QAAA,EAAU,gBAAA;;;;;;;EAOV,OAAA;;;;;;;;;;;EAWA,WAAA,GAAc,mBAAA;;;;;;;;;;;;;;;EAed,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{c as e,n as t,o as n}from"./transform-DloItB80.mjs";import{registerImportsStorageFactory as r}from"./storage.mjs";import{createImportsRunDeadListener as i,createRunImportHandler as a,importsRunJob as o}from"./worker.mjs";import{definePlugin as s}from"@murumets-ee/core";const c=Symbol.for(`@murumets-ee/imports:dead-listener-unsubscribe`);function l(l){let u=l.esIndex??`parts`,d=l.storageBucket===null?null:{bucket:l.storageBucket??`lumi-imports`,orphanTtlHours:l.orphanUploadTtlHours??2,processedRetentionDays:l.processedRetentionDays??7};return l.storage&&r(l.storage),s({name:`@murumets-ee/imports`,server:{entities:[e,n],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 r=await import(`@murumets-ee/queue/client`),s=n.getClient(e),f=t();r.registerJob(o,a({importRuns:s,transforms:f,esClient:l.esClient,esIndex:u,...l.resolveFilePath!==void 0&&{resolveFilePath:l.resolveFilePath},logger:n.logger.child({pkg:`imports`})}));let p=globalThis[c];p&&p();let m=r.addQueueTerminalListener(i({importRuns:s,logger:n.logger.child({pkg:`imports`})}));if(globalThis[c]=m,d!==null){let{cleanupOrphanedImports:e,cleanupProcessedImports:t}=await import(`./cleanup-CFo7Pw0t.mjs`).then(e=>e.r),{runAsCli:i}=await import(`@murumets-ee/core`),a=n.logger.child({pkg:`imports`,job:`cleanup`});r.registerJob(r.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 i(()=>e({app:n,bucket:d.bucket,ttlHours:d.orphanTtlHours,logger:a}))}),r.registerJob(r.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 i(()=>t({app:n,bucket:d.bucket,retentionDays:d.processedRetentionDays,logger:a}))})}n.logger.info({esIndex:u,cleanup:d===null?{enabled:!1}:{enabled:!0,...d}},`Imports plugin initialized`)}}})}export{l as imports};
1
+ import{c as e,n as t,o as n}from"./transform-DloItB80.mjs";import{registerImportsStorageFactory as r}from"./storage.mjs";import{createImportsRunDeadListener as i,createRunImportHandler as a,importsRunJob as o}from"./worker.mjs";import{definePlugin as s}from"@murumets-ee/core";const c=Symbol.for(`@murumets-ee/imports:dead-listener-unsubscribe`);function l(l){let u=l.esIndex??`parts`,d=l.storageBucket===null?null:{bucket:l.storageBucket??`lumi-imports`,orphanTtlHours:l.orphanUploadTtlHours??2,processedRetentionDays:l.processedRetentionDays??7};return l.storage&&r(l.storage),s({name:`@murumets-ee/imports`,server:{entities:[e,n],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 r=await import(`@murumets-ee/queue/client`),s=n.getClient(e),f=t();r.registerJob(o,a({importRuns:s,transforms:f,esClient:l.esClient,esIndex:u,...l.ensureIndex!==void 0&&{ensureIndex:l.ensureIndex},...l.resolveFilePath!==void 0&&{resolveFilePath:l.resolveFilePath},logger:n.logger.child({pkg:`imports`})}));let p=globalThis[c];p&&p();let m=r.addQueueTerminalListener(i({importRuns:s,logger:n.logger.child({pkg:`imports`})}));if(globalThis[c]=m,d!==null){let{cleanupOrphanedImports:e,cleanupProcessedImports:t}=await import(`./cleanup-CFo7Pw0t.mjs`).then(e=>e.r),{runAsCli:i}=await import(`@murumets-ee/core`),a=n.logger.child({pkg:`imports`,job:`cleanup`});r.registerJob(r.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 i(()=>e({app:n,bucket:d.bucket,ttlHours:d.orphanTtlHours,logger:a}))}),r.registerJob(r.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 i(()=>t({app:n,bucket:d.bucket,retentionDays:d.processedRetentionDays,logger:a}))})}n.logger.info({esIndex:u,cleanup:d===null?{enabled:!1}:{enabled:!0,...d}},`Imports plugin initialized`)}}})}export{l as imports};
2
2
  //# sourceMappingURL=plugin.mjs.map
@@ -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 { definePlugin } from '@murumets-ee/core'\n// **Why no static `@murumets-ee/core/clients` import in this file.**\n//\n// `@murumets-ee/core/clients` begins with `import 'server-only'`, which\n// throws when this plugin module is loaded at `lumi.config.ts`\n// evaluation time by the CLI (`lumi migrate`/`seed`/`doctor` use jiti,\n// which doesn't apply the `react-server` export condition).\n// Static-importing would propagate that throw all the way through\n// `lumi.config.ts` → CLI startup, blocking every CLI invocation.\n//\n// **Today (after PLAN-DECLARATIVE-PLUGINS PR 2):** plugin `init`\n// reaches `AdminClient` via the framework-provided `app.getClient(\n// entity)` accessor — no `createAdminClient` call, no dynamic import\n// either. The remaining `@murumets-ee/core/clients` dependency lives\n// in `./admin/cleanup.js`, which IS still dynamic-imported inside\n// `init` and which still benefits from this rationale. The pattern\n// matches `getDb()` in `@murumets-ee/queue/admin.ts`.\nimport { ImportMapping } from './entities/import-mapping.js'\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, ImportMapping],\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 // `@murumets-ee/core/clients` is NO LONGER imported here —\n // `app.getClient(entity)` gives us a typed, app-bound\n // AdminClient without the F1+F2 foot-guns from\n // PLAN-DECLARATIVE-PLUGINS.md.\n const queueClientModule = await import('@murumets-ee/queue/client')\n\n const importRuns = app.getClient(ImportRun)\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":"qRAgEA,MAAM,EAA4B,OAAO,IAAI,gDAAgD,EAkG7F,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,CAC5D,EAcN,OAJI,EAAQ,SACV,EAA8B,EAAQ,OAAO,EAGxC,EAAa,CAClB,KAAM,uBACN,OAAQ,CACN,SAAU,CAAC,EAAW,CAAa,EACnC,KAAM,KAAO,IAAQ,CAKnB,GAAI,CADuB,EAAI,QAAQ,IAAI,CAAC,CAAC,KAAM,GAAM,EAAE,OAAS,oBAC9C,EAAG,CACvB,EAAI,OAAO,KACT,uFACF,EACA,MACF,CAUA,IAAM,EAAoB,MAAM,OAAO,6BAEjC,EAAa,EAAI,UAAU,CAAS,EACpC,EAAa,EAAqB,EAExC,EAAkB,YAChB,EACA,EAAuB,CACrB,aACA,aACA,SAAU,EAAQ,SAClB,UACA,GAAI,EAAQ,kBAAoB,IAAA,IAAa,CAC3C,gBAAiB,EAAQ,eAC3B,EACA,OAAQ,EAAI,OAAO,MAAM,CAAE,IAAK,SAAU,CAAC,CAC7C,CAAC,CACH,EAwBA,IAAM,EAAuB,WAC3B,GAEE,GAAqB,EAAoB,EAC7C,IAAM,EAAc,EAAkB,yBACpC,EAA6B,CAC3B,aACA,OAAQ,EAAI,OAAO,MAAM,CAAE,IAAK,SAAU,CAAC,CAC7C,CAAC,CACH,EAgBA,GAfC,WAA0C,GAA6B,EAepE,IAAkB,KAAM,CAC1B,GAAM,CAAE,yBAAwB,2BAA4B,MAAM,OAChE,yBACD,CAAA,KAAA,GAAA,EAAA,CAAA,EACK,CAAE,YAAa,MAAM,OAAO,qBAC5B,EAAgB,EAAI,OAAO,MAAM,CAAE,IAAK,UAAW,IAAK,SAAU,CAAC,EAEzE,EAAkB,YAChB,EAAkB,UAAU,CAC1B,KAAM,mCACN,YACE,qIACF,SAAU,WACZ,CAAC,EACD,SAAY,CAUV,MAAM,MACJ,EAAuB,CACrB,MACA,OAAQ,EAAc,OACtB,SAAU,EAAc,eACxB,OAAQ,CACV,CAAC,CACH,CACF,CACF,EAEA,EAAkB,YAChB,EAAkB,UAAU,CAC1B,KAAM,oCACN,YACE,oMACF,SAAU,WACZ,CAAC,EACD,SAAY,CACV,MAAM,MACJ,EAAwB,CACtB,MACA,OAAQ,EAAc,OACtB,cAAe,EAAc,uBAC7B,OAAQ,CACV,CAAC,CACH,CACF,CACF,CACF,CAEA,EAAI,OAAO,KACT,CACE,UAIA,QACE,IAAkB,KAAO,CAAE,QAAS,EAAM,EAAI,CAAE,QAAS,GAAM,GAAG,CAAc,CACpF,EACA,4BACF,CACF,CACF,CACF,CAAC,CACH"}
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// **Why no static `@murumets-ee/core/clients` import in this file.**\n//\n// `@murumets-ee/core/clients` begins with `import 'server-only'`, which\n// throws when this plugin module is loaded at `lumi.config.ts`\n// evaluation time by the CLI (`lumi migrate`/`seed`/`doctor` use jiti,\n// which doesn't apply the `react-server` export condition).\n// Static-importing would propagate that throw all the way through\n// `lumi.config.ts` → CLI startup, blocking every CLI invocation.\n//\n// **Today (after PLAN-DECLARATIVE-PLUGINS PR 2):** plugin `init`\n// reaches `AdminClient` via the framework-provided `app.getClient(\n// entity)` accessor — no `createAdminClient` call, no dynamic import\n// either. The remaining `@murumets-ee/core/clients` dependency lives\n// in `./admin/cleanup.js`, which IS still dynamic-imported inside\n// `init` and which still benefits from this rationale. The pattern\n// matches `getDb()` in `@murumets-ee/queue/admin.ts`.\nimport { ImportMapping } from './entities/import-mapping.js'\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 EnsureIndexResolver,\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 * Optional bootstrap for the destination index/alias, run once per import\n * job before any bulk-write (see {@link EnsureIndexResolver}). Wire your\n * app's `ensureAliasedIndex`-backed helper here so the worker can never\n * auto-create a mis-mapped physical index — WITHOUT gating app boot on ES.\n *\n * Omit only when the index is bootstrapped out-of-band; without it a\n * first-ever import against an unbootstrapped alias lets ES auto-create a\n * `text`-mapped physical index, breaking aggregations (issue #610).\n */\n ensureIndex?: EnsureIndexResolver\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, ImportMapping],\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 // `@murumets-ee/core/clients` is NO LONGER imported here —\n // `app.getClient(entity)` gives us a typed, app-bound\n // AdminClient without the F1+F2 foot-guns from\n // PLAN-DECLARATIVE-PLUGINS.md.\n const queueClientModule = await import('@murumets-ee/queue/client')\n\n const importRuns = app.getClient(ImportRun)\n const transforms = getTransformRegistry()\n\n queueClientModule.registerJob(\n importsRunJob,\n createRunImportHandler({\n importRuns,\n transforms,\n esClient: options.esClient,\n esIndex,\n ...(options.ensureIndex !== undefined && { ensureIndex: options.ensureIndex }),\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":"qRAiEA,MAAM,EAA4B,OAAO,IAAI,gDAAgD,EA6G7F,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,CAC5D,EAcN,OAJI,EAAQ,SACV,EAA8B,EAAQ,OAAO,EAGxC,EAAa,CAClB,KAAM,uBACN,OAAQ,CACN,SAAU,CAAC,EAAW,CAAa,EACnC,KAAM,KAAO,IAAQ,CAKnB,GAAI,CADuB,EAAI,QAAQ,IAAI,CAAC,CAAC,KAAM,GAAM,EAAE,OAAS,oBAC9C,EAAG,CACvB,EAAI,OAAO,KACT,uFACF,EACA,MACF,CAUA,IAAM,EAAoB,MAAM,OAAO,6BAEjC,EAAa,EAAI,UAAU,CAAS,EACpC,EAAa,EAAqB,EAExC,EAAkB,YAChB,EACA,EAAuB,CACrB,aACA,aACA,SAAU,EAAQ,SAClB,UACA,GAAI,EAAQ,cAAgB,IAAA,IAAa,CAAE,YAAa,EAAQ,WAAY,EAC5E,GAAI,EAAQ,kBAAoB,IAAA,IAAa,CAC3C,gBAAiB,EAAQ,eAC3B,EACA,OAAQ,EAAI,OAAO,MAAM,CAAE,IAAK,SAAU,CAAC,CAC7C,CAAC,CACH,EAwBA,IAAM,EAAuB,WAC3B,GAEE,GAAqB,EAAoB,EAC7C,IAAM,EAAc,EAAkB,yBACpC,EAA6B,CAC3B,aACA,OAAQ,EAAI,OAAO,MAAM,CAAE,IAAK,SAAU,CAAC,CAC7C,CAAC,CACH,EAgBA,GAfC,WAA0C,GAA6B,EAepE,IAAkB,KAAM,CAC1B,GAAM,CAAE,yBAAwB,2BAA4B,MAAM,OAChE,yBACD,CAAA,KAAA,GAAA,EAAA,CAAA,EACK,CAAE,YAAa,MAAM,OAAO,qBAC5B,EAAgB,EAAI,OAAO,MAAM,CAAE,IAAK,UAAW,IAAK,SAAU,CAAC,EAEzE,EAAkB,YAChB,EAAkB,UAAU,CAC1B,KAAM,mCACN,YACE,qIACF,SAAU,WACZ,CAAC,EACD,SAAY,CAUV,MAAM,MACJ,EAAuB,CACrB,MACA,OAAQ,EAAc,OACtB,SAAU,EAAc,eACxB,OAAQ,CACV,CAAC,CACH,CACF,CACF,EAEA,EAAkB,YAChB,EAAkB,UAAU,CAC1B,KAAM,oCACN,YACE,oMACF,SAAU,WACZ,CAAC,EACD,SAAY,CACV,MAAM,MACJ,EAAwB,CACtB,MACA,OAAQ,EAAc,OACtB,cAAe,EAAc,uBAC7B,OAAQ,CACV,CAAC,CACH,CACF,CACF,CACF,CAEA,EAAI,OAAO,KACT,CACE,UAIA,QACE,IAAkB,KAAO,CAAE,QAAS,EAAM,EAAI,CAAE,QAAS,GAAM,GAAG,CAAc,CACpF,EACA,4BACF,CACF,CACF,CACF,CAAC,CACH"}
@@ -1,4 +1,4 @@
1
- import { n as FilePathResolver } from "./worker-ByPOP6Qe.mjs";
1
+ import { r as FilePathResolver } from "./worker-C-5Xqf8_.mjs";
2
2
 
3
3
  //#region src/storage-resolver.d.ts
4
4
  /**
@@ -472,6 +472,21 @@ declare const importsRunJob: JobDefinition<ImportsRunJobPayload>;
472
472
  * route-handler initialiser, not at plugin-init time.
473
473
  */
474
474
  type EsClientResolver = () => EsClientLike | Promise<EsClientLike>;
475
+ /**
476
+ * Ensure the destination index/alias exists before the first bulk-write.
477
+ * Invoked once per job, BEFORE {@link runImport}. Idempotent: the canonical
478
+ * implementation (`ensureAliasedIndex` in `@murumets-ee/search-elasticsearch`)
479
+ * no-ops when the alias already resolves.
480
+ *
481
+ * **Why it lives here.** This is the home of the foot-gun it guards: a worker
482
+ * that bulk-writes to alias `<x>` before the alias exists makes ES
483
+ * auto-create a physical `<x>` index with default `text` mappings, breaking
484
+ * aggregations forever. The bootstrap used to run at app boot, which turned
485
+ * "ES is down" into a full-admin 500 (issue #610). Moving it onto the import
486
+ * path keeps the guarantee while scoping ES availability to imports/search —
487
+ * the rest of the admin boots and runs with ES down.
488
+ */
489
+ type EnsureIndexResolver = () => Promise<unknown>;
475
490
  /**
476
491
  * Resolves the value of `import_run.filePath` (whatever the upload
477
492
  * route persisted there — typically a storage adapter key) to a
@@ -510,6 +525,12 @@ interface RunImportHandlerConfig {
510
525
  esClient: EsClientResolver;
511
526
  /** ES alias / index to bulk-write into. Per D6 callers always pass an alias. */
512
527
  esIndex: string;
528
+ /**
529
+ * Optional bootstrap for the destination index/alias, run once per job
530
+ * before any bulk-write. See {@link EnsureIndexResolver}. Omit for
531
+ * deployments that bootstrap the index out-of-band (e.g. a reindex job).
532
+ */
533
+ ensureIndex?: EnsureIndexResolver;
513
534
  /**
514
535
  * Optional resolver for `import_run.filePath`. See {@link FilePathResolver}.
515
536
  * Required when uploads land in remote storage (R2/S3); optional for
@@ -574,5 +595,5 @@ interface ImportsRunDeadListenerConfig {
574
595
  */
575
596
  declare function createImportsRunDeadListener(config: ImportsRunDeadListenerConfig): QueueTerminalNotifier;
576
597
  //#endregion
577
- export { ImportRunClient as A, DEFAULT_MAX_SAMPLES_PER_PATTERN as C, ErrorTrackerConfig as D, ErrorTracker as E, IMPORT_RUN_STATUSES as O, DEFAULT_MAX_PATTERNS as S, ErrorSample as T, readFeed as _, ImportsRunJobPayload as a, countDataRows as b, createRunImportHandler as c, DEFAULT_BATCH_SIZE as d, ImportRunProgress as f, ReadFeedOptions as g, runImport as h, ImportsRunJobContext as i, ImportRunStatus as j, ImportRun 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, StreamFeedOptions as v, ErrorPattern as w, streamFeed as x, StreamFeedRow as y };
578
- //# sourceMappingURL=worker-ByPOP6Qe.d.mts.map
598
+ export { ImportRun as A, DEFAULT_MAX_PATTERNS as C, ErrorTracker as D, ErrorSample as E, ImportRunStatus as M, ErrorTrackerConfig as O, streamFeed as S, ErrorPattern as T, ReadFeedOptions as _, ImportsRunJobContext as a, StreamFeedRow as b, createImportsRunDeadListener as c, importsRunJobPayloadSchema as d, DEFAULT_BATCH_SIZE as f, runImport as g, RunImportResult as h, ImportsRunDeadListenerConfig as i, ImportRunClient as j, IMPORT_RUN_STATUSES as k, createRunImportHandler as l, RunImportOptions as m, EsClientResolver as n, ImportsRunJobPayload as o, ImportRunProgress as p, FilePathResolver as r, RunImportHandlerConfig as s, EnsureIndexResolver as t, importsRunJob as u, readFeed as v, DEFAULT_MAX_SAMPLES_PER_PATTERN as w, countDataRows as x, StreamFeedOptions as y };
599
+ //# sourceMappingURL=worker-C-5Xqf8_.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"worker-ByPOP6Qe.d.mts","names":[],"sources":["../src/entities/import-run.ts","../src/error-tracker.ts","../src/streaming.ts","../src/read-feed.ts","../src/runner.ts","../src/worker.ts"],"mappings":"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"}
1
+ {"version":3,"file":"worker-C-5Xqf8_.d.mts","names":[],"sources":["../src/entities/import-run.ts","../src/error-tracker.ts","../src/streaming.ts","../src/read-feed.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-ByPOP6Qe.mjs";
2
- export { EsClientResolver, FilePathResolver, ImportsRunDeadListenerConfig, ImportsRunJobContext, ImportsRunJobPayload, RunImportHandlerConfig, createImportsRunDeadListener, createRunImportHandler, importsRunJob, importsRunJobPayloadSchema };
1
+ import { a as ImportsRunJobContext, c as createImportsRunDeadListener, d as importsRunJobPayloadSchema, i as ImportsRunDeadListenerConfig, l as createRunImportHandler, n as EsClientResolver, o as ImportsRunJobPayload, r as FilePathResolver, s as RunImportHandlerConfig, t as EnsureIndexResolver, u as importsRunJob } from "./worker-C-5Xqf8_.mjs";
2
+ export { EnsureIndexResolver, EsClientResolver, FilePathResolver, ImportsRunDeadListenerConfig, ImportsRunJobContext, ImportsRunJobPayload, RunImportHandlerConfig, createImportsRunDeadListener, createRunImportHandler, importsRunJob, importsRunJobPayloadSchema };
package/dist/worker.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import{a as e,n as t}from"./runner-DvKNfQJO.mjs";import{a as n,i as r,r as i}from"./publish-DCXIPwTE.mjs";import{eq as a}from"drizzle-orm";import{z as o}from"zod";import{runAsCli as s}from"@murumets-ee/core";import{defineJob as c}from"@murumets-ee/queue/client";const l=o.object({importRunId:o.string().uuid()}),u=c({name:`imports:run`,description:`Stream a feed file, transform rows, bulk-write to Elasticsearch.`,schema:l,defaultRetries:0});function d(i){let{importRuns:a,transforms:o,esClient:c,esIndex:l,resolveFilePath:u,logger:d}=i;return async i=>s(async()=>{let{importRunId:s}=i.payload,p=d?.child({jobId:i.id,importRunId:s,type:`imports:run`}),m=await a.findById(s);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=o.get(m.transformName);if(!h)throw await f(a,s,`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 a.update(s,{status:`running`,queueJobId:i.id,startedAt:new Date}),r(s);let _;try{_=await c()}catch(e){throw await f(a,s,`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(a,s,`Could not resolve filePath: ${e instanceof Error?e.message:String(e)}`),e}else v=m.filePath;let b;try{b=await e(v),p?.info({importRunId:s,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 t({importRunId:s,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(a,s,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 a.update(s,{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(s,{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 s(async()=>{let r=n?.child({event:`imports.run.dead`,jobId:e.jobId,type:`imports:run`}),i=t.getTable(),o=(await t.findMany({where:a(i.queueJobId,e.jobId),limit:1}))[0];if(!o){r?.warn(`queue.job.dead with no matching import_run — skipping`);return}if(o.status!==`running`&&o.status!==`pending`){r?.info({importRunId:o.id,status:o.status},`queue.job.dead arrived for a row already in a terminal state — leaving as-is`);return}o.status===`pending`&&r?.warn({importRunId:o.id,attempts:e.attempts},`queue.job.dead arrived for an import_run still in pending — worker crashed BEFORE claim/lookup`),await f(t,o.id,e.error),r?.info({importRunId:o.id,attempts:e.attempts,priorStatus:o.status},`queue.job.dead → import_run marked failed`)})}}}export{p as createImportsRunDeadListener,d as createRunImportHandler,u as importsRunJob,l as importsRunJobPayloadSchema};
1
+ import{a as e,n as t}from"./runner-DvKNfQJO.mjs";import{a as n,i as r,r as i}from"./publish-DCXIPwTE.mjs";import{eq as a}from"drizzle-orm";import{z as o}from"zod";import{runAsCli as s}from"@murumets-ee/core";import{defineJob as c}from"@murumets-ee/queue/client";const l=o.object({importRunId:o.string().uuid()}),u=c({name:`imports:run`,description:`Stream a feed file, transform rows, bulk-write to Elasticsearch.`,schema:l,defaultRetries:0});function d(i){let{importRuns:a,transforms:o,esClient:c,esIndex:l,resolveFilePath:u,ensureIndex:d,logger:p}=i;return async i=>s(async()=>{let{importRunId:s}=i.payload,m=p?.child({jobId:i.id,importRunId:s,type:`imports:run`}),h=await a.findById(s);if(!h){m?.warn(`import_run row not found — skipping`);return}h.status===`running`&&m?.warn({previousQueueJobId:h.queueJobId,previousStartedAt:h.startedAt},`imports: re-claiming a row left in running by a prior worker — likely a crash recovered by queue lock-recovery`);let g=o.get(h.transformName);if(!g)throw await f(a,s,`Unknown transform "${h.transformName}"`),Error(`No transform registered for name "${h.transformName}"`);let _=h.params&&typeof h.params==`object`&&!Array.isArray(h.params)?h.params:{};await a.update(s,{status:`running`,queueJobId:i.id,startedAt:new Date}),r(s);let v;try{v=await c()}catch(e){throw await f(a,s,`Could not resolve ES client: ${e instanceof Error?e.message:String(e)}`),e}if(d)try{await d()}catch(e){throw await f(a,s,`Could not ensure ES index/alias: ${e instanceof Error?e.message:String(e)}`),e}let y,b;if(u)try{let e=await u(h.filePath);y=e.localPath,b=e.cleanup}catch(e){throw await f(a,s,`Could not resolve filePath: ${e instanceof Error?e.message:String(e)}`),e}else y=h.filePath;let x;try{x=await e(y),m?.info({importRunId:s,totalRows:x},`imports: pre-pass row count`)}catch(e){m?.warn({err:e},`imports: pre-pass row count failed — continuing without total`)}let S;try{S=await t({importRunId:s,runLabel:h.label,params:_,transform:g,feed:{filePath:y},esClient:v,esIndex:l,onProgress:e=>i.updateProgress(x===void 0?e:{...e,totalRows:x}),...m!==void 0&&{logger:m}})}catch(e){throw await f(a,s,e instanceof Error?e.message:String(e)),b&&await b().catch(e=>{m?.warn({err:e},`filePath cleanup after failed run threw — ignoring`)}),e}b&&await b().catch(e=>{m?.warn({err:e},`filePath cleanup after successful run threw — ignoring`)}),await a.update(s,{status:`succeeded`,finishedAt:new Date,totals:{rowsRead:S.rowsRead,rowsSucceeded:S.rowsSucceeded,rowsFailed:S.rowsFailed,rowsSkipped:S.rowsSkipped,batchesCompleted:S.batchesCompleted},errorSummary:JSON.parse(JSON.stringify(S.errors))}),n(s,{rowsRead:S.rowsRead,rowsSucceeded:S.rowsSucceeded,rowsFailed:S.rowsFailed}),m?.info({rowsRead:S.rowsRead,rowsSucceeded:S.rowsSucceeded,rowsFailed:S.rowsFailed,rowsSkipped:S.rowsSkipped,batches:S.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 s(async()=>{let r=n?.child({event:`imports.run.dead`,jobId:e.jobId,type:`imports:run`}),i=t.getTable(),o=(await t.findMany({where:a(i.queueJobId,e.jobId),limit:1}))[0];if(!o){r?.warn(`queue.job.dead with no matching import_run — skipping`);return}if(o.status!==`running`&&o.status!==`pending`){r?.info({importRunId:o.id,status:o.status},`queue.job.dead arrived for a row already in a terminal state — leaving as-is`);return}o.status===`pending`&&r?.warn({importRunId:o.id,attempts:e.attempts},`queue.job.dead arrived for an import_run still in pending — worker crashed BEFORE claim/lookup`),await f(t,o.id,e.error),r?.info({importRunId:o.id,attempts:e.attempts,priorStatus:o.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
@@ -1 +1 @@
1
- {"version":3,"file":"worker.mjs","names":[],"sources":["../src/worker.ts"],"sourcesContent":["/**\n * Queue handler for `imports:run`. The route handler that creates the\n * `import_run` row enqueues this job carrying `{ importRunId }`; the\n * worker module here resolves the run, the transform, and the ES\n * client, then runs `runImport` and writes results back.\n *\n * Per PLAN-ECOMMERCE.md PR 7 (PoC scope): one job type, no resumability.\n * A failed handler attempt re-runs from row 1 on retry — fine for the\n * tens-of-MB feed sizes the PoC exercises.\n *\n * Per CLAUDE.md \"leaf packages\" boundary rule: this handler reads the\n * import_run row via an injected `AdminClient`, NOT a direct `db.select`.\n * Construction happens at plugin init in `plugin.ts`.\n */\n\nimport { type Logger, runAsCli } from '@murumets-ee/core'\nimport {\n defineJob,\n type JobDefinition,\n type QueueJobDeadEvent,\n type QueueTerminalNotifier,\n} from '@murumets-ee/queue/client'\nimport type { EsClientLike } from '@murumets-ee/search-elasticsearch'\nimport { eq } from 'drizzle-orm'\nimport type { PgColumn } from 'drizzle-orm/pg-core'\nimport { z } from 'zod'\nimport { type ImportRunClient, type ImportRunStatus } from './entities/import-run.js'\nimport {\n publishImportRunFailed,\n publishImportRunStarted,\n publishImportRunSucceeded,\n} from './realtime/publish.js'\nimport { type ImportRunProgress, runImport } from './runner.js'\nimport { countDataRows } from './streaming.js'\nimport { TransformRegistry } from './transform.js'\n\n/**\n * Structural subset of `JobContext` the handler reads. Declared\n * locally so we don't depend on a `JobHandler` import — the queue\n * package doesn't re-export it from any subpath today (matches the\n * `SendEmailJobContext` pattern in `@murumets-ee/notifications`).\n *\n * Function-param contravariance lets a handler returned with this\n * narrower shape be assigned to the wider `JobHandler<TPayload>` that\n * `registerJob` expects.\n */\nexport interface ImportsRunJobContext {\n id: string\n payload: ImportsRunJobPayload\n updateProgress(data: ImportRunProgress): void\n}\n\n/**\n * Payload schema. `importRunId` is the `import_run.id` UUID — the worker\n * looks up everything else (filePath, transformName, params, …) from\n * that row. Keeping the payload tiny means the queue's progress JSON\n * column never bloats with feed metadata duplicated across `toolkit_jobs`.\n */\nexport const importsRunJobPayloadSchema = z.object({\n importRunId: z.string().uuid(),\n})\nexport type ImportsRunJobPayload = z.infer<typeof importsRunJobPayloadSchema>\n\n/**\n * Job definition. Consumers register their handler against this with\n * `registerJob(importsRunJob, createRunImportHandler({...}))`.\n *\n * `defaultRetries: 0` — re-running a multi-batch import against the\n * same `import_run.id` while the previous handler may still be writing\n * is a footgun (duplicate batches, double-counted progress). The\n * operator handles retries explicitly via PR 8a's \"retry\" button by\n * creating a new `import_run` row. When the resumable design lands\n * (post-PoC), retries become safe to enable.\n */\nexport const importsRunJob: JobDefinition<ImportsRunJobPayload> = defineJob({\n name: 'imports:run',\n description: 'Stream a feed file, transform rows, bulk-write to Elasticsearch.',\n schema: importsRunJobPayloadSchema,\n defaultRetries: 0,\n})\n\n/**\n * Resolves the ES client at handler-invocation time. A function (rather\n * than the bare `EsClientLike`) so the consumer can lazy-construct the\n * client — typical Next.js setups create the ES connection in a\n * route-handler initialiser, not at plugin-init time.\n */\nexport type EsClientResolver = () => EsClientLike | Promise<EsClientLike>\n\n/**\n * Resolves the value of `import_run.filePath` (whatever the upload\n * route persisted there — typically a storage adapter key) to a\n * readable LOCAL filesystem path that {@link runImport} can hand to\n * `node:fs.createReadStream`.\n *\n * **Why this exists:** the streaming reader (`streamFeed`) reads from\n * local disk via `createReadStream`. The upload route may persist a\n * remote-storage object key (R2, S3, …) on `import_run.filePath`\n * because that's what `@murumets-ee/storage` returns. Without a\n * resolver, `createReadStream('uploads/2026/05/<uuid>/feed.txt')`\n * crashes with `ENOENT`. The resolver is the documented integration\n * point — typical wiring downloads the storage object to a tmpfile\n * and returns its path.\n *\n * **`cleanup`** runs after the run finishes (success OR failure). The\n * worker awaits it best-effort — a failed cleanup logs but does not\n * crash the run.\n *\n * **No-resolver fallback:** when `resolveFilePath` is unset, the\n * worker treats `import_run.filePath` as already a local FS path\n * (back-compat with the original PoC design where uploads landed on\n * local disk). This stays valid for fixture-driven tests + on-disk\n * deployments.\n */\nexport type FilePathResolver = (\n storageKey: string,\n) => Promise<{ localPath: string; cleanup?: () => Promise<void> }>\n\nexport interface RunImportHandlerConfig {\n /** AdminClient over the `import_run` entity. */\n importRuns: ImportRunClient\n /** Transform registry to dispatch against. Defaults to the process-global singleton. */\n transforms: TransformRegistry\n /** Resolver for the ES client. */\n esClient: EsClientResolver\n /** ES alias / index to bulk-write into. Per D6 callers always pass an alias. */\n esIndex: string\n /**\n * Optional resolver for `import_run.filePath`. See {@link FilePathResolver}.\n * Required when uploads land in remote storage (R2/S3); optional for\n * on-disk PoC setups.\n */\n resolveFilePath?: FilePathResolver\n /** Optional structured logger. Defaults to silent. */\n logger?: Logger\n}\n\n/**\n * Build the `JobHandler` for {@link importsRunJob}. The returned\n * handler is what gets passed to `registerJob`.\n *\n * The body is wrapped in `runAsCli` so AdminClient calls inside have a\n * synthetic `cli` admin context — `auditable()` records `updatedBy:\n * 'cli'` rather than NULL, and the firewall checker passes. This is\n * the documented worker entry-point pattern (see `runAsCli` JSDoc in\n * `@murumets-ee/core`).\n */\nexport function createRunImportHandler(\n config: RunImportHandlerConfig,\n): (job: ImportsRunJobContext) => Promise<void> {\n const { importRuns, transforms, esClient, esIndex, resolveFilePath, logger } = config\n\n return async (job: ImportsRunJobContext): Promise<void> =>\n runAsCli(async () => {\n const { importRunId } = job.payload\n const log = logger?.child({ jobId: job.id, importRunId, type: 'imports:run' })\n\n const run = await importRuns.findById(importRunId)\n if (!run) {\n // Row was deleted between enqueue and dispatch — nothing to do.\n // Don't throw: a thrown handler retries with no payoff because\n // the row stays gone. Log + return so the queue marks the job\n // completed.\n log?.warn('import_run row not found — skipping')\n return\n }\n\n // Re-claim detection. The queue's `recoverStuckJobs` sweep flips a\n // crashed worker's `toolkit_jobs` row from `processing` back to\n // `pending` — but the queue layer has no knowledge of `import_run`,\n // so this row stays at `status='running'` from the prior attempt.\n // When the queue re-dispatches the job, we land here with a row\n // that the operator-facing Active panel has been showing as live\n // for however long lock-recovery took (default 30 min). The\n // existing `update` below overwrites `queueJobId` + `startedAt` so\n // there is no STATE corruption — but emit a structured warning so\n // ops can correlate this re-run with the worker crash that\n // produced it. The sibling `sweepStaleImportRuns` job below catches\n // the case where re-dispatch never happens (queue marks the job\n // dead, or the queue worker is offline long-term).\n if (run.status === 'running') {\n log?.warn(\n {\n previousQueueJobId: run.queueJobId,\n previousStartedAt: run.startedAt,\n },\n 'imports: re-claiming a row left in running by a prior worker — likely a crash recovered by queue lock-recovery',\n )\n }\n\n // `transformName`, `filePath`, `label` are declared `field.text({\n // required: true })` so the inferred DTO already types them as\n // `string` — no cast needed. `params` is `field.json()` which\n // infers as `JsonValue | undefined`; narrow to a record so the\n // transform receives an object.\n const transform = transforms.get(run.transformName)\n if (!transform) {\n await markFailed(importRuns, importRunId, `Unknown transform \"${run.transformName}\"`)\n throw new Error(`No transform registered for name \"${run.transformName}\"`)\n }\n const params: Record<string, unknown> =\n run.params && typeof run.params === 'object' && !Array.isArray(run.params)\n ? (run.params as Record<string, unknown>)\n : {}\n\n await importRuns.update(importRunId, {\n status: 'running' satisfies ImportRunStatus,\n queueJobId: job.id,\n startedAt: new Date(),\n })\n // Realtime: row transitioned pending → running. Subscribers\n // refetch to surface the change in the imports admin page.\n // Best-effort — a publish failure is logged inside `publishSafe`\n // and does NOT roll back the row update.\n publishImportRunStarted(importRunId)\n\n let resolvedClient: EsClientLike\n try {\n resolvedClient = await esClient()\n } catch (err) {\n const reason = err instanceof Error ? err.message : String(err)\n await markFailed(importRuns, importRunId, `Could not resolve ES client: ${reason}`)\n throw err\n }\n\n // Materialise the file locally if the consumer wired a resolver.\n // The `cleanup` callback (when present) runs in the `finally`\n // below regardless of run outcome so a tmpfile created here\n // doesn't outlive the handler.\n let localPath: string\n let fileCleanup: (() => Promise<void>) | undefined\n if (resolveFilePath) {\n try {\n const resolved = await resolveFilePath(run.filePath)\n localPath = resolved.localPath\n fileCleanup = resolved.cleanup\n } catch (err) {\n const reason = err instanceof Error ? err.message : String(err)\n await markFailed(importRuns, importRunId, `Could not resolve filePath: ${reason}`)\n throw err\n }\n } else {\n // Back-compat: no resolver wired → treat `filePath` as already\n // a local FS path. Fine for fixture-driven tests + the original\n // on-disk PoC design.\n localPath = run.filePath\n }\n\n // Pre-pass line count: a 16 MB tab-delimited feed counts in\n // ~50ms; a 500 MB feed in <2s. Cheap relative to the full\n // import. Gives the operator a denominator (`processed / total`)\n // so the imports-page panel can render an actual percent\n // instead of just \"rows so far\".\n let totalRows: number | undefined\n try {\n totalRows = await countDataRows(localPath)\n log?.info({ importRunId, totalRows }, 'imports: pre-pass row count')\n } catch (err) {\n // Don't fail the run on a count miss — the actual import is\n // unaffected; the operator just loses the percent denominator.\n log?.warn({ err }, 'imports: pre-pass row count failed — continuing without total')\n }\n\n let result: Awaited<ReturnType<typeof runImport>>\n try {\n result = await runImport({\n importRunId,\n runLabel: run.label,\n params,\n transform,\n feed: { filePath: localPath },\n esClient: resolvedClient,\n esIndex,\n onProgress: (progress) =>\n job.updateProgress(totalRows !== undefined ? { ...progress, totalRows } : progress),\n ...(log !== undefined && { logger: log }),\n })\n } catch (err) {\n const reason = err instanceof Error ? err.message : String(err)\n await markFailed(importRuns, importRunId, reason)\n if (fileCleanup) {\n await fileCleanup().catch((cleanupErr: unknown) => {\n log?.warn({ err: cleanupErr }, 'filePath cleanup after failed run threw — ignoring')\n })\n }\n throw err\n }\n\n // Cleanup runs before the success-update so a cleanup throw\n // surfaces as a logged warning rather than a crashed handler at\n // the worst possible moment (just after the row was about to be\n // marked succeeded).\n if (fileCleanup) {\n await fileCleanup().catch((cleanupErr: unknown) => {\n log?.warn({ err: cleanupErr }, 'filePath cleanup after successful run threw — ignoring')\n })\n }\n\n await importRuns.update(importRunId, {\n status: 'succeeded' satisfies ImportRunStatus,\n finishedAt: new Date(),\n totals: {\n rowsRead: result.rowsRead,\n rowsSucceeded: result.rowsSucceeded,\n rowsFailed: result.rowsFailed,\n rowsSkipped: result.rowsSkipped,\n batchesCompleted: result.batchesCompleted,\n },\n // The typed `ErrorTrackerSnapshot` is fully JSON-serialisable but\n // doesn't structurally satisfy entity's recursive `JsonValue`\n // (objects without an index signature aren't `Record<string,\n // JsonValue>`). Round-trip through `JSON.parse(JSON.stringify(...))`\n // to land as the recursive JSON shape — the runtime cost is one\n // serialise per import_run completion, the type-cast cost is zero.\n errorSummary: JSON.parse(JSON.stringify(result.errors)),\n })\n // Realtime: row transitioned to terminal-success. Subscribers\n // refetch to move the row from Active → Recent.\n publishImportRunSucceeded(importRunId, {\n rowsRead: result.rowsRead,\n rowsSucceeded: result.rowsSucceeded,\n rowsFailed: result.rowsFailed,\n })\n\n log?.info(\n {\n rowsRead: result.rowsRead,\n rowsSucceeded: result.rowsSucceeded,\n rowsFailed: result.rowsFailed,\n rowsSkipped: result.rowsSkipped,\n batches: result.batchesCompleted,\n },\n 'import_run completed',\n )\n })\n}\n\n/** Write a `failed` row with a fatal error reason. Best-effort — logs but does not throw on DB failure. */\nasync function markFailed(\n importRuns: ImportRunClient,\n id: string,\n reason: string,\n): Promise<void> {\n try {\n await importRuns.update(id, {\n status: 'failed' satisfies ImportRunStatus,\n finishedAt: new Date(),\n errorSummary: { fatal: reason },\n })\n // Realtime: row transitioned to terminal-failure. Subscribers\n // refetch to surface the failure in the Recent panel. Publish\n // is best-effort and only fires when the persist succeeds —\n // skipping it on persist failure keeps subscribers consistent\n // with the DB (no event for a row that didn't transition).\n publishImportRunFailed(id, reason)\n } catch {\n // The handler already threw / will throw — the queue will surface\n // the original error. A second failure here just logs noise.\n }\n}\n\nexport interface ImportsRunDeadListenerConfig {\n importRuns: ImportRunClient\n /** Optional structured logger. */\n logger?: Logger\n}\n\n/**\n * Build a {@link QueueTerminalNotifier} that mirrors `imports:run`\n * dead-job events into `import_run.status='failed'`.\n *\n * **Why this exists.** The `imports:run` handler's normal failure path\n * already calls `markFailed` before re-throwing, so a synchronous\n * handler error reaches the operator-visible `failed` status without\n * help from anyone. The case THIS listener catches is the one\n * `markFailed` never gets to: the worker process is killed mid-handler\n * (OOM, container restart, kill -9), the queue's lock-recovery sweep\n * eventually reclaims the row, and the queue ultimately marks the job\n * `dead` (defaultRetries: 0 + repeated process death = dead-letter\n * with `import_run.status` still `running`). Without this listener\n * the row sits in the operator's Active panel forever.\n *\n * **Generic primitive.** This is a thin subscriber over the queue's\n * multi-subscriber `QueueTerminalNotifier` registry — the same shape\n * graphile-worker (`job:failed`) and BullMQ (`failed`) expose. Other\n * domain plugins that mirror queue state (ticketing-reply, future\n * exports/reports/...) can register their own listeners exactly the\n * same way without coordinating via a per-domain sweep job.\n *\n * **Filtering.** The listener is invoked for EVERY dead event in the\n * process. We early-return on any `jobType` other than `imports:run`\n * so the per-event cost on unrelated jobs is a single property read.\n *\n * **Idempotency.** A dead event delivered after the row has already\n * reached its own terminal state (succeeded / failed / cancelled) is\n * ignored — the only transition we make is `running → failed`. This\n * keeps a late replay or out-of-order delivery from clobbering a\n * row whose worker reached `markFailed` faster than the queue's\n * dead-letter dispatch.\n *\n * The handler is wrapped in `runAsCli` so AdminClient calls inside\n * have a synthetic CLI admin context — same pattern as\n * {@link createRunImportHandler}.\n */\nexport function createImportsRunDeadListener(\n config: ImportsRunDeadListenerConfig,\n): QueueTerminalNotifier {\n const { importRuns, logger } = config\n\n return {\n onJobDead: async (event: QueueJobDeadEvent): Promise<void> => {\n if (event.jobType !== 'imports:run') return\n\n await runAsCli(async () => {\n const log = logger?.child({\n event: 'imports.run.dead',\n jobId: event.jobId,\n type: 'imports:run',\n })\n\n // Look up the mirror row by `queueJobId`. Cap to 1 — the\n // upload route writes one `import_run` per enqueued job.\n //\n // `AdminClient.getTable()` returns `PgTableWithColumns<any>`\n // because entity tables have dynamic column shapes (see the\n // `getTable()` JSDoc in `packages/entity/src/admin/client.ts`\n // and the documented-`any`-cases list in CLAUDE.md). Cast\n // the table to a structural shape covering only the column\n // we touch, so both `eq()` and type-coverage see a typed\n // operand instead of an `any`-derived property access.\n const table = importRuns.getTable() as unknown as { queueJobId: PgColumn }\n const matches = await importRuns.findMany({\n where: eq(table.queueJobId, event.jobId),\n limit: 1,\n })\n const row = matches[0]\n if (!row) {\n // No mirror row for this dead job. Either it was deleted\n // by the operator, or the dead event is for a job\n // unrelated to imports despite the type filter\n // (shouldn't happen, but is cheap to log).\n log?.warn('queue.job.dead with no matching import_run — skipping')\n return\n }\n\n // Only transition rows that haven't already reached their\n // own terminal state. A late dead-event must not overwrite\n // a `succeeded` row that the worker reached via the normal\n // path before the queue's dead-letter dispatch fired.\n if (row.status !== 'running' && row.status !== 'pending') {\n log?.info(\n { importRunId: row.id, status: row.status },\n 'queue.job.dead arrived for a row already in a terminal state — leaving as-is',\n )\n return\n }\n\n // Dead-from-`pending` is the rarer + more interesting case:\n // the worker process died (or never claimed) BEFORE it could\n // flip the row to `running`. Log it at warn so ops can spot\n // a queue+worker-startup pathology that the standard `running\n // → failed` transition wouldn't surface. Dead-from-`running`\n // is the documented \"crash mid-import\" case — info is enough.\n if (row.status === 'pending') {\n log?.warn(\n { importRunId: row.id, attempts: event.attempts },\n 'queue.job.dead arrived for an import_run still in pending — worker crashed BEFORE claim/lookup',\n )\n }\n\n await markFailed(importRuns, row.id, event.error)\n log?.info(\n { importRunId: row.id, attempts: event.attempts, priorStatus: row.status },\n 'queue.job.dead → import_run marked failed',\n )\n })\n },\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"worker.mjs","names":[],"sources":["../src/worker.ts"],"sourcesContent":["/**\n * Queue handler for `imports:run`. The route handler that creates the\n * `import_run` row enqueues this job carrying `{ importRunId }`; the\n * worker module here resolves the run, the transform, and the ES\n * client, then runs `runImport` and writes results back.\n *\n * Per PLAN-ECOMMERCE.md PR 7 (PoC scope): one job type, no resumability.\n * A failed handler attempt re-runs from row 1 on retry — fine for the\n * tens-of-MB feed sizes the PoC exercises.\n *\n * Per CLAUDE.md \"leaf packages\" boundary rule: this handler reads the\n * import_run row via an injected `AdminClient`, NOT a direct `db.select`.\n * Construction happens at plugin init in `plugin.ts`.\n */\n\nimport { type Logger, runAsCli } from '@murumets-ee/core'\nimport {\n defineJob,\n type JobDefinition,\n type QueueJobDeadEvent,\n type QueueTerminalNotifier,\n} from '@murumets-ee/queue/client'\nimport type { EsClientLike } from '@murumets-ee/search-elasticsearch'\nimport { eq } from 'drizzle-orm'\nimport type { PgColumn } from 'drizzle-orm/pg-core'\nimport { z } from 'zod'\nimport { type ImportRunClient, type ImportRunStatus } from './entities/import-run.js'\nimport {\n publishImportRunFailed,\n publishImportRunStarted,\n publishImportRunSucceeded,\n} from './realtime/publish.js'\nimport { type ImportRunProgress, runImport } from './runner.js'\nimport { countDataRows } from './streaming.js'\nimport { TransformRegistry } from './transform.js'\n\n/**\n * Structural subset of `JobContext` the handler reads. Declared\n * locally so we don't depend on a `JobHandler` import — the queue\n * package doesn't re-export it from any subpath today (matches the\n * `SendEmailJobContext` pattern in `@murumets-ee/notifications`).\n *\n * Function-param contravariance lets a handler returned with this\n * narrower shape be assigned to the wider `JobHandler<TPayload>` that\n * `registerJob` expects.\n */\nexport interface ImportsRunJobContext {\n id: string\n payload: ImportsRunJobPayload\n updateProgress(data: ImportRunProgress): void\n}\n\n/**\n * Payload schema. `importRunId` is the `import_run.id` UUID — the worker\n * looks up everything else (filePath, transformName, params, …) from\n * that row. Keeping the payload tiny means the queue's progress JSON\n * column never bloats with feed metadata duplicated across `toolkit_jobs`.\n */\nexport const importsRunJobPayloadSchema = z.object({\n importRunId: z.string().uuid(),\n})\nexport type ImportsRunJobPayload = z.infer<typeof importsRunJobPayloadSchema>\n\n/**\n * Job definition. Consumers register their handler against this with\n * `registerJob(importsRunJob, createRunImportHandler({...}))`.\n *\n * `defaultRetries: 0` — re-running a multi-batch import against the\n * same `import_run.id` while the previous handler may still be writing\n * is a footgun (duplicate batches, double-counted progress). The\n * operator handles retries explicitly via PR 8a's \"retry\" button by\n * creating a new `import_run` row. When the resumable design lands\n * (post-PoC), retries become safe to enable.\n */\nexport const importsRunJob: JobDefinition<ImportsRunJobPayload> = defineJob({\n name: 'imports:run',\n description: 'Stream a feed file, transform rows, bulk-write to Elasticsearch.',\n schema: importsRunJobPayloadSchema,\n defaultRetries: 0,\n})\n\n/**\n * Resolves the ES client at handler-invocation time. A function (rather\n * than the bare `EsClientLike`) so the consumer can lazy-construct the\n * client — typical Next.js setups create the ES connection in a\n * route-handler initialiser, not at plugin-init time.\n */\nexport type EsClientResolver = () => EsClientLike | Promise<EsClientLike>\n\n/**\n * Ensure the destination index/alias exists before the first bulk-write.\n * Invoked once per job, BEFORE {@link runImport}. Idempotent: the canonical\n * implementation (`ensureAliasedIndex` in `@murumets-ee/search-elasticsearch`)\n * no-ops when the alias already resolves.\n *\n * **Why it lives here.** This is the home of the foot-gun it guards: a worker\n * that bulk-writes to alias `<x>` before the alias exists makes ES\n * auto-create a physical `<x>` index with default `text` mappings, breaking\n * aggregations forever. The bootstrap used to run at app boot, which turned\n * \"ES is down\" into a full-admin 500 (issue #610). Moving it onto the import\n * path keeps the guarantee while scoping ES availability to imports/search —\n * the rest of the admin boots and runs with ES down.\n */\nexport type EnsureIndexResolver = () => Promise<unknown>\n\n/**\n * Resolves the value of `import_run.filePath` (whatever the upload\n * route persisted there — typically a storage adapter key) to a\n * readable LOCAL filesystem path that {@link runImport} can hand to\n * `node:fs.createReadStream`.\n *\n * **Why this exists:** the streaming reader (`streamFeed`) reads from\n * local disk via `createReadStream`. The upload route may persist a\n * remote-storage object key (R2, S3, …) on `import_run.filePath`\n * because that's what `@murumets-ee/storage` returns. Without a\n * resolver, `createReadStream('uploads/2026/05/<uuid>/feed.txt')`\n * crashes with `ENOENT`. The resolver is the documented integration\n * point — typical wiring downloads the storage object to a tmpfile\n * and returns its path.\n *\n * **`cleanup`** runs after the run finishes (success OR failure). The\n * worker awaits it best-effort — a failed cleanup logs but does not\n * crash the run.\n *\n * **No-resolver fallback:** when `resolveFilePath` is unset, the\n * worker treats `import_run.filePath` as already a local FS path\n * (back-compat with the original PoC design where uploads landed on\n * local disk). This stays valid for fixture-driven tests + on-disk\n * deployments.\n */\nexport type FilePathResolver = (\n storageKey: string,\n) => Promise<{ localPath: string; cleanup?: () => Promise<void> }>\n\nexport interface RunImportHandlerConfig {\n /** AdminClient over the `import_run` entity. */\n importRuns: ImportRunClient\n /** Transform registry to dispatch against. Defaults to the process-global singleton. */\n transforms: TransformRegistry\n /** Resolver for the ES client. */\n esClient: EsClientResolver\n /** ES alias / index to bulk-write into. Per D6 callers always pass an alias. */\n esIndex: string\n /**\n * Optional bootstrap for the destination index/alias, run once per job\n * before any bulk-write. See {@link EnsureIndexResolver}. Omit for\n * deployments that bootstrap the index out-of-band (e.g. a reindex job).\n */\n ensureIndex?: EnsureIndexResolver\n /**\n * Optional resolver for `import_run.filePath`. See {@link FilePathResolver}.\n * Required when uploads land in remote storage (R2/S3); optional for\n * on-disk PoC setups.\n */\n resolveFilePath?: FilePathResolver\n /** Optional structured logger. Defaults to silent. */\n logger?: Logger\n}\n\n/**\n * Build the `JobHandler` for {@link importsRunJob}. The returned\n * handler is what gets passed to `registerJob`.\n *\n * The body is wrapped in `runAsCli` so AdminClient calls inside have a\n * synthetic `cli` admin context — `auditable()` records `updatedBy:\n * 'cli'` rather than NULL, and the firewall checker passes. This is\n * the documented worker entry-point pattern (see `runAsCli` JSDoc in\n * `@murumets-ee/core`).\n */\nexport function createRunImportHandler(\n config: RunImportHandlerConfig,\n): (job: ImportsRunJobContext) => Promise<void> {\n const { importRuns, transforms, esClient, esIndex, resolveFilePath, ensureIndex, logger } = config\n\n return async (job: ImportsRunJobContext): Promise<void> =>\n runAsCli(async () => {\n const { importRunId } = job.payload\n const log = logger?.child({ jobId: job.id, importRunId, type: 'imports:run' })\n\n const run = await importRuns.findById(importRunId)\n if (!run) {\n // Row was deleted between enqueue and dispatch — nothing to do.\n // Don't throw: a thrown handler retries with no payoff because\n // the row stays gone. Log + return so the queue marks the job\n // completed.\n log?.warn('import_run row not found — skipping')\n return\n }\n\n // Re-claim detection. The queue's `recoverStuckJobs` sweep flips a\n // crashed worker's `toolkit_jobs` row from `processing` back to\n // `pending` — but the queue layer has no knowledge of `import_run`,\n // so this row stays at `status='running'` from the prior attempt.\n // When the queue re-dispatches the job, we land here with a row\n // that the operator-facing Active panel has been showing as live\n // for however long lock-recovery took (default 30 min). The\n // existing `update` below overwrites `queueJobId` + `startedAt` so\n // there is no STATE corruption — but emit a structured warning so\n // ops can correlate this re-run with the worker crash that\n // produced it. The sibling `sweepStaleImportRuns` job below catches\n // the case where re-dispatch never happens (queue marks the job\n // dead, or the queue worker is offline long-term).\n if (run.status === 'running') {\n log?.warn(\n {\n previousQueueJobId: run.queueJobId,\n previousStartedAt: run.startedAt,\n },\n 'imports: re-claiming a row left in running by a prior worker — likely a crash recovered by queue lock-recovery',\n )\n }\n\n // `transformName`, `filePath`, `label` are declared `field.text({\n // required: true })` so the inferred DTO already types them as\n // `string` — no cast needed. `params` is `field.json()` which\n // infers as `JsonValue | undefined`; narrow to a record so the\n // transform receives an object.\n const transform = transforms.get(run.transformName)\n if (!transform) {\n await markFailed(importRuns, importRunId, `Unknown transform \"${run.transformName}\"`)\n throw new Error(`No transform registered for name \"${run.transformName}\"`)\n }\n const params: Record<string, unknown> =\n run.params && typeof run.params === 'object' && !Array.isArray(run.params)\n ? (run.params as Record<string, unknown>)\n : {}\n\n await importRuns.update(importRunId, {\n status: 'running' satisfies ImportRunStatus,\n queueJobId: job.id,\n startedAt: new Date(),\n })\n // Realtime: row transitioned pending → running. Subscribers\n // refetch to surface the change in the imports admin page.\n // Best-effort — a publish failure is logged inside `publishSafe`\n // and does NOT roll back the row update.\n publishImportRunStarted(importRunId)\n\n let resolvedClient: EsClientLike\n try {\n resolvedClient = await esClient()\n } catch (err) {\n const reason = err instanceof Error ? err.message : String(err)\n await markFailed(importRuns, importRunId, `Could not resolve ES client: ${reason}`)\n throw err\n }\n\n // Ensure the destination alias exists BEFORE any bulk-write. Moved here\n // from app boot (issue #610): the foot-gun is the worker auto-creating a\n // mis-mapped physical index, so the guard belongs on the import path —\n // not gating the whole app on ES. Idempotent + scoped: an ES outage (or\n // the `ensureAliasedIndex` physical-collision guard firing) fails THIS\n // job loudly while the rest of the admin keeps working.\n if (ensureIndex) {\n try {\n await ensureIndex()\n } catch (err) {\n const reason = err instanceof Error ? err.message : String(err)\n await markFailed(importRuns, importRunId, `Could not ensure ES index/alias: ${reason}`)\n throw err\n }\n }\n\n // Materialise the file locally if the consumer wired a resolver.\n // The `cleanup` callback (when present) runs in the `finally`\n // below regardless of run outcome so a tmpfile created here\n // doesn't outlive the handler.\n let localPath: string\n let fileCleanup: (() => Promise<void>) | undefined\n if (resolveFilePath) {\n try {\n const resolved = await resolveFilePath(run.filePath)\n localPath = resolved.localPath\n fileCleanup = resolved.cleanup\n } catch (err) {\n const reason = err instanceof Error ? err.message : String(err)\n await markFailed(importRuns, importRunId, `Could not resolve filePath: ${reason}`)\n throw err\n }\n } else {\n // Back-compat: no resolver wired → treat `filePath` as already\n // a local FS path. Fine for fixture-driven tests + the original\n // on-disk PoC design.\n localPath = run.filePath\n }\n\n // Pre-pass line count: a 16 MB tab-delimited feed counts in\n // ~50ms; a 500 MB feed in <2s. Cheap relative to the full\n // import. Gives the operator a denominator (`processed / total`)\n // so the imports-page panel can render an actual percent\n // instead of just \"rows so far\".\n let totalRows: number | undefined\n try {\n totalRows = await countDataRows(localPath)\n log?.info({ importRunId, totalRows }, 'imports: pre-pass row count')\n } catch (err) {\n // Don't fail the run on a count miss — the actual import is\n // unaffected; the operator just loses the percent denominator.\n log?.warn({ err }, 'imports: pre-pass row count failed — continuing without total')\n }\n\n let result: Awaited<ReturnType<typeof runImport>>\n try {\n result = await runImport({\n importRunId,\n runLabel: run.label,\n params,\n transform,\n feed: { filePath: localPath },\n esClient: resolvedClient,\n esIndex,\n onProgress: (progress) =>\n job.updateProgress(totalRows !== undefined ? { ...progress, totalRows } : progress),\n ...(log !== undefined && { logger: log }),\n })\n } catch (err) {\n const reason = err instanceof Error ? err.message : String(err)\n await markFailed(importRuns, importRunId, reason)\n if (fileCleanup) {\n await fileCleanup().catch((cleanupErr: unknown) => {\n log?.warn({ err: cleanupErr }, 'filePath cleanup after failed run threw — ignoring')\n })\n }\n throw err\n }\n\n // Cleanup runs before the success-update so a cleanup throw\n // surfaces as a logged warning rather than a crashed handler at\n // the worst possible moment (just after the row was about to be\n // marked succeeded).\n if (fileCleanup) {\n await fileCleanup().catch((cleanupErr: unknown) => {\n log?.warn({ err: cleanupErr }, 'filePath cleanup after successful run threw — ignoring')\n })\n }\n\n await importRuns.update(importRunId, {\n status: 'succeeded' satisfies ImportRunStatus,\n finishedAt: new Date(),\n totals: {\n rowsRead: result.rowsRead,\n rowsSucceeded: result.rowsSucceeded,\n rowsFailed: result.rowsFailed,\n rowsSkipped: result.rowsSkipped,\n batchesCompleted: result.batchesCompleted,\n },\n // The typed `ErrorTrackerSnapshot` is fully JSON-serialisable but\n // doesn't structurally satisfy entity's recursive `JsonValue`\n // (objects without an index signature aren't `Record<string,\n // JsonValue>`). Round-trip through `JSON.parse(JSON.stringify(...))`\n // to land as the recursive JSON shape — the runtime cost is one\n // serialise per import_run completion, the type-cast cost is zero.\n errorSummary: JSON.parse(JSON.stringify(result.errors)),\n })\n // Realtime: row transitioned to terminal-success. Subscribers\n // refetch to move the row from Active → Recent.\n publishImportRunSucceeded(importRunId, {\n rowsRead: result.rowsRead,\n rowsSucceeded: result.rowsSucceeded,\n rowsFailed: result.rowsFailed,\n })\n\n log?.info(\n {\n rowsRead: result.rowsRead,\n rowsSucceeded: result.rowsSucceeded,\n rowsFailed: result.rowsFailed,\n rowsSkipped: result.rowsSkipped,\n batches: result.batchesCompleted,\n },\n 'import_run completed',\n )\n })\n}\n\n/** Write a `failed` row with a fatal error reason. Best-effort — logs but does not throw on DB failure. */\nasync function markFailed(\n importRuns: ImportRunClient,\n id: string,\n reason: string,\n): Promise<void> {\n try {\n await importRuns.update(id, {\n status: 'failed' satisfies ImportRunStatus,\n finishedAt: new Date(),\n errorSummary: { fatal: reason },\n })\n // Realtime: row transitioned to terminal-failure. Subscribers\n // refetch to surface the failure in the Recent panel. Publish\n // is best-effort and only fires when the persist succeeds —\n // skipping it on persist failure keeps subscribers consistent\n // with the DB (no event for a row that didn't transition).\n publishImportRunFailed(id, reason)\n } catch {\n // The handler already threw / will throw — the queue will surface\n // the original error. A second failure here just logs noise.\n }\n}\n\nexport interface ImportsRunDeadListenerConfig {\n importRuns: ImportRunClient\n /** Optional structured logger. */\n logger?: Logger\n}\n\n/**\n * Build a {@link QueueTerminalNotifier} that mirrors `imports:run`\n * dead-job events into `import_run.status='failed'`.\n *\n * **Why this exists.** The `imports:run` handler's normal failure path\n * already calls `markFailed` before re-throwing, so a synchronous\n * handler error reaches the operator-visible `failed` status without\n * help from anyone. The case THIS listener catches is the one\n * `markFailed` never gets to: the worker process is killed mid-handler\n * (OOM, container restart, kill -9), the queue's lock-recovery sweep\n * eventually reclaims the row, and the queue ultimately marks the job\n * `dead` (defaultRetries: 0 + repeated process death = dead-letter\n * with `import_run.status` still `running`). Without this listener\n * the row sits in the operator's Active panel forever.\n *\n * **Generic primitive.** This is a thin subscriber over the queue's\n * multi-subscriber `QueueTerminalNotifier` registry — the same shape\n * graphile-worker (`job:failed`) and BullMQ (`failed`) expose. Other\n * domain plugins that mirror queue state (ticketing-reply, future\n * exports/reports/...) can register their own listeners exactly the\n * same way without coordinating via a per-domain sweep job.\n *\n * **Filtering.** The listener is invoked for EVERY dead event in the\n * process. We early-return on any `jobType` other than `imports:run`\n * so the per-event cost on unrelated jobs is a single property read.\n *\n * **Idempotency.** A dead event delivered after the row has already\n * reached its own terminal state (succeeded / failed / cancelled) is\n * ignored — the only transition we make is `running → failed`. This\n * keeps a late replay or out-of-order delivery from clobbering a\n * row whose worker reached `markFailed` faster than the queue's\n * dead-letter dispatch.\n *\n * The handler is wrapped in `runAsCli` so AdminClient calls inside\n * have a synthetic CLI admin context — same pattern as\n * {@link createRunImportHandler}.\n */\nexport function createImportsRunDeadListener(\n config: ImportsRunDeadListenerConfig,\n): QueueTerminalNotifier {\n const { importRuns, logger } = config\n\n return {\n onJobDead: async (event: QueueJobDeadEvent): Promise<void> => {\n if (event.jobType !== 'imports:run') return\n\n await runAsCli(async () => {\n const log = logger?.child({\n event: 'imports.run.dead',\n jobId: event.jobId,\n type: 'imports:run',\n })\n\n // Look up the mirror row by `queueJobId`. Cap to 1 — the\n // upload route writes one `import_run` per enqueued job.\n //\n // `AdminClient.getTable()` returns `PgTableWithColumns<any>`\n // because entity tables have dynamic column shapes (see the\n // `getTable()` JSDoc in `packages/entity/src/admin/client.ts`\n // and the documented-`any`-cases list in CLAUDE.md). Cast\n // the table to a structural shape covering only the column\n // we touch, so both `eq()` and type-coverage see a typed\n // operand instead of an `any`-derived property access.\n const table = importRuns.getTable() as unknown as { queueJobId: PgColumn }\n const matches = await importRuns.findMany({\n where: eq(table.queueJobId, event.jobId),\n limit: 1,\n })\n const row = matches[0]\n if (!row) {\n // No mirror row for this dead job. Either it was deleted\n // by the operator, or the dead event is for a job\n // unrelated to imports despite the type filter\n // (shouldn't happen, but is cheap to log).\n log?.warn('queue.job.dead with no matching import_run — skipping')\n return\n }\n\n // Only transition rows that haven't already reached their\n // own terminal state. A late dead-event must not overwrite\n // a `succeeded` row that the worker reached via the normal\n // path before the queue's dead-letter dispatch fired.\n if (row.status !== 'running' && row.status !== 'pending') {\n log?.info(\n { importRunId: row.id, status: row.status },\n 'queue.job.dead arrived for a row already in a terminal state — leaving as-is',\n )\n return\n }\n\n // Dead-from-`pending` is the rarer + more interesting case:\n // the worker process died (or never claimed) BEFORE it could\n // flip the row to `running`. Log it at warn so ops can spot\n // a queue+worker-startup pathology that the standard `running\n // → failed` transition wouldn't surface. Dead-from-`running`\n // is the documented \"crash mid-import\" case — info is enough.\n if (row.status === 'pending') {\n log?.warn(\n { importRunId: row.id, attempts: event.attempts },\n 'queue.job.dead arrived for an import_run still in pending — worker crashed BEFORE claim/lookup',\n )\n }\n\n await markFailed(importRuns, row.id, event.error)\n log?.info(\n { importRunId: row.id, attempts: event.attempts, priorStatus: row.status },\n 'queue.job.dead → import_run marked failed',\n )\n })\n },\n }\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@murumets-ee/imports",
3
- "version": "0.32.2",
3
+ "version": "0.32.3",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "exports": {
@@ -45,13 +45,13 @@
45
45
  "drizzle-orm": "^0.45.2",
46
46
  "exceljs": "^4.4.0",
47
47
  "zod": "^3.24.1",
48
- "@murumets-ee/core": "0.32.2",
49
- "@murumets-ee/db": "0.32.2",
50
- "@murumets-ee/entity": "0.32.2",
51
- "@murumets-ee/logging": "0.32.2",
52
- "@murumets-ee/queue": "0.32.2",
53
- "@murumets-ee/search-elasticsearch": "0.32.2",
54
- "@murumets-ee/storage": "0.32.2"
48
+ "@murumets-ee/core": "0.32.3",
49
+ "@murumets-ee/db": "0.32.3",
50
+ "@murumets-ee/entity": "0.32.3",
51
+ "@murumets-ee/logging": "0.32.3",
52
+ "@murumets-ee/queue": "0.32.3",
53
+ "@murumets-ee/search-elasticsearch": "0.32.3",
54
+ "@murumets-ee/storage": "0.32.3"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@types/node": "^20.19.42",