@open-mercato/scheduler 0.6.7-develop.6630.1.c8a615e536 → 0.6.7-develop.6645.1.52f0b37813

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.
@@ -1,2 +1,2 @@
1
- [build:scheduler] found 52 entry points
1
+ [build:scheduler] found 53 entry points
2
2
  [build:scheduler] built successfully
@@ -0,0 +1,17 @@
1
+ function buildQueueTargetPayload(input) {
2
+ const base = input.targetPayload && typeof input.targetPayload === "object" && !Array.isArray(input.targetPayload) ? { ...input.targetPayload } : {};
3
+ return {
4
+ ...base,
5
+ tenantId: input.tenantId ?? null,
6
+ organizationId: input.organizationId ?? null,
7
+ _idempotencyKey: input.idempotencyKey
8
+ };
9
+ }
10
+ function buildSchedulerIdempotencyKey(scheduleId, executionKey) {
11
+ return `scheduler-${scheduleId}-${executionKey}`;
12
+ }
13
+ export {
14
+ buildQueueTargetPayload,
15
+ buildSchedulerIdempotencyKey
16
+ };
17
+ //# sourceMappingURL=queueTargetPayload.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/scheduler/lib/queueTargetPayload.ts"],
4
+ "sourcesContent": ["export type QueueTargetPayloadInput = {\n targetPayload: unknown\n tenantId: string | null | undefined\n organizationId: string | null | undefined\n idempotencyKey: string\n}\n\n/**\n * Single source of truth for the payload a scheduled queue target receives.\n * Both the local scheduler and the asynchronous execute-schedule worker MUST\n * build their enqueue payload here so workers see one flat contract:\n * the configured targetPayload fields on the root, with scheduler-owned\n * tenantId/organizationId/_idempotencyKey applied last so they always win\n * over conflicting targetPayload fields.\n */\nexport function buildQueueTargetPayload(input: QueueTargetPayloadInput): Record<string, unknown> {\n const base =\n input.targetPayload && typeof input.targetPayload === 'object' && !Array.isArray(input.targetPayload)\n ? { ...(input.targetPayload as Record<string, unknown>) }\n : {}\n return {\n ...base,\n tenantId: input.tenantId ?? null,\n organizationId: input.organizationId ?? null,\n _idempotencyKey: input.idempotencyKey,\n }\n}\n\n/**\n * One logical scheduled firing must keep one idempotency key across worker\n * retries, so downstream consumers can deduplicate. Pass a retry-stable\n * execution key: the execute-schedule job id in async mode, or the firing\n * timestamp in local mode (which runs each firing exactly once).\n */\nexport function buildSchedulerIdempotencyKey(scheduleId: string, executionKey: string | number): string {\n return `scheduler-${scheduleId}-${executionKey}`\n}\n"],
5
+ "mappings": "AAeO,SAAS,wBAAwB,OAAyD;AAC/F,QAAM,OACJ,MAAM,iBAAiB,OAAO,MAAM,kBAAkB,YAAY,CAAC,MAAM,QAAQ,MAAM,aAAa,IAChG,EAAE,GAAI,MAAM,cAA0C,IACtD,CAAC;AACP,SAAO;AAAA,IACL,GAAG;AAAA,IACH,UAAU,MAAM,YAAY;AAAA,IAC5B,gBAAgB,MAAM,kBAAkB;AAAA,IACxC,iBAAiB,MAAM;AAAA,EACzB;AACF;AAQO,SAAS,6BAA6B,YAAoB,cAAuC;AACtG,SAAO,aAAa,UAAU,IAAI,YAAY;AAChD;",
6
+ "names": []
7
+ }
@@ -5,6 +5,7 @@ import { recalculateNextRun } from "../lib/nextRunCalculator.js";
5
5
  import { emitSchedulerEvent } from "../events.js";
6
6
  import { getGlobalEventBus } from "@open-mercato/shared/modules/events";
7
7
  import { buildScheduledCommandContext } from "../lib/commandContext.js";
8
+ import { buildQueueTargetPayload, buildSchedulerIdempotencyKey } from "../lib/queueTargetPayload.js";
8
9
  import { createLogger } from "@open-mercato/shared/lib/logger";
9
10
  const logger = createLogger("scheduler").child({ component: "local" });
10
11
  class LocalSchedulerService {
@@ -167,15 +168,12 @@ class LocalSchedulerService {
167
168
  throw new Error("Target queue is required for queue target type");
168
169
  }
169
170
  const queue = this.queueFactory(schedule.targetQueue);
170
- await queue.enqueue({
171
- scheduleId: schedule.id,
172
- scheduleName: schedule.name,
173
- scopeType: schedule.scopeType,
171
+ await queue.enqueue(buildQueueTargetPayload({
172
+ targetPayload: schedule.targetPayload,
174
173
  tenantId: schedule.tenantId,
175
174
  organizationId: schedule.organizationId,
176
- payload: schedule.targetPayload || {},
177
- triggeredAt: /* @__PURE__ */ new Date()
178
- });
175
+ idempotencyKey: buildSchedulerIdempotencyKey(schedule.id, Date.now())
176
+ }));
179
177
  logger.info("Enqueued job to target queue", { scheduleId: schedule.id, targetQueue: schedule.targetQueue });
180
178
  }
181
179
  /**
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/scheduler/services/localSchedulerService.ts"],
4
- "sourcesContent": ["import type { EntityManager } from '@mikro-orm/core'\nimport type { Queue } from '@open-mercato/queue'\nimport { CommandBus } from '@open-mercato/shared/lib/commands'\nimport type { AppContainer } from '@open-mercato/shared/lib/di/container'\nimport { ScheduledJob } from '../data/entities.js'\nimport { LocalLockStrategy } from '../lib/localLockStrategy'\nimport { recalculateNextRun } from '../lib/nextRunCalculator'\nimport { emitSchedulerEvent } from '../events.js'\nimport { getGlobalEventBus } from '@open-mercato/shared/modules/events'\nimport { buildScheduledCommandContext } from '../lib/commandContext.js'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('scheduler').child({ component: 'local' })\n\nexport interface RbacServiceLike {\n tenantHasFeature(tenantId: string | null | undefined, feature: string, opts?: { organizationId?: string | null }): Promise<boolean>\n}\n\nexport interface LocalSchedulerConfig {\n pollIntervalMs: number\n}\n\n/**\n * Local scheduler service for development\n * \n * This service polls the database for due schedules and executes them locally\n * without requiring Redis. Perfect for local development.\n * \n * Features:\n * - PostgreSQL polling (configurable interval, default 30s)\n * - PostgreSQL advisory locks for duplicate prevention\n * - Supports both queue and command targets\n * - Emits the same events as async strategy\n * - Updates nextRunAt after execution\n * \n * Limitations:\n * - Polling delay (up to configured interval)\n * - Single instance only (no distributed locking across instances)\n * - Higher database load than async strategy\n * \n * Set QUEUE_STRATEGY=local to use this service.\n */\nexport class LocalSchedulerService {\n private isRunning = false\n private pollTimer?: NodeJS.Timeout\n private lockStrategy: LocalLockStrategy\n\n constructor(\n private em: () => EntityManager,\n private queueFactory: (name: string) => Queue,\n private rbacService: RbacServiceLike,\n private config: LocalSchedulerConfig = { pollIntervalMs: 30000 },\n ) {\n this.lockStrategy = new LocalLockStrategy(em)\n }\n\n /**\n * Start the local scheduler polling engine\n */\n async start(): Promise<void> {\n if (this.isRunning) {\n logger.warn('Polling engine already running')\n return\n }\n\n this.isRunning = true\n logger.info('Starting polling engine', { pollIntervalMs: this.config.pollIntervalMs })\n\n // Run initial poll immediately\n await this.poll()\n\n // Schedule recurring polls\n this.pollTimer = setInterval(() => {\n this.poll().catch((error) => {\n logger.error('Poll error', { err: error })\n })\n }, this.config.pollIntervalMs)\n\n logger.info('Polling engine started')\n }\n\n /**\n * Stop the local scheduler\n */\n async stop(): Promise<void> {\n logger.info('Stopping polling engine')\n this.isRunning = false\n \n if (this.pollTimer) {\n clearInterval(this.pollTimer)\n this.pollTimer = undefined\n }\n\n logger.info('Polling engine stopped')\n }\n\n /**\n * Poll for due schedules and execute them\n */\n private async poll(): Promise<void> {\n if (!this.isRunning) {\n return\n }\n\n const em = this.em().fork()\n\n try {\n // Find enabled schedules that are due (limited to avoid spikes after outages)\n const dueSchedules = await em.find(ScheduledJob, {\n isEnabled: true,\n deletedAt: null,\n nextRunAt: { $lte: new Date() },\n }, {\n limit: 100,\n orderBy: { nextRunAt: 'ASC' },\n })\n\n if (dueSchedules.length === 0) {\n logger.debug('No due schedules')\n return\n }\n\n logger.info('Found due schedules', { count: dueSchedules.length })\n\n // Execute each schedule\n for (const schedule of dueSchedules) {\n await this.executeSchedule(schedule)\n }\n } catch (error: unknown) {\n logger.error('Poll failed', { err: error })\n }\n }\n\n /**\n * Execute a single schedule\n */\n private async executeSchedule(schedule: ScheduledJob): Promise<void> {\n const lockKey = `schedule:${schedule.id}`\n const scheduleLogger = logger.child({ scheduleId: schedule.id, scheduleName: schedule.name })\n\n const { acquired } = await this.lockStrategy.runWithLock(lockKey, async () => {\n scheduleLogger.info('Executing schedule')\n\n // Emit started event\n await emitSchedulerEvent('scheduler.job.started', {\n id: schedule.id,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n scheduleName: schedule.name,\n scopeType: schedule.scopeType,\n triggerType: 'scheduled',\n startedAt: new Date(),\n })\n\n try {\n // Check feature flag if required\n if (schedule.requireFeature) {\n const hasFeature = await this.checkFeature(schedule)\n \n if (!hasFeature) {\n scheduleLogger.info('Schedule skipped: missing required feature', { requireFeature: schedule.requireFeature })\n \n await emitSchedulerEvent('scheduler.job.skipped', {\n id: schedule.id,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n scheduleName: schedule.name,\n scopeType: schedule.scopeType,\n reason: `Missing required feature: ${schedule.requireFeature}`,\n skippedAt: new Date(),\n })\n\n // Still update next run time\n await this.updateNextRun(schedule)\n return\n }\n }\n\n // Execute based on target type\n if (schedule.targetType === 'queue') {\n await this.executeQueueTarget(schedule)\n } else if (schedule.targetType === 'command') {\n await this.executeCommandTarget(schedule)\n } else {\n throw new Error(`Unknown target type: ${schedule.targetType}`)\n }\n\n // Update last run and next run times\n const em = this.em().fork()\n const freshSchedule = await em.findOne(ScheduledJob, { id: schedule.id })\n \n if (freshSchedule) {\n freshSchedule.lastRunAt = new Date()\n \n // Calculate next run time\n const nextRun = recalculateNextRun(\n freshSchedule.scheduleType,\n freshSchedule.scheduleValue,\n freshSchedule.timezone\n )\n \n if (nextRun) {\n freshSchedule.nextRunAt = nextRun\n }\n \n await em.flush()\n }\n\n scheduleLogger.info('Schedule completed')\n\n // Emit completed event\n await emitSchedulerEvent('scheduler.job.completed', {\n id: schedule.id,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n scheduleName: schedule.name,\n scopeType: schedule.scopeType,\n completedAt: new Date(),\n })\n } catch (error: unknown) {\n scheduleLogger.error('Schedule failed', { err: error })\n\n // Emit failed event\n await emitSchedulerEvent('scheduler.job.failed', {\n id: schedule.id,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n scheduleName: schedule.name,\n scopeType: schedule.scopeType,\n error: error instanceof Error ? error.message : String(error),\n failedAt: new Date(),\n })\n\n // Still update next run time even on failure\n await this.updateNextRun(schedule)\n }\n })\n\n if (!acquired) {\n scheduleLogger.debug('Schedule already locked, skipping')\n return\n }\n }\n\n /**\n * Execute a queue-based target\n */\n private async executeQueueTarget(schedule: ScheduledJob): Promise<void> {\n if (!schedule.targetQueue) {\n throw new Error('Target queue is required for queue target type')\n }\n\n const queue = this.queueFactory(schedule.targetQueue)\n \n await queue.enqueue({\n scheduleId: schedule.id,\n scheduleName: schedule.name,\n scopeType: schedule.scopeType,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n payload: schedule.targetPayload || {},\n triggeredAt: new Date(),\n })\n\n logger.info('Enqueued job to target queue', { scheduleId: schedule.id, targetQueue: schedule.targetQueue })\n }\n\n /**\n * Execute a command-based target\n */\n private async executeCommandTarget(schedule: ScheduledJob): Promise<void> {\n if (!schedule.targetCommand) {\n throw new Error('Target command is required for command target type')\n }\n\n const commandBus = new CommandBus()\n \n const commandInput = {\n ...((schedule.targetPayload as Record<string, unknown>) || {}),\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n }\n \n const commandCtx = buildScheduledCommandContext(\n schedule,\n {\n resolve: (name: string) => {\n // Simple resolver that forwards to our dependencies\n if (name === 'em') return this.em()\n if (name === 'eventBus') return getGlobalEventBus()\n if (name === 'rbacService') return this.rbacService\n throw new Error(`Service not available in scheduler context: ${name}`)\n },\n } as AppContainer,\n )\n\n await commandBus.execute(schedule.targetCommand, {\n input: commandInput,\n ctx: commandCtx,\n })\n\n logger.info('Executed command', { scheduleId: schedule.id, targetCommand: schedule.targetCommand })\n }\n\n /**\n * Check if user/tenant has required feature\n */\n private async checkFeature(schedule: ScheduledJob): Promise<boolean> {\n if (!schedule.requireFeature) {\n return true\n }\n\n try {\n // For system-scoped schedules, no RBAC check needed\n if (schedule.scopeType === 'system') {\n return true\n }\n\n // For tenant/org scoped schedules, check if ANY admin user has the feature\n // This is a simplified check - in reality, scheduled jobs run without a user context\n // so we're just checking if the feature is enabled for the tenant\n const hasFeature = await this.rbacService.tenantHasFeature(\n schedule.tenantId,\n schedule.requireFeature,\n {\n organizationId: schedule.organizationId,\n }\n )\n\n return hasFeature\n } catch (error: unknown) {\n logger.error('Feature check failed', { scheduleId: schedule.id, err: error })\n return false\n }\n }\n\n /**\n * Update next run time for a schedule\n */\n private async updateNextRun(schedule: ScheduledJob): Promise<void> {\n const em = this.em().fork()\n const freshSchedule = await em.findOne(ScheduledJob, { id: schedule.id })\n \n if (freshSchedule) {\n const nextRun = recalculateNextRun(\n freshSchedule.scheduleType,\n freshSchedule.scheduleValue,\n freshSchedule.timezone\n )\n \n if (nextRun) {\n freshSchedule.nextRunAt = nextRun\n await em.flush()\n }\n }\n }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import type { EntityManager } from '@mikro-orm/core'\nimport type { Queue } from '@open-mercato/queue'\nimport { CommandBus } from '@open-mercato/shared/lib/commands'\nimport type { AppContainer } from '@open-mercato/shared/lib/di/container'\nimport { ScheduledJob } from '../data/entities.js'\nimport { LocalLockStrategy } from '../lib/localLockStrategy'\nimport { recalculateNextRun } from '../lib/nextRunCalculator'\nimport { emitSchedulerEvent } from '../events.js'\nimport { getGlobalEventBus } from '@open-mercato/shared/modules/events'\nimport { buildScheduledCommandContext } from '../lib/commandContext.js'\nimport { buildQueueTargetPayload, buildSchedulerIdempotencyKey } from '../lib/queueTargetPayload.js'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('scheduler').child({ component: 'local' })\n\nexport interface RbacServiceLike {\n tenantHasFeature(tenantId: string | null | undefined, feature: string, opts?: { organizationId?: string | null }): Promise<boolean>\n}\n\nexport interface LocalSchedulerConfig {\n pollIntervalMs: number\n}\n\n/**\n * Local scheduler service for development\n * \n * This service polls the database for due schedules and executes them locally\n * without requiring Redis. Perfect for local development.\n * \n * Features:\n * - PostgreSQL polling (configurable interval, default 30s)\n * - PostgreSQL advisory locks for duplicate prevention\n * - Supports both queue and command targets\n * - Emits the same events as async strategy\n * - Updates nextRunAt after execution\n * \n * Limitations:\n * - Polling delay (up to configured interval)\n * - Single instance only (no distributed locking across instances)\n * - Higher database load than async strategy\n * \n * Set QUEUE_STRATEGY=local to use this service.\n */\nexport class LocalSchedulerService {\n private isRunning = false\n private pollTimer?: NodeJS.Timeout\n private lockStrategy: LocalLockStrategy\n\n constructor(\n private em: () => EntityManager,\n private queueFactory: (name: string) => Queue,\n private rbacService: RbacServiceLike,\n private config: LocalSchedulerConfig = { pollIntervalMs: 30000 },\n ) {\n this.lockStrategy = new LocalLockStrategy(em)\n }\n\n /**\n * Start the local scheduler polling engine\n */\n async start(): Promise<void> {\n if (this.isRunning) {\n logger.warn('Polling engine already running')\n return\n }\n\n this.isRunning = true\n logger.info('Starting polling engine', { pollIntervalMs: this.config.pollIntervalMs })\n\n // Run initial poll immediately\n await this.poll()\n\n // Schedule recurring polls\n this.pollTimer = setInterval(() => {\n this.poll().catch((error) => {\n logger.error('Poll error', { err: error })\n })\n }, this.config.pollIntervalMs)\n\n logger.info('Polling engine started')\n }\n\n /**\n * Stop the local scheduler\n */\n async stop(): Promise<void> {\n logger.info('Stopping polling engine')\n this.isRunning = false\n \n if (this.pollTimer) {\n clearInterval(this.pollTimer)\n this.pollTimer = undefined\n }\n\n logger.info('Polling engine stopped')\n }\n\n /**\n * Poll for due schedules and execute them\n */\n private async poll(): Promise<void> {\n if (!this.isRunning) {\n return\n }\n\n const em = this.em().fork()\n\n try {\n // Find enabled schedules that are due (limited to avoid spikes after outages)\n const dueSchedules = await em.find(ScheduledJob, {\n isEnabled: true,\n deletedAt: null,\n nextRunAt: { $lte: new Date() },\n }, {\n limit: 100,\n orderBy: { nextRunAt: 'ASC' },\n })\n\n if (dueSchedules.length === 0) {\n logger.debug('No due schedules')\n return\n }\n\n logger.info('Found due schedules', { count: dueSchedules.length })\n\n // Execute each schedule\n for (const schedule of dueSchedules) {\n await this.executeSchedule(schedule)\n }\n } catch (error: unknown) {\n logger.error('Poll failed', { err: error })\n }\n }\n\n /**\n * Execute a single schedule\n */\n private async executeSchedule(schedule: ScheduledJob): Promise<void> {\n const lockKey = `schedule:${schedule.id}`\n const scheduleLogger = logger.child({ scheduleId: schedule.id, scheduleName: schedule.name })\n\n const { acquired } = await this.lockStrategy.runWithLock(lockKey, async () => {\n scheduleLogger.info('Executing schedule')\n\n // Emit started event\n await emitSchedulerEvent('scheduler.job.started', {\n id: schedule.id,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n scheduleName: schedule.name,\n scopeType: schedule.scopeType,\n triggerType: 'scheduled',\n startedAt: new Date(),\n })\n\n try {\n // Check feature flag if required\n if (schedule.requireFeature) {\n const hasFeature = await this.checkFeature(schedule)\n \n if (!hasFeature) {\n scheduleLogger.info('Schedule skipped: missing required feature', { requireFeature: schedule.requireFeature })\n \n await emitSchedulerEvent('scheduler.job.skipped', {\n id: schedule.id,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n scheduleName: schedule.name,\n scopeType: schedule.scopeType,\n reason: `Missing required feature: ${schedule.requireFeature}`,\n skippedAt: new Date(),\n })\n\n // Still update next run time\n await this.updateNextRun(schedule)\n return\n }\n }\n\n // Execute based on target type\n if (schedule.targetType === 'queue') {\n await this.executeQueueTarget(schedule)\n } else if (schedule.targetType === 'command') {\n await this.executeCommandTarget(schedule)\n } else {\n throw new Error(`Unknown target type: ${schedule.targetType}`)\n }\n\n // Update last run and next run times\n const em = this.em().fork()\n const freshSchedule = await em.findOne(ScheduledJob, { id: schedule.id })\n \n if (freshSchedule) {\n freshSchedule.lastRunAt = new Date()\n \n // Calculate next run time\n const nextRun = recalculateNextRun(\n freshSchedule.scheduleType,\n freshSchedule.scheduleValue,\n freshSchedule.timezone\n )\n \n if (nextRun) {\n freshSchedule.nextRunAt = nextRun\n }\n \n await em.flush()\n }\n\n scheduleLogger.info('Schedule completed')\n\n // Emit completed event\n await emitSchedulerEvent('scheduler.job.completed', {\n id: schedule.id,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n scheduleName: schedule.name,\n scopeType: schedule.scopeType,\n completedAt: new Date(),\n })\n } catch (error: unknown) {\n scheduleLogger.error('Schedule failed', { err: error })\n\n // Emit failed event\n await emitSchedulerEvent('scheduler.job.failed', {\n id: schedule.id,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n scheduleName: schedule.name,\n scopeType: schedule.scopeType,\n error: error instanceof Error ? error.message : String(error),\n failedAt: new Date(),\n })\n\n // Still update next run time even on failure\n await this.updateNextRun(schedule)\n }\n })\n\n if (!acquired) {\n scheduleLogger.debug('Schedule already locked, skipping')\n return\n }\n }\n\n /**\n * Execute a queue-based target\n */\n private async executeQueueTarget(schedule: ScheduledJob): Promise<void> {\n if (!schedule.targetQueue) {\n throw new Error('Target queue is required for queue target type')\n }\n\n const queue = this.queueFactory(schedule.targetQueue)\n\n // Deliver the same flat contract as the asynchronous execute-schedule\n // worker: targetPayload fields on the root, scheduler-owned scope and\n // idempotency fields applied last. Local mode runs each firing exactly\n // once, so the firing timestamp is a valid logical execution key.\n await queue.enqueue(buildQueueTargetPayload({\n targetPayload: schedule.targetPayload,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n idempotencyKey: buildSchedulerIdempotencyKey(schedule.id, Date.now()),\n }))\n\n logger.info('Enqueued job to target queue', { scheduleId: schedule.id, targetQueue: schedule.targetQueue })\n }\n\n /**\n * Execute a command-based target\n */\n private async executeCommandTarget(schedule: ScheduledJob): Promise<void> {\n if (!schedule.targetCommand) {\n throw new Error('Target command is required for command target type')\n }\n\n const commandBus = new CommandBus()\n \n const commandInput = {\n ...((schedule.targetPayload as Record<string, unknown>) || {}),\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n }\n \n const commandCtx = buildScheduledCommandContext(\n schedule,\n {\n resolve: (name: string) => {\n // Simple resolver that forwards to our dependencies\n if (name === 'em') return this.em()\n if (name === 'eventBus') return getGlobalEventBus()\n if (name === 'rbacService') return this.rbacService\n throw new Error(`Service not available in scheduler context: ${name}`)\n },\n } as AppContainer,\n )\n\n await commandBus.execute(schedule.targetCommand, {\n input: commandInput,\n ctx: commandCtx,\n })\n\n logger.info('Executed command', { scheduleId: schedule.id, targetCommand: schedule.targetCommand })\n }\n\n /**\n * Check if user/tenant has required feature\n */\n private async checkFeature(schedule: ScheduledJob): Promise<boolean> {\n if (!schedule.requireFeature) {\n return true\n }\n\n try {\n // For system-scoped schedules, no RBAC check needed\n if (schedule.scopeType === 'system') {\n return true\n }\n\n // For tenant/org scoped schedules, check if ANY admin user has the feature\n // This is a simplified check - in reality, scheduled jobs run without a user context\n // so we're just checking if the feature is enabled for the tenant\n const hasFeature = await this.rbacService.tenantHasFeature(\n schedule.tenantId,\n schedule.requireFeature,\n {\n organizationId: schedule.organizationId,\n }\n )\n\n return hasFeature\n } catch (error: unknown) {\n logger.error('Feature check failed', { scheduleId: schedule.id, err: error })\n return false\n }\n }\n\n /**\n * Update next run time for a schedule\n */\n private async updateNextRun(schedule: ScheduledJob): Promise<void> {\n const em = this.em().fork()\n const freshSchedule = await em.findOne(ScheduledJob, { id: schedule.id })\n \n if (freshSchedule) {\n const nextRun = recalculateNextRun(\n freshSchedule.scheduleType,\n freshSchedule.scheduleValue,\n freshSchedule.timezone\n )\n \n if (nextRun) {\n freshSchedule.nextRunAt = nextRun\n await em.flush()\n }\n }\n }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -4,6 +4,7 @@ import { ScheduledJob } from "../data/entities.js";
4
4
  import { CommandBus } from "@open-mercato/shared/lib/commands";
5
5
  import { emitSchedulerEvent } from "../events.js";
6
6
  import { buildScheduledCommandContext } from "../lib/commandContext.js";
7
+ import { buildQueueTargetPayload, buildSchedulerIdempotencyKey } from "../lib/queueTargetPayload.js";
7
8
  import { createLogger } from "@open-mercato/shared/lib/logger";
8
9
  const logger = createLogger("scheduler").child({ component: "worker" });
9
10
  const metadata = {
@@ -97,15 +98,13 @@ async function executeScheduleWorker(job, ctx) {
97
98
  });
98
99
  let targetJobId;
99
100
  try {
100
- const executionTimestamp = Date.now();
101
- const idempotencyKey = `scheduler-${schedule.id}-${executionTimestamp}`;
102
- const queuePayload = {
103
- ...schedule.targetPayload || {},
101
+ const idempotencyKey = buildSchedulerIdempotencyKey(schedule.id, ctx.jobId ?? Date.now());
102
+ targetJobId = await targetQueue.enqueue(buildQueueTargetPayload({
103
+ targetPayload: schedule.targetPayload,
104
104
  tenantId: schedule.tenantId,
105
105
  organizationId: schedule.organizationId,
106
- _idempotencyKey: idempotencyKey
107
- };
108
- targetJobId = await targetQueue.enqueue(queuePayload);
106
+ idempotencyKey
107
+ }));
109
108
  } finally {
110
109
  await targetQueue.close();
111
110
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/scheduler/workers/execute-schedule.worker.ts"],
4
- "sourcesContent": ["import type { QueuedJob, JobContext, WorkerMeta } from '@open-mercato/queue'\nimport { createQueue } from '@open-mercato/queue'\nimport { getRedisUrlOrThrow } from '@open-mercato/shared/lib/redis/connection'\nimport type { EntityManager } from '@mikro-orm/core'\nimport { ScheduledJob } from '../data/entities.js'\nimport { CommandBus } from '@open-mercato/shared/lib/commands'\nimport type { AppContainer } from '@open-mercato/shared/lib/di/container'\nimport { emitSchedulerEvent } from '../events.js'\nimport { buildScheduledCommandContext } from '../lib/commandContext.js'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('scheduler').child({ component: 'worker' })\n\n// Worker metadata for auto-discovery\nexport const metadata: WorkerMeta = {\n queue: 'scheduler-execution',\n concurrency: 5, // Process up to 5 schedules concurrently\n}\n\nexport type ExecuteSchedulePayload = {\n scheduleId: string\n tenantId?: string | null\n organizationId?: string | null\n scopeType: 'system' | 'organization' | 'tenant'\n triggerType?: 'scheduled' | 'manual'\n triggeredByUserId?: string | null\n}\n\ntype HandlerContext = { resolve: <T = unknown>(name: string) => T }\ntype RbacServiceLike = {\n tenantHasFeature(\n tenantId: string | null | undefined,\n feature: string,\n opts?: { organizationId?: string | null },\n ): Promise<boolean>\n}\n\n/**\n * Worker that executes scheduled jobs.\n * \n * This worker is triggered by BullMQ repeatable jobs at the scheduled times.\n * It loads the fresh schedule configuration from the database, validates\n * conditions, and enqueues the target job or executes the command.\n * \n * BullMQ handles:\n * - Timing (exact cron/interval execution)\n * - Distributed locking (prevents duplicate execution)\n * - Retries (if worker fails)\n * - Execution history (via job state, logs, timestamps)\n * \n * This worker handles:\n * - Loading fresh schedule config\n * - Checking feature flags and conditions\n * - Enqueuing target job or executing command\n * - Updating last run time\n */\nexport default async function executeScheduleWorker(\n job: QueuedJob<ExecuteSchedulePayload>,\n ctx: JobContext & HandlerContext,\n): Promise<void> {\n logger.debug('Processing job', {\n jobId: ctx.jobId,\n attemptNumber: ctx.attemptNumber,\n })\n \n // Defensive: handle both data and payload for BullMQ compatibility\n const payload = (job.payload || (job as unknown as { data?: ExecuteSchedulePayload }).data) as ExecuteSchedulePayload | undefined\n \n if (!payload || !payload.scheduleId) {\n logger.error('Invalid job payload: scheduleId missing', { jobId: ctx.jobId })\n throw new Error('scheduleId is required in job payload')\n }\n\n const { scheduleId } = payload\n\n const em = ctx.resolve<EntityManager>('em')\n const rbacService = ctx.resolve<RbacServiceLike>('rbacService')\n\n // Load fresh schedule from database\n const schedule = await em.findOne(ScheduledJob, { \n id: scheduleId,\n deletedAt: null,\n })\n\n if (!schedule) {\n logger.info('Schedule not found or deleted', { scheduleId })\n return\n }\n\n // CRITICAL: Verify scope integrity - ensure payload scope matches database\n // This prevents scope tampering and ensures proper multi-tenant isolation\n if (payload.scopeType !== schedule.scopeType) {\n logger.error('Scope type mismatch for schedule', {\n scheduleId,\n payloadScope: payload.scopeType,\n dbScope: schedule.scopeType,\n })\n throw new Error('Schedule scope type mismatch - potential security issue')\n }\n\n if (payload.tenantId !== schedule.tenantId) {\n logger.error('Tenant ID mismatch for schedule', {\n scheduleId,\n payloadTenant: payload.tenantId,\n dbTenant: schedule.tenantId,\n })\n throw new Error('Schedule tenant ID mismatch - potential security issue')\n }\n\n if (payload.organizationId !== schedule.organizationId) {\n logger.error('Organization ID mismatch for schedule', {\n scheduleId,\n payloadOrg: payload.organizationId,\n dbOrg: schedule.organizationId,\n })\n throw new Error('Schedule organization ID mismatch - potential security issue')\n }\n\n // Check if schedule is still enabled\n if (!schedule.isEnabled) {\n logger.debug('Schedule is disabled', { scheduleId })\n await emitSchedulerEvent('scheduler.job.skipped', {\n id: schedule.id,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n reason: 'Schedule is disabled',\n })\n return\n }\n\n // Emit started event\n await emitSchedulerEvent('scheduler.job.started', {\n id: schedule.id,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n scheduleName: schedule.name,\n attemptNumber: ctx.attemptNumber || 1,\n })\n\n // Check feature flag if required\n if (schedule.requireFeature) {\n const hasFeature = await rbacService.tenantHasFeature(\n schedule.tenantId,\n schedule.requireFeature,\n { organizationId: schedule.organizationId },\n )\n \n if (!hasFeature) {\n await emitSchedulerEvent('scheduler.job.skipped', {\n id: schedule.id,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n reason: `Feature not enabled: ${schedule.requireFeature}`,\n })\n\n logger.debug('Schedule skipped: feature not enabled', { scheduleId, requireFeature: schedule.requireFeature })\n return\n }\n }\n\n // Enqueue target job or execute command\n if (schedule.targetType === 'queue' && schedule.targetQueue) {\n // Determine queue strategy from environment\n const queueStrategy = (process.env.QUEUE_STRATEGY || 'local') as 'local' | 'async'\n const targetQueue = createQueue(schedule.targetQueue, queueStrategy, {\n connection: { url: getRedisUrlOrThrow('QUEUE') },\n })\n \n let targetJobId: string | undefined\n try {\n // Generate a deterministic idempotency key so that if BullMQ retries\n // this worker after a crash between enqueue and DB flush, downstream\n // workers can deduplicate using this key.\n const executionTimestamp = Date.now()\n const idempotencyKey = `scheduler-${schedule.id}-${executionTimestamp}`\n\n const queuePayload = {\n ...((schedule.targetPayload as Record<string, unknown>) || {}),\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n _idempotencyKey: idempotencyKey,\n }\n\n targetJobId = await targetQueue.enqueue(queuePayload)\n } finally {\n // Always close the queue instance to free Redis connections\n await targetQueue.close()\n }\n \n // Update schedule's last run time\n schedule.lastRunAt = new Date()\n await em.flush()\n\n await emitSchedulerEvent('scheduler.job.completed', {\n id: schedule.id,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n queueJobId: targetJobId,\n queueName: schedule.targetQueue,\n })\n\n logger.debug('Successfully enqueued job', {\n scheduleId: schedule.id,\n targetQueue: schedule.targetQueue,\n queueJobId: targetJobId,\n })\n\n } else if (schedule.targetType === 'command' && schedule.targetCommand) {\n const commandBus = new CommandBus()\n \n const commandInput = {\n ...((schedule.targetPayload as Record<string, unknown>) || {}),\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n }\n \n const commandCtx = buildScheduledCommandContext(schedule, ctx as unknown as AppContainer)\n \n const commandResult = await commandBus.execute(schedule.targetCommand, {\n input: commandInput,\n ctx: commandCtx,\n })\n \n // Update schedule's last run time\n schedule.lastRunAt = new Date()\n await em.flush()\n \n await emitSchedulerEvent('scheduler.job.completed', {\n id: schedule.id,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n commandId: schedule.targetCommand,\n commandResult: commandResult.result,\n })\n \n logger.debug('Successfully executed command', {\n scheduleId: schedule.id,\n commandId: schedule.targetCommand,\n })\n\n } else {\n throw new Error('Invalid target configuration')\n }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import type { QueuedJob, JobContext, WorkerMeta } from '@open-mercato/queue'\nimport { createQueue } from '@open-mercato/queue'\nimport { getRedisUrlOrThrow } from '@open-mercato/shared/lib/redis/connection'\nimport type { EntityManager } from '@mikro-orm/core'\nimport { ScheduledJob } from '../data/entities.js'\nimport { CommandBus } from '@open-mercato/shared/lib/commands'\nimport type { AppContainer } from '@open-mercato/shared/lib/di/container'\nimport { emitSchedulerEvent } from '../events.js'\nimport { buildScheduledCommandContext } from '../lib/commandContext.js'\nimport { buildQueueTargetPayload, buildSchedulerIdempotencyKey } from '../lib/queueTargetPayload.js'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('scheduler').child({ component: 'worker' })\n\n// Worker metadata for auto-discovery\nexport const metadata: WorkerMeta = {\n queue: 'scheduler-execution',\n concurrency: 5, // Process up to 5 schedules concurrently\n}\n\nexport type ExecuteSchedulePayload = {\n scheduleId: string\n tenantId?: string | null\n organizationId?: string | null\n scopeType: 'system' | 'organization' | 'tenant'\n triggerType?: 'scheduled' | 'manual'\n triggeredByUserId?: string | null\n}\n\ntype HandlerContext = { resolve: <T = unknown>(name: string) => T }\ntype RbacServiceLike = {\n tenantHasFeature(\n tenantId: string | null | undefined,\n feature: string,\n opts?: { organizationId?: string | null },\n ): Promise<boolean>\n}\n\n/**\n * Worker that executes scheduled jobs.\n * \n * This worker is triggered by BullMQ repeatable jobs at the scheduled times.\n * It loads the fresh schedule configuration from the database, validates\n * conditions, and enqueues the target job or executes the command.\n * \n * BullMQ handles:\n * - Timing (exact cron/interval execution)\n * - Distributed locking (prevents duplicate execution)\n * - Retries (if worker fails)\n * - Execution history (via job state, logs, timestamps)\n * \n * This worker handles:\n * - Loading fresh schedule config\n * - Checking feature flags and conditions\n * - Enqueuing target job or executing command\n * - Updating last run time\n */\nexport default async function executeScheduleWorker(\n job: QueuedJob<ExecuteSchedulePayload>,\n ctx: JobContext & HandlerContext,\n): Promise<void> {\n logger.debug('Processing job', {\n jobId: ctx.jobId,\n attemptNumber: ctx.attemptNumber,\n })\n \n // Defensive: handle both data and payload for BullMQ compatibility\n const payload = (job.payload || (job as unknown as { data?: ExecuteSchedulePayload }).data) as ExecuteSchedulePayload | undefined\n \n if (!payload || !payload.scheduleId) {\n logger.error('Invalid job payload: scheduleId missing', { jobId: ctx.jobId })\n throw new Error('scheduleId is required in job payload')\n }\n\n const { scheduleId } = payload\n\n const em = ctx.resolve<EntityManager>('em')\n const rbacService = ctx.resolve<RbacServiceLike>('rbacService')\n\n // Load fresh schedule from database\n const schedule = await em.findOne(ScheduledJob, { \n id: scheduleId,\n deletedAt: null,\n })\n\n if (!schedule) {\n logger.info('Schedule not found or deleted', { scheduleId })\n return\n }\n\n // CRITICAL: Verify scope integrity - ensure payload scope matches database\n // This prevents scope tampering and ensures proper multi-tenant isolation\n if (payload.scopeType !== schedule.scopeType) {\n logger.error('Scope type mismatch for schedule', {\n scheduleId,\n payloadScope: payload.scopeType,\n dbScope: schedule.scopeType,\n })\n throw new Error('Schedule scope type mismatch - potential security issue')\n }\n\n if (payload.tenantId !== schedule.tenantId) {\n logger.error('Tenant ID mismatch for schedule', {\n scheduleId,\n payloadTenant: payload.tenantId,\n dbTenant: schedule.tenantId,\n })\n throw new Error('Schedule tenant ID mismatch - potential security issue')\n }\n\n if (payload.organizationId !== schedule.organizationId) {\n logger.error('Organization ID mismatch for schedule', {\n scheduleId,\n payloadOrg: payload.organizationId,\n dbOrg: schedule.organizationId,\n })\n throw new Error('Schedule organization ID mismatch - potential security issue')\n }\n\n // Check if schedule is still enabled\n if (!schedule.isEnabled) {\n logger.debug('Schedule is disabled', { scheduleId })\n await emitSchedulerEvent('scheduler.job.skipped', {\n id: schedule.id,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n reason: 'Schedule is disabled',\n })\n return\n }\n\n // Emit started event\n await emitSchedulerEvent('scheduler.job.started', {\n id: schedule.id,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n scheduleName: schedule.name,\n attemptNumber: ctx.attemptNumber || 1,\n })\n\n // Check feature flag if required\n if (schedule.requireFeature) {\n const hasFeature = await rbacService.tenantHasFeature(\n schedule.tenantId,\n schedule.requireFeature,\n { organizationId: schedule.organizationId },\n )\n \n if (!hasFeature) {\n await emitSchedulerEvent('scheduler.job.skipped', {\n id: schedule.id,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n reason: `Feature not enabled: ${schedule.requireFeature}`,\n })\n\n logger.debug('Schedule skipped: feature not enabled', { scheduleId, requireFeature: schedule.requireFeature })\n return\n }\n }\n\n // Enqueue target job or execute command\n if (schedule.targetType === 'queue' && schedule.targetQueue) {\n // Determine queue strategy from environment\n const queueStrategy = (process.env.QUEUE_STRATEGY || 'local') as 'local' | 'async'\n const targetQueue = createQueue(schedule.targetQueue, queueStrategy, {\n connection: { url: getRedisUrlOrThrow('QUEUE') },\n })\n \n let targetJobId: string | undefined\n try {\n // The execute-schedule job id is stable across BullMQ retries, so if\n // this worker crashes between enqueue and DB flush the retried attempt\n // reuses the same idempotency key and downstream workers can dedupe.\n const idempotencyKey = buildSchedulerIdempotencyKey(schedule.id, ctx.jobId ?? Date.now())\n\n targetJobId = await targetQueue.enqueue(buildQueueTargetPayload({\n targetPayload: schedule.targetPayload,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n idempotencyKey,\n }))\n } finally {\n // Always close the queue instance to free Redis connections\n await targetQueue.close()\n }\n \n // Update schedule's last run time\n schedule.lastRunAt = new Date()\n await em.flush()\n\n await emitSchedulerEvent('scheduler.job.completed', {\n id: schedule.id,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n queueJobId: targetJobId,\n queueName: schedule.targetQueue,\n })\n\n logger.debug('Successfully enqueued job', {\n scheduleId: schedule.id,\n targetQueue: schedule.targetQueue,\n queueJobId: targetJobId,\n })\n\n } else if (schedule.targetType === 'command' && schedule.targetCommand) {\n const commandBus = new CommandBus()\n \n const commandInput = {\n ...((schedule.targetPayload as Record<string, unknown>) || {}),\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n }\n \n const commandCtx = buildScheduledCommandContext(schedule, ctx as unknown as AppContainer)\n \n const commandResult = await commandBus.execute(schedule.targetCommand, {\n input: commandInput,\n ctx: commandCtx,\n })\n \n // Update schedule's last run time\n schedule.lastRunAt = new Date()\n await em.flush()\n \n await emitSchedulerEvent('scheduler.job.completed', {\n id: schedule.id,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n commandId: schedule.targetCommand,\n commandResult: commandResult.result,\n })\n \n logger.debug('Successfully executed command', {\n scheduleId: schedule.id,\n commandId: schedule.targetCommand,\n })\n\n } else {\n throw new Error('Invalid target configuration')\n }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/scheduler",
3
- "version": "0.6.7-develop.6630.1.c8a615e536",
3
+ "version": "0.6.7-develop.6645.1.52f0b37813",
4
4
  "license": "MIT",
5
5
  "description": "Database-managed scheduled jobs with admin UI",
6
6
  "type": "module",
@@ -59,13 +59,13 @@
59
59
  "./*/*/*/*/*/*.json": "./src/*/*/*/*/*/*.json"
60
60
  },
61
61
  "dependencies": {
62
- "@open-mercato/events": "0.6.7-develop.6630.1.c8a615e536",
63
- "@open-mercato/queue": "0.6.7-develop.6630.1.c8a615e536",
62
+ "@open-mercato/events": "0.6.7-develop.6645.1.52f0b37813",
63
+ "@open-mercato/queue": "0.6.7-develop.6645.1.52f0b37813",
64
64
  "cron-parser": "^5.6.2"
65
65
  },
66
66
  "peerDependencies": {
67
67
  "@mikro-orm/core": "^7.0.14",
68
- "@open-mercato/shared": "0.6.7-develop.6630.1.c8a615e536",
68
+ "@open-mercato/shared": "0.6.7-develop.6645.1.52f0b37813",
69
69
  "bullmq": "^5.0.0",
70
70
  "ioredis": "^5.0.0",
71
71
  "zod": ">=3.23.0"
@@ -79,7 +79,7 @@
79
79
  }
80
80
  },
81
81
  "devDependencies": {
82
- "@open-mercato/shared": "0.6.7-develop.6630.1.c8a615e536",
82
+ "@open-mercato/shared": "0.6.7-develop.6645.1.52f0b37813",
83
83
  "@types/jest": "^30.0.0",
84
84
  "@types/node": "^26.0.1",
85
85
  "jest": "^30.4.2",
@@ -0,0 +1,85 @@
1
+ import { buildQueueTargetPayload, buildSchedulerIdempotencyKey } from '../queueTargetPayload'
2
+
3
+ describe('buildQueueTargetPayload', () => {
4
+ it('spreads targetPayload fields onto the payload root', () => {
5
+ const payload = buildQueueTargetPayload({
6
+ targetPayload: { connectionId: 'connection-id', scope: 'organization' },
7
+ tenantId: 'tenant-1',
8
+ organizationId: 'org-1',
9
+ idempotencyKey: 'scheduler-s1-key',
10
+ })
11
+ expect(payload).toEqual({
12
+ connectionId: 'connection-id',
13
+ scope: 'organization',
14
+ tenantId: 'tenant-1',
15
+ organizationId: 'org-1',
16
+ _idempotencyKey: 'scheduler-s1-key',
17
+ })
18
+ })
19
+
20
+ it('lets scheduler-owned scope and idempotency fields win over colliding targetPayload fields', () => {
21
+ const payload = buildQueueTargetPayload({
22
+ targetPayload: {
23
+ tenantId: 'spoofed-tenant',
24
+ organizationId: 'spoofed-org',
25
+ _idempotencyKey: 'spoofed-key',
26
+ },
27
+ tenantId: 'tenant-1',
28
+ organizationId: 'org-1',
29
+ idempotencyKey: 'scheduler-s1-key',
30
+ })
31
+ expect(payload.tenantId).toBe('tenant-1')
32
+ expect(payload.organizationId).toBe('org-1')
33
+ expect(payload._idempotencyKey).toBe('scheduler-s1-key')
34
+ })
35
+
36
+ it('handles empty and non-object targetPayload values', () => {
37
+ for (const targetPayload of [null, undefined, {}, 'text', 42, ['array']]) {
38
+ const payload = buildQueueTargetPayload({
39
+ targetPayload,
40
+ tenantId: null,
41
+ organizationId: null,
42
+ idempotencyKey: 'scheduler-s1-key',
43
+ })
44
+ expect(payload).toEqual({
45
+ tenantId: null,
46
+ organizationId: null,
47
+ _idempotencyKey: 'scheduler-s1-key',
48
+ })
49
+ }
50
+ })
51
+
52
+ it('preserves a literal application field named payload on the root', () => {
53
+ const payload = buildQueueTargetPayload({
54
+ targetPayload: { payload: { nested: true }, other: 1 },
55
+ tenantId: 'tenant-1',
56
+ organizationId: null,
57
+ idempotencyKey: 'scheduler-s1-key',
58
+ })
59
+ expect(payload.payload).toEqual({ nested: true })
60
+ expect(payload.other).toBe(1)
61
+ })
62
+
63
+ it('does not mutate the configured targetPayload', () => {
64
+ const configured = { connectionId: 'connection-id' }
65
+ buildQueueTargetPayload({
66
+ targetPayload: configured,
67
+ tenantId: 'tenant-1',
68
+ organizationId: 'org-1',
69
+ idempotencyKey: 'scheduler-s1-key',
70
+ })
71
+ expect(configured).toEqual({ connectionId: 'connection-id' })
72
+ })
73
+ })
74
+
75
+ describe('buildSchedulerIdempotencyKey', () => {
76
+ it('is deterministic for one logical execution key', () => {
77
+ expect(buildSchedulerIdempotencyKey('s1', 'job-1')).toBe('scheduler-s1-job-1')
78
+ expect(buildSchedulerIdempotencyKey('s1', 'job-1')).toBe(buildSchedulerIdempotencyKey('s1', 'job-1'))
79
+ })
80
+
81
+ it('differs across schedules and executions', () => {
82
+ expect(buildSchedulerIdempotencyKey('s1', 'job-1')).not.toBe(buildSchedulerIdempotencyKey('s2', 'job-1'))
83
+ expect(buildSchedulerIdempotencyKey('s1', 'job-1')).not.toBe(buildSchedulerIdempotencyKey('s1', 'job-2'))
84
+ })
85
+ })
@@ -0,0 +1,37 @@
1
+ export type QueueTargetPayloadInput = {
2
+ targetPayload: unknown
3
+ tenantId: string | null | undefined
4
+ organizationId: string | null | undefined
5
+ idempotencyKey: string
6
+ }
7
+
8
+ /**
9
+ * Single source of truth for the payload a scheduled queue target receives.
10
+ * Both the local scheduler and the asynchronous execute-schedule worker MUST
11
+ * build their enqueue payload here so workers see one flat contract:
12
+ * the configured targetPayload fields on the root, with scheduler-owned
13
+ * tenantId/organizationId/_idempotencyKey applied last so they always win
14
+ * over conflicting targetPayload fields.
15
+ */
16
+ export function buildQueueTargetPayload(input: QueueTargetPayloadInput): Record<string, unknown> {
17
+ const base =
18
+ input.targetPayload && typeof input.targetPayload === 'object' && !Array.isArray(input.targetPayload)
19
+ ? { ...(input.targetPayload as Record<string, unknown>) }
20
+ : {}
21
+ return {
22
+ ...base,
23
+ tenantId: input.tenantId ?? null,
24
+ organizationId: input.organizationId ?? null,
25
+ _idempotencyKey: input.idempotencyKey,
26
+ }
27
+ }
28
+
29
+ /**
30
+ * One logical scheduled firing must keep one idempotency key across worker
31
+ * retries, so downstream consumers can deduplicate. Pass a retry-stable
32
+ * execution key: the execute-schedule job id in async mode, or the firing
33
+ * timestamp in local mode (which runs each firing exactly once).
34
+ */
35
+ export function buildSchedulerIdempotencyKey(scheduleId: string, executionKey: string | number): string {
36
+ return `scheduler-${scheduleId}-${executionKey}`
37
+ }
@@ -271,9 +271,9 @@ describe('LocalSchedulerService', () => {
271
271
 
272
272
  expect(mockQueue.enqueue).toHaveBeenCalledWith(
273
273
  expect.objectContaining({
274
- scheduleId: 'test-1',
275
- scheduleName: 'Test Schedule',
276
- scopeType: 'system',
274
+ tenantId: null,
275
+ organizationId: null,
276
+ _idempotencyKey: expect.stringMatching(/^scheduler-test-1-/),
277
277
  })
278
278
  )
279
279
  })
@@ -548,9 +548,9 @@ describe('LocalSchedulerService', () => {
548
548
  )
549
549
  })
550
550
 
551
- it('should pass payload to queue job', async () => {
551
+ it('should deliver the flat targetPayload contract to the queue job', async () => {
552
552
  const schedule = createSchedule({
553
- targetPayload: { foo: 'bar', baz: 123 },
553
+ targetPayload: { foo: 'bar', baz: 123, tenantId: 'spoofed-tenant' },
554
554
  })
555
555
 
556
556
  mockForkedEm.find.mockResolvedValue([schedule])
@@ -565,9 +565,17 @@ describe('LocalSchedulerService', () => {
565
565
 
566
566
  expect(mockQueue.enqueue).toHaveBeenCalledWith(
567
567
  expect.objectContaining({
568
- payload: { foo: 'bar', baz: 123 },
568
+ foo: 'bar',
569
+ baz: 123,
570
+ tenantId: schedule.tenantId,
571
+ organizationId: schedule.organizationId,
572
+ _idempotencyKey: expect.stringMatching(new RegExp(`^scheduler-${schedule.id}-`)),
569
573
  })
570
574
  )
575
+ const enqueued = mockQueue.enqueue.mock.calls[0][0] as Record<string, unknown>
576
+ expect(enqueued).not.toHaveProperty('payload')
577
+ expect(enqueued).not.toHaveProperty('scheduleId')
578
+ expect(enqueued).not.toHaveProperty('scheduleName')
571
579
  })
572
580
 
573
581
  it('should pass scope to command input', async () => {
@@ -8,6 +8,7 @@ import { recalculateNextRun } from '../lib/nextRunCalculator'
8
8
  import { emitSchedulerEvent } from '../events.js'
9
9
  import { getGlobalEventBus } from '@open-mercato/shared/modules/events'
10
10
  import { buildScheduledCommandContext } from '../lib/commandContext.js'
11
+ import { buildQueueTargetPayload, buildSchedulerIdempotencyKey } from '../lib/queueTargetPayload.js'
11
12
  import { createLogger } from '@open-mercato/shared/lib/logger'
12
13
 
13
14
  const logger = createLogger('scheduler').child({ component: 'local' })
@@ -251,16 +252,17 @@ export class LocalSchedulerService {
251
252
  }
252
253
 
253
254
  const queue = this.queueFactory(schedule.targetQueue)
254
-
255
- await queue.enqueue({
256
- scheduleId: schedule.id,
257
- scheduleName: schedule.name,
258
- scopeType: schedule.scopeType,
255
+
256
+ // Deliver the same flat contract as the asynchronous execute-schedule
257
+ // worker: targetPayload fields on the root, scheduler-owned scope and
258
+ // idempotency fields applied last. Local mode runs each firing exactly
259
+ // once, so the firing timestamp is a valid logical execution key.
260
+ await queue.enqueue(buildQueueTargetPayload({
261
+ targetPayload: schedule.targetPayload,
259
262
  tenantId: schedule.tenantId,
260
263
  organizationId: schedule.organizationId,
261
- payload: schedule.targetPayload || {},
262
- triggeredAt: new Date(),
263
- })
264
+ idempotencyKey: buildSchedulerIdempotencyKey(schedule.id, Date.now()),
265
+ }))
264
266
 
265
267
  logger.info('Enqueued job to target queue', { scheduleId: schedule.id, targetQueue: schedule.targetQueue })
266
268
  }
@@ -1,5 +1,6 @@
1
1
  import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
2
2
  import { ensureTenantScope } from '@open-mercato/shared/lib/commands/scope'
3
+ import { createQueue } from '@open-mercato/queue'
3
4
  import { ScheduledJob } from '../../data/entities'
4
5
  import executeScheduleWorker from '../execute-schedule.worker'
5
6
 
@@ -15,6 +16,10 @@ jest.mock('@open-mercato/queue', () => ({
15
16
  createQueue: jest.fn(),
16
17
  }), { virtual: true })
17
18
 
19
+ jest.mock('@open-mercato/shared/lib/redis/connection', () => ({
20
+ getRedisUrlOrThrow: jest.fn(() => 'redis://localhost:6379'),
21
+ }))
22
+
18
23
  jest.mock('../../events', () => ({
19
24
  emitSchedulerEvent: jest.fn(async () => undefined),
20
25
  }))
@@ -100,3 +105,76 @@ describe('executeScheduleWorker command scope', () => {
100
105
  expect(em.flush).not.toHaveBeenCalled()
101
106
  })
102
107
  })
108
+
109
+ describe('executeScheduleWorker queue target payload contract', () => {
110
+ const enqueue = jest.fn(async () => 'target-job-1')
111
+ const close = jest.fn(async () => undefined)
112
+
113
+ function buildQueueSchedule(overrides: Partial<ScheduledJob> = {}): ScheduledJob {
114
+ return buildCommandSchedule({
115
+ targetType: 'queue',
116
+ targetQueue: 'example',
117
+ targetCommand: null,
118
+ targetPayload: { connectionId: 'connection-id', scope: 'organization' },
119
+ ...overrides,
120
+ })
121
+ }
122
+
123
+ function runWorker(schedule: ScheduledJob, jobId = 'worker-job-1', attemptNumber = 1) {
124
+ const { context } = buildWorkerContext(schedule)
125
+ return executeScheduleWorker(
126
+ {
127
+ id: 'queued-job-1',
128
+ queue: 'scheduler-execution',
129
+ payload: {
130
+ scheduleId,
131
+ tenantId: schedule.tenantId,
132
+ organizationId: schedule.organizationId,
133
+ scopeType: schedule.scopeType,
134
+ },
135
+ attempts: 0,
136
+ createdAt: Date.now(),
137
+ },
138
+ { ...context, jobId, attemptNumber },
139
+ )
140
+ }
141
+
142
+ beforeEach(() => {
143
+ enqueue.mockClear()
144
+ close.mockClear()
145
+ ;(createQueue as jest.Mock).mockReturnValue({ enqueue, close })
146
+ })
147
+
148
+ it('delivers the flat targetPayload contract with scheduler-owned fields applied last', async () => {
149
+ const schedule = buildQueueSchedule({
150
+ targetPayload: {
151
+ connectionId: 'connection-id',
152
+ scope: 'organization',
153
+ tenantId: 'spoofed-tenant',
154
+ payload: { nested: true },
155
+ },
156
+ })
157
+
158
+ await runWorker(schedule)
159
+
160
+ expect(enqueue).toHaveBeenCalledWith({
161
+ connectionId: 'connection-id',
162
+ scope: 'organization',
163
+ payload: { nested: true },
164
+ tenantId: 'tenant-a',
165
+ organizationId: 'org-a',
166
+ _idempotencyKey: `scheduler-${scheduleId}-worker-job-1`,
167
+ })
168
+ expect(close).toHaveBeenCalledTimes(1)
169
+ })
170
+
171
+ it('keeps one idempotency key across retries of the same logical firing', async () => {
172
+ await runWorker(buildQueueSchedule(), 'worker-job-1', 1)
173
+ await runWorker(buildQueueSchedule(), 'worker-job-1', 2)
174
+
175
+ const firstKey = (enqueue.mock.calls[0][0] as Record<string, unknown>)._idempotencyKey
176
+ const secondKey = (enqueue.mock.calls[1][0] as Record<string, unknown>)._idempotencyKey
177
+ expect(firstKey).toBe(`scheduler-${scheduleId}-worker-job-1`)
178
+ expect(secondKey).toBe(firstKey)
179
+ })
180
+ })
@@ -7,6 +7,7 @@ import { CommandBus } from '@open-mercato/shared/lib/commands'
7
7
  import type { AppContainer } from '@open-mercato/shared/lib/di/container'
8
8
  import { emitSchedulerEvent } from '../events.js'
9
9
  import { buildScheduledCommandContext } from '../lib/commandContext.js'
10
+ import { buildQueueTargetPayload, buildSchedulerIdempotencyKey } from '../lib/queueTargetPayload.js'
10
11
  import { createLogger } from '@open-mercato/shared/lib/logger'
11
12
 
12
13
  const logger = createLogger('scheduler').child({ component: 'worker' })
@@ -168,20 +169,17 @@ export default async function executeScheduleWorker(
168
169
 
169
170
  let targetJobId: string | undefined
170
171
  try {
171
- // Generate a deterministic idempotency key so that if BullMQ retries
172
- // this worker after a crash between enqueue and DB flush, downstream
173
- // workers can deduplicate using this key.
174
- const executionTimestamp = Date.now()
175
- const idempotencyKey = `scheduler-${schedule.id}-${executionTimestamp}`
176
-
177
- const queuePayload = {
178
- ...((schedule.targetPayload as Record<string, unknown>) || {}),
172
+ // The execute-schedule job id is stable across BullMQ retries, so if
173
+ // this worker crashes between enqueue and DB flush the retried attempt
174
+ // reuses the same idempotency key and downstream workers can dedupe.
175
+ const idempotencyKey = buildSchedulerIdempotencyKey(schedule.id, ctx.jobId ?? Date.now())
176
+
177
+ targetJobId = await targetQueue.enqueue(buildQueueTargetPayload({
178
+ targetPayload: schedule.targetPayload,
179
179
  tenantId: schedule.tenantId,
180
180
  organizationId: schedule.organizationId,
181
- _idempotencyKey: idempotencyKey,
182
- }
183
-
184
- targetJobId = await targetQueue.enqueue(queuePayload)
181
+ idempotencyKey,
182
+ }))
185
183
  } finally {
186
184
  // Always close the queue instance to free Redis connections
187
185
  await targetQueue.close()