@murumets-ee/imports 0.47.0 → 0.49.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/index.d.mts CHANGED
@@ -228,14 +228,14 @@ declare const ImportMapping: import("@murumets-ee/entity").Entity<{
228
228
  id: import("@murumets-ee/entity").IdField;
229
229
  } & import("@murumets-ee/entity").AuditableFields & Record<never, never> & {
230
230
  /** FK to commerce `supplier` (by name string — no cross-package import). Required. */supplierId: import("@murumets-ee/entity").ReferenceField & {
231
- cardinality: "one" | "many";
231
+ cardinality: "many" | "one";
232
232
  } & {
233
233
  readonly entity: "supplier";
234
234
  readonly required: true;
235
235
  readonly indexed: true;
236
236
  }; /** FK to commerce `brand`. NULL = supplier default (applies to all that supplier's brands). */
237
237
  brandId: import("@murumets-ee/entity").ReferenceField & {
238
- cardinality: "one" | "many";
238
+ cardinality: "many" | "one";
239
239
  } & {
240
240
  readonly entity: "brand";
241
241
  readonly indexed: true;
package/dist/plugin.d.mts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { n as EsClientResolver, r as FilePathResolver, s as PairSink, t as EnsureIndexResolver } from "./worker-DRTgbtT7.mjs";
2
2
  import { ImportsStorageFactory } from "./storage.mjs";
3
+ import { IndexConfig } from "@murumets-ee/search-elasticsearch";
3
4
 
4
5
  //#region src/plugin.d.ts
5
6
  interface ImportsPluginOptions {
@@ -10,12 +11,22 @@ interface ImportsPluginOptions {
10
11
  */
11
12
  esClient: EsClientResolver;
12
13
  /**
13
- * ES index alias to write to. Defaults to `'parts'` (the
14
- * `PARTS_INDEX_ALIAS` re-exported from `@murumets-ee/search-elasticsearch`,
15
- * inlined here to avoid a runtime import for a one-line constant).
16
- * Per D6 always pass an alias, never a physical index.
14
+ * ES index alias to write to. Per D6 always pass an ALIAS, never a physical
15
+ * index — that is what makes {@link importsReindexJob}'s atomic swap possible.
16
+ *
17
+ * **Required, deliberately.** It defaulted to `'parts'` until #665: two apps
18
+ * sharing one Elasticsearch cluster both took that default and wrote the same
19
+ * physical index, so staging's 858k-row Ford import surfaced in production's
20
+ * parts search. The pod workaround was to set the alias per deployment — but
21
+ * a DEFAULT here means an app can set `PARTS_INDEX_ALIAS`, wire it into the
22
+ * read path, forget this option, and have its reads and writes silently name
23
+ * different indices. That is the same defect one level down, and it fails
24
+ * SILENTLY. Making the option required turns it into a compile error instead.
25
+ *
26
+ * Pass the SAME value the search provider's `indexAlias` uses; the app's
27
+ * `lib/parts-search.ts` exports one `partsIndexAlias` const for exactly this.
17
28
  */
18
- esIndex?: string;
29
+ esIndex: string;
19
30
  /**
20
31
  * Optional bootstrap for the destination index/alias, run once per import
21
32
  * job before any bulk-write (see {@link EnsureIndexResolver}). Wire your
@@ -27,6 +38,22 @@ interface ImportsPluginOptions {
27
38
  * `text`-mapped physical index, breaking aggregations (issue #610).
28
39
  */
29
40
  ensureIndex?: EnsureIndexResolver;
41
+ /**
42
+ * Settings + mappings the `imports:reindex` job creates its DESTINATION index
43
+ * with. Pass the same config `ensureIndex` bootstraps the alias from (for the
44
+ * parts catalog: `partsIndexConfig` from `@murumets-ee/search-elasticsearch`).
45
+ *
46
+ * Omit it and the job still registers, but refuses to run with an actionable
47
+ * error — a reindex into an index Elasticsearch auto-created with inferred
48
+ * `text` mappings would silently discard the mapping change the reindex
49
+ * exists to ship, which is a worse outcome than not running.
50
+ *
51
+ * This is what makes a mapping change deployable at all: `ensureAliasedIndex`
52
+ * returns as soon as the alias resolves and never issues a `put_mapping`, so
53
+ * on an already-bootstrapped cluster, editing the mapping config has no
54
+ * effect whatsoever without a reindex.
55
+ */
56
+ indexConfig?: IndexConfig;
30
57
  /**
31
58
  * Resolve `import_run.filePath` to a local FS path before streaming.
32
59
  * **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":";;;;UAsFiB,oBAAA;;;;;;EAMf,QAAA,EAAU,gBAAA;;;;;;;EAOV,OAAA;;;;;;;;;;;EAWA,WAAA,GAAc,mBAAA;;;;;;;;;;;;;;;EAed,eAAA,GAAkB,gBAAA;;;;;;;;EAQlB,QAAA,GAAW,QAAA;;;;;;;;;;;;;;;;;;EAkBX,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":";;;;;UAwFiB,oBAAA;;;;;;EAMf,QAAA,EAAU,gBAAA;;;;;;;;;;;;;;;;;EAiBV,OAAA;;;;;;;;;;;EAWA,WAAA,GAAc,mBAAA;;;;;;;;;;;;;;;;EAgBd,WAAA,GAAc,WAAA;;;;;;;;;;;;;;;EAed,eAAA,GAAkB,gBAAA;;;;;;;;EAQlB,QAAA,GAAW,QAAA;;;;;;;;;;;;;;;;;;EAkBX,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,o as t,u as n}from"./transform-DIzWppgJ.mjs";import{registerImportsStorageFactory as r}from"./storage.mjs";import{n as i,t as a}from"./mapping-transform-D76b-P-p.mjs";import{r as o,t as s}from"./carmaker-feed-CxhZBrkO.mjs";import{createImportsRunDeadListener as c,createRunImportHandler as l,importsRunJob as u}from"./worker.mjs";import{definePlugin as d}from"@murumets-ee/core";const f=Symbol.for(`@murumets-ee/imports:dead-listener-unsubscribe`);function p(p){let m=p.esIndex??`parts`,h=p.storageBucket===null?null:{bucket:p.storageBucket??`lumi-imports`,orphanTtlHours:p.orphanUploadTtlHours??2,processedRetentionDays:p.processedRetentionDays??7};return p.storage&&r(p.storage),d({name:`@murumets-ee/imports`,server:{entities:[n,t],init:async t=>{let r=e();if(r.has(`commerce:mapping`)||r.register(a,i),r.has(`commerce:carmaker-feed`)||r.register(s,o),!t.plugins.all().some(e=>e.name===`@murumets-ee/queue`)){t.logger.warn(`imports: queue() plugin not in plugins array — imports:run jobs will not be processed`);return}let d=await import(`@murumets-ee/queue/client`),g=t.getClient(n);d.registerJob(u,l({importRuns:g,transforms:r,esClient:p.esClient,esIndex:m,...p.ensureIndex!==void 0&&{ensureIndex:p.ensureIndex},...p.resolveFilePath!==void 0&&{resolveFilePath:p.resolveFilePath},...p.pairSink!==void 0&&{pairSink:p.pairSink},logger:t.logger.child({pkg:`imports`})}));let _=globalThis[f];_&&_();let v=d.addQueueTerminalListener(c({importRuns:g,logger:t.logger.child({pkg:`imports`})}));if(globalThis[f]=v,h!==null){let{cleanupOrphanedImports:e,cleanupProcessedImports:n}=await import(`./cleanup-CHjDgvKT.mjs`).then(e=>e.r),{runAsCli:r}=await import(`@murumets-ee/core`),i=t.logger.child({pkg:`imports`,job:`cleanup`});d.registerJob(d.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:t,bucket:h.bucket,ttlHours:h.orphanTtlHours,logger:i}))}),d.registerJob(d.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(()=>n({app:t,bucket:h.bucket,retentionDays:h.processedRetentionDays,logger:i}))})}t.logger.info({esIndex:m,cleanup:h===null?{enabled:!1}:{enabled:!0,...h}},`Imports plugin initialized`)}}})}export{p as imports};
1
+ import{n as e,o as t,u as n}from"./transform-DIzWppgJ.mjs";import{registerImportsStorageFactory as r}from"./storage.mjs";import{n as i,t as a}from"./mapping-transform-D76b-P-p.mjs";import{r as o,t as s}from"./carmaker-feed-CxhZBrkO.mjs";import{createImportsRunDeadListener as c,createRunImportHandler as l,importsRunJob as u}from"./worker.mjs";import{definePlugin as d}from"@murumets-ee/core";import{z as f}from"zod";import{inArray as p}from"drizzle-orm";import{createIndex as m,dropIndex as h,readAliasTargets as g,reindex as _}from"@murumets-ee/search-elasticsearch";import{defineJob as v}from"@murumets-ee/queue/client";const y=f.object({toIndex:f.string().min(1).optional(),allowExisting:f.boolean().optional(),dropOld:f.boolean().optional(),batchSize:f.number().int().min(1).max(1e4).optional()}),b=v({name:`imports:reindex`,description:`Copy the aliased parts index into a new physical index built with the current mapping, then atomically swap the alias onto it.`,schema:y,defaultRetries:0}),x=[`pending`,`running`];async function S(e){let t=e.getTable();return e.count({where:p(t.status,[...x])})}function C(e){let t=/^(.*)_v(\d+)$/.exec(e);if(!t)return null;let[,n,r]=t;if(n===void 0||r===void 0)return null;let i=Number.parseInt(r,10)+1;return Number.isSafeInteger(i)?`${n}_v${i}`:null}function w(e){let{esClient:t,esIndex:n,indexConfig:r,importRuns:i,logger:a}=e;return async e=>{let o=a?.child({jobId:e.id,type:`imports:reindex`});if(r===void 0)throw Error(`imports:reindex requires imports({ indexConfig }) — the destination index must be created with the settings + mappings this app intends, otherwise Elasticsearch auto-creates it with inferred text mappings and the reindex silently discards the mapping change it exists to ship. Pass your app's index config (for the parts catalog: partsIndexConfig from @murumets-ee/search-elasticsearch).`);let s=y.parse(e.payload);if(i!==void 0){let e=await S(i);if(e>0)throw Error(`imports:reindex: ${e} feed import(s) are pending or running. An import writes through the alias "${n}", which points at the SOURCE index for the whole copy, so anything it writes now would be lost at the swap. Wait for them to finish (or cancel them) and re-run.`)}let c=await t(),l=Object.keys(await g(c,n)).sort();if(l.length>1)throw Error(`imports:reindex: alias "${n}" points at ${l.length} indices (${l.join(`, `)}). Reindexing would copy only one of them and then swap, leaving the alias on a mix of stale and fresh data. Resolve the alias to a single index first, then re-run. An interrupted reindex cannot cause this — swapAlias sends its remove with must_exist, so a stale swap fails instead of adding a second target. Look for a manual alias edit, another tool writing to this cluster, or a swap issued before that guard existed.`);let u=l[0];if(u===void 0)throw Error(`imports:reindex: alias "${n}" does not exist, so there is nothing to reindex. Run an import first — imports({ ensureIndex }) bootstraps the alias on the import path.`);let d=s.toIndex??C(u);if(d===null)throw Error(`imports:reindex: alias "${n}" points at "${u}", which does not follow the "<name>_v<N>" convention, so the next version cannot be derived. Pass an explicit destination (\`lumi imports:reindex --to <index>\`).`);let f=d;if(f===u)throw Error(`imports:reindex: destination "${f}" is the index the alias already points at. Reindexing an index onto itself would copy every document over itself and swap the alias onto where it already is. Pass a different --to.`);let p=await c.indices.exists({index:f});if(p&&s.allowExisting!==!0)throw Error(`imports:reindex: destination "${f}" already exists. If it is debris from a failed run, re-run with --resume to continue into it (bulkUpsert is idempotent by _id, so already-copied documents are simply overwritten); if it was created by hand with a different mapping, delete it or pass --to <another-index> instead. Refusing to reuse it silently, because doing so would apply ITS mapping rather than the one you changed.`);e.updateProgress({phase:p?`resuming`:`creating`,fromIndex:u,toIndex:f,processed:0,total:0}),p?o?.warn({fromIndex:u,toIndex:f},`imports:reindex: resuming into an existing destination index (--resume); its EXISTING mapping applies, not indexConfig`):(o?.info({fromIndex:u,toIndex:f},`imports:reindex: creating destination index`),await m(c,{name:f,config:r})),o?.info({fromIndex:u,toIndex:f},`imports:reindex: copying documents`);let v=await _({client:c,alias:n,fromIndex:u,toIndex:f,refreshOnCompletion:!0,...s.batchSize!==void 0&&{batchSize:s.batchSize},...i!==void 0&&{beforeSwap:async()=>{let e=await S(i);if(e>0)throw Error(`imports:reindex: ${e} feed import(s) started while the copy was running. Their documents went to the source index through the alias and are NOT in "${f}", so swapping now would drop them. The alias is untouched and "${f}" is left in place — wait for the import(s), then re-run with --resume to finish.`)}},reportProgress:async({processed:t,total:n})=>{e.updateProgress({phase:`copying`,fromIndex:u,toIndex:f,processed:t,total:n})}});e.updateProgress({phase:`swapped`,fromIndex:u,toIndex:f,processed:v.processed,total:v.processed+v.skipped,skipped:v.skipped}),o?.info({fromIndex:u,toIndex:f,processed:v.processed,skipped:v.skipped},`imports:reindex: alias swapped onto destination`),s.dropOld===!0&&(await h(c,u),o?.info({fromIndex:u},`imports:reindex: dropped source index (--drop-old)`))}}const T=Symbol.for(`@murumets-ee/imports:dead-listener-unsubscribe`);function E(f){let p=f.esIndex,m=f.storageBucket===null?null:{bucket:f.storageBucket??`lumi-imports`,orphanTtlHours:f.orphanUploadTtlHours??2,processedRetentionDays:f.processedRetentionDays??7};return f.storage&&r(f.storage),d({name:`@murumets-ee/imports`,server:{entities:[n,t],init:async t=>{let r=e();if(r.has(`commerce:mapping`)||r.register(a,i),r.has(`commerce:carmaker-feed`)||r.register(s,o),!t.plugins.all().some(e=>e.name===`@murumets-ee/queue`)){t.logger.warn(`imports: queue() plugin not in plugins array — imports:run jobs will not be processed`);return}let d=await import(`@murumets-ee/queue/client`),h=t.getClient(n);d.registerJob(u,l({importRuns:h,transforms:r,esClient:f.esClient,esIndex:p,...f.ensureIndex!==void 0&&{ensureIndex:f.ensureIndex},...f.resolveFilePath!==void 0&&{resolveFilePath:f.resolveFilePath},...f.pairSink!==void 0&&{pairSink:f.pairSink},logger:t.logger.child({pkg:`imports`})})),d.registerJob(b,w({esClient:f.esClient,esIndex:p,importRuns:h,...f.indexConfig!==void 0&&{indexConfig:f.indexConfig},logger:t.logger.child({pkg:`imports`})}));let g=globalThis[T];g&&g();let _=d.addQueueTerminalListener(c({importRuns:h,logger:t.logger.child({pkg:`imports`})}));if(globalThis[T]=_,m!==null){let{cleanupOrphanedImports:e,cleanupProcessedImports:n}=await import(`./cleanup-CHjDgvKT.mjs`).then(e=>e.r),{runAsCli:r}=await import(`@murumets-ee/core`),i=t.logger.child({pkg:`imports`,job:`cleanup`});d.registerJob(d.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:t,bucket:m.bucket,ttlHours:m.orphanTtlHours,logger:i}))}),d.registerJob(d.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(()=>n({app:t,bucket:m.bucket,retentionDays:m.processedRetentionDays,logger:i}))})}t.logger.info({esIndex:p,cleanup:m===null?{enabled:!1}:{enabled:!0,...m}},`Imports plugin initialized`)}}})}export{E 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.\n *\n * **Built-in transforms register HERE, at plugin init.** Both transforms\n * this package ships (`commerce:mapping`, `commerce:carmaker-feed`) go\n * into the process-global registry inside `init`, so every process that\n * boots the plugin — Next.js web server, standalone queue worker — can\n * dispatch them with zero app-side wiring. Registration used to be the\n * app's job (`lib/app.ts` calling `registry.register(...)`), which is\n * exactly the drift foot-gun CLAUDE.md's \"no forgotten registrations\"\n * rule warns about: an app scaffolded before the mapping transform\n * existed keeps its old `lib/app.ts` through `pnpm up` toolkit upgrades,\n * and every mapping-driven run then dies in the worker with\n * `No transform registered for name \"commerce:mapping\"`. App-side\n * registration remains harmless even when unguarded —\n * `registerImportTransform` overwrites last-wins instead of throwing —\n * it is just no longer required.\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() must be present too — it runs the imports:run jobs.\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 { MAPPING_TRANSFORM_NAME, mappingTransform } from './mapping/mapping-transform.js'\nimport { type ImportsStorageFactory, registerImportsStorageFactory } from './storage.js'\nimport { getTransformRegistry } from './transform.js'\nimport { CARMAKER_FEED_TRANSFORM_NAME, carmakerFeedTransform } from './transforms/carmaker-feed.js'\nimport {\n createImportsRunDeadListener,\n createRunImportHandler,\n type EnsureIndexResolver,\n type EsClientResolver,\n type FilePathResolver,\n importsRunJob,\n type PairSink,\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 consumer for the directional supersession pairs the runner harvests as\n * a side-output (commerce-demo PR6, SD007). The app composes it — commerce exports\n * the replacement-merge sink and passes it here, same injection shape as\n * {@link esClient} / {@link resolveFilePath}. Omit when not ingesting replacement\n * codes; the pairs are then discarded after the run.\n */\n pairSink?: PairSink\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/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 // Built-in transforms — registered FIRST, before the queue-plugin\n // probe below, so the registry reflects this package's dispatch\n // capability even in queue-less processes (the admin Catalog tab\n // lists registry names). The `has()` guards make this idempotent\n // under dev HMR re-init AND let older scaffolds that still\n // self-register from `lib/app.ts` coexist (their registration is\n // also `has()`-guarded; whichever runs first wins — it's the same\n // function either way). See the module JSDoc for why app-side-only\n // registration was a drift foot-gun.\n //\n // Why imperative-in-init rather than a declarative `contributes`\n // slot (CLAUDE.md's default for contributions): these are this\n // package's OWN built-ins landing in this package's OWN\n // process-global registry — nothing crosses a plugin boundary, and\n // the registry is consumed at job-dispatch time, not at the\n // framework's merge step. A contributes slot buys cross-plugin\n // introspection we don't need at the cost of a core-visible slot\n // type for an imports-internal concern.\n const transformRegistry = getTransformRegistry()\n if (!transformRegistry.has(MAPPING_TRANSFORM_NAME)) {\n transformRegistry.register(MAPPING_TRANSFORM_NAME, mappingTransform)\n }\n if (!transformRegistry.has(CARMAKER_FEED_TRANSFORM_NAME)) {\n transformRegistry.register(CARMAKER_FEED_TRANSFORM_NAME, carmakerFeedTransform)\n }\n\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\n queueClientModule.registerJob(\n importsRunJob,\n createRunImportHandler({\n importRuns,\n transforms: transformRegistry,\n esClient: options.esClient,\n esIndex,\n ...(options.ensureIndex !== undefined && { ensureIndex: options.ensureIndex }),\n ...(options.resolveFilePath !== undefined && {\n resolveFilePath: options.resolveFilePath,\n }),\n ...(options.pairSink !== undefined && { pairSink: options.pairSink }),\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":"yYAgFA,MAAM,EAA4B,OAAO,IAAI,gDAAgD,EAqH7F,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,CAmBnB,IAAM,EAAoB,EAAqB,EAY/C,GAXK,EAAkB,IAAA,kBAA0B,GAC/C,EAAkB,SAAS,EAAwB,CAAgB,EAEhE,EAAkB,IAAA,wBAAgC,GACrD,EAAkB,SAAS,EAA8B,CAAqB,EAO5E,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,EAE1C,EAAkB,YAChB,EACA,EAAuB,CACrB,aACA,WAAY,EACZ,SAAU,EAAQ,SAClB,UACA,GAAI,EAAQ,cAAgB,IAAA,IAAa,CAAE,YAAa,EAAQ,WAAY,EAC5E,GAAI,EAAQ,kBAAoB,IAAA,IAAa,CAC3C,gBAAiB,EAAQ,eAC3B,EACA,GAAI,EAAQ,WAAa,IAAA,IAAa,CAAE,SAAU,EAAQ,QAAS,EACnE,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/reindex-job.ts","../src/plugin.ts"],"sourcesContent":["/**\n * Queue handler for `imports:reindex` — the OPERATOR ENTRY POINT for the\n * alias-versioned reindex (PLAN-ECOMMERCE D6).\n *\n * `@murumets-ee/search-elasticsearch` has shipped `createIndex` → `reindex` →\n * `swapAlias` since commerce-demo PR1, and until now nothing could invoke them\n * on a running box: `reindex()` was exported, unit-tested, and had zero callers\n * outside its own test file. That is not a cosmetic gap. `ensureAliasedIndex`\n * returns the moment the alias resolves and NEVER issues a `put_mapping`, so\n * editing `partsIndexConfig` has no effect whatsoever on a cluster that has\n * already been bootstrapped — which makes every future mapping change\n * unshippable until this job exists. commerce-launch PR 13 hit exactly that\n * constraint and routed around it (reading `supplier.leadTimeDaysDefault` live\n * rather than denormalising it into the doc).\n *\n * **Why a queue job and not a CLI command that does the work itself.** The ES\n * client is APP-level wiring (`apps/<app>/lib/parts-search.ts`), injected into this\n * plugin as `imports({ esClient })`. `@murumets-ee/cli` has no Elasticsearch\n * dependency and should not grow one for a parts-shaped command. So the CLI\n * enqueues by job NAME (`lumi imports:reindex`) exactly as `lumi paths:rebuild`\n * enqueues `content:paths-rebuild`, and the worker — which already holds the\n * client — does the work. The job also shows up in the queue admin page with\n * live progress, for free.\n *\n * **Safety contract**, inherited from `reindex()` and extended here:\n * - The destination is created explicitly with the app-supplied\n * {@link ImportsPluginOptions.indexConfig}, so the new mapping is what\n * actually lands. Reindexing into an auto-created index would silently\n * defeat the entire point.\n * - The alias is swapped ONLY when every document that could be copied WAS\n * copied. A bulk failure leaves the alias pointing at the source, so the live\n * read target is never half-built. Note the exact shape: `reindex()` also\n * counts `skipped` hits (a source document with no `_id` or no `_source`) and\n * those do NOT block the swap — so this job surfaces the count in its\n * progress blob and the CLI prints it, because a silent skip plus\n * `--drop-old` is how you lose documents and are told it succeeded.\n * - ⚠️ **The job cannot be interrupted once it starts.** `JobContext` carries\n * no `AbortSignal`, so the queue admin's \"cancel\" only flips the ROW to\n * `dead` — the handler runs to completion and still swaps the alias (and\n * still drops the source, if `--drop-old`). Treat the enqueue as the\n * point of no return, not the poll.\n * - A pre-existing destination is REFUSED unless `allowExisting` is set. The\n * resume case is real (`bulkUpsert` is idempotent by `_id`, so re-running\n * into a partly-filled destination is correct and cheap), but it must be an\n * explicit operator decision — silently reusing an index somebody created by\n * hand, with who-knows-what mapping, is how a mapping change gets lost.\n * - The OLD index is never dropped automatically. `dropOld` exists, defaults\n * off, and the confidence window is the operator's call.\n * - ⚠️ **Documents written to the SOURCE while the copy runs are LOST at the\n * swap.** The alias still points at the source throughout, so a concurrent\n * `imports:run` writes there, and a page this job already read is never\n * re-read.\n *\n * GUARDED, NOT SOLVED. The job refuses to start while any `import_run` is\n * `pending` or `running`, and refuses to swap if one appears during the copy\n * (`beforeSwap`) — which covers the realistic case, an operator running both\n * at once. It does NOT close the window: a write landing between the final\n * check and the swap is still lost, as is one that landed on a page already\n * copied. Closing it properly means dual-writing during the copy;\n * `django-elasticsearch-dsl` documents the identical hole in its own\n * alias-swap path rather than solving it. The guard needs `importRuns`\n * wired — without it nothing stands between an import and this race.\n *\n * That last one is a DELIBERATE deviation from prior art, so it is worth the\n * sentence: searchkick, django-elasticsearch-dsl and Elastic's own runbook all\n * delete the old index by default (retention is the opt-in flag). They can,\n * because their reindex rebuilds from the system of record — a Rails model, a\n * Django queryset — so the old index is a derived artifact that can always be\n * regenerated. Ours copies index → index. The source index IS the only copy of\n * pre-swap state, so dropping it by default would make a bad mapping\n * discovered after the swap unrecoverable, when keeping it makes recovery a\n * single `swapAlias` back. It also matches the D6 contract already written on\n * `reindex()`: \"caller decides when to dropIndex(fromIndex) after a confidence\n * window\".\n */\n\nimport type { Logger } from '@murumets-ee/core'\nimport { defineJob, type JobDefinition } from '@murumets-ee/queue/client'\nimport {\n createIndex,\n dropIndex,\n type IndexConfig,\n readAliasTargets,\n reindex,\n} from '@murumets-ee/search-elasticsearch'\nimport { inArray } from 'drizzle-orm'\nimport type { PgColumn } from 'drizzle-orm/pg-core'\nimport { z } from 'zod'\nimport type { ImportRunClient } from './entities/import-run.js'\nimport type { EsClientResolver } from './worker.js'\n\n/**\n * Payload schema. Every field is optional — the common invocation is\n * `lumi imports:reindex` with no flags, which reads the alias, derives\n * `<alias>_v<N+1>`, and copies.\n */\nexport const importsReindexJobPayloadSchema = z.object({\n /**\n * Destination physical index. Omit to derive `<alias>_v<N+1>` from whatever\n * the alias currently points at. Pass explicitly when the current physical\n * index does not follow the `_v<N>` convention, or to reindex into a name of\n * your own choosing.\n */\n toIndex: z.string().min(1).optional(),\n /**\n * Continue into a destination index that already exists. Off by default: an\n * existing destination is normally the debris of a failed run, and reusing it\n * blind would apply whatever mapping it was created with rather than the one\n * you just changed.\n */\n allowExisting: z.boolean().optional(),\n /** Drop the source index after a successful swap. Off by default. */\n dropOld: z.boolean().optional(),\n /** Documents per copy batch. Defaults to `reindex()`'s own default (1000). */\n batchSize: z.number().int().min(1).max(10_000).optional(),\n})\nexport type ImportsReindexJobPayload = z.infer<typeof importsReindexJobPayloadSchema>\n\n/**\n * Progress blob written to `toolkit_jobs.progress`. Small and fixed-shape — the\n * queue caps the blob at ~7 KB and silently DROPS anything larger, so this\n * carries counters and index names only.\n */\nexport interface ImportsReindexProgress {\n phase: 'creating' | 'resuming' | 'copying' | 'swapped'\n fromIndex: string\n toIndex: string\n processed: number\n total: number\n /**\n * Source hits `reindex()` could not copy (missing `_id` or `_source`). Carried\n * here rather than left in the worker log because it does NOT block the swap:\n * without it the CLI would report a clean success over an index that is\n * missing rows, and `--drop-old` would then delete the only copy of them.\n */\n skipped?: number\n}\n\n/**\n * Job definition. Registered by `imports()` in `plugin.ts`.\n *\n * `defaultRetries: 0` — a reindex is a deliberate operator action over a large\n * index, and an automatic retry would race the run it is retrying (both writing\n * the same destination, both racing to swap). The operator re-runs, which is\n * safe by design: `bulkUpsert` is idempotent by `_id`, so a re-run with\n * `allowExisting` resumes rather than duplicates.\n *\n * ⚠️ **This field does NOT bind a caller that enqueues by NAME.**\n * `QueueClient.enqueue` reads `defaultRetries` off this OBJECT, and the only\n * shipped caller — `lumi imports:reindex` — enqueues by string, because this\n * module sits behind the queue client that the CLI keeps off its load graph. It\n * therefore passes `{ maxRetries: 0 }` explicitly (`REINDEX_ENQUEUE_OPTIONS` in\n * `@murumets-ee/cli`), and any FUTURE by-name caller must do the same or\n * silently get 3 retries — reproducing the exact failure this setting exists to\n * prevent, with the real cause hidden behind an \"already exists\" `lastError`\n * from a bounced retry.\n */\nexport const importsReindexJob: JobDefinition<ImportsReindexJobPayload> = defineJob({\n name: 'imports:reindex',\n description:\n 'Copy the aliased parts index into a new physical index built with the current mapping, then atomically swap the alias onto it.',\n schema: importsReindexJobPayloadSchema,\n defaultRetries: 0,\n})\n\n/** Structural subset of `JobContext` this handler reads — mirrors `ImportsRunJobContext`. */\nexport interface ImportsReindexJobContext {\n id: string\n payload: ImportsReindexJobPayload\n updateProgress(data: ImportsReindexProgress): void\n}\n\nexport interface ReindexHandlerConfig {\n /** Resolver for the ES client. */\n esClient: EsClientResolver\n /** The alias to reindex. Same value the import path writes to. */\n esIndex: string\n /**\n * Settings + mappings the DESTINATION index is created with. Without it there\n * is no way to apply a mapping change, so the handler refuses to run rather\n * than copy into a default-mapped index.\n */\n indexConfig?: IndexConfig\n /**\n * AdminClient over `import_run`, used to refuse a reindex while a feed import\n * is in flight. Optional so a consumer without the entity wired still gets a\n * working reindex — but omitting it removes the ONLY guard against the\n * lost-writes race described in the safety contract.\n */\n importRuns?: ImportRunClient\n /** Optional structured logger. Defaults to silent. */\n logger?: Logger\n}\n\n/** Statuses that mean a feed import is writing, or is about to. */\nconst ACTIVE_IMPORT_STATUSES = ['pending', 'running'] as const\n\n/**\n * Count `import_run` rows that are writing or about to write.\n *\n * A feed import bulk-writes THROUGH THE ALIAS, which points at the source for\n * the whole copy — so its documents land in the index we are replacing and are\n * never copied forward. `pending` counts as active because a queued run can\n * start mid-copy.\n */\nasync function countActiveImports(importRuns: ImportRunClient): Promise<number> {\n // `getTable()` returns `PgTableWithColumns<any>`; narrow just the column we\n // touch so `inArray()` and type-coverage both see a typed value. Same shape\n // the dead-listener in `worker.ts` uses.\n const table = importRuns.getTable() as unknown as { status: PgColumn }\n return importRuns.count({ where: inArray(table.status, [...ACTIVE_IMPORT_STATUSES]) })\n}\n\n/** `<base>_v<N>` → `<base>_v<N+1>`. Returns null when the name doesn't follow the convention. */\nexport function nextVersionedIndexName(current: string): string | null {\n const match = /^(.*)_v(\\d+)$/.exec(current)\n if (!match) return null\n const [, base, version] = match\n if (base === undefined || version === undefined) return null\n const next = Number.parseInt(version, 10) + 1\n if (!Number.isSafeInteger(next)) return null\n return `${base}_v${next}`\n}\n\n/**\n * Build the `JobHandler` for {@link importsReindexJob}.\n *\n * Deliberately NOT wrapped in `runAsCli`, unlike `createRunImportHandler`. That\n * wrapper is a privilege grant (`checker: () => true`, `groups: ['admin']`) and\n * this handler performs no entity operation at all — it only talks to\n * Elasticsearch. Per CLAUDE.md's \"reach is not authority\", acquiring an admin\n * identity for a path that never uses one is exactly the habit that makes the\n * elevation allowlist meaningless. If a future change here needs an\n * `AdminClient`, add the wrapper then, with the reason.\n */\nexport function createReindexHandler(\n config: ReindexHandlerConfig,\n): (job: ImportsReindexJobContext) => Promise<void> {\n const { esClient, esIndex, indexConfig, importRuns, logger } = config\n\n return async (job: ImportsReindexJobContext): Promise<void> => {\n const log = logger?.child({ jobId: job.id, type: 'imports:reindex' })\n\n if (indexConfig === undefined) {\n throw new Error(\n `imports:reindex requires imports({ indexConfig }) — the destination index must be ` +\n `created with the settings + mappings this app intends, otherwise Elasticsearch ` +\n `auto-creates it with inferred text mappings and the reindex silently discards the ` +\n `mapping change it exists to ship. Pass your app's index config (for the parts ` +\n `catalog: partsIndexConfig from @murumets-ee/search-elasticsearch).`,\n )\n }\n\n // Validate the payload HERE, not at enqueue. The job's Zod schema is\n // applied by `QueueClient.enqueue` only for the `defineJob`-object form,\n // and the CLI enqueues by NAME (the definition sits behind the queue\n // client, which the CLI keeps off its load graph) — so without this parse\n // the schema is decorative and `batchSize` is unbounded on the worker\n // side. `content:paths-rebuild` parses in its handler for the same reason.\n //\n // Ordered AFTER the `indexConfig` check on purpose: a misconfigured app\n // should read \"pass imports({ indexConfig })\", not a multi-line ZodError\n // dump, and the CLI is the only thing that produces payloads anyway.\n const payload = importsReindexJobPayloadSchema.parse(job.payload)\n\n // Refuse to START while a feed import is in flight. An import writes\n // THROUGH the alias, which points at the source for the whole copy, so its\n // documents land in the index being replaced and are never copied forward —\n // the swap would silently drop them. Checked here so the operator is told\n // before minutes of copying, and again just before the swap for a run that\n // started in between.\n if (importRuns !== undefined) {\n const active = await countActiveImports(importRuns)\n if (active > 0) {\n throw new Error(\n `imports:reindex: ${active} feed import(s) are pending or running. An import writes ` +\n `through the alias \"${esIndex}\", which points at the SOURCE index for the whole copy, ` +\n `so anything it writes now would be lost at the swap. Wait for them to finish (or ` +\n `cancel them) and re-run.`,\n )\n }\n }\n\n const client = await esClient()\n\n // An alias may legitimately point at MORE than one physical index (that is\n // how a rolling reindex looks mid-flight). `readAliasIndex` collapses that\n // to a single name, so proceeding would copy only one of them, then swap —\n // leaving the alias on {untouched sibling + new index} and quietly serving\n // a union of stale and fresh documents, with `--drop-old` deleting only\n // the one that was read. Refuse, and name what we found.\n const aliasTargets = Object.keys(await readAliasTargets(client, esIndex)).sort()\n if (aliasTargets.length > 1) {\n throw new Error(\n `imports:reindex: alias \"${esIndex}\" points at ${aliasTargets.length} indices ` +\n `(${aliasTargets.join(', ')}). Reindexing would copy only one of them and then ` +\n `swap, leaving the alias on a mix of stale and fresh data. Resolve the alias to a ` +\n `single index first, then re-run. An interrupted reindex cannot cause this — ` +\n `swapAlias sends its remove with must_exist, so a stale swap fails instead of ` +\n `adding a second target. Look for a manual alias edit, another tool writing to this ` +\n `cluster, or a swap issued before that guard existed.`,\n )\n }\n\n // Taken from the SAME read as the guard above, not a second `getAlias`. Two\n // round trips would leave a window in which a target added between them\n // passes the guard and is then silently collapsed by `readAliasIndex`'s\n // tiebreak — the guard defeated by the very state it exists to refuse.\n const fromIndex = aliasTargets[0]\n if (fromIndex === undefined) {\n throw new Error(\n `imports:reindex: alias \"${esIndex}\" does not exist, so there is nothing to reindex. ` +\n `Run an import first — imports({ ensureIndex }) bootstraps the alias on the import path.`,\n )\n }\n\n const derived = payload.toIndex ?? nextVersionedIndexName(fromIndex)\n if (derived === null) {\n throw new Error(\n `imports:reindex: alias \"${esIndex}\" points at \"${fromIndex}\", which does not follow the ` +\n `\"<name>_v<N>\" convention, so the next version cannot be derived. Pass an explicit ` +\n `destination (\\`lumi imports:reindex --to <index>\\`).`,\n )\n }\n const toIndex = derived\n\n if (toIndex === fromIndex) {\n throw new Error(\n `imports:reindex: destination \"${toIndex}\" is the index the alias already points at. ` +\n `Reindexing an index onto itself would copy every document over itself and swap the ` +\n `alias onto where it already is. Pass a different --to.`,\n )\n }\n\n const destExists = await client.indices.exists({ index: toIndex })\n if (destExists && payload.allowExisting !== true) {\n throw new Error(\n `imports:reindex: destination \"${toIndex}\" already exists. If it is debris from a failed ` +\n `run, re-run with --resume to continue into it (bulkUpsert is idempotent by _id, so ` +\n `already-copied documents are simply overwritten); if it was created by hand with a ` +\n `different mapping, delete it or pass --to <another-index> instead. Refusing to reuse ` +\n `it silently, because doing so would apply ITS mapping rather than the one you changed.`,\n )\n }\n\n // Distinct phases, because they are materially different operations and\n // the operator can only see the progress blob. A `--resume` reuses an\n // index somebody else created, so `indexConfig` is NOT applied — and if\n // the reason for the reindex was a mapping change, that means it silently\n // does not ship. Saying so in the worker log alone would be saying it\n // where the person running the command cannot read it.\n job.updateProgress({\n phase: destExists ? 'resuming' : 'creating',\n fromIndex,\n toIndex,\n processed: 0,\n total: 0,\n })\n\n if (!destExists) {\n log?.info({ fromIndex, toIndex }, 'imports:reindex: creating destination index')\n await createIndex(client, { name: toIndex, config: indexConfig })\n } else {\n log?.warn(\n { fromIndex, toIndex },\n 'imports:reindex: resuming into an existing destination index (--resume); its EXISTING mapping applies, not indexConfig',\n )\n }\n\n log?.info({ fromIndex, toIndex }, 'imports:reindex: copying documents')\n const result = await reindex({\n client,\n alias: esIndex,\n fromIndex,\n toIndex,\n // ONE refresh before the swap. NOT `refresh: 'wait_for'`, which is the\n // obvious-looking choice and is a trap: it blocks until the next\n // SCHEDULED refresh, per batch, and the parts index sets\n // `refresh_interval: '30s'` — hours of waiting for a large catalogue,\n // to achieve what one final refresh achieves in milliseconds.\n refreshOnCompletion: true,\n ...(payload.batchSize !== undefined && { batchSize: payload.batchSize }),\n // Re-check immediately before the swap: an import that STARTED during the\n // copy wrote to the source and is not in the destination. Throwing here\n // leaves the alias untouched and the destination in place, so `--resume`\n // can finish the job once the import has.\n //\n // ⚠️ Narrows the race, does not close it. A write landing between this\n // check and the swap is still lost, and so is one that landed on a page\n // already copied. Closing it properly needs dual-writes during the copy.\n ...(importRuns !== undefined && {\n beforeSwap: async () => {\n const active = await countActiveImports(importRuns)\n if (active > 0) {\n throw new Error(\n `imports:reindex: ${active} feed import(s) started while the copy was running. ` +\n `Their documents went to the source index through the alias and are NOT in ` +\n `\"${toIndex}\", so swapping now would drop them. The alias is untouched and ` +\n `\"${toIndex}\" is left in place — wait for the import(s), then re-run with ` +\n `--resume to finish.`,\n )\n }\n },\n }),\n reportProgress: async ({ processed, total }) => {\n job.updateProgress({ phase: 'copying', fromIndex, toIndex, processed, total })\n },\n })\n\n job.updateProgress({\n phase: 'swapped',\n fromIndex,\n toIndex,\n processed: result.processed,\n total: result.processed + result.skipped,\n skipped: result.skipped,\n })\n log?.info(\n { fromIndex, toIndex, processed: result.processed, skipped: result.skipped },\n 'imports:reindex: alias swapped onto destination',\n )\n\n if (payload.dropOld === true) {\n await dropIndex(client, fromIndex)\n log?.info({ fromIndex }, 'imports:reindex: dropped source index (--drop-old)')\n }\n }\n}\n","/**\n * Imports plugin — registers the `import_run` entity and (when ES + queue\n * are wired) registers the `imports:run` and `imports:reindex` job handlers.\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.\n *\n * **Built-in transforms register HERE, at plugin init.** Both transforms\n * this package ships (`commerce:mapping`, `commerce:carmaker-feed`) go\n * into the process-global registry inside `init`, so every process that\n * boots the plugin — Next.js web server, standalone queue worker — can\n * dispatch them with zero app-side wiring. Registration used to be the\n * app's job (`lib/app.ts` calling `registry.register(...)`), which is\n * exactly the drift foot-gun CLAUDE.md's \"no forgotten registrations\"\n * rule warns about: an app scaffolded before the mapping transform\n * existed keeps its old `lib/app.ts` through `pnpm up` toolkit upgrades,\n * and every mapping-driven run then dies in the worker with\n * `No transform registered for name \"commerce:mapping\"`. App-side\n * registration remains harmless even when unguarded —\n * `registerImportTransform` overwrites last-wins instead of throwing —\n * it is just no longer required.\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() must be present too — it runs the imports:run jobs.\n * queue(),\n * imports({ esClient: () => es, esIndex: 'parts', indexConfig: partsIndexConfig }),\n * ],\n * })\n * ```\n */\n\nimport { definePlugin } from '@murumets-ee/core'\nimport type { IndexConfig } from '@murumets-ee/search-elasticsearch'\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 { MAPPING_TRANSFORM_NAME, mappingTransform } from './mapping/mapping-transform.js'\nimport { createReindexHandler, importsReindexJob } from './reindex-job.js'\nimport { type ImportsStorageFactory, registerImportsStorageFactory } from './storage.js'\nimport { getTransformRegistry } from './transform.js'\nimport { CARMAKER_FEED_TRANSFORM_NAME, carmakerFeedTransform } from './transforms/carmaker-feed.js'\nimport {\n createImportsRunDeadListener,\n createRunImportHandler,\n type EnsureIndexResolver,\n type EsClientResolver,\n type FilePathResolver,\n importsRunJob,\n type PairSink,\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. Per D6 always pass an ALIAS, never a physical\n * index — that is what makes {@link importsReindexJob}'s atomic swap possible.\n *\n * **Required, deliberately.** It defaulted to `'parts'` until #665: two apps\n * sharing one Elasticsearch cluster both took that default and wrote the same\n * physical index, so staging's 858k-row Ford import surfaced in production's\n * parts search. The pod workaround was to set the alias per deployment — but\n * a DEFAULT here means an app can set `PARTS_INDEX_ALIAS`, wire it into the\n * read path, forget this option, and have its reads and writes silently name\n * different indices. That is the same defect one level down, and it fails\n * SILENTLY. Making the option required turns it into a compile error instead.\n *\n * Pass the SAME value the search provider's `indexAlias` uses; the app's\n * `lib/parts-search.ts` exports one `partsIndexAlias` const for exactly this.\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 * Settings + mappings the `imports:reindex` job creates its DESTINATION index\n * with. Pass the same config `ensureIndex` bootstraps the alias from (for the\n * parts catalog: `partsIndexConfig` from `@murumets-ee/search-elasticsearch`).\n *\n * Omit it and the job still registers, but refuses to run with an actionable\n * error — a reindex into an index Elasticsearch auto-created with inferred\n * `text` mappings would silently discard the mapping change the reindex\n * exists to ship, which is a worse outcome than not running.\n *\n * This is what makes a mapping change deployable at all: `ensureAliasedIndex`\n * returns as soon as the alias resolves and never issues a `put_mapping`, so\n * on an already-bootstrapped cluster, editing the mapping config has no\n * effect whatsoever without a reindex.\n */\n indexConfig?: IndexConfig\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 consumer for the directional supersession pairs the runner harvests as\n * a side-output (commerce-demo PR6, SD007). The app composes it — commerce exports\n * the replacement-merge sink and passes it here, same injection shape as\n * {@link esClient} / {@link resolveFilePath}. Omit when not ingesting replacement\n * codes; the pairs are then discarded after the run.\n */\n pairSink?: PairSink\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/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\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 // Built-in transforms — registered FIRST, before the queue-plugin\n // probe below, so the registry reflects this package's dispatch\n // capability even in queue-less processes (the admin Catalog tab\n // lists registry names). The `has()` guards make this idempotent\n // under dev HMR re-init AND let older scaffolds that still\n // self-register from `lib/app.ts` coexist (their registration is\n // also `has()`-guarded; whichever runs first wins — it's the same\n // function either way). See the module JSDoc for why app-side-only\n // registration was a drift foot-gun.\n //\n // Why imperative-in-init rather than a declarative `contributes`\n // slot (CLAUDE.md's default for contributions): these are this\n // package's OWN built-ins landing in this package's OWN\n // process-global registry — nothing crosses a plugin boundary, and\n // the registry is consumed at job-dispatch time, not at the\n // framework's merge step. A contributes slot buys cross-plugin\n // introspection we don't need at the cost of a core-visible slot\n // type for an imports-internal concern.\n const transformRegistry = getTransformRegistry()\n if (!transformRegistry.has(MAPPING_TRANSFORM_NAME)) {\n transformRegistry.register(MAPPING_TRANSFORM_NAME, mappingTransform)\n }\n if (!transformRegistry.has(CARMAKER_FEED_TRANSFORM_NAME)) {\n transformRegistry.register(CARMAKER_FEED_TRANSFORM_NAME, carmakerFeedTransform)\n }\n\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\n queueClientModule.registerJob(\n importsRunJob,\n createRunImportHandler({\n importRuns,\n transforms: transformRegistry,\n esClient: options.esClient,\n esIndex,\n ...(options.ensureIndex !== undefined && { ensureIndex: options.ensureIndex }),\n ...(options.resolveFilePath !== undefined && {\n resolveFilePath: options.resolveFilePath,\n }),\n ...(options.pairSink !== undefined && { pairSink: options.pairSink }),\n logger: app.logger.child({ pkg: 'imports' }),\n }),\n )\n\n // The operator entry point for the alias-versioned reindex (D6).\n // Registered unconditionally: a missing `indexConfig` is reported by\n // the handler with an actionable message, which beats a job that\n // silently never exists and leaves `lumi imports:reindex` enqueuing\n // work no worker will ever claim.\n queueClientModule.registerJob(\n importsReindexJob,\n createReindexHandler({\n esClient: options.esClient,\n esIndex,\n // Lets the reindex refuse to run (and refuse to swap) while a feed\n // import is in flight — those writes go to the SOURCE through the\n // alias and would be dropped by the swap.\n importRuns,\n ...(options.indexConfig !== undefined && { indexConfig: options.indexConfig }),\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":"+mBAgGA,MAAa,EAAiC,EAAE,OAAO,CAOrD,QAAS,EAAE,OAAO,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,SAAS,EAOpC,cAAe,EAAE,QAAQ,CAAC,CAAC,SAAS,EAEpC,QAAS,EAAE,QAAQ,CAAC,CAAC,SAAS,EAE9B,UAAW,EAAE,OAAO,CAAC,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,GAAM,CAAC,CAAC,SAAS,CAC1D,CAAC,EA0CY,EAA6D,EAAU,CAClF,KAAM,kBACN,YACE,iIACF,OAAQ,EACR,eAAgB,CAClB,CAAC,EAgCK,EAAyB,CAAC,UAAW,SAAS,EAUpD,eAAe,EAAmB,EAA8C,CAI9E,IAAM,EAAQ,EAAW,SAAS,EAClC,OAAO,EAAW,MAAM,CAAE,MAAO,EAAQ,EAAM,OAAQ,CAAC,GAAG,CAAsB,CAAC,CAAE,CAAC,CACvF,CAGA,SAAgB,EAAuB,EAAgC,CACrE,IAAM,EAAQ,gBAAgB,KAAK,CAAO,EAC1C,GAAI,CAAC,EAAO,OAAO,KACnB,GAAM,EAAG,EAAM,GAAW,EAC1B,GAAI,IAAS,IAAA,IAAa,IAAY,IAAA,GAAW,OAAO,KACxD,IAAM,EAAO,OAAO,SAAS,EAAS,EAAE,EAAI,EAE5C,OADK,OAAO,cAAc,CAAI,EACvB,GAAG,EAAK,IAAI,IADqB,IAE1C,CAaA,SAAgB,EACd,EACkD,CAClD,GAAM,CAAE,WAAU,UAAS,cAAa,aAAY,UAAW,EAE/D,OAAO,KAAO,IAAiD,CAC7D,IAAM,EAAM,GAAQ,MAAM,CAAE,MAAO,EAAI,GAAI,KAAM,iBAAkB,CAAC,EAEpE,GAAI,IAAgB,IAAA,GAClB,MAAU,MACR,qYAKF,EAaF,IAAM,EAAU,EAA+B,MAAM,EAAI,OAAO,EAQhE,GAAI,IAAe,IAAA,GAAW,CAC5B,IAAM,EAAS,MAAM,EAAmB,CAAU,EAClD,GAAI,EAAS,EACX,MAAU,MACR,oBAAoB,EAAO,8EACH,EAAQ,kKAGlC,CAEJ,CAEA,IAAM,EAAS,MAAM,EAAS,EAQxB,EAAe,OAAO,KAAK,MAAM,EAAiB,EAAQ,CAAO,CAAC,CAAC,CAAC,KAAK,EAC/E,GAAI,EAAa,OAAS,EACxB,MAAU,MACR,2BAA2B,EAAQ,cAAc,EAAa,OAAO,YAC/D,EAAa,KAAK,IAAI,EAAE,qaAMhC,EAOF,IAAM,EAAY,EAAa,GAC/B,GAAI,IAAc,IAAA,GAChB,MAAU,MACR,2BAA2B,EAAQ,0IAErC,EAGF,IAAM,EAAU,EAAQ,SAAW,EAAuB,CAAS,EACnE,GAAI,IAAY,KACd,MAAU,MACR,2BAA2B,EAAQ,eAAe,EAAU,oKAG9D,EAEF,IAAM,EAAU,EAEhB,GAAI,IAAY,EACd,MAAU,MACR,iCAAiC,EAAQ,sLAG3C,EAGF,IAAM,EAAa,MAAM,EAAO,QAAQ,OAAO,CAAE,MAAO,CAAQ,CAAC,EACjE,GAAI,GAAc,EAAQ,gBAAkB,GAC1C,MAAU,MACR,iCAAiC,EAAQ,kYAK3C,EASF,EAAI,eAAe,CACjB,MAAO,EAAa,WAAa,WACjC,YACA,UACA,UAAW,EACX,MAAO,CACT,CAAC,EAEI,EAIH,GAAK,KACH,CAAE,YAAW,SAAQ,EACrB,wHACF,GANA,GAAK,KAAK,CAAE,YAAW,SAAQ,EAAG,6CAA6C,EAC/E,MAAM,EAAY,EAAQ,CAAE,KAAM,EAAS,OAAQ,CAAY,CAAC,GAQlE,GAAK,KAAK,CAAE,YAAW,SAAQ,EAAG,oCAAoC,EACtE,IAAM,EAAS,MAAM,EAAQ,CAC3B,SACA,MAAO,EACP,YACA,UAMA,oBAAqB,GACrB,GAAI,EAAQ,YAAc,IAAA,IAAa,CAAE,UAAW,EAAQ,SAAU,EAStE,GAAI,IAAe,IAAA,IAAa,CAC9B,WAAY,SAAY,CACtB,IAAM,EAAS,MAAM,EAAmB,CAAU,EAClD,GAAI,EAAS,EACX,MAAU,MACR,oBAAoB,EAAO,iIAErB,EAAQ,kEACR,EAAQ,kFAEhB,CAEJ,CACF,EACA,eAAgB,MAAO,CAAE,YAAW,WAAY,CAC9C,EAAI,eAAe,CAAE,MAAO,UAAW,YAAW,UAAS,YAAW,OAAM,CAAC,CAC/E,CACF,CAAC,EAED,EAAI,eAAe,CACjB,MAAO,UACP,YACA,UACA,UAAW,EAAO,UAClB,MAAO,EAAO,UAAY,EAAO,QACjC,QAAS,EAAO,OAClB,CAAC,EACD,GAAK,KACH,CAAE,YAAW,UAAS,UAAW,EAAO,UAAW,QAAS,EAAO,OAAQ,EAC3E,iDACF,EAEI,EAAQ,UAAY,KACtB,MAAM,EAAU,EAAQ,CAAS,EACjC,GAAK,KAAK,CAAE,WAAU,EAAG,oDAAoD,EAEjF,CACF,CCzVA,MAAM,EAA4B,OAAO,IAAI,gDAAgD,EA+I7F,SAAgB,EAAQ,EAA+B,CACrD,IAAM,EAAU,EAAQ,QAMlB,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,CAmBnB,IAAM,EAAoB,EAAqB,EAY/C,GAXK,EAAkB,IAAA,kBAA0B,GAC/C,EAAkB,SAAS,EAAwB,CAAgB,EAEhE,EAAkB,IAAA,wBAAgC,GACrD,EAAkB,SAAS,EAA8B,CAAqB,EAO5E,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,EAE1C,EAAkB,YAChB,EACA,EAAuB,CACrB,aACA,WAAY,EACZ,SAAU,EAAQ,SAClB,UACA,GAAI,EAAQ,cAAgB,IAAA,IAAa,CAAE,YAAa,EAAQ,WAAY,EAC5E,GAAI,EAAQ,kBAAoB,IAAA,IAAa,CAC3C,gBAAiB,EAAQ,eAC3B,EACA,GAAI,EAAQ,WAAa,IAAA,IAAa,CAAE,SAAU,EAAQ,QAAS,EACnE,OAAQ,EAAI,OAAO,MAAM,CAAE,IAAK,SAAU,CAAC,CAC7C,CAAC,CACH,EAOA,EAAkB,YAChB,EACA,EAAqB,CACnB,SAAU,EAAQ,SAClB,UAIA,aACA,GAAI,EAAQ,cAAgB,IAAA,IAAa,CAAE,YAAa,EAAQ,WAAY,EAC5E,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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@murumets-ee/imports",
3
- "version": "0.47.0",
3
+ "version": "0.49.0",
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/db": "0.47.0",
49
- "@murumets-ee/entity": "0.47.0",
50
- "@murumets-ee/logging": "0.47.0",
51
- "@murumets-ee/core": "0.47.0",
52
- "@murumets-ee/queue": "0.47.0",
53
- "@murumets-ee/search-elasticsearch": "0.47.0",
54
- "@murumets-ee/storage": "0.47.0"
48
+ "@murumets-ee/core": "0.49.0",
49
+ "@murumets-ee/db": "0.49.0",
50
+ "@murumets-ee/entity": "0.49.0",
51
+ "@murumets-ee/logging": "0.49.0",
52
+ "@murumets-ee/queue": "0.49.0",
53
+ "@murumets-ee/search-elasticsearch": "0.49.0",
54
+ "@murumets-ee/storage": "0.49.0"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@types/node": "^20.19.42",