@murumets-ee/imports 0.21.1 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/plugin.d.mts CHANGED
@@ -93,7 +93,7 @@ interface ImportsPluginOptions {
93
93
  */
94
94
  processedRetentionDays?: number;
95
95
  }
96
- declare function imports(options: ImportsPluginOptions): Pick<import("@murumets-ee/core").Plugin, "server" | "adminUi" | "postInstall"> & {
96
+ declare function imports(options: ImportsPluginOptions): Pick<import("@murumets-ee/core").Plugin, "server" | "adminUi" | "postInstall" | "requires" | "contributes"> & {
97
97
  readonly name: "@murumets-ee/imports";
98
98
  readonly shared?: undefined;
99
99
  };
@@ -1 +1 @@
1
- {"version":3,"file":"plugin.d.mts","names":[],"sources":["../src/plugin.ts"],"mappings":";;;;UA+DiB,oBAAA;EA4FoC;;;;;EAtFnD,QAAA,EAAU,gBAAA;;;;;;;EAOV,OAAA;;;;;;;;;;;;;;;EAeA,eAAA,GAAkB,gBAAA;;;;;;;;;;;;;;;;;;EAkBlB,OAAA,GAAU,qBAAA;;;;;;;;;;;;;;;EAeV,aAAA;;;;;;;;;;;;;;;EAeA,oBAAA;;;;;;;;;;;;;EAaA,sBAAA;AAAA;AAAA,iBAGc,OAAA,CAAQ,OAAA,EAAS,oBAAA,GAAoB,IAAA,6BAAA,MAAA;EAAA"}
1
+ {"version":3,"file":"plugin.d.mts","names":[],"sources":["../src/plugin.ts"],"mappings":";;;;UAqEiB,oBAAA;EA4FoC;;;;;EAtFnD,QAAA,EAAU,gBAAA;;;;;;;EAOV,OAAA;;;;;;;;;;;;;;;EAeA,eAAA,GAAkB,gBAAA;;;;;;;;;;;;;;;;;;EAkBlB,OAAA,GAAU,qBAAA;;;;;;;;;;;;;;;EAeV,aAAA;;;;;;;;;;;;;;;EAeA,oBAAA;;;;;;;;;;;;;EAaA,sBAAA;AAAA;AAAA,iBAGc,OAAA,CAAQ,OAAA,EAAS,oBAAA,GAAoB,IAAA,6BAAA,MAAA;EAAA"}
package/dist/plugin.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import{n as e}from"./import-run-CviKL4jq.mjs";import{n as t}from"./transform-B1KpU3lo.mjs";import{registerImportsStorageFactory as n}from"./storage.mjs";import{createImportsRunDeadListener as r,createRunImportHandler as i,importsRunJob as a}from"./worker.mjs";import{definePlugin as o}from"@murumets-ee/core";const s=Symbol.for(`@murumets-ee/imports:dead-listener-unsubscribe`);function c(c){let l=c.esIndex??`parts`,u=c.storageBucket===null?null:{bucket:c.storageBucket??`lumi-imports`,orphanTtlHours:c.orphanUploadTtlHours??2,processedRetentionDays:c.processedRetentionDays??7};return c.storage&&n(c.storage),o({name:`@murumets-ee/imports`,server:{entities:[e],init:async n=>{if(!n.plugins.all().some(e=>e.name===`@murumets-ee/queue`)){n.logger.warn(`imports: queue() plugin not in plugins array — imports:run jobs will not be processed`);return}let o=await import(`@murumets-ee/queue/client`),{createAdminClient:d}=await import(`@murumets-ee/core/clients`),f=d(e,n),p=t();o.registerJob(a,i({importRuns:f,transforms:p,esClient:c.esClient,esIndex:l,...c.resolveFilePath!==void 0&&{resolveFilePath:c.resolveFilePath},logger:n.logger.child({pkg:`imports`})}));let m=globalThis[s];m&&m();let h=o.addQueueTerminalListener(r({importRuns:f,logger:n.logger.child({pkg:`imports`})}));if(globalThis[s]=h,u!==null){let{cleanupOrphanedImports:e,cleanupProcessedImports:t}=await import(`./cleanup-C069GTkO.mjs`).then(e=>e.r),{runAsCli:r}=await import(`@murumets-ee/core`),i=n.logger.child({pkg:`imports`,job:`cleanup`});o.registerJob(o.defineJob({name:`imports:cleanup-orphaned-uploads`,description:`Delete toolkit_files rows in the imports bucket with no matching import_run reference and createdAt older than the configured TTL.`,schedule:`@every 1h`}),async()=>{await r(()=>e({app:n,bucket:u.bucket,ttlHours:u.orphanTtlHours,logger:i}))}),o.registerJob(o.defineJob({name:`imports:cleanup-processed-uploads`,description:`Delete toolkit_files rows for import_runs in terminal status (succeeded/failed/cancelled) whose finishedAt is older than the configured retention window. The import_run row itself is preserved.`,schedule:`@every 1h`}),async()=>{await r(()=>t({app:n,bucket:u.bucket,retentionDays:u.processedRetentionDays,logger:i}))})}n.logger.info({esIndex:l,cleanup:u===null?{enabled:!1}:{enabled:!0,...u}},`Imports plugin initialized`)}}})}export{c as imports};
1
+ import{n as e}from"./import-run-CviKL4jq.mjs";import{n as t}from"./transform-B1KpU3lo.mjs";import{registerImportsStorageFactory as n}from"./storage.mjs";import{createImportsRunDeadListener as r,createRunImportHandler as i,importsRunJob as a}from"./worker.mjs";import{definePlugin as o}from"@murumets-ee/core";const s=Symbol.for(`@murumets-ee/imports:dead-listener-unsubscribe`);function c(c){let l=c.esIndex??`parts`,u=c.storageBucket===null?null:{bucket:c.storageBucket??`lumi-imports`,orphanTtlHours:c.orphanUploadTtlHours??2,processedRetentionDays:c.processedRetentionDays??7};return c.storage&&n(c.storage),o({name:`@murumets-ee/imports`,server:{entities:[e],init:async n=>{if(!n.plugins.all().some(e=>e.name===`@murumets-ee/queue`)){n.logger.warn(`imports: queue() plugin not in plugins array — imports:run jobs will not be processed`);return}let o=await import(`@murumets-ee/queue/client`),d=n.getClient(e),f=t();o.registerJob(a,i({importRuns:d,transforms:f,esClient:c.esClient,esIndex:l,...c.resolveFilePath!==void 0&&{resolveFilePath:c.resolveFilePath},logger:n.logger.child({pkg:`imports`})}));let p=globalThis[s];p&&p();let m=o.addQueueTerminalListener(r({importRuns:d,logger:n.logger.child({pkg:`imports`})}));if(globalThis[s]=m,u!==null){let{cleanupOrphanedImports:e,cleanupProcessedImports:t}=await import(`./cleanup-C069GTkO.mjs`).then(e=>e.r),{runAsCli:r}=await import(`@murumets-ee/core`),i=n.logger.child({pkg:`imports`,job:`cleanup`});o.registerJob(o.defineJob({name:`imports:cleanup-orphaned-uploads`,description:`Delete toolkit_files rows in the imports bucket with no matching import_run reference and createdAt older than the configured TTL.`,schedule:`@every 1h`}),async()=>{await r(()=>e({app:n,bucket:u.bucket,ttlHours:u.orphanTtlHours,logger:i}))}),o.registerJob(o.defineJob({name:`imports:cleanup-processed-uploads`,description:`Delete toolkit_files rows for import_runs in terminal status (succeeded/failed/cancelled) whose finishedAt is older than the configured retention window. The import_run row itself is preserved.`,schedule:`@every 1h`}),async()=>{await r(()=>t({app:n,bucket:u.bucket,retentionDays:u.processedRetentionDays,logger:i}))})}n.logger.info({esIndex:l,cleanup:u===null?{enabled:!1}:{enabled:!0,...u}},`Imports plugin initialized`)}}})}export{c as imports};
2
2
  //# sourceMappingURL=plugin.mjs.map
@@ -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// `@murumets-ee/core/clients` is dynamic-imported inside `init` below —\n// its module begins with `import 'server-only'`, which throws when this\n// plugin module is loaded at `lumi.config.ts` evaluation time by the\n// CLI (`lumi migrate`/`seed`/`doctor` use jiti, which doesn't apply the\n// `react-server` export condition). Static-importing here would propagate\n// that throw all the way through `lumi.config.ts` → CLI startup, blocking\n// every CLI invocation. Same documented pattern as `getDb()` in\n// `@murumets-ee/queue/admin.ts`. The `init` hook only runs at\n// `createApp()` time (Next.js request init / worker boot), where the\n// `server-only` resolves to its empty stub.\nimport { ImportRun } from './entities/import-run.js'\nimport { type ImportsStorageFactory, registerImportsStorageFactory } from './storage.js'\nimport { getTransformRegistry } from './transform.js'\nimport {\n createImportsRunDeadListener,\n createRunImportHandler,\n type EsClientResolver,\n type FilePathResolver,\n importsRunJob,\n} from './worker.js'\n\n/**\n * `Symbol.for`-keyed slot for the active dead-listener unsubscribe\n * handle. Symbol.for so an HMR-replaced module instance still finds\n * the previous unsubscribe (`Symbol`-by-reference would skip the\n * lookup across module-evaluation boundaries). Same pattern as\n * `transform.ts`'s registry slot and `storage.ts`'s factory slot.\n */\nconst IMPORTS_DEAD_LISTENER_KEY = Symbol.for('@murumets-ee/imports:dead-listener-unsubscribe')\n\ninterface GlobalThisWithUnsubscribe {\n [IMPORTS_DEAD_LISTENER_KEY]?: () => void\n}\n\nexport interface ImportsPluginOptions {\n /**\n * Elasticsearch client resolver. Lazy so consumers can construct\n * the client inside a route initialiser instead of at plugin-init\n * time. Required — without it, the worker can't bulk-write.\n */\n esClient: EsClientResolver\n /**\n * ES index alias to write to. Defaults to `'parts'` (the\n * `PARTS_INDEX_ALIAS` re-exported from `@murumets-ee/search-elasticsearch`,\n * inlined here to avoid a runtime import for a one-line constant).\n * Per D6 always pass an alias, never a physical index.\n */\n esIndex?: string\n /**\n * Resolve `import_run.filePath` to a local FS path before streaming.\n * **Required when uploads land in remote storage** (R2/S3/etc.) —\n * without it, the worker hands the storage key to `createReadStream`\n * and crashes with `ENOENT`.\n *\n * Typical wiring downloads the storage object to a tmpfile and\n * returns its path; the optional `cleanup` callback runs after the\n * run finishes (success or failure).\n *\n * Omit this option for the original on-disk PoC setup where the\n * upload route writes directly to a local directory and persists\n * the absolute path on `import_run.filePath`.\n */\n resolveFilePath?: FilePathResolver\n /**\n * Optional dedicated `StorageClient` factory for imports. When\n * provided, the upload route (in `@murumets-ee/commerce`) and the\n * worker resolver consult this client instead of the global\n * `getStorageConfig()` default — so a deployment can pin imports\n * to a local-disk adapter (single-VPS / dev) while media,\n * ticketing attachments, and the rest of the app keep using the\n * default R2 (or S3) adapter.\n *\n * Lazy / async so consumers can defer adapter construction until\n * the first import — useful when the local-disk root is\n * environment-dependent or when the factory needs `getApp()` to\n * have resolved.\n *\n * Omit to keep the legacy single-storage behaviour (everything on\n * the global default).\n */\n storage?: ImportsStorageFactory\n /**\n * Bucket name to scope the orphan-cleanup queue job. Rows in\n * `toolkit_files` with this bucket value AND no matching\n * `import_run.filePath` AND `createdAt < cutoff` get garbage-\n * collected on each cleanup tick. Defaults to `'lumi-imports'` —\n * the convention `apps/admin-playground/lib/imports-storage.ts`\n * uses by default. Override if your deployment pins a different\n * bucket name via `IMPORTS_STORAGE_BUCKET` or equivalent.\n *\n * Set to `null` to DISABLE the cleanup job entirely (e.g. for\n * deployments that handle their own retention via S3 lifecycle\n * rules). Disabling without an alternative will leak storage\n * over time as failed uploads accumulate.\n */\n storageBucket?: string | null\n /**\n * Hours after which an unreferenced `toolkit_files` row in the\n * imports bucket is considered orphaned and deleted by the\n * cleanup job. Default 2h — long enough that a normal\n * upload-then-register round-trip never wipes a fresh upload,\n * short enough that storage waste from failed uploads doesn't\n * accumulate.\n *\n * The upload→register flow happens client-side automatically (no\n * \"operator clicks register\" step in the current UI), so the TTL\n * just needs to cover network blips + retries. Bumping the TTL\n * is safe if your operators commonly upload then wait before\n * processing.\n */\n orphanUploadTtlHours?: number\n /**\n * Days after which a terminal-status `import_run`'s source file is\n * deleted from `toolkit_files`. Default 7 days — gives operators a\n * reprocess window if a transform bug surfaces post-success, while\n * keeping storage costs bounded. Set to 0 for instant cleanup at\n * the next cleanup tick after `import_run.status` becomes terminal\n * (`succeeded` / `failed` / `cancelled`).\n *\n * The `import_run` row itself is preserved — the audit trail and\n * History panel still show the past run; only the source feed\n * file is removed.\n */\n processedRetentionDays?: number\n}\n\nexport function imports(options: ImportsPluginOptions) {\n const esIndex = options.esIndex ?? 'parts'\n // Resolve the cleanup config eagerly. `storageBucket === null` is the\n // explicit opt-out path; `undefined` falls back to the convention.\n // Two cleanup jobs share the same bucket but have independent TTLs:\n // orphans (no import_run ref) churn fast (2h); processed runs\n // (terminal status) get a longer reprocess window (7d).\n const cleanupConfig =\n options.storageBucket === null\n ? null\n : {\n bucket: options.storageBucket ?? 'lumi-imports',\n orphanTtlHours: options.orphanUploadTtlHours ?? 2,\n processedRetentionDays: options.processedRetentionDays ?? 7,\n }\n\n // Register the storage factory eagerly — runs at `imports()` factory\n // call time during `lumi.config.ts` evaluation, NOT inside the\n // `init` hook. The hook only fires at `createApp()` time (Next.js\n // request boot / worker boot), but the factory needs to be visible\n // to consumers that read it at module-load (e.g. the commerce\n // route's lazy `getStorageClient` thunk closes over a value that\n // resolves the moment a request comes in). Late registration in\n // `init` would race the first request.\n if (options.storage) {\n registerImportsStorageFactory(options.storage)\n }\n\n return definePlugin({\n name: '@murumets-ee/imports',\n server: {\n entities: [ImportRun],\n init: async (app) => {\n // Queue PACKAGE being importable doesn't mean the queue() PLUGIN\n // is in the consumer's plugins array. Without it, registerJob()\n // succeeds but no worker ever picks up the job. Probe and warn.\n const queuePluginPresent = app.plugins.all().some((p) => p.name === '@murumets-ee/queue')\n if (!queuePluginPresent) {\n app.logger.warn(\n 'imports: queue() plugin not in plugins array — imports:run jobs will not be processed',\n )\n return\n }\n\n // Queue client stays dynamic-imported: we already early-returned\n // above when the queue() plugin isn't in the consumer's plugins\n // array, so static-importing here would force-load the queue\n // client module in deployments that don't enable the worker.\n const queueClientModule = await import('@murumets-ee/queue/client')\n const { createAdminClient } = await import('@murumets-ee/core/clients')\n\n const importRuns = createAdminClient(ImportRun, app)\n const transforms = getTransformRegistry()\n\n queueClientModule.registerJob(\n importsRunJob,\n createRunImportHandler({\n importRuns,\n transforms,\n esClient: options.esClient,\n esIndex,\n ...(options.resolveFilePath !== undefined && {\n resolveFilePath: options.resolveFilePath,\n }),\n logger: app.logger.child({ pkg: 'imports' }),\n }),\n )\n\n // Subscribe to the queue's terminal-state event stream so a\n // worker that crashes mid-import — and whose `imports:run` job\n // ultimately reaches `dead` without the handler reaching its\n // own `markFailed` path — flips the mirror `import_run` row to\n // `failed`. This replaces what would otherwise be a per-domain\n // periodic sweep; the queue's terminal notifier is the generic\n // primitive (graphile-worker's `job:failed` event, BullMQ's\n // `failed` listener — same shape).\n //\n // The listener is multi-subscriber, so we coexist with the\n // notifications plugin's admin-email handler without either of\n // us clobbering the other. Filtering on `event.jobType` keeps\n // this listener cheap on jobs we don't own.\n //\n // **HMR**: each re-init of this `init` hook constructs a new\n // listener instance. The notifier registry stores listeners\n // by reference, so without explicit cleanup an HMR cycle would\n // STACK duplicate listeners — `findMany`+`update` per dead\n // event growing linearly with the number of HMR rounds. Stash\n // the unsubscribe on a globalThis Symbol-keyed slot (same\n // pattern as `transform.ts` / `storage.ts`) and call it before\n // re-subscribing so HMR is idempotent.\n const previousUnsubscribe = (globalThis as GlobalThisWithUnsubscribe)[\n IMPORTS_DEAD_LISTENER_KEY\n ]\n if (previousUnsubscribe) previousUnsubscribe()\n const unsubscribe = queueClientModule.addQueueTerminalListener(\n createImportsRunDeadListener({\n importRuns,\n logger: app.logger.child({ pkg: 'imports' }),\n }),\n )\n ;(globalThis as GlobalThisWithUnsubscribe)[IMPORTS_DEAD_LISTENER_KEY] = unsubscribe\n\n // Storage-cleanup jobs. Two passes, same bucket:\n // 1. Orphan sweep — uploads whose register handshake never\n // completed (no `import_run` reference). 2h TTL.\n // 2. Processed sweep — uploads whose `import_run` is terminal\n // (succeeded / failed / cancelled). 7-day reprocess\n // window then drop.\n // `defineJob({ schedule })` declares the cadence; the queue\n // worker reads the metadata at startup, materialises a\n // `toolkit_scheduled_jobs` row, and its cron loop fires the\n // registered handler each tick. No explicit\n // `client.schedule()` call needed — the worker owns the\n // schedule, not the handler. Same helper-signature shape as\n // ticketing's cleanup.\n if (cleanupConfig !== null) {\n const { cleanupOrphanedImports, cleanupProcessedImports } = await import(\n './admin/cleanup.js'\n )\n const { runAsCli } = await import('@murumets-ee/core')\n const cleanupLogger = app.logger.child({ pkg: 'imports', job: 'cleanup' })\n\n queueClientModule.registerJob(\n queueClientModule.defineJob({\n name: 'imports:cleanup-orphaned-uploads',\n description:\n 'Delete toolkit_files rows in the imports bucket with no matching import_run reference and createdAt older than the configured TTL.',\n schedule: '@every 1h',\n }),\n async () => {\n // `runAsCli` is load-bearing today: the queue worker's\n // handler context (see `packages/queue/src/handler-context.ts`)\n // tracks the job type but NOT a user / RequestContext.\n // Any AdminClient call reachable from the cleanup\n // helpers — currently `createAdminClient(ImportRun).getTable()`\n // for column-metadata resolution — runs without a user\n // unless we enter a CLI scope. The synthetic CLI user\n // is the right attribution for scheduled-job work\n // (matches ticketing's `cleanupOrphanedUploads` shape).\n await runAsCli(() =>\n cleanupOrphanedImports({\n app,\n bucket: cleanupConfig.bucket,\n ttlHours: cleanupConfig.orphanTtlHours,\n logger: cleanupLogger,\n }),\n )\n },\n )\n\n queueClientModule.registerJob(\n queueClientModule.defineJob({\n name: 'imports:cleanup-processed-uploads',\n description:\n 'Delete toolkit_files rows for import_runs in terminal status (succeeded/failed/cancelled) whose finishedAt is older than the configured retention window. The import_run row itself is preserved.',\n schedule: '@every 1h',\n }),\n async () => {\n await runAsCli(() =>\n cleanupProcessedImports({\n app,\n bucket: cleanupConfig.bucket,\n retentionDays: cleanupConfig.processedRetentionDays,\n logger: cleanupLogger,\n }),\n )\n },\n )\n }\n\n app.logger.info(\n {\n esIndex,\n // Stable shape regardless of enabled/disabled so structured-\n // log queries (`cleanup.enabled = false`) don't have to\n // handle a string fallback.\n cleanup:\n cleanupConfig === null ? { enabled: false } : { enabled: true, ...cleanupConfig },\n },\n 'Imports plugin initialized',\n )\n },\n },\n })\n}\n"],"mappings":"qTAyDA,MAAM,EAA4B,OAAO,IAAI,iDAAiD,CAkG9F,SAAgB,EAAQ,EAA+B,CACrD,IAAM,EAAU,EAAQ,SAAW,QAM7B,EACJ,EAAQ,gBAAkB,KACtB,KACA,CACE,OAAQ,EAAQ,eAAiB,eACjC,eAAgB,EAAQ,sBAAwB,EAChD,uBAAwB,EAAQ,wBAA0B,EAC3D,CAcP,OAJI,EAAQ,SACV,EAA8B,EAAQ,QAAQ,CAGzC,EAAa,CAClB,KAAM,uBACN,OAAQ,CACN,SAAU,CAAC,EAAU,CACrB,KAAM,KAAO,IAAQ,CAKnB,GAAI,CADuB,EAAI,QAAQ,KAAK,CAAC,KAAM,GAAM,EAAE,OAAS,qBAC7C,CAAE,CACvB,EAAI,OAAO,KACT,wFACD,CACD,OAOF,IAAM,EAAoB,MAAM,OAAO,6BACjC,CAAE,qBAAsB,MAAM,OAAO,6BAErC,EAAa,EAAkB,EAAW,EAAI,CAC9C,EAAa,GAAsB,CAEzC,EAAkB,YAChB,EACA,EAAuB,CACrB,aACA,aACA,SAAU,EAAQ,SAClB,UACA,GAAI,EAAQ,kBAAoB,IAAA,IAAa,CAC3C,gBAAiB,EAAQ,gBAC1B,CACD,OAAQ,EAAI,OAAO,MAAM,CAAE,IAAK,UAAW,CAAC,CAC7C,CAAC,CACH,CAwBD,IAAM,EAAuB,WAC3B,GAEE,GAAqB,GAAqB,CAC9C,IAAM,EAAc,EAAkB,yBACpC,EAA6B,CAC3B,aACA,OAAQ,EAAI,OAAO,MAAM,CAAE,IAAK,UAAW,CAAC,CAC7C,CAAC,CACH,CAgBD,GAfE,WAAyC,GAA6B,EAepE,IAAkB,KAAM,CAC1B,GAAM,CAAE,yBAAwB,2BAA4B,MAAM,OAChE,0BAAA,KAAA,GAAA,EAAA,EAAA,CAEI,CAAE,YAAa,MAAM,OAAO,qBAC5B,EAAgB,EAAI,OAAO,MAAM,CAAE,IAAK,UAAW,IAAK,UAAW,CAAC,CAE1E,EAAkB,YAChB,EAAkB,UAAU,CAC1B,KAAM,mCACN,YACE,qIACF,SAAU,YACX,CAAC,CACF,SAAY,CAUV,MAAM,MACJ,EAAuB,CACrB,MACA,OAAQ,EAAc,OACtB,SAAU,EAAc,eACxB,OAAQ,EACT,CAAC,CACH,EAEJ,CAED,EAAkB,YAChB,EAAkB,UAAU,CAC1B,KAAM,oCACN,YACE,oMACF,SAAU,YACX,CAAC,CACF,SAAY,CACV,MAAM,MACJ,EAAwB,CACtB,MACA,OAAQ,EAAc,OACtB,cAAe,EAAc,uBAC7B,OAAQ,EACT,CAAC,CACH,EAEJ,CAGH,EAAI,OAAO,KACT,CACE,UAIA,QACE,IAAkB,KAAO,CAAE,QAAS,GAAO,CAAG,CAAE,QAAS,GAAM,GAAG,EAAe,CACpF,CACD,6BACD,EAEJ,CACF,CAAC"}
1
+ {"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 { ImportRun } from './entities/import-run.js'\nimport { type ImportsStorageFactory, registerImportsStorageFactory } from './storage.js'\nimport { getTransformRegistry } from './transform.js'\nimport {\n createImportsRunDeadListener,\n createRunImportHandler,\n type EsClientResolver,\n type FilePathResolver,\n importsRunJob,\n} from './worker.js'\n\n/**\n * `Symbol.for`-keyed slot for the active dead-listener unsubscribe\n * handle. Symbol.for so an HMR-replaced module instance still finds\n * the previous unsubscribe (`Symbol`-by-reference would skip the\n * lookup across module-evaluation boundaries). Same pattern as\n * `transform.ts`'s registry slot and `storage.ts`'s factory slot.\n */\nconst IMPORTS_DEAD_LISTENER_KEY = Symbol.for('@murumets-ee/imports:dead-listener-unsubscribe')\n\ninterface GlobalThisWithUnsubscribe {\n [IMPORTS_DEAD_LISTENER_KEY]?: () => void\n}\n\nexport interface ImportsPluginOptions {\n /**\n * Elasticsearch client resolver. Lazy so consumers can construct\n * the client inside a route initialiser instead of at plugin-init\n * time. Required — without it, the worker can't bulk-write.\n */\n esClient: EsClientResolver\n /**\n * ES index alias to write to. Defaults to `'parts'` (the\n * `PARTS_INDEX_ALIAS` re-exported from `@murumets-ee/search-elasticsearch`,\n * inlined here to avoid a runtime import for a one-line constant).\n * Per D6 always pass an alias, never a physical index.\n */\n esIndex?: string\n /**\n * Resolve `import_run.filePath` to a local FS path before streaming.\n * **Required when uploads land in remote storage** (R2/S3/etc.) —\n * without it, the worker hands the storage key to `createReadStream`\n * and crashes with `ENOENT`.\n *\n * Typical wiring downloads the storage object to a tmpfile and\n * returns its path; the optional `cleanup` callback runs after the\n * run finishes (success or failure).\n *\n * Omit this option for the original on-disk PoC setup where the\n * upload route writes directly to a local directory and persists\n * the absolute path on `import_run.filePath`.\n */\n resolveFilePath?: FilePathResolver\n /**\n * Optional dedicated `StorageClient` factory for imports. When\n * provided, the upload route (in `@murumets-ee/commerce`) and the\n * worker resolver consult this client instead of the global\n * `getStorageConfig()` default — so a deployment can pin imports\n * to a local-disk adapter (single-VPS / dev) while media,\n * ticketing attachments, and the rest of the app keep using the\n * default R2 (or S3) adapter.\n *\n * Lazy / async so consumers can defer adapter construction until\n * the first import — useful when the local-disk root is\n * environment-dependent or when the factory needs `getApp()` to\n * have resolved.\n *\n * Omit to keep the legacy single-storage behaviour (everything on\n * the global default).\n */\n storage?: ImportsStorageFactory\n /**\n * Bucket name to scope the orphan-cleanup queue job. Rows in\n * `toolkit_files` with this bucket value AND no matching\n * `import_run.filePath` AND `createdAt < cutoff` get garbage-\n * collected on each cleanup tick. Defaults to `'lumi-imports'` —\n * the convention `apps/admin-playground/lib/imports-storage.ts`\n * uses by default. Override if your deployment pins a different\n * bucket name via `IMPORTS_STORAGE_BUCKET` or equivalent.\n *\n * Set to `null` to DISABLE the cleanup job entirely (e.g. for\n * deployments that handle their own retention via S3 lifecycle\n * rules). Disabling without an alternative will leak storage\n * over time as failed uploads accumulate.\n */\n storageBucket?: string | null\n /**\n * Hours after which an unreferenced `toolkit_files` row in the\n * imports bucket is considered orphaned and deleted by the\n * cleanup job. Default 2h — long enough that a normal\n * upload-then-register round-trip never wipes a fresh upload,\n * short enough that storage waste from failed uploads doesn't\n * accumulate.\n *\n * The upload→register flow happens client-side automatically (no\n * \"operator clicks register\" step in the current UI), so the TTL\n * just needs to cover network blips + retries. Bumping the TTL\n * is safe if your operators commonly upload then wait before\n * processing.\n */\n orphanUploadTtlHours?: number\n /**\n * Days after which a terminal-status `import_run`'s source file is\n * deleted from `toolkit_files`. Default 7 days — gives operators a\n * reprocess window if a transform bug surfaces post-success, while\n * keeping storage costs bounded. Set to 0 for instant cleanup at\n * the next cleanup tick after `import_run.status` becomes terminal\n * (`succeeded` / `failed` / `cancelled`).\n *\n * The `import_run` row itself is preserved — the audit trail and\n * History panel still show the past run; only the source feed\n * file is removed.\n */\n processedRetentionDays?: number\n}\n\nexport function imports(options: ImportsPluginOptions) {\n const esIndex = options.esIndex ?? 'parts'\n // Resolve the cleanup config eagerly. `storageBucket === null` is the\n // explicit opt-out path; `undefined` falls back to the convention.\n // Two cleanup jobs share the same bucket but have independent TTLs:\n // orphans (no import_run ref) churn fast (2h); processed runs\n // (terminal status) get a longer reprocess window (7d).\n const cleanupConfig =\n options.storageBucket === null\n ? null\n : {\n bucket: options.storageBucket ?? 'lumi-imports',\n orphanTtlHours: options.orphanUploadTtlHours ?? 2,\n processedRetentionDays: options.processedRetentionDays ?? 7,\n }\n\n // Register the storage factory eagerly — runs at `imports()` factory\n // call time during `lumi.config.ts` evaluation, NOT inside the\n // `init` hook. The hook only fires at `createApp()` time (Next.js\n // request boot / worker boot), but the factory needs to be visible\n // to consumers that read it at module-load (e.g. the commerce\n // route's lazy `getStorageClient` thunk closes over a value that\n // resolves the moment a request comes in). Late registration in\n // `init` would race the first request.\n if (options.storage) {\n registerImportsStorageFactory(options.storage)\n }\n\n return definePlugin({\n name: '@murumets-ee/imports',\n server: {\n entities: [ImportRun],\n init: async (app) => {\n // Queue PACKAGE being importable doesn't mean the queue() PLUGIN\n // is in the consumer's plugins array. Without it, registerJob()\n // succeeds but no worker ever picks up the job. Probe and warn.\n const queuePluginPresent = app.plugins.all().some((p) => p.name === '@murumets-ee/queue')\n if (!queuePluginPresent) {\n app.logger.warn(\n 'imports: queue() plugin not in plugins array — imports:run jobs will not be processed',\n )\n return\n }\n\n // Queue client stays dynamic-imported: we already early-returned\n // above when the queue() plugin isn't in the consumer's plugins\n // array, so static-importing here would force-load the queue\n // client module in deployments that don't enable the worker.\n // `@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":"qTA+DA,MAAM,EAA4B,OAAO,IAAI,iDAAiD,CAkG9F,SAAgB,EAAQ,EAA+B,CACrD,IAAM,EAAU,EAAQ,SAAW,QAM7B,EACJ,EAAQ,gBAAkB,KACtB,KACA,CACE,OAAQ,EAAQ,eAAiB,eACjC,eAAgB,EAAQ,sBAAwB,EAChD,uBAAwB,EAAQ,wBAA0B,EAC3D,CAcP,OAJI,EAAQ,SACV,EAA8B,EAAQ,QAAQ,CAGzC,EAAa,CAClB,KAAM,uBACN,OAAQ,CACN,SAAU,CAAC,EAAU,CACrB,KAAM,KAAO,IAAQ,CAKnB,GAAI,CADuB,EAAI,QAAQ,KAAK,CAAC,KAAM,GAAM,EAAE,OAAS,qBAC7C,CAAE,CACvB,EAAI,OAAO,KACT,wFACD,CACD,OAWF,IAAM,EAAoB,MAAM,OAAO,6BAEjC,EAAa,EAAI,UAAU,EAAU,CACrC,EAAa,GAAsB,CAEzC,EAAkB,YAChB,EACA,EAAuB,CACrB,aACA,aACA,SAAU,EAAQ,SAClB,UACA,GAAI,EAAQ,kBAAoB,IAAA,IAAa,CAC3C,gBAAiB,EAAQ,gBAC1B,CACD,OAAQ,EAAI,OAAO,MAAM,CAAE,IAAK,UAAW,CAAC,CAC7C,CAAC,CACH,CAwBD,IAAM,EAAuB,WAC3B,GAEE,GAAqB,GAAqB,CAC9C,IAAM,EAAc,EAAkB,yBACpC,EAA6B,CAC3B,aACA,OAAQ,EAAI,OAAO,MAAM,CAAE,IAAK,UAAW,CAAC,CAC7C,CAAC,CACH,CAgBD,GAfE,WAAyC,GAA6B,EAepE,IAAkB,KAAM,CAC1B,GAAM,CAAE,yBAAwB,2BAA4B,MAAM,OAChE,0BAAA,KAAA,GAAA,EAAA,EAAA,CAEI,CAAE,YAAa,MAAM,OAAO,qBAC5B,EAAgB,EAAI,OAAO,MAAM,CAAE,IAAK,UAAW,IAAK,UAAW,CAAC,CAE1E,EAAkB,YAChB,EAAkB,UAAU,CAC1B,KAAM,mCACN,YACE,qIACF,SAAU,YACX,CAAC,CACF,SAAY,CAUV,MAAM,MACJ,EAAuB,CACrB,MACA,OAAQ,EAAc,OACtB,SAAU,EAAc,eACxB,OAAQ,EACT,CAAC,CACH,EAEJ,CAED,EAAkB,YAChB,EAAkB,UAAU,CAC1B,KAAM,oCACN,YACE,oMACF,SAAU,YACX,CAAC,CACF,SAAY,CACV,MAAM,MACJ,EAAwB,CACtB,MACA,OAAQ,EAAc,OACtB,cAAe,EAAc,uBAC7B,OAAQ,EACT,CAAC,CACH,EAEJ,CAGH,EAAI,OAAO,KACT,CACE,UAIA,QACE,IAAkB,KAAO,CAAE,QAAS,GAAO,CAAG,CAAE,QAAS,GAAM,GAAG,EAAe,CACpF,CACD,6BACD,EAEJ,CACF,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@murumets-ee/imports",
3
- "version": "0.21.1",
3
+ "version": "0.22.0",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "exports": {
@@ -40,13 +40,13 @@
40
40
  "csv-parse": "^5.5.3",
41
41
  "drizzle-orm": "^0.45.2",
42
42
  "zod": "^3.24.1",
43
- "@murumets-ee/core": "0.21.1",
44
- "@murumets-ee/db": "0.21.1",
45
- "@murumets-ee/entity": "0.21.1",
46
- "@murumets-ee/logging": "0.21.1",
47
- "@murumets-ee/queue": "0.21.1",
48
- "@murumets-ee/search-elasticsearch": "0.21.1",
49
- "@murumets-ee/storage": "0.21.1"
43
+ "@murumets-ee/core": "0.22.0",
44
+ "@murumets-ee/db": "0.22.0",
45
+ "@murumets-ee/entity": "0.22.0",
46
+ "@murumets-ee/logging": "0.22.0",
47
+ "@murumets-ee/queue": "0.22.0",
48
+ "@murumets-ee/search-elasticsearch": "0.22.0",
49
+ "@murumets-ee/storage": "0.22.0"
50
50
  },
51
51
  "devDependencies": {
52
52
  "@types/node": "^20.19.40",