@open-mercato/core 0.6.3-develop.3857.1.da89d7530c → 0.6.3-develop.3881.1.0b590ac4eb

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.
Files changed (107) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/attachments/api/file/[id]/route.js +7 -2
  3. package/dist/modules/attachments/api/file/[id]/route.js.map +2 -2
  4. package/dist/modules/attachments/api/image/[id]/[[...slug]]/route.js +7 -4
  5. package/dist/modules/attachments/api/image/[id]/[[...slug]]/route.js.map +2 -2
  6. package/dist/modules/audit_logs/services/accessLogService.js +127 -8
  7. package/dist/modules/audit_logs/services/accessLogService.js.map +2 -2
  8. package/dist/modules/auth/di.js +17 -3
  9. package/dist/modules/auth/di.js.map +2 -2
  10. package/dist/modules/auth/services/rbacDefaultCache.js +110 -0
  11. package/dist/modules/auth/services/rbacDefaultCache.js.map +7 -0
  12. package/dist/modules/currencies/api/currencies/route.js +3 -4
  13. package/dist/modules/currencies/api/currencies/route.js.map +2 -2
  14. package/dist/modules/currencies/api/exchange-rates/route.js +3 -4
  15. package/dist/modules/currencies/api/exchange-rates/route.js.map +2 -2
  16. package/dist/modules/customers/api/people/route.js +26 -24
  17. package/dist/modules/customers/api/people/route.js.map +2 -2
  18. package/dist/modules/directory/subscribers/invalidateOrgScopeCache.js +26 -0
  19. package/dist/modules/directory/subscribers/invalidateOrgScopeCache.js.map +7 -0
  20. package/dist/modules/directory/utils/organizationScope.js +85 -0
  21. package/dist/modules/directory/utils/organizationScope.js.map +2 -2
  22. package/dist/modules/workflows/backend/definitions/[id]/page.js +2 -1
  23. package/dist/modules/workflows/backend/definitions/[id]/page.js.map +2 -2
  24. package/dist/modules/workflows/backend/definitions/create/page.js +4 -2
  25. package/dist/modules/workflows/backend/definitions/create/page.js.map +2 -2
  26. package/dist/modules/workflows/backend/definitions/visual-editor/page.js +20 -3
  27. package/dist/modules/workflows/backend/definitions/visual-editor/page.js.map +2 -2
  28. package/dist/modules/workflows/components/ActivitiesEditor.js +34 -1
  29. package/dist/modules/workflows/components/ActivitiesEditor.js.map +2 -2
  30. package/dist/modules/workflows/components/NodeEditDialog.js +153 -17
  31. package/dist/modules/workflows/components/NodeEditDialog.js.map +2 -2
  32. package/dist/modules/workflows/components/StepsEditor.js +31 -0
  33. package/dist/modules/workflows/components/StepsEditor.js.map +2 -2
  34. package/dist/modules/workflows/components/WorkflowGraph.js +3 -2
  35. package/dist/modules/workflows/components/WorkflowGraph.js.map +2 -2
  36. package/dist/modules/workflows/components/nodes/WaitForTimerNode.js +54 -0
  37. package/dist/modules/workflows/components/nodes/WaitForTimerNode.js.map +7 -0
  38. package/dist/modules/workflows/components/nodes/index.js +3 -1
  39. package/dist/modules/workflows/components/nodes/index.js.map +2 -2
  40. package/dist/modules/workflows/data/validators.js +117 -0
  41. package/dist/modules/workflows/data/validators.js.map +2 -2
  42. package/dist/modules/workflows/di.js +5 -1
  43. package/dist/modules/workflows/di.js.map +2 -2
  44. package/dist/modules/workflows/lib/activity-executor.js +42 -1
  45. package/dist/modules/workflows/lib/activity-executor.js.map +2 -2
  46. package/dist/modules/workflows/lib/activity-queue-types.js.map +2 -2
  47. package/dist/modules/workflows/lib/activity-worker-handler.js +24 -0
  48. package/dist/modules/workflows/lib/activity-worker-handler.js.map +2 -2
  49. package/dist/modules/workflows/lib/duration.js +32 -0
  50. package/dist/modules/workflows/lib/duration.js.map +7 -0
  51. package/dist/modules/workflows/lib/event-logger.js +1 -0
  52. package/dist/modules/workflows/lib/event-logger.js.map +2 -2
  53. package/dist/modules/workflows/lib/format-validation-error.js +12 -0
  54. package/dist/modules/workflows/lib/format-validation-error.js.map +7 -0
  55. package/dist/modules/workflows/lib/graph-utils.js +6 -3
  56. package/dist/modules/workflows/lib/graph-utils.js.map +2 -2
  57. package/dist/modules/workflows/lib/node-type-icons.js +9 -5
  58. package/dist/modules/workflows/lib/node-type-icons.js.map +2 -2
  59. package/dist/modules/workflows/lib/signal-handler.js +55 -23
  60. package/dist/modules/workflows/lib/signal-handler.js.map +2 -2
  61. package/dist/modules/workflows/lib/step-handler.js +79 -29
  62. package/dist/modules/workflows/lib/step-handler.js.map +2 -2
  63. package/dist/modules/workflows/lib/timer-handler.js +159 -0
  64. package/dist/modules/workflows/lib/timer-handler.js.map +7 -0
  65. package/dist/modules/workflows/lib/workflow-executor.js +1 -1
  66. package/dist/modules/workflows/lib/workflow-executor.js.map +2 -2
  67. package/dist/modules/workflows/workers/workflow-activities.worker.js +20 -4
  68. package/dist/modules/workflows/workers/workflow-activities.worker.js.map +2 -2
  69. package/package.json +7 -7
  70. package/src/modules/attachments/api/file/[id]/route.ts +7 -2
  71. package/src/modules/attachments/api/image/[id]/[[...slug]]/route.ts +7 -4
  72. package/src/modules/audit_logs/services/accessLogService.ts +179 -15
  73. package/src/modules/auth/di.ts +26 -3
  74. package/src/modules/auth/services/rbacDefaultCache.ts +145 -0
  75. package/src/modules/currencies/api/currencies/route.ts +3 -4
  76. package/src/modules/currencies/api/exchange-rates/route.ts +3 -4
  77. package/src/modules/customers/api/people/route.ts +27 -25
  78. package/src/modules/directory/subscribers/invalidateOrgScopeCache.ts +39 -0
  79. package/src/modules/directory/utils/organizationScope.ts +121 -0
  80. package/src/modules/workflows/backend/definitions/[id]/page.tsx +3 -2
  81. package/src/modules/workflows/backend/definitions/create/page.tsx +4 -2
  82. package/src/modules/workflows/backend/definitions/visual-editor/page.tsx +18 -1
  83. package/src/modules/workflows/components/ActivitiesEditor.tsx +40 -0
  84. package/src/modules/workflows/components/NodeEditDialog.tsx +218 -30
  85. package/src/modules/workflows/components/StepsEditor.tsx +36 -0
  86. package/src/modules/workflows/components/WorkflowGraph.tsx +2 -1
  87. package/src/modules/workflows/components/nodes/WaitForTimerNode.tsx +70 -0
  88. package/src/modules/workflows/components/nodes/index.ts +3 -0
  89. package/src/modules/workflows/data/validators.ts +121 -0
  90. package/src/modules/workflows/di.ts +4 -0
  91. package/src/modules/workflows/i18n/de.json +10 -1
  92. package/src/modules/workflows/i18n/en.json +10 -1
  93. package/src/modules/workflows/i18n/es.json +10 -1
  94. package/src/modules/workflows/i18n/pl.json +10 -1
  95. package/src/modules/workflows/lib/activity-executor.ts +86 -2
  96. package/src/modules/workflows/lib/activity-queue-types.ts +18 -11
  97. package/src/modules/workflows/lib/activity-worker-handler.ts +29 -0
  98. package/src/modules/workflows/lib/duration.ts +51 -0
  99. package/src/modules/workflows/lib/event-logger.ts +1 -0
  100. package/src/modules/workflows/lib/format-validation-error.ts +30 -0
  101. package/src/modules/workflows/lib/graph-utils.ts +3 -0
  102. package/src/modules/workflows/lib/node-type-icons.ts +6 -2
  103. package/src/modules/workflows/lib/signal-handler.ts +62 -24
  104. package/src/modules/workflows/lib/step-handler.ts +107 -50
  105. package/src/modules/workflows/lib/timer-handler.ts +213 -0
  106. package/src/modules/workflows/lib/workflow-executor.ts +1 -1
  107. package/src/modules/workflows/workers/workflow-activities.worker.ts +33 -7
@@ -1,4 +1,3 @@
1
- import { WORKFLOW_ACTIVITIES_QUEUE_NAME } from "../lib/activity-queue-types.js";
2
1
  import { WorkflowInstance } from "../data/entities.js";
3
2
  import { logWorkflowEvent } from "../lib/event-logger.js";
4
3
  import {
@@ -9,21 +8,36 @@ import {
9
8
  executeCallWebhook,
10
9
  executeFunction
11
10
  } from "../lib/activity-executor.js";
11
+ const WORKFLOW_ACTIVITIES_QUEUE = "workflow-activities";
12
12
  const DEFAULT_CONCURRENCY = 1;
13
13
  const envConcurrency = process.env.WORKERS_WORKFLOW_ACTIVITIES_CONCURRENCY;
14
14
  const metadata = {
15
- queue: WORKFLOW_ACTIVITIES_QUEUE_NAME,
15
+ queue: WORKFLOW_ACTIVITIES_QUEUE,
16
16
  id: "workflows:workflow-activities",
17
17
  concurrency: envConcurrency ? parseInt(envConcurrency, 10) : DEFAULT_CONCURRENCY
18
18
  };
19
19
  async function handle(job, ctx) {
20
20
  const { payload } = job;
21
21
  const startTime = Date.now();
22
+ const em = ctx.resolve("em");
23
+ const container = ctx;
24
+ if (payload.kind === "timer") {
25
+ console.log(
26
+ `[workflows:activity-worker] Firing timer for instance ${payload.workflowInstanceId} (job ${ctx.jobId})`
27
+ );
28
+ const { fireTimer } = await import("../lib/timer-handler.js");
29
+ await fireTimer(em, container, {
30
+ instanceId: payload.workflowInstanceId,
31
+ stepInstanceId: payload.stepInstanceId,
32
+ tenantId: payload.tenantId,
33
+ organizationId: payload.organizationId,
34
+ userId: payload.userId
35
+ });
36
+ return;
37
+ }
22
38
  console.log(
23
39
  `[workflows:activity-worker] Processing activity ${payload.activityId} (${payload.activityType}) for workflow instance ${payload.workflowInstanceId} (job ${ctx.jobId}, attempt ${ctx.attemptNumber})`
24
40
  );
25
- const em = ctx.resolve("em");
26
- const container = ctx;
27
41
  try {
28
42
  const instance = await em.findOne(WorkflowInstance, {
29
43
  id: payload.workflowInstanceId,
@@ -67,6 +81,8 @@ async function handle(job, ctx) {
67
81
  return await executeCallWebhook(payload.activityConfig, activityContext, { signal });
68
82
  case "EXECUTE_FUNCTION":
69
83
  return await executeFunction(payload.activityConfig, activityContext, container);
84
+ case "WAIT":
85
+ return { waited: true };
70
86
  default:
71
87
  throw new Error(`Unsupported activity type: ${payload.activityType}`);
72
88
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/workflows/workers/workflow-activities.worker.ts"],
4
- "sourcesContent": ["/**\n * Workflow Activity Worker\n *\n * Background worker that processes async activities from the workflow queue.\n * Executes activities with timeout, logs events, and triggers workflow resume.\n *\n * This worker is auto-discovered by the queue system and processes jobs from\n * the 'workflow-activities' queue.\n */\n\nimport type { QueuedJob, JobContext, WorkerMeta } from '@open-mercato/queue'\nimport { WORKFLOW_ACTIVITIES_QUEUE_NAME, type WorkflowActivityJob } from '../lib/activity-queue-types'\nimport type { EntityManager } from '@mikro-orm/core'\nimport type { AwilixContainer } from 'awilix'\nimport { WorkflowInstance } from '../data/entities'\nimport { logWorkflowEvent } from '../lib/event-logger'\nimport {\n executeSendEmail,\n executeCallApi,\n executeEmitEvent,\n executeUpdateEntity,\n executeCallWebhook,\n executeFunction,\n} from '../lib/activity-executor'\n\n// Worker metadata for auto-discovery\nconst DEFAULT_CONCURRENCY = 1\nconst envConcurrency = process.env.WORKERS_WORKFLOW_ACTIVITIES_CONCURRENCY\n\nexport const metadata: WorkerMeta = {\n queue: WORKFLOW_ACTIVITIES_QUEUE_NAME,\n id: 'workflows:workflow-activities',\n concurrency: envConcurrency ? parseInt(envConcurrency, 10) : DEFAULT_CONCURRENCY,\n}\n\ntype HandlerContext = { resolve: <T = unknown>(name: string) => T }\n\n/**\n * Process a workflow activity job.\n *\n * This handler:\n * 1. Fetches the workflow instance\n * 2. Executes the activity by type with timeout support\n * 3. Logs success/failure events to workflow event log\n * 4. Attempts to resume the workflow if all activities complete\n *\n * @param job - The queued job containing activity payload\n * @param ctx - Job context with DI container access\n */\nexport default async function handle(\n job: QueuedJob<WorkflowActivityJob>,\n ctx: JobContext & HandlerContext\n): Promise<void> {\n const { payload } = job\n const startTime = Date.now()\n\n console.log(\n `[workflows:activity-worker] Processing activity ${payload.activityId} (${payload.activityType}) for workflow instance ${payload.workflowInstanceId} (job ${ctx.jobId}, attempt ${ctx.attemptNumber})`\n )\n\n // Resolve services from DI container\n const em = ctx.resolve<EntityManager>('em')\n\n // Create a container-like object from ctx.resolve for activity executors\n // The ctx already has the resolve method we need, we just need to cast it\n const container = ctx as unknown as AwilixContainer\n\n try {\n // Fetch workflow instance with tenant/org scoping\n const instance = await em.findOne(WorkflowInstance, {\n id: payload.workflowInstanceId,\n tenantId: payload.tenantId,\n organizationId: payload.organizationId,\n })\n\n if (!instance) {\n throw new Error(\n `Workflow instance ${payload.workflowInstanceId} not found (tenant: ${payload.tenantId}, org: ${payload.organizationId})`\n )\n }\n\n // Build activity execution context\n const activityContext = {\n workflowInstance: instance,\n workflowContext: payload.workflowContext,\n stepContext: payload.stepContext,\n stepInstanceId: payload.stepInstanceId,\n userId: payload.userId,\n }\n\n // Execute activity by type\n const executeActivityByType = async (signal?: AbortSignal) => {\n switch (payload.activityType) {\n case 'SEND_EMAIL':\n return await executeSendEmail(payload.activityConfig, activityContext, container)\n case 'CALL_API':\n return await executeCallApi(\n em,\n payload.activityConfig,\n activityContext,\n container,\n signal\n )\n case 'EMIT_EVENT':\n return await executeEmitEvent(payload.activityConfig, activityContext, container)\n case 'UPDATE_ENTITY':\n return await executeUpdateEntity(\n em,\n payload.activityConfig,\n activityContext,\n container\n )\n case 'CALL_WEBHOOK':\n return await executeCallWebhook(payload.activityConfig, activityContext, { signal })\n case 'EXECUTE_FUNCTION':\n return await executeFunction(payload.activityConfig, activityContext, container)\n default:\n throw new Error(`Unsupported activity type: ${payload.activityType}`)\n }\n }\n\n // Execute with optional timeout. AbortController aborts in-flight fetches\n // when the timeout wins the race, preventing phantom executions.\n let result: any\n if (payload.timeoutMs && payload.timeoutMs > 0) {\n const abortController = new AbortController()\n const timeoutId = setTimeout(() => {\n abortController.abort()\n }, payload.timeoutMs)\n\n try {\n result = await Promise.race([\n executeActivityByType(abortController.signal),\n new Promise((_, reject) =>\n setTimeout(\n () => reject(new Error(`Activity timeout after ${payload.timeoutMs}ms`)),\n payload.timeoutMs\n )\n ),\n ])\n } finally {\n clearTimeout(timeoutId)\n }\n } else {\n result = await executeActivityByType()\n }\n\n const executionTimeMs = Date.now() - startTime\n\n // Log success event to workflow event log\n await logWorkflowEvent(em, {\n workflowInstanceId: payload.workflowInstanceId,\n stepInstanceId: payload.stepInstanceId,\n eventType: 'ACTIVITY_COMPLETED',\n eventData: {\n activityId: payload.activityId,\n activityName: payload.activityName,\n activityType: payload.activityType,\n async: true,\n jobId: ctx.jobId,\n attemptNumber: ctx.attemptNumber,\n executionTimeMs,\n output: result,\n },\n userId: payload.userId,\n tenantId: payload.tenantId,\n organizationId: payload.organizationId,\n })\n\n console.log(\n `[workflows:activity-worker] Activity ${payload.activityId} (${payload.activityType}) completed successfully for workflow instance ${payload.workflowInstanceId} in ${executionTimeMs}ms`\n )\n\n // Attempt to resume workflow if all activities complete\n await checkAndResumeWorkflow(em, ctx, payload.workflowInstanceId)\n } catch (error: any) {\n const executionTimeMs = Date.now() - startTime\n\n console.error(\n `[workflows:activity-worker] Activity ${payload.activityId} (${payload.activityType}) failed for workflow instance ${payload.workflowInstanceId} (attempt ${ctx.attemptNumber}):`,\n error.message\n )\n\n // Log failure event to workflow event log\n await logWorkflowEvent(em, {\n workflowInstanceId: payload.workflowInstanceId,\n stepInstanceId: payload.stepInstanceId,\n eventType: 'ACTIVITY_FAILED',\n eventData: {\n activityId: payload.activityId,\n activityName: payload.activityName,\n activityType: payload.activityType,\n async: true,\n jobId: ctx.jobId,\n attemptNumber: ctx.attemptNumber,\n error: error.message,\n errorStack: error.stack,\n executionTimeMs,\n },\n userId: payload.userId,\n tenantId: payload.tenantId,\n organizationId: payload.organizationId,\n })\n\n // Check if this was final attempt (BullMQ handles retries automatically)\n const maxAttempts = payload.retryPolicy?.maxAttempts || 1\n if (ctx.attemptNumber >= maxAttempts) {\n console.error(\n `[workflows:activity-worker] Activity ${payload.activityId} (${payload.activityType}) failed after ${maxAttempts} attempts for workflow instance ${payload.workflowInstanceId} - triggering workflow failure handling`\n )\n // Final failure - attempt to resume workflow (may transition to FAILED state)\n await checkAndResumeWorkflow(em, ctx, payload.workflowInstanceId)\n }\n\n // Re-throw to let BullMQ handle retry logic\n throw error\n }\n}\n\n/**\n * Helper to check if workflow can resume after activities complete/fail.\n *\n * This function is called after each activity completes or fails.\n * It checks if all pending activities are done and resumes workflow execution.\n *\n * @param em - Entity manager\n * @param ctx - Handler context with resolve method for DI\n * @param workflowInstanceId - Workflow instance ID to resume\n */\nasync function checkAndResumeWorkflow(\n em: EntityManager,\n ctx: HandlerContext,\n workflowInstanceId: string\n): Promise<void> {\n // Import here to avoid circular dependency\n const { resumeWorkflowAfterActivities } = await import('../lib/workflow-executor')\n\n // Cast ctx to AwilixContainer for the resume function\n const container = ctx as unknown as AwilixContainer\n\n try {\n await resumeWorkflowAfterActivities(em, container, workflowInstanceId)\n } catch (error: any) {\n // Ignore error if workflow not ready to resume yet (activities still pending)\n if (!error.message?.includes('Activities still pending')) {\n console.error(\n `[workflows:activity-worker] Failed to resume workflow instance ${workflowInstanceId}:`,\n error.message\n )\n }\n }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["/**\n * Workflow Activity Worker\n *\n * Background worker that processes async activities from the workflow queue.\n * Executes activities with timeout, logs events, and triggers workflow resume.\n *\n * This worker is auto-discovered by the queue system and processes jobs from\n * the 'workflow-activities' queue.\n */\n\nimport type { QueuedJob, JobContext, WorkerMeta } from '@open-mercato/queue'\nimport type { WorkflowActivityJob } from '../lib/activity-queue-types'\nimport type { EntityManager } from '@mikro-orm/core'\nimport type { AwilixContainer } from 'awilix'\nimport { WorkflowInstance } from '../data/entities'\nimport { logWorkflowEvent } from '../lib/event-logger'\nimport {\n executeSendEmail,\n executeCallApi,\n executeEmitEvent,\n executeUpdateEntity,\n executeCallWebhook,\n executeFunction,\n} from '../lib/activity-executor'\n\n// Worker metadata for auto-discovery.\n// NOTE: `queue` MUST be a string literal (or locally-declared const) so the\n// generator's AST-based extractor can resolve it when Node cannot import the\n// .ts source file directly. Importing `WORKFLOW_ACTIVITIES_QUEUE_NAME` from\n// another module breaks auto-discovery and silently drops the worker from\n// `modules.generated.ts`.\nconst WORKFLOW_ACTIVITIES_QUEUE = 'workflow-activities'\nconst DEFAULT_CONCURRENCY = 1\nconst envConcurrency = process.env.WORKERS_WORKFLOW_ACTIVITIES_CONCURRENCY\n\nexport const metadata: WorkerMeta = {\n queue: WORKFLOW_ACTIVITIES_QUEUE,\n id: 'workflows:workflow-activities',\n concurrency: envConcurrency ? parseInt(envConcurrency, 10) : DEFAULT_CONCURRENCY,\n}\n\ntype HandlerContext = { resolve: <T = unknown>(name: string) => T }\n\n/**\n * Process a workflow activity job.\n *\n * This handler:\n * 1. Fetches the workflow instance\n * 2. Executes the activity by type with timeout support\n * 3. Logs success/failure events to workflow event log\n * 4. Attempts to resume the workflow if all activities complete\n *\n * @param job - The queued job containing activity payload\n * @param ctx - Job context with DI container access\n */\nexport default async function handle(\n job: QueuedJob<WorkflowActivityJob>,\n ctx: JobContext & HandlerContext\n): Promise<void> {\n const { payload } = job\n const startTime = Date.now()\n\n // Resolve services from DI container\n const em = ctx.resolve<EntityManager>('em')\n\n // Create a container-like object from ctx.resolve for activity executors\n // The ctx already has the resolve method we need, we just need to cast it\n const container = ctx as unknown as AwilixContainer\n\n // Timer jobs (kind: 'timer') are a distinct flow \u2014 they resume a paused\n // workflow at a WAIT_FOR_TIMER step rather than running an activity.\n if (payload.kind === 'timer') {\n console.log(\n `[workflows:activity-worker] Firing timer for instance ${payload.workflowInstanceId} (job ${ctx.jobId})`\n )\n const { fireTimer } = await import('../lib/timer-handler')\n await fireTimer(em, container, {\n instanceId: payload.workflowInstanceId,\n stepInstanceId: payload.stepInstanceId,\n tenantId: payload.tenantId,\n organizationId: payload.organizationId,\n userId: payload.userId,\n })\n return\n }\n\n console.log(\n `[workflows:activity-worker] Processing activity ${payload.activityId} (${payload.activityType}) for workflow instance ${payload.workflowInstanceId} (job ${ctx.jobId}, attempt ${ctx.attemptNumber})`\n )\n\n try {\n // Fetch workflow instance with tenant/org scoping\n const instance = await em.findOne(WorkflowInstance, {\n id: payload.workflowInstanceId,\n tenantId: payload.tenantId,\n organizationId: payload.organizationId,\n })\n\n if (!instance) {\n throw new Error(\n `Workflow instance ${payload.workflowInstanceId} not found (tenant: ${payload.tenantId}, org: ${payload.organizationId})`\n )\n }\n\n // Build activity execution context\n const activityContext = {\n workflowInstance: instance,\n workflowContext: payload.workflowContext,\n stepContext: payload.stepContext,\n stepInstanceId: payload.stepInstanceId,\n userId: payload.userId,\n }\n\n // Execute activity by type\n const executeActivityByType = async (signal?: AbortSignal) => {\n switch (payload.activityType) {\n case 'SEND_EMAIL':\n return await executeSendEmail(payload.activityConfig, activityContext, container)\n case 'CALL_API':\n return await executeCallApi(\n em,\n payload.activityConfig,\n activityContext,\n container,\n signal\n )\n case 'EMIT_EVENT':\n return await executeEmitEvent(payload.activityConfig, activityContext, container)\n case 'UPDATE_ENTITY':\n return await executeUpdateEntity(\n em,\n payload.activityConfig,\n activityContext,\n container\n )\n case 'CALL_WEBHOOK':\n return await executeCallWebhook(payload.activityConfig, activityContext, { signal })\n case 'EXECUTE_FUNCTION':\n return await executeFunction(payload.activityConfig, activityContext, container)\n case 'WAIT':\n // Delay already handled by queue's delayMs \u2014 return success immediately\n return { waited: true }\n default:\n throw new Error(`Unsupported activity type: ${payload.activityType}`)\n }\n }\n\n // Execute with optional timeout. AbortController aborts in-flight fetches\n // when the timeout wins the race, preventing phantom executions.\n let result: any\n if (payload.timeoutMs && payload.timeoutMs > 0) {\n const abortController = new AbortController()\n const timeoutId = setTimeout(() => {\n abortController.abort()\n }, payload.timeoutMs)\n\n try {\n result = await Promise.race([\n executeActivityByType(abortController.signal),\n new Promise((_, reject) =>\n setTimeout(\n () => reject(new Error(`Activity timeout after ${payload.timeoutMs}ms`)),\n payload.timeoutMs\n )\n ),\n ])\n } finally {\n clearTimeout(timeoutId)\n }\n } else {\n result = await executeActivityByType()\n }\n\n const executionTimeMs = Date.now() - startTime\n\n // Log success event to workflow event log\n await logWorkflowEvent(em, {\n workflowInstanceId: payload.workflowInstanceId,\n stepInstanceId: payload.stepInstanceId,\n eventType: 'ACTIVITY_COMPLETED',\n eventData: {\n activityId: payload.activityId,\n activityName: payload.activityName,\n activityType: payload.activityType,\n async: true,\n jobId: ctx.jobId,\n attemptNumber: ctx.attemptNumber,\n executionTimeMs,\n output: result,\n },\n userId: payload.userId,\n tenantId: payload.tenantId,\n organizationId: payload.organizationId,\n })\n\n console.log(\n `[workflows:activity-worker] Activity ${payload.activityId} (${payload.activityType}) completed successfully for workflow instance ${payload.workflowInstanceId} in ${executionTimeMs}ms`\n )\n\n // Attempt to resume workflow if all activities complete\n await checkAndResumeWorkflow(em, ctx, payload.workflowInstanceId)\n } catch (error: any) {\n const executionTimeMs = Date.now() - startTime\n\n console.error(\n `[workflows:activity-worker] Activity ${payload.activityId} (${payload.activityType}) failed for workflow instance ${payload.workflowInstanceId} (attempt ${ctx.attemptNumber}):`,\n error.message\n )\n\n // Log failure event to workflow event log\n await logWorkflowEvent(em, {\n workflowInstanceId: payload.workflowInstanceId,\n stepInstanceId: payload.stepInstanceId,\n eventType: 'ACTIVITY_FAILED',\n eventData: {\n activityId: payload.activityId,\n activityName: payload.activityName,\n activityType: payload.activityType,\n async: true,\n jobId: ctx.jobId,\n attemptNumber: ctx.attemptNumber,\n error: error.message,\n errorStack: error.stack,\n executionTimeMs,\n },\n userId: payload.userId,\n tenantId: payload.tenantId,\n organizationId: payload.organizationId,\n })\n\n // Check if this was final attempt (BullMQ handles retries automatically)\n const maxAttempts = payload.retryPolicy?.maxAttempts || 1\n if (ctx.attemptNumber >= maxAttempts) {\n console.error(\n `[workflows:activity-worker] Activity ${payload.activityId} (${payload.activityType}) failed after ${maxAttempts} attempts for workflow instance ${payload.workflowInstanceId} - triggering workflow failure handling`\n )\n // Final failure - attempt to resume workflow (may transition to FAILED state)\n await checkAndResumeWorkflow(em, ctx, payload.workflowInstanceId)\n }\n\n // Re-throw to let BullMQ handle retry logic\n throw error\n }\n}\n\n/**\n * Helper to check if workflow can resume after activities complete/fail.\n *\n * This function is called after each activity completes or fails.\n * It checks if all pending activities are done and resumes workflow execution.\n *\n * @param em - Entity manager\n * @param ctx - Handler context with resolve method for DI\n * @param workflowInstanceId - Workflow instance ID to resume\n */\nasync function checkAndResumeWorkflow(\n em: EntityManager,\n ctx: HandlerContext,\n workflowInstanceId: string\n): Promise<void> {\n // Import here to avoid circular dependency\n const { resumeWorkflowAfterActivities } = await import('../lib/workflow-executor')\n\n // Cast ctx to AwilixContainer for the resume function\n const container = ctx as unknown as AwilixContainer\n\n try {\n await resumeWorkflowAfterActivities(em, container, workflowInstanceId)\n } catch (error: any) {\n // Ignore error if workflow not ready to resume yet (activities still pending)\n if (!error.message?.includes('Activities still pending')) {\n console.error(\n `[workflows:activity-worker] Failed to resume workflow instance ${workflowInstanceId}:`,\n error.message\n )\n }\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/core",
3
- "version": "0.6.3-develop.3857.1.da89d7530c",
3
+ "version": "0.6.3-develop.3881.1.0b590ac4eb",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "scripts": {
@@ -243,16 +243,16 @@
243
243
  "zod": "^4.4.3"
244
244
  },
245
245
  "peerDependencies": {
246
- "@open-mercato/ai-assistant": "0.6.3-develop.3857.1.da89d7530c",
247
- "@open-mercato/shared": "0.6.3-develop.3857.1.da89d7530c",
248
- "@open-mercato/ui": "0.6.3-develop.3857.1.da89d7530c",
246
+ "@open-mercato/ai-assistant": "0.6.3-develop.3881.1.0b590ac4eb",
247
+ "@open-mercato/shared": "0.6.3-develop.3881.1.0b590ac4eb",
248
+ "@open-mercato/ui": "0.6.3-develop.3881.1.0b590ac4eb",
249
249
  "react": "^19.0.0",
250
250
  "react-dom": "^19.0.0"
251
251
  },
252
252
  "devDependencies": {
253
- "@open-mercato/ai-assistant": "0.6.3-develop.3857.1.da89d7530c",
254
- "@open-mercato/shared": "0.6.3-develop.3857.1.da89d7530c",
255
- "@open-mercato/ui": "0.6.3-develop.3857.1.da89d7530c",
253
+ "@open-mercato/ai-assistant": "0.6.3-develop.3881.1.0b590ac4eb",
254
+ "@open-mercato/shared": "0.6.3-develop.3881.1.0b590ac4eb",
255
+ "@open-mercato/ui": "0.6.3-develop.3881.1.0b590ac4eb",
256
256
  "@testing-library/dom": "^10.4.1",
257
257
  "@testing-library/jest-dom": "^6.9.1",
258
258
  "@testing-library/react": "^16.3.1",
@@ -7,7 +7,7 @@ import {
7
7
  AttachmentPartition,
8
8
  } from "@open-mercato/core/modules/attachments/data/entities";
9
9
  import type { EntityManager } from "@mikro-orm/postgresql";
10
- import { checkAttachmentAccess } from "@open-mercato/core/modules/attachments/lib/access";
10
+ import { checkAttachmentAccess, isSuperAdminAuth } from "@open-mercato/core/modules/attachments/lib/access";
11
11
  import { z } from "zod";
12
12
  import { attachmentsTag, attachmentErrorSchema } from "../../openapi";
13
13
  import {
@@ -38,7 +38,12 @@ export async function GET(
38
38
  (resolve("storageDriverFactory") as StorageDriverFactory | null) ??
39
39
  new StorageDriverFactory(em);
40
40
 
41
- const attachment = await em.findOne(Attachment, { id });
41
+ const findFilter: Record<string, unknown> = { id };
42
+ if (auth && !isSuperAdminAuth(auth)) {
43
+ if (auth.tenantId) findFilter.tenantId = auth.tenantId;
44
+ if (auth.orgId) findFilter.organizationId = auth.orgId;
45
+ }
46
+ const attachment = await em.findOne(Attachment, findFilter);
42
47
  if (!attachment) {
43
48
  return NextResponse.json(
44
49
  { error: "Attachment not found" },
@@ -11,7 +11,7 @@ import {
11
11
  writeThumbnailCache,
12
12
  } from '@open-mercato/core/modules/attachments/lib/thumbnailCache'
13
13
  import { canRenderInlineAttachment } from '@open-mercato/core/modules/attachments/lib/security'
14
- import { checkAttachmentAccess } from '@open-mercato/core/modules/attachments/lib/access'
14
+ import { checkAttachmentAccess, isSuperAdminAuth } from '@open-mercato/core/modules/attachments/lib/access'
15
15
  import type { EntityManager } from '@mikro-orm/postgresql'
16
16
  import { attachmentsTag, imageQuerySchema, attachmentErrorSchema } from '../../../openapi'
17
17
  import {
@@ -53,9 +53,12 @@ export async function GET(
53
53
  const storageDriverFactory =
54
54
  (resolve('storageDriverFactory') as StorageDriverFactory | null) ?? new StorageDriverFactory(em)
55
55
 
56
- const attachment = await em.findOne(Attachment, {
57
- id,
58
- })
56
+ const findFilter: Record<string, unknown> = { id }
57
+ if (auth && !isSuperAdminAuth(auth)) {
58
+ if (auth.tenantId) findFilter.tenantId = auth.tenantId
59
+ if (auth.orgId) findFilter.organizationId = auth.orgId
60
+ }
61
+ const attachment = await em.findOne(Attachment, findFilter)
59
62
  if (!attachment) {
60
63
  return NextResponse.json({ error: 'Not found' }, { status: 404 })
61
64
  }
@@ -25,24 +25,191 @@ const NON_CORE_RETENTION_HOURS = toPositiveNumber(process.env.AUDIT_LOGS_NON_COR
25
25
  const CORE_RETENTION_MS = CORE_RETENTION_DAYS * 24 * 60 * 60 * 1000
26
26
  const NON_CORE_RETENTION_MS = NON_CORE_RETENTION_HOURS * 60 * 60 * 1000
27
27
  const UUID_REGEX = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$/
28
+ // Postgres has a hard limit of 65k bind parameters per statement. Each access
29
+ // log row uses 10 bind values (see INSERT below), so 500 rows × 10 = 5 000
30
+ // parameters — well below the limit while keeping memory bounded.
31
+ const MAX_BATCH_ROWS = 500
28
32
 
29
33
  let validationWarningLogged = false
30
34
  let runtimeValidationAvailable: boolean | null = null
31
35
 
36
+ // Module-level registry of in-flight access-log writes. Both `log` and
37
+ // `logMany` opt every promise they kick off into this set so that
38
+ // `flushAccessLog()` can drain them. This is what makes the new
39
+ // fire-and-forget CRUD path safe for test code that asserts on `access_logs`
40
+ // rows immediately after a response — the integration harness defaults to
41
+ // blocking via `OM_CRUD_ACCESS_LOG_BLOCKING=1`, and direct callers can opt
42
+ // in to draining explicitly via `flushAccessLog()`.
43
+ const pendingAccessLogWrites = new Set<Promise<unknown>>()
44
+
45
+ function trackPendingAccessLogWrite<T>(promise: Promise<T>): Promise<T> {
46
+ pendingAccessLogWrites.add(promise as unknown as Promise<unknown>)
47
+ promise
48
+ .catch(() => undefined)
49
+ .finally(() => {
50
+ pendingAccessLogWrites.delete(promise as unknown as Promise<unknown>)
51
+ })
52
+ return promise
53
+ }
54
+
55
+ export async function flushAccessLog(): Promise<void> {
56
+ while (pendingAccessLogWrites.size > 0) {
57
+ const snapshot = Array.from(pendingAccessLogWrites)
58
+ await Promise.allSettled(snapshot)
59
+ }
60
+ }
61
+
32
62
  const isZodRuntimeMissing = (err: unknown) => err instanceof TypeError && typeof err.message === 'string' && err.message.includes('_zod')
33
63
 
64
+ type RawEncryptedFields = {
65
+ resourceKind?: unknown
66
+ resourceId?: unknown
67
+ accessType?: unknown
68
+ fieldsJson?: unknown
69
+ contextJson?: unknown
70
+ }
71
+
72
+ function serializeJsonColumn(value: unknown): string | null {
73
+ if (value === null || value === undefined) return null
74
+ if (typeof value === 'string') {
75
+ try {
76
+ JSON.parse(value)
77
+ return value
78
+ } catch {
79
+ return JSON.stringify(value)
80
+ }
81
+ }
82
+ try {
83
+ return JSON.stringify(value)
84
+ } catch {
85
+ return null
86
+ }
87
+ }
88
+
34
89
  export class AccessLogService {
35
90
  constructor(private readonly em: EntityManager) {}
36
91
 
37
92
  async log(input: AccessLogCreateInput): Promise<AccessLog | null> {
38
- let data: AccessLogCreateInput
93
+ const promise = this.logInternal(input)
94
+ return trackPendingAccessLogWrite(promise)
95
+ }
96
+
97
+ async logMany(inputs: AccessLogCreateInput[]): Promise<number> {
98
+ if (!Array.isArray(inputs) || inputs.length === 0) return 0
99
+ const promise = this.logManyInternal(inputs)
100
+ return trackPendingAccessLogWrite(promise)
101
+ }
102
+
103
+ flush(): Promise<void> {
104
+ return flushAccessLog()
105
+ }
106
+
107
+ private async logManyInternal(inputs: AccessLogCreateInput[]): Promise<number> {
108
+ // Parsing in parallel matches the legacy fan-out `Promise.all(map(...service.log()))`
109
+ // path's wall-clock; the previous sequential loop made batched writes slower than
110
+ // un-batched on tenants with encryption enabled and pushed UI integration tests
111
+ // over their dialog-stability budget.
112
+ const parsedResults = await Promise.all(inputs.map((input) => this.parseInput(input)))
113
+ const normalized: AccessLogCreateInput[] = []
114
+ for (const parsed of parsedResults) {
115
+ if (parsed) normalized.push(parsed)
116
+ }
117
+ if (!normalized.length) return 0
118
+
119
+ let written = 0
120
+ for (let offset = 0; offset < normalized.length; offset += MAX_BATCH_ROWS) {
121
+ const chunk = normalized.slice(offset, offset + MAX_BATCH_ROWS)
122
+ written += await this.writeChunk(chunk)
123
+ }
124
+ if (written > 0) {
125
+ const fork = this.em.fork({ useContext: true })
126
+ await this.rotate(fork)
127
+ }
128
+ return written
129
+ }
130
+
131
+ private async writeChunk(chunk: AccessLogCreateInput[]): Promise<number> {
132
+ if (!chunk.length) return 0
133
+ const fork = this.em.fork({ useContext: true })
134
+ const encryption = resolveTenantEncryptionService(fork as any)
135
+ const createdAt = new Date()
136
+
137
+ // Encrypt every row in parallel so encryption-enabled tenants do not pay
138
+ // the N-rows × per-row latency penalty that the previous sequential
139
+ // for-of loop introduced. The legacy `service.log()` fan-out resolved
140
+ // its 50 encryption calls concurrently via `Promise.all`; preserve that
141
+ // characteristic here so the batched single-INSERT path is strictly
142
+ // faster than the legacy parallel-INSERTs path.
143
+ type PreparedRow = {
144
+ tenantId: string | null
145
+ organizationId: string | null
146
+ data: AccessLogCreateInput
147
+ fields: unknown[] | null
148
+ context: Record<string, unknown> | null
149
+ encrypted: RawEncryptedFields | null
150
+ }
151
+ const prepared: PreparedRow[] = await Promise.all(
152
+ chunk.map(async (data) => {
153
+ const fields = Array.isArray(data.fields) && data.fields.length ? data.fields : null
154
+ const context = data.context && Object.keys(data.context).length ? data.context : null
155
+ const tenantId = data.tenantId ?? null
156
+ const organizationId = data.organizationId ?? null
157
+ const encrypted = encryption
158
+ ? ((await encryption.encryptEntityPayload(
159
+ E.audit_logs.access_log,
160
+ {
161
+ resourceKind: data.resourceKind,
162
+ resourceId: data.resourceId,
163
+ accessType: data.accessType,
164
+ fieldsJson: fields,
165
+ contextJson: context,
166
+ },
167
+ tenantId,
168
+ organizationId,
169
+ )) as RawEncryptedFields)
170
+ : null
171
+ return { tenantId, organizationId, data, fields, context, encrypted }
172
+ }),
173
+ )
174
+
175
+ const placeholders: string[] = []
176
+ const params: unknown[] = []
177
+ for (const row of prepared) {
178
+ const { tenantId, organizationId, data, fields, context, encrypted } = row
179
+ const resourceKindOut = encrypted?.resourceKind ?? data.resourceKind
180
+ const resourceIdOut = encrypted?.resourceId ?? data.resourceId
181
+ const accessTypeOut = encrypted?.accessType ?? data.accessType
182
+ const fieldsOut = encrypted?.fieldsJson ?? fields
183
+ const contextOut = encrypted?.contextJson ?? context
184
+ placeholders.push('(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)')
185
+ params.push(
186
+ tenantId,
187
+ organizationId,
188
+ data.actorUserId ?? null,
189
+ resourceKindOut,
190
+ resourceIdOut,
191
+ accessTypeOut,
192
+ serializeJsonColumn(fieldsOut),
193
+ serializeJsonColumn(contextOut),
194
+ createdAt,
195
+ null,
196
+ )
197
+ }
198
+ if (!placeholders.length) return 0
199
+ const sql = `insert into "access_logs" ("tenant_id", "organization_id", "actor_user_id", "resource_kind", "resource_id", "access_type", "fields_json", "context_json", "created_at", "deleted_at") values ${placeholders.join(', ')}`
200
+ await fork.getConnection().execute(sql, params)
201
+ return chunk.length
202
+ }
203
+
204
+ private async parseInput(input: AccessLogCreateInput): Promise<AccessLogCreateInput | null> {
39
205
  const schema = accessLogCreateSchema as typeof accessLogCreateSchema & { _zod?: unknown }
40
206
  const canValidate = Boolean(schema && typeof schema.parse === 'function')
41
207
  const shouldValidate = canValidate && runtimeValidationAvailable !== false
42
208
  if (shouldValidate) {
43
209
  try {
44
- data = schema.parse(input)
210
+ const data = schema.parse(input)
45
211
  runtimeValidationAvailable = true
212
+ return data
46
213
  } catch (err) {
47
214
  if (!isZodRuntimeMissing(err) && !validationWarningLogged) {
48
215
  validationWarningLogged = true
@@ -50,11 +217,15 @@ export class AccessLogService {
50
217
  console.warn('[audit_logs] falling back to permissive access log payload parser', err)
51
218
  }
52
219
  if (isZodRuntimeMissing(err)) runtimeValidationAvailable = false
53
- data = this.normalizeInput(input)
220
+ return this.normalizeInput(input)
54
221
  }
55
- } else {
56
- data = this.normalizeInput(input)
57
222
  }
223
+ return this.normalizeInput(input)
224
+ }
225
+
226
+ private async logInternal(input: AccessLogCreateInput): Promise<AccessLog | null> {
227
+ const data = await this.parseInput(input)
228
+ if (!data) return null
58
229
  const fork = this.em.fork({ useContext: true })
59
230
  const fields = Array.isArray(data.fields) && data.fields.length ? data.fields : null
60
231
  const context = data.context && Object.keys(data.context).length ? data.context : null
@@ -62,13 +233,6 @@ export class AccessLogService {
62
233
  const tenantId = data.tenantId ?? null
63
234
  const organizationId = data.organizationId ?? null
64
235
 
65
- type AccessLogEncryptedFields = {
66
- resourceKind?: unknown
67
- resourceId?: unknown
68
- accessType?: unknown
69
- fieldsJson?: unknown
70
- contextJson?: unknown
71
- }
72
236
  const encryption = resolveTenantEncryptionService(fork as any)
73
237
  const encrypted = encryption
74
238
  ? ((await encryption.encryptEntityPayload(
@@ -82,7 +246,7 @@ export class AccessLogService {
82
246
  },
83
247
  tenantId,
84
248
  organizationId,
85
- )) as AccessLogEncryptedFields)
249
+ )) as RawEncryptedFields)
86
250
  : null
87
251
 
88
252
  const payload = {
@@ -102,8 +266,8 @@ export class AccessLogService {
102
266
  payload.resourceKind,
103
267
  payload.resourceId,
104
268
  payload.accessType,
105
- payload.fieldsJson !== null && payload.fieldsJson !== undefined ? JSON.stringify(payload.fieldsJson) : null,
106
- payload.contextJson !== null && payload.contextJson !== undefined ? JSON.stringify(payload.contextJson) : null,
269
+ serializeJsonColumn(payload.fieldsJson),
270
+ serializeJsonColumn(payload.contextJson),
107
271
  createdAt,
108
272
  null,
109
273
  ],
@@ -1,11 +1,34 @@
1
- import { asClass } from 'awilix'
1
+ import { asClass, asFunction } from 'awilix'
2
2
  import type { AppContainer } from '@open-mercato/shared/lib/di/container'
3
+ import type { EntityManager } from '@mikro-orm/postgresql'
4
+ import type { CacheStrategy } from '@open-mercato/cache'
3
5
  import { AuthService } from '@open-mercato/core/modules/auth/services/authService'
4
6
  import { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
7
+ import {
8
+ createRbacFallbackCache,
9
+ isRbacDefaultCacheEnabled,
10
+ resetRbacFallbackCache,
11
+ } from '@open-mercato/core/modules/auth/services/rbacDefaultCache'
12
+
13
+ export { resetRbacFallbackCache }
5
14
 
6
15
  export function register(container: AppContainer) {
7
16
  // Register or override core auth service
8
17
  container.register({ authService: asClass(AuthService).scoped() })
9
- // RBAC service
10
- container.register({ rbacService: asClass(RbacService).scoped() })
18
+ // RBAC service. The bare `asClass(...).scoped()` registration matches
19
+ // develop and is the default. Setting `OM_RBAC_DEFAULT_CACHE=on` opts
20
+ // into the in-process LRU fallback for deployments that don't wire a
21
+ // shared CacheStrategy (CLI scripts, lean test bootstraps). Production
22
+ // bootstraps that already register `cache` via `@open-mercato/cache`
23
+ // preempt this fallback because the container's existing `cache`
24
+ // registration wins when `RbacService` reaches for it.
25
+ if (isRbacDefaultCacheEnabled()) {
26
+ container.register({
27
+ rbacService: asFunction((cradle: { em: EntityManager; cache?: CacheStrategy }) => {
28
+ return new RbacService(cradle.em, cradle.cache ?? createRbacFallbackCache())
29
+ }).scoped(),
30
+ })
31
+ } else {
32
+ container.register({ rbacService: asClass(RbacService).scoped() })
33
+ }
11
34
  }
@@ -0,0 +1,145 @@
1
+ import type { CacheStrategy, CacheValue, CacheSetOptions, CacheGetOptions } from '@open-mercato/cache'
2
+
3
+ /**
4
+ * Process-scoped fallback CacheStrategy for RbacService.
5
+ *
6
+ * Used only when no shared `cache` service is registered in DI
7
+ * (CLI scripts, lean test bootstraps, isolated unit harnesses). Production
8
+ * deployments wire `@open-mercato/cache` via bootstrap.ts, which preempts
9
+ * this fallback.
10
+ *
11
+ * Goals:
12
+ * - Match the CacheStrategy contract that RbacService consumes (`get`,
13
+ * `set`, `has`, `delete`, `deleteByTags`, `clear`).
14
+ * - Bound memory: LRU eviction at MAX_ENTRIES.
15
+ * - Honor `OM_RBAC_DEFAULT_CACHE=off` so callers can disable it explicitly.
16
+ * - Stay process-scoped via `globalThis` so HMR / module duplication does
17
+ * not produce divergent caches (same pattern as registerDiRegistrars).
18
+ */
19
+
20
+ type FallbackEntry = {
21
+ value: CacheValue
22
+ tags: string[]
23
+ expiresAt: number | null
24
+ }
25
+
26
+ type FallbackCache = CacheStrategy & {
27
+ __reset: () => void
28
+ }
29
+
30
+ const GLOBAL_KEY = '__openMercatoRbacFallbackCache__'
31
+ const MAX_ENTRIES = 5000
32
+
33
+ export function isRbacDefaultCacheEnabled(): boolean {
34
+ // Default OFF — same gating posture as Phases 2/4/5 in this PR. The
35
+ // integration runtime stays on the bare `asClass(RbacService).scoped()`
36
+ // path (matching develop) unless an operator opts in explicitly.
37
+ // Set `OM_RBAC_DEFAULT_CACHE=on` (or `1`/`true`/`yes`) to enable the
38
+ // in-process LRU fallback.
39
+ const raw = process.env.OM_RBAC_DEFAULT_CACHE
40
+ if (raw === undefined) return false
41
+ const normalized = raw.trim().toLowerCase()
42
+ if (!normalized.length) return false
43
+ return normalized === 'on' || normalized === '1' || normalized === 'true' || normalized === 'yes'
44
+ }
45
+
46
+ function nowMs(): number {
47
+ return Date.now()
48
+ }
49
+
50
+ function createCache(): FallbackCache {
51
+ const store = new Map<string, FallbackEntry>()
52
+ const touch = (key: string) => {
53
+ const entry = store.get(key)
54
+ if (!entry) return undefined
55
+ if (entry.expiresAt !== null && entry.expiresAt < nowMs()) {
56
+ store.delete(key)
57
+ return undefined
58
+ }
59
+ // Move to most-recently-used position (Map preserves insertion order).
60
+ store.delete(key)
61
+ store.set(key, entry)
62
+ return entry
63
+ }
64
+ const evictIfNeeded = () => {
65
+ while (store.size > MAX_ENTRIES) {
66
+ const oldest = store.keys().next().value
67
+ if (typeof oldest !== 'string') break
68
+ store.delete(oldest)
69
+ }
70
+ }
71
+ const cache: FallbackCache = {
72
+ async get(key: string, _options?: CacheGetOptions): Promise<CacheValue | null> {
73
+ const entry = touch(key)
74
+ return entry ? entry.value : null
75
+ },
76
+ async set(key: string, value: CacheValue, options?: CacheSetOptions): Promise<void> {
77
+ const ttl = options?.ttl ?? null
78
+ store.delete(key)
79
+ store.set(key, {
80
+ value,
81
+ tags: options?.tags ?? [],
82
+ expiresAt: typeof ttl === 'number' && ttl > 0 ? nowMs() + ttl : null,
83
+ })
84
+ evictIfNeeded()
85
+ },
86
+ async has(key: string): Promise<boolean> {
87
+ return touch(key) !== undefined
88
+ },
89
+ async delete(key: string): Promise<boolean> {
90
+ return store.delete(key)
91
+ },
92
+ async deleteByTags(tags: string[]): Promise<number> {
93
+ if (!tags.length) return 0
94
+ const tagSet = new Set(tags)
95
+ let removed = 0
96
+ for (const [key, entry] of store.entries()) {
97
+ if (entry.tags.some((tag) => tagSet.has(tag))) {
98
+ store.delete(key)
99
+ removed += 1
100
+ }
101
+ }
102
+ return removed
103
+ },
104
+ async clear(): Promise<number> {
105
+ const count = store.size
106
+ store.clear()
107
+ return count
108
+ },
109
+ async keys(pattern?: string): Promise<string[]> {
110
+ if (!pattern) return Array.from(store.keys())
111
+ const matcher = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.')
112
+ const regex = new RegExp(`^${matcher}$`)
113
+ return Array.from(store.keys()).filter((key) => regex.test(key))
114
+ },
115
+ async size(): Promise<number> {
116
+ return store.size
117
+ },
118
+ async stats(): Promise<{ size: number; expired: number }> {
119
+ const now = nowMs()
120
+ let expired = 0
121
+ for (const entry of store.values()) {
122
+ if (entry.expiresAt !== null && entry.expiresAt < now) expired += 1
123
+ }
124
+ return { size: store.size, expired }
125
+ },
126
+ __reset() {
127
+ store.clear()
128
+ },
129
+ } as FallbackCache
130
+ return cache
131
+ }
132
+
133
+ export function createRbacFallbackCache(): CacheStrategy {
134
+ const existing = (globalThis as any)[GLOBAL_KEY] as FallbackCache | undefined
135
+ if (existing) return existing
136
+ const cache = createCache()
137
+ ;(globalThis as any)[GLOBAL_KEY] = cache
138
+ return cache
139
+ }
140
+
141
+ /** Test-only helper. Clears the process-scoped fallback cache. */
142
+ export function resetRbacFallbackCache(): void {
143
+ const existing = (globalThis as any)[GLOBAL_KEY] as FallbackCache | undefined
144
+ existing?.__reset()
145
+ }
@@ -172,10 +172,9 @@ export async function GET(req: Request) {
172
172
  orderBy.code = 'ASC'
173
173
  }
174
174
 
175
- const [all, total] = await em.findAndCount(Currency, filter, { orderBy })
176
- const start = (page - 1) * pageSize
177
- const paged = all.slice(start, start + pageSize)
178
- const items = paged.map(toRow)
175
+ const offset = (page - 1) * pageSize
176
+ const [rows, total] = await em.findAndCount(Currency, filter, { orderBy, limit: pageSize, offset })
177
+ const items = rows.map(toRow)
179
178
  const totalPages = Math.max(1, Math.ceil(total / pageSize))
180
179
 
181
180
  return NextResponse.json({ items, total, page, pageSize, totalPages })
@@ -169,10 +169,9 @@ export async function GET(req: Request) {
169
169
  orderBy.date = 'DESC'
170
170
  }
171
171
 
172
- const [all, total] = await em.findAndCount(ExchangeRate, where, { orderBy })
173
- const start = (page - 1) * pageSize
174
- const paged = all.slice(start, start + pageSize)
175
- const items = paged.map(toRow)
172
+ const offset = (page - 1) * pageSize
173
+ const [rows, total] = await em.findAndCount(ExchangeRate, where, { orderBy, limit: pageSize, offset })
174
+ const items = rows.map(toRow)
176
175
  const totalPages = Math.max(1, Math.ceil(total / pageSize))
177
176
 
178
177
  return NextResponse.json({ items, total, page, pageSize, totalPages })