@open-mercato/core 0.10.1-develop.7741.1.61a2652671 → 0.10.1-develop.7748.1.d745ba61aa
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/dist/generated/entities/attachment_partition/index.js +2 -0
- package/dist/generated/entities/attachment_partition/index.js.map +2 -2
- package/dist/generated/entity-fields-registry.js +1 -0
- package/dist/generated/entity-fields-registry.js.map +2 -2
- package/dist/helpers/integration/attachmentAccessFixtures.js +144 -0
- package/dist/helpers/integration/attachmentAccessFixtures.js.map +7 -0
- package/dist/modules/attachments/api/file/[id]/route.js +16 -10
- package/dist/modules/attachments/api/file/[id]/route.js.map +2 -2
- package/dist/modules/attachments/api/image/[id]/[[...slug]]/route.js +15 -9
- package/dist/modules/attachments/api/image/[id]/[[...slug]]/route.js.map +2 -2
- package/dist/modules/attachments/api/library/[id]/route.js +100 -56
- package/dist/modules/attachments/api/library/[id]/route.js.map +3 -3
- package/dist/modules/attachments/api/library/route.js +56 -17
- package/dist/modules/attachments/api/library/route.js.map +2 -2
- package/dist/modules/attachments/api/partitions/route.js +2 -0
- package/dist/modules/attachments/api/partitions/route.js.map +2 -2
- package/dist/modules/attachments/api/route.js +74 -34
- package/dist/modules/attachments/api/route.js.map +3 -3
- package/dist/modules/attachments/api/transfer/route.js +63 -37
- package/dist/modules/attachments/api/transfer/route.js.map +2 -2
- package/dist/modules/attachments/data/entities.js +3 -0
- package/dist/modules/attachments/data/entities.js.map +2 -2
- package/dist/modules/attachments/data/validators.js +41 -0
- package/dist/modules/attachments/data/validators.js.map +7 -0
- package/dist/modules/attachments/di.js +5 -2
- package/dist/modules/attachments/di.js.map +2 -2
- package/dist/modules/attachments/generators.js +29 -0
- package/dist/modules/attachments/generators.js.map +7 -0
- package/dist/modules/attachments/index.js +8 -1
- package/dist/modules/attachments/index.js.map +2 -2
- package/dist/modules/attachments/lib/access-errors.js +35 -0
- package/dist/modules/attachments/lib/access-errors.js.map +7 -0
- package/dist/modules/attachments/lib/access-mutation.js +23 -0
- package/dist/modules/attachments/lib/access-mutation.js.map +7 -0
- package/dist/modules/attachments/lib/access-protection.js +73 -0
- package/dist/modules/attachments/lib/access-protection.js.map +7 -0
- package/dist/modules/attachments/lib/access-query.js +74 -0
- package/dist/modules/attachments/lib/access-query.js.map +7 -0
- package/dist/modules/attachments/lib/access-registry.js +87 -0
- package/dist/modules/attachments/lib/access-registry.js.map +7 -0
- package/dist/modules/attachments/lib/access-runner.js +161 -0
- package/dist/modules/attachments/lib/access-runner.js.map +7 -0
- package/dist/modules/attachments/lib/access-types.js +1 -0
- package/dist/modules/attachments/lib/access-types.js.map +7 -0
- package/dist/modules/attachments/lib/attachment-service.js +14 -6
- package/dist/modules/attachments/lib/attachment-service.js.map +2 -2
- package/dist/modules/attachments/lib/createFromBuffer.js +2 -0
- package/dist/modules/attachments/lib/createFromBuffer.js.map +2 -2
- package/dist/modules/attachments/lib/partitions.js +3 -2
- package/dist/modules/attachments/lib/partitions.js.map +2 -2
- package/dist/modules/attachments/lib/scoped-upload-service.js +2 -0
- package/dist/modules/attachments/lib/scoped-upload-service.js.map +2 -2
- package/dist/modules/attachments/lib/target-access-service.js +12 -3
- package/dist/modules/attachments/lib/target-access-service.js.map +2 -2
- package/dist/modules/attachments/migrations/Migration20261001120000_attachments.js +38 -0
- package/dist/modules/attachments/migrations/Migration20261001120000_attachments.js.map +7 -0
- package/dist/modules/attachments/setup.js +10 -0
- package/dist/modules/attachments/setup.js.map +2 -2
- package/dist/modules/notifications/api/[id]/action/route.js +8 -0
- package/dist/modules/notifications/api/[id]/action/route.js.map +2 -2
- package/dist/modules/notifications/api/route.js +17 -0
- package/dist/modules/notifications/api/route.js.map +2 -2
- package/dist/modules/notifications/data/validators.js +1 -1
- package/dist/modules/notifications/data/validators.js.map +2 -2
- package/dist/modules/notifications/lib/notificationService.js +45 -3
- package/dist/modules/notifications/lib/notificationService.js.map +2 -2
- package/dist/modules/notifications/lib/routeHelpers.js +27 -0
- package/dist/modules/notifications/lib/routeHelpers.js.map +2 -2
- package/dist/modules/payment_gateways/lib/gateway-service.js +50 -34
- package/dist/modules/payment_gateways/lib/gateway-service.js.map +2 -2
- package/dist/modules/payment_gateways/lib/status-machine.js +10 -1
- package/dist/modules/payment_gateways/lib/status-machine.js.map +2 -2
- package/dist/modules/payment_gateways/lib/webhook-processor.js +1 -1
- package/dist/modules/payment_gateways/lib/webhook-processor.js.map +2 -2
- package/dist/modules/staff/notifications.js +4 -2
- package/dist/modules/staff/notifications.js.map +2 -2
- package/dist/modules/workflows/data/entities.js.map +2 -2
- package/dist/modules/workflows/lib/activity-executor.js +18 -4
- package/dist/modules/workflows/lib/activity-executor.js.map +2 -2
- package/dist/modules/workflows/lib/activity-types.js +2 -2
- package/dist/modules/workflows/lib/activity-types.js.map +2 -2
- package/dist/modules/workflows/lib/condition-handler.js +7 -1
- package/dist/modules/workflows/lib/condition-handler.js.map +2 -2
- package/dist/modules/workflows/lib/duration.js +13 -0
- package/dist/modules/workflows/lib/duration.js.map +2 -2
- package/dist/modules/workflows/lib/step-handler.js +9 -2
- package/dist/modules/workflows/lib/step-handler.js.map +2 -2
- package/dist/modules/workflows/lib/task-quick-action.js +13 -10
- package/dist/modules/workflows/lib/task-quick-action.js.map +2 -2
- package/dist/modules/workflows/lib/transition-handler.js +2 -1
- package/dist/modules/workflows/lib/transition-handler.js.map +2 -2
- package/dist/modules/workflows/notifications.js +4 -0
- package/dist/modules/workflows/notifications.js.map +2 -2
- package/generated/entities/attachment_partition/index.ts +1 -0
- package/generated/entity-fields-registry.ts +1 -0
- package/package.json +7 -7
- package/src/helpers/integration/attachmentAccessFixtures.ts +141 -0
- package/src/modules/attachments/api/file/[id]/route.ts +12 -9
- package/src/modules/attachments/api/image/[id]/[[...slug]]/route.ts +10 -8
- package/src/modules/attachments/api/library/[id]/route.ts +87 -58
- package/src/modules/attachments/api/library/route.ts +40 -11
- package/src/modules/attachments/api/partitions/route.ts +2 -0
- package/src/modules/attachments/api/route.ts +68 -35
- package/src/modules/attachments/api/transfer/route.ts +65 -42
- package/src/modules/attachments/data/entities.ts +4 -0
- package/src/modules/attachments/data/validators.ts +36 -0
- package/src/modules/attachments/di.ts +4 -1
- package/src/modules/attachments/generators.ts +27 -0
- package/src/modules/attachments/i18n/de.json +4 -0
- package/src/modules/attachments/i18n/en.json +4 -0
- package/src/modules/attachments/i18n/es.json +4 -0
- package/src/modules/attachments/i18n/ko.json +4 -0
- package/src/modules/attachments/i18n/pl.json +4 -0
- package/src/modules/attachments/index.ts +18 -0
- package/src/modules/attachments/lib/access-errors.ts +32 -0
- package/src/modules/attachments/lib/access-mutation.ts +26 -0
- package/src/modules/attachments/lib/access-protection.ts +83 -0
- package/src/modules/attachments/lib/access-query.ts +86 -0
- package/src/modules/attachments/lib/access-registry.ts +117 -0
- package/src/modules/attachments/lib/access-runner.ts +183 -0
- package/src/modules/attachments/lib/access-types.ts +78 -0
- package/src/modules/attachments/lib/attachment-service.ts +8 -4
- package/src/modules/attachments/lib/createFromBuffer.ts +3 -0
- package/src/modules/attachments/lib/partitions.ts +3 -2
- package/src/modules/attachments/lib/scoped-upload-service.ts +2 -0
- package/src/modules/attachments/lib/target-access-service.ts +10 -3
- package/src/modules/attachments/migrations/.snapshot-open-mercato.json +17 -0
- package/src/modules/attachments/migrations/Migration20261001120000_attachments.ts +35 -0
- package/src/modules/attachments/setup.ts +10 -0
- package/src/modules/notifications/api/[id]/action/route.ts +8 -0
- package/src/modules/notifications/api/route.ts +17 -0
- package/src/modules/notifications/data/validators.ts +1 -1
- package/src/modules/notifications/i18n/de.json +2 -0
- package/src/modules/notifications/i18n/en.json +2 -0
- package/src/modules/notifications/i18n/es.json +2 -0
- package/src/modules/notifications/i18n/ko.json +2 -0
- package/src/modules/notifications/i18n/pl.json +2 -0
- package/src/modules/notifications/lib/notificationService.ts +67 -1
- package/src/modules/notifications/lib/routeHelpers.ts +38 -0
- package/src/modules/payment_gateways/lib/gateway-service.ts +50 -33
- package/src/modules/payment_gateways/lib/status-machine.ts +19 -0
- package/src/modules/payment_gateways/lib/webhook-processor.ts +1 -1
- package/src/modules/staff/notifications.ts +2 -0
- package/src/modules/workflows/AGENTS.md +2 -2
- package/src/modules/workflows/data/entities.ts +6 -0
- package/src/modules/workflows/lib/activity-executor.ts +36 -4
- package/src/modules/workflows/lib/activity-types.ts +2 -2
- package/src/modules/workflows/lib/condition-handler.ts +16 -1
- package/src/modules/workflows/lib/duration.ts +25 -0
- package/src/modules/workflows/lib/step-handler.ts +16 -1
- package/src/modules/workflows/lib/task-quick-action.ts +19 -10
- package/src/modules/workflows/lib/transition-handler.ts +2 -1
- package/src/modules/workflows/notifications.ts +4 -0
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
"notifications.delivery.settings.invalid": "Ungültige Zustelleinstellungen",
|
|
15
15
|
"notifications.empty": "Keine Benachrichtigungen",
|
|
16
16
|
"notifications.error.action": "Aktion konnte nicht ausgeführt werden",
|
|
17
|
+
"notifications.error.actionCommandNotAllowed": "Über die API erstellte Benachrichtigungsaktionen dürfen keine Befehle ausführen",
|
|
18
|
+
"notifications.error.actionForbidden": "Sie dürfen diese Benachrichtigungsaktion nicht ausführen",
|
|
17
19
|
"notifications.error.load": "Benachrichtigungen konnten nicht geladen werden",
|
|
18
20
|
"notifications.filters.actionRequired": "Aktion erforderlich",
|
|
19
21
|
"notifications.filters.all": "Alle",
|
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
"notifications.delivery.settings.invalid": "Invalid delivery settings",
|
|
15
15
|
"notifications.empty": "No notifications",
|
|
16
16
|
"notifications.error.action": "Failed to execute action",
|
|
17
|
+
"notifications.error.actionCommandNotAllowed": "Notification actions created through the API cannot run commands",
|
|
18
|
+
"notifications.error.actionForbidden": "You are not allowed to run this notification action",
|
|
17
19
|
"notifications.error.load": "Failed to load notifications",
|
|
18
20
|
"notifications.filters.actionRequired": "Action Required",
|
|
19
21
|
"notifications.filters.all": "All",
|
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
"notifications.delivery.settings.invalid": "Configuración de entrega no válida",
|
|
15
15
|
"notifications.empty": "Sin notificaciones",
|
|
16
16
|
"notifications.error.action": "No se pudo ejecutar la acción",
|
|
17
|
+
"notifications.error.actionCommandNotAllowed": "Las acciones de notificación creadas mediante la API no pueden ejecutar comandos",
|
|
18
|
+
"notifications.error.actionForbidden": "No tienes permiso para ejecutar esta acción de notificación",
|
|
17
19
|
"notifications.error.load": "No se pudieron cargar las notificaciones",
|
|
18
20
|
"notifications.filters.actionRequired": "Acción requerida",
|
|
19
21
|
"notifications.filters.all": "Todas",
|
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
"notifications.delivery.settings.invalid": "잘못된 전달 설정입니다",
|
|
15
15
|
"notifications.empty": "알림 없음",
|
|
16
16
|
"notifications.error.action": "작업을 실행하지 못했습니다",
|
|
17
|
+
"notifications.error.actionCommandNotAllowed": "API로 생성한 알림 작업은 명령을 실행할 수 없습니다",
|
|
18
|
+
"notifications.error.actionForbidden": "이 알림 작업을 실행할 권한이 없습니다",
|
|
17
19
|
"notifications.error.load": "알림을 불러오지 못했습니다",
|
|
18
20
|
"notifications.filters.actionRequired": "조치 필요",
|
|
19
21
|
"notifications.filters.all": "전체",
|
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
"notifications.delivery.settings.invalid": "Nieprawidłowe ustawienia dostarczania",
|
|
15
15
|
"notifications.empty": "Brak powiadomień",
|
|
16
16
|
"notifications.error.action": "Nie udało się wykonać akcji",
|
|
17
|
+
"notifications.error.actionCommandNotAllowed": "Akcje powiadomień utworzone przez API nie mogą uruchamiać poleceń",
|
|
18
|
+
"notifications.error.actionForbidden": "Nie masz uprawnień do wykonania tej akcji powiadomienia",
|
|
17
19
|
"notifications.error.load": "Nie udało się załadować powiadomień",
|
|
18
20
|
"notifications.filters.actionRequired": "Wymagana akcja",
|
|
19
21
|
"notifications.filters.all": "Wszystkie",
|
|
@@ -6,8 +6,13 @@ import { Notification, type NotificationStatus } from '../data/entities'
|
|
|
6
6
|
import type { CreateNotificationInput, CreateBatchNotificationInput, CreateRoleNotificationInput, CreateFeatureNotificationInput, ExecuteActionInput } from '../data/validators'
|
|
7
7
|
import {
|
|
8
8
|
NOTIFICATION_ACTION_ALREADY_EXECUTED_ERROR_CODE,
|
|
9
|
+
NOTIFICATION_ACTION_FORBIDDEN_ERROR_CODE,
|
|
10
|
+
type NotificationAction,
|
|
9
11
|
type NotificationPollData,
|
|
12
|
+
type NotificationTypeAction,
|
|
10
13
|
} from '@open-mercato/shared/modules/notifications/types'
|
|
14
|
+
import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
|
|
15
|
+
import { getTelemetryRuntime } from '@open-mercato/shared/lib/telemetry/runtime'
|
|
11
16
|
import { NOTIFICATION_EVENTS, NOTIFICATION_SSE_EVENTS } from './events'
|
|
12
17
|
import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
13
18
|
import {
|
|
@@ -97,6 +102,27 @@ function actionAlreadyExecuted(): CrudHttpError {
|
|
|
97
102
|
})
|
|
98
103
|
}
|
|
99
104
|
|
|
105
|
+
async function actionForbidden(): Promise<CrudHttpError> {
|
|
106
|
+
const { translate } = await resolveTranslations()
|
|
107
|
+
return new CrudHttpError(403, {
|
|
108
|
+
error: translate('notifications.error.actionForbidden', 'You are not allowed to run this notification action'),
|
|
109
|
+
code: NOTIFICATION_ACTION_FORBIDDEN_ERROR_CODE,
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The command action the notification's registered type declares for a stored action: same action
|
|
115
|
+
* id (across `actions` and `optionalActions`) and same `commandId`. Anything else is not dispatched.
|
|
116
|
+
*/
|
|
117
|
+
function findDeclaredCommandAction(type: string, action: NotificationAction): NotificationTypeAction | null {
|
|
118
|
+
const definition = getNotificationType(type)
|
|
119
|
+
if (!definition) return null
|
|
120
|
+
const declared = [...definition.actions, ...(definition.optionalActions ?? [])]
|
|
121
|
+
.find((candidate) => candidate.id === action.id)
|
|
122
|
+
if (!declared?.commandId || declared.commandId !== action.commandId) return null
|
|
123
|
+
return declared
|
|
124
|
+
}
|
|
125
|
+
|
|
100
126
|
function applyNotificationContent(
|
|
101
127
|
notification: Notification,
|
|
102
128
|
input: NotificationContentInput,
|
|
@@ -285,6 +311,39 @@ export interface NotificationServiceDeps {
|
|
|
285
311
|
export function createNotificationService(deps: NotificationServiceDeps): NotificationService {
|
|
286
312
|
const { em: rootEm, eventBus, commandBus, container } = deps
|
|
287
313
|
|
|
314
|
+
/**
|
|
315
|
+
* Whether the clicking user holds every feature a declared notification action requires.
|
|
316
|
+
* Fails closed: no user, no container, or a failing RBAC check denies the action.
|
|
317
|
+
*/
|
|
318
|
+
const userHoldsActionFeatures = async (
|
|
319
|
+
requiredFeatures: string[] | undefined,
|
|
320
|
+
scopeCtx: NotificationServiceContext,
|
|
321
|
+
): Promise<boolean> => {
|
|
322
|
+
if (!requiredFeatures || requiredFeatures.length === 0) return true
|
|
323
|
+
if (!scopeCtx.userId || !container) return false
|
|
324
|
+
try {
|
|
325
|
+
const rbacService = container.resolve('rbacService') as {
|
|
326
|
+
userHasAllFeatures: (
|
|
327
|
+
userId: string,
|
|
328
|
+
features: string[],
|
|
329
|
+
scope: { tenantId: string | null; organizationId: string | null },
|
|
330
|
+
) => Promise<boolean>
|
|
331
|
+
}
|
|
332
|
+
const allowed = await rbacService.userHasAllFeatures(
|
|
333
|
+
scopeCtx.userId,
|
|
334
|
+
requiredFeatures,
|
|
335
|
+
{ tenantId: scopeCtx.tenantId, organizationId: scopeCtx.organizationId ?? null },
|
|
336
|
+
)
|
|
337
|
+
return allowed === true
|
|
338
|
+
} catch (err) {
|
|
339
|
+
getTelemetryRuntime()?.reportError(err, {
|
|
340
|
+
module: 'notifications',
|
|
341
|
+
code: 'notifications.action_feature_check_failed',
|
|
342
|
+
})
|
|
343
|
+
return false
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
288
347
|
/**
|
|
289
348
|
* Resolves the authoritative delivery-channel set for one recipient at create time — the single
|
|
290
349
|
* gate that folds per-send target, per-type eligibility, registered strategies, and the
|
|
@@ -696,6 +755,13 @@ export function createNotificationService(deps: NotificationServiceDeps): Notifi
|
|
|
696
755
|
throw actionAlreadyExecuted()
|
|
697
756
|
}
|
|
698
757
|
|
|
758
|
+
if (action.commandId) {
|
|
759
|
+
const declared = findDeclaredCommandAction(notification.type, action)
|
|
760
|
+
if (!declared || !(await userHoldsActionFeatures(declared.requiredFeatures, ctx))) {
|
|
761
|
+
throw await actionForbidden()
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
699
765
|
const actionedAt = new Date()
|
|
700
766
|
const previousStatus = notification.status
|
|
701
767
|
const previousActionedAt = notification.actionedAt ?? null
|
|
@@ -749,8 +815,8 @@ export function createNotificationService(deps: NotificationServiceDeps): Notifi
|
|
|
749
815
|
|
|
750
816
|
if (action.commandId && commandBus && container) {
|
|
751
817
|
const commandInput = {
|
|
752
|
-
id: notification.sourceEntityId,
|
|
753
818
|
...input.payload,
|
|
819
|
+
id: notification.sourceEntityId,
|
|
754
820
|
}
|
|
755
821
|
|
|
756
822
|
// Build a CommandRuntimeContext from the notification service context
|
|
@@ -5,7 +5,9 @@ import { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/d
|
|
|
5
5
|
import { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
|
|
6
6
|
import { runRouteMutationGuards } from '@open-mercato/shared/lib/crud/route-mutation-guard'
|
|
7
7
|
import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
|
|
8
|
+
import { NOTIFICATION_ACTION_COMMAND_NOT_ALLOWED_ERROR_CODE } from '@open-mercato/shared/modules/notifications/types'
|
|
8
9
|
import { resolveNotificationService, type NotificationService } from './notificationService'
|
|
10
|
+
import { actionConflictResponseSchema } from '../api/openapi'
|
|
9
11
|
|
|
10
12
|
/**
|
|
11
13
|
* Mutation-guard resource kind for notification rows.
|
|
@@ -60,6 +62,32 @@ export async function notificationValidationErrorResponse(error: z.ZodError): Pr
|
|
|
60
62
|
)
|
|
61
63
|
}
|
|
62
64
|
|
|
65
|
+
export type NotificationActionsPayload = { actions?: Array<{ commandId?: string | null }> | null }
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Reject an API-supplied action that carries a `commandId`.
|
|
69
|
+
*
|
|
70
|
+
* Command-backed actions run with the clicking user's identity, so only server code may attach
|
|
71
|
+
* them, from the notification type definition. Called after payload validation and before the
|
|
72
|
+
* mutation guard or the service write.
|
|
73
|
+
*/
|
|
74
|
+
export async function rejectNotificationActionCommands(
|
|
75
|
+
data: NotificationActionsPayload,
|
|
76
|
+
): Promise<Response | null> {
|
|
77
|
+
if (!data.actions?.some((action) => action.commandId)) return null
|
|
78
|
+
const { t } = await resolveTranslations()
|
|
79
|
+
return Response.json(
|
|
80
|
+
{
|
|
81
|
+
error: t(
|
|
82
|
+
'notifications.error.actionCommandNotAllowed',
|
|
83
|
+
'Notification actions created through the API cannot run commands',
|
|
84
|
+
),
|
|
85
|
+
code: NOTIFICATION_ACTION_COMMAND_NOT_ALLOWED_ERROR_CODE,
|
|
86
|
+
},
|
|
87
|
+
{ status: 400 },
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
|
|
63
91
|
export function notificationCrudErrorResponse(error: unknown): Response | null {
|
|
64
92
|
if (!isCrudHttpError(error)) return null
|
|
65
93
|
return Response.json(error.body ?? { error: 'Notification request failed' }, { status: error.status })
|
|
@@ -235,6 +263,8 @@ export function createBulkNotificationRoute<TSchema extends z.ZodTypeAny>(
|
|
|
235
263
|
if (!parsed.success) {
|
|
236
264
|
return notificationValidationErrorResponse(parsed.error)
|
|
237
265
|
}
|
|
266
|
+
const commandRejection = await rejectNotificationActionCommands(parsed.data as NotificationActionsPayload)
|
|
267
|
+
if (commandRejection) return commandRejection
|
|
238
268
|
|
|
239
269
|
try {
|
|
240
270
|
const guarded = await runGuardedNotificationWrite(
|
|
@@ -298,6 +328,14 @@ export function createBulkNotificationOpenApi<TSchema extends z.ZodTypeAny>(
|
|
|
298
328
|
},
|
|
299
329
|
},
|
|
300
330
|
},
|
|
331
|
+
400: {
|
|
332
|
+
description: `Invalid payload, or an action carries a \`commandId\` (code: ${NOTIFICATION_ACTION_COMMAND_NOT_ALLOWED_ERROR_CODE})`,
|
|
333
|
+
content: {
|
|
334
|
+
'application/json': {
|
|
335
|
+
schema: actionConflictResponseSchema,
|
|
336
|
+
},
|
|
337
|
+
},
|
|
338
|
+
},
|
|
301
339
|
},
|
|
302
340
|
},
|
|
303
341
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto'
|
|
2
|
-
import { QueryOrder } from '@mikro-orm/core'
|
|
2
|
+
import { LockMode, QueryOrder } from '@mikro-orm/core'
|
|
3
3
|
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
4
4
|
import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
5
5
|
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
@@ -21,7 +21,7 @@ import type { IntegrationStateService } from '../../integrations/lib/state-servi
|
|
|
21
21
|
import type { IntegrationLogService } from '../../integrations/lib/log-service'
|
|
22
22
|
import { conflict, CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
|
|
23
23
|
import { GatewayPaymentOperation, GatewaySessionInitialization, GatewayTransaction } from '../data/entities'
|
|
24
|
-
import { canApplyManualAction, isValidTransition, type ManualGatewayAction } from './status-machine'
|
|
24
|
+
import { canApplyManualAction, isValidTransition, resolveImpliedCaptureStatus, type ManualGatewayAction } from './status-machine'
|
|
25
25
|
import { emitPaymentGatewayEvent } from '../events'
|
|
26
26
|
import { readGatewayMetadata, readWebhookLog } from './transaction-fields'
|
|
27
27
|
import { reconcileSessionAmountWithOrder } from './order-amount-reconciliation'
|
|
@@ -124,6 +124,7 @@ export function createPaymentGatewayService(deps: PaymentGatewayServiceDeps) {
|
|
|
124
124
|
transactionId: string,
|
|
125
125
|
scope: { organizationId: string; tenantId: string },
|
|
126
126
|
targetEm: EntityManager = em,
|
|
127
|
+
options?: { lockMode: LockMode.PESSIMISTIC_WRITE },
|
|
127
128
|
): Promise<GatewayTransaction> {
|
|
128
129
|
const transaction = await findOneWithDecryption(
|
|
129
130
|
targetEm,
|
|
@@ -134,7 +135,7 @@ export function createPaymentGatewayService(deps: PaymentGatewayServiceDeps) {
|
|
|
134
135
|
tenantId: scope.tenantId,
|
|
135
136
|
deletedAt: null,
|
|
136
137
|
},
|
|
137
|
-
|
|
138
|
+
options,
|
|
138
139
|
scope,
|
|
139
140
|
)
|
|
140
141
|
if (!transaction) {
|
|
@@ -765,42 +766,57 @@ export function createPaymentGatewayService(deps: PaymentGatewayServiceDeps) {
|
|
|
765
766
|
receivedAt?: string
|
|
766
767
|
}
|
|
767
768
|
}, scope: { organizationId: string; tenantId: string }): Promise<void> {
|
|
768
|
-
const transaction = await
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
769
|
+
const { transaction, previousStatus, impliedCaptureStatus, shouldApplyStatus } = await em.fork().transactional(async (tx) => {
|
|
770
|
+
const locked = await findTransactionOrThrow(transactionId, scope, tx, { lockMode: LockMode.PESSIMISTIC_WRITE })
|
|
771
|
+
const currentStatus = locked.unifiedStatus as UnifiedPaymentStatus
|
|
772
|
+
const implied = resolveImpliedCaptureStatus(currentStatus, update.unifiedStatus)
|
|
773
|
+
const canTransition = isValidTransition(currentStatus, update.unifiedStatus) || implied !== null
|
|
774
|
+
const applies = canTransition && update.unifiedStatus !== currentStatus
|
|
775
|
+
if (applies) {
|
|
776
|
+
if (implied) alignCapturedAmountWithStatus(locked, implied)
|
|
777
|
+
locked.unifiedStatus = update.unifiedStatus
|
|
778
|
+
alignCapturedAmountWithStatus(locked, update.unifiedStatus)
|
|
779
|
+
}
|
|
780
|
+
if (update.providerStatus) {
|
|
781
|
+
locked.gatewayStatus = update.providerStatus
|
|
782
|
+
}
|
|
783
|
+
if (update.providerData) {
|
|
784
|
+
locked.gatewayMetadata = { ...readGatewayMetadata(locked.gatewayMetadata), ...update.providerData }
|
|
785
|
+
}
|
|
786
|
+
if (update.webhookEvent) {
|
|
787
|
+
const webhookLog = readWebhookLog(locked.webhookLog)
|
|
788
|
+
webhookLog.push({
|
|
789
|
+
eventType: update.webhookEvent.eventType,
|
|
790
|
+
receivedAt: update.webhookEvent.receivedAt ?? new Date().toISOString(),
|
|
791
|
+
idempotencyKey: update.webhookEvent.idempotencyKey,
|
|
792
|
+
unifiedStatus: update.unifiedStatus,
|
|
793
|
+
processed: update.webhookEvent.processed,
|
|
794
|
+
})
|
|
795
|
+
locked.webhookLog = webhookLog
|
|
796
|
+
}
|
|
797
|
+
locked.lastWebhookAt = new Date()
|
|
798
|
+
await tx.flush()
|
|
799
|
+
return {
|
|
800
|
+
transaction: locked,
|
|
801
|
+
previousStatus: currentStatus,
|
|
802
|
+
impliedCaptureStatus: applies ? implied : null,
|
|
803
|
+
shouldApplyStatus: applies,
|
|
804
|
+
}
|
|
805
|
+
})
|
|
796
806
|
if (shouldApplyStatus) {
|
|
797
|
-
|
|
807
|
+
const eventPayload = {
|
|
798
808
|
transactionId: transaction.id,
|
|
799
809
|
paymentId: transaction.paymentId,
|
|
800
810
|
providerKey: transaction.providerKey,
|
|
801
|
-
previousStatus,
|
|
802
811
|
organizationId: transaction.organizationId,
|
|
803
812
|
tenantId: transaction.tenantId,
|
|
813
|
+
}
|
|
814
|
+
if (impliedCaptureStatus) {
|
|
815
|
+
await emitStatusEvent(impliedCaptureStatus, { ...eventPayload, previousStatus })
|
|
816
|
+
}
|
|
817
|
+
await emitStatusEvent(update.unifiedStatus, {
|
|
818
|
+
...eventPayload,
|
|
819
|
+
previousStatus: impliedCaptureStatus ?? previousStatus,
|
|
804
820
|
})
|
|
805
821
|
}
|
|
806
822
|
await writeTransactionLog(
|
|
@@ -811,6 +827,7 @@ export function createPaymentGatewayService(deps: PaymentGatewayServiceDeps) {
|
|
|
811
827
|
shouldApplyStatus ? 'Payment status synchronized from webhook' : 'Webhook received with no status transition',
|
|
812
828
|
{
|
|
813
829
|
previousStatus,
|
|
830
|
+
...(impliedCaptureStatus ? { impliedStatus: impliedCaptureStatus } : {}),
|
|
814
831
|
nextStatus: update.unifiedStatus,
|
|
815
832
|
providerStatus: update.providerStatus ?? null,
|
|
816
833
|
eventType: update.webhookEvent?.eventType ?? null,
|
|
@@ -42,3 +42,22 @@ export function canApplyManualAction(action: ManualGatewayAction, from: UnifiedP
|
|
|
42
42
|
if (targets.includes(from)) return true
|
|
43
43
|
return targets.some((target) => isValidTransition(from, target))
|
|
44
44
|
}
|
|
45
|
+
|
|
46
|
+
const REFUND_STATUSES: Set<UnifiedPaymentStatus> = new Set(['refunded', 'partially_refunded'])
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A provider-confirmed refund proves the payment was captured. When a refund status reaches a
|
|
50
|
+
* transaction whose capture was not recorded yet (the capture webhook is late, or was never
|
|
51
|
+
* delivered), the transaction passes through `captured` on its way to the refund status instead
|
|
52
|
+
* of dropping the refund. Returns that intermediate status, or `null` when no bridge applies.
|
|
53
|
+
*/
|
|
54
|
+
export function resolveImpliedCaptureStatus(
|
|
55
|
+
from: UnifiedPaymentStatus,
|
|
56
|
+
to: UnifiedPaymentStatus,
|
|
57
|
+
): UnifiedPaymentStatus | null {
|
|
58
|
+
if (!REFUND_STATUSES.has(to)) return null
|
|
59
|
+
if (isValidTransition(from, to)) return null
|
|
60
|
+
if (!isValidTransition(from, 'captured')) return null
|
|
61
|
+
if (!isValidTransition('captured', to)) return null
|
|
62
|
+
return 'captured'
|
|
63
|
+
}
|
|
@@ -103,7 +103,7 @@ export async function processPaymentGatewayWebhookJob(
|
|
|
103
103
|
}
|
|
104
104
|
|
|
105
105
|
const providerStatus = typeof event.data.status === 'string' ? event.data.status : ''
|
|
106
|
-
const unifiedStatus = adapter.mapStatus(providerStatus, event.eventType)
|
|
106
|
+
const unifiedStatus = adapter.mapStatus(providerStatus, event.eventType, { ...event, timestamp: new Date(event.timestamp) })
|
|
107
107
|
await writeTransactionLog(integrationLogService, providerKey, scope, transaction.id, 'info', 'Payment gateway webhook received', {
|
|
108
108
|
eventType: event.eventType,
|
|
109
109
|
providerStatus,
|
|
@@ -17,6 +17,7 @@ export const notificationTypes: NotificationTypeDefinition[] = [
|
|
|
17
17
|
variant: 'default',
|
|
18
18
|
icon: 'check',
|
|
19
19
|
commandId: 'staff.leave-requests.accept',
|
|
20
|
+
requiredFeatures: ['staff.leave_requests.manage'],
|
|
20
21
|
},
|
|
21
22
|
{
|
|
22
23
|
id: 'reject',
|
|
@@ -24,6 +25,7 @@ export const notificationTypes: NotificationTypeDefinition[] = [
|
|
|
24
25
|
variant: 'destructive',
|
|
25
26
|
icon: 'x',
|
|
26
27
|
commandId: 'staff.leave-requests.reject',
|
|
28
|
+
requiredFeatures: ['staff.leave_requests.manage'],
|
|
27
29
|
},
|
|
28
30
|
],
|
|
29
31
|
primaryActionId: 'approve',
|
|
@@ -100,7 +100,7 @@ Definition → startWorkflow() → Instance → executeWorkflow() loop
|
|
|
100
100
|
| `UPDATE_ENTITY` | Mutate an entity via the command bus. `CommandBus.execute` does not declare a signal; a write that has already begun cannot be cancelled on timeout. |
|
|
101
101
|
| `EMIT_EVENT` | Emit a domain event to the event bus. `eventBus.emitEvent` does not declare a signal; an emit that has already begun cannot be cancelled on timeout. |
|
|
102
102
|
| `EXECUTE_FUNCTION` | Run a registered custom function. Functions receive `(args, context, signal?: AbortSignal)` — the third arg is additive; existing two-arg functions are unaffected. |
|
|
103
|
-
| `WAIT` | Delay execution for a configured duration |
|
|
103
|
+
| `WAIT` | Delay execution for a configured duration. A sync WAIT over `MAX_INLINE_WAIT_MS` (60 s) that ENDS its transition is queued like an async one (`executeActivities` `queueLongInlineWaits`, opted into by the transition handler only — the AUTOMATED-step caller does not park on a queued result). An in-process WAIT never hands `setTimeout` more than `MAX_TIMER_DELAY_MS` (2^31−1 ms): Node would fire it after 1 ms. |
|
|
104
104
|
| `SET_VARIABLE` | Write values into workflow context at dot paths (assignments land at top-level context, not namespaced under the activity) |
|
|
105
105
|
|
|
106
106
|
## Context Backbone (contextSchema · ledger · picker · samples · test step)
|
|
@@ -934,7 +934,7 @@ Treat a regression here as a broken feature, not a nit.
|
|
|
934
934
|
| Variable | Effect | Default |
|
|
935
935
|
|----------|--------|---------|
|
|
936
936
|
| `OM_WORKFLOWS_ALLOW_PRIVATE_URLS` | When `1`/`true`/`yes`, bypasses the SSRF guard in `CALL_WEBHOOK` so workflow authors can hit `localhost`, RFC1918, and `.internal` targets. For dev only — MUST remain unset in production. | unset (guard enforced) |
|
|
937
|
-
| `OM_WORKFLOWS_MAX_CONDITION_ATTEMPTS` |
|
|
937
|
+
| `OM_WORKFLOWS_MAX_CONDITION_ATTEMPTS` | Floor of the cap on WAIT_FOR_CONDITION poll re-enqueues; reaching the cap forces `CONDITION_TIMED_OUT` so a never-satisfiable predicate cannot saturate the queue. Each waiter's cap is raised to the polls its own timeout needs (`resolveConditionAttemptCap`: `timeoutMs / pollIntervalMs + 1`), so a long timeout is never cut short by the floor. | `1000` |
|
|
938
938
|
| `OM_WORKFLOWS_ENV_INTERPOLATION_ALLOWLIST` | Comma-separated non-secret process env keys allowed for `{{env.*}}` interpolation in workflow activity config. `APP_URL` is always allowed. Never include secrets. | unset (`APP_URL` only) |
|
|
939
939
|
|
|
940
940
|
## DI Services
|
|
@@ -675,6 +675,12 @@ export class StepInstance {
|
|
|
675
675
|
@Property({ name: 'exited_at', type: Date, nullable: true })
|
|
676
676
|
exitedAt?: Date | null
|
|
677
677
|
|
|
678
|
+
/**
|
|
679
|
+
* @deprecated Written on the COMPLETED path only and `null` for any step that
|
|
680
|
+
* lasted 2^31 ms (~24.86 days) or longer, which a Postgres `integer` cannot
|
|
681
|
+
* hold. Derive the duration from `exitedAt - enteredAt` instead; the column
|
|
682
|
+
* is scheduled for removal no earlier than 0.11.0 (see UPGRADE_NOTES.md).
|
|
683
|
+
*/
|
|
678
684
|
@Property({ name: 'execution_time_ms', type: 'integer', nullable: true })
|
|
679
685
|
executionTimeMs?: number | null
|
|
680
686
|
|
|
@@ -62,7 +62,7 @@ import './activity-registry-bootstrap'
|
|
|
62
62
|
import { bindActivityExecutor } from './activity-types'
|
|
63
63
|
import { getActivityType } from './activity-registry'
|
|
64
64
|
import { logWorkflowEvent } from './event-logger'
|
|
65
|
-
import { calculateWaitDelayMs, parseDuration } from './duration'
|
|
65
|
+
import { MAX_INLINE_WAIT_MS, assertInProcessWaitDelay, calculateWaitDelayMs, parseDuration } from './duration'
|
|
66
66
|
|
|
67
67
|
export { calculateWaitDelayMs } from './duration'
|
|
68
68
|
import { resolveActivityTimeoutMs } from './activityTimeoutFields'
|
|
@@ -683,6 +683,31 @@ export async function executeActivity(
|
|
|
683
683
|
}
|
|
684
684
|
}
|
|
685
685
|
|
|
686
|
+
export interface ExecuteActivitiesOptions {
|
|
687
|
+
/**
|
|
688
|
+
* Hand a synchronous activity with a long `enqueueDelayMs` (a WAIT over
|
|
689
|
+
* `MAX_INLINE_WAIT_MS`) to the activity queue instead of sleeping in-process.
|
|
690
|
+
* Only the transition's LAST activity qualifies, so nothing authored after the
|
|
691
|
+
* wait can run before it elapses. Only callers that park on an `async` result
|
|
692
|
+
* (the transition handler) may opt in — a caller that does not would let the
|
|
693
|
+
* run move on without waiting at all.
|
|
694
|
+
*/
|
|
695
|
+
queueLongInlineWaits?: boolean
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function isLongInlineWait(activity: ActivityDefinition, context: ActivityContext): boolean {
|
|
699
|
+
const entry = getActivityType(activity.activityType)
|
|
700
|
+
if (!entry?.enqueueDelayMs || entry.async.capable === false) return false
|
|
701
|
+
try {
|
|
702
|
+
const delayMs = entry.enqueueDelayMs(
|
|
703
|
+
interpolateActivityConfig(activity.config, context, activity.activityType, activity.activityName)
|
|
704
|
+
)
|
|
705
|
+
return delayMs !== null && delayMs > MAX_INLINE_WAIT_MS
|
|
706
|
+
} catch {
|
|
707
|
+
return false
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
686
711
|
/**
|
|
687
712
|
* Execute multiple activities in sequence
|
|
688
713
|
* Supports both synchronous and asynchronous (queued) execution
|
|
@@ -697,7 +722,8 @@ export async function executeActivities(
|
|
|
697
722
|
em: EntityManager,
|
|
698
723
|
container: AwilixContainer,
|
|
699
724
|
activities: ActivityDefinition[],
|
|
700
|
-
context: ActivityContext
|
|
725
|
+
context: ActivityContext,
|
|
726
|
+
options: ExecuteActivitiesOptions = {}
|
|
701
727
|
): Promise<ActivityExecutionResult[]> {
|
|
702
728
|
const results: ActivityExecutionResult[] = []
|
|
703
729
|
|
|
@@ -709,9 +735,15 @@ export async function executeActivities(
|
|
|
709
735
|
|
|
710
736
|
for (let i = 0; i < activities.length; i++) {
|
|
711
737
|
const activity = activities[i]
|
|
738
|
+
const isLastActivity = i === activities.length - 1
|
|
739
|
+
const queueAsLongWait =
|
|
740
|
+
!activity.async &&
|
|
741
|
+
options.queueLongInlineWaits === true &&
|
|
742
|
+
isLastActivity &&
|
|
743
|
+
isLongInlineWait(activity, context)
|
|
712
744
|
|
|
713
745
|
// Check if activity should run async
|
|
714
|
-
if (activity.async && !forceSynchronous) {
|
|
746
|
+
if ((activity.async || queueAsLongWait) && !forceSynchronous) {
|
|
715
747
|
// Enqueue for background execution
|
|
716
748
|
const jobId = await enqueueActivity(em, activity, context)
|
|
717
749
|
|
|
@@ -1389,7 +1421,7 @@ export async function executeFunction(
|
|
|
1389
1421
|
* this handler returns immediately when called from the worker
|
|
1390
1422
|
*/
|
|
1391
1423
|
export async function executeWait(config: any): Promise<any> {
|
|
1392
|
-
const durationMs = calculateWaitDelayMs(config)
|
|
1424
|
+
const durationMs = assertInProcessWaitDelay(calculateWaitDelayMs(config))
|
|
1393
1425
|
|
|
1394
1426
|
// In sync mode, actually sleep for the duration
|
|
1395
1427
|
// In async mode (called from worker), the delay already happened via queue scheduling
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
import { z, type ZodTypeAny } from 'zod'
|
|
24
24
|
import { commandRegistry } from '@open-mercato/shared/lib/commands/registry'
|
|
25
25
|
import { getActivityType, registerActivityType } from './activity-registry'
|
|
26
|
-
import { calculateWaitDelayMs } from './duration'
|
|
26
|
+
import { assertInProcessWaitDelay, calculateWaitDelayMs } from './duration'
|
|
27
27
|
import {
|
|
28
28
|
callApiConfigSchema,
|
|
29
29
|
callWebhookConfigSchema,
|
|
@@ -342,7 +342,7 @@ export function registerBuiltinActivityTypes(): void {
|
|
|
342
342
|
{ id: 'until', component: 'datetime' },
|
|
343
343
|
],
|
|
344
344
|
execute: (config) => {
|
|
345
|
-
const durationMs = calculateWaitDelayMs(config)
|
|
345
|
+
const durationMs = assertInProcessWaitDelay(calculateWaitDelayMs(config))
|
|
346
346
|
return new Promise((resolve) => {
|
|
347
347
|
setTimeout(() => resolve({ waited: true, durationMs }), durationMs)
|
|
348
348
|
})
|
|
@@ -106,6 +106,21 @@ export function resolveMaxConditionAttempts(): number {
|
|
|
106
106
|
return parsed
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
/**
|
|
110
|
+
* The attempt cap one waiter actually gets: the configured floor, raised to the
|
|
111
|
+
* number of polls its own timeout needs (`timeoutMs / pollIntervalMs`, plus
|
|
112
|
+
* the entry poll). A fixed 1000 would force a 90-day wait polled hourly to
|
|
113
|
+
* time out after ~42 days. The deadline still ends the wait first; this only
|
|
114
|
+
* bounds a loop whose deadline arithmetic went wrong.
|
|
115
|
+
*/
|
|
116
|
+
export function resolveConditionAttemptCap(
|
|
117
|
+
config: Pick<WaitForConditionConfig, 'timeoutMs' | 'pollIntervalMs'>
|
|
118
|
+
): number {
|
|
119
|
+
const floor = resolveMaxConditionAttempts()
|
|
120
|
+
if (!(config.timeoutMs > 0) || !(config.pollIntervalMs > 0)) return floor
|
|
121
|
+
return Math.max(floor, Math.ceil(config.timeoutMs / config.pollIntervalMs) + 1)
|
|
122
|
+
}
|
|
123
|
+
|
|
109
124
|
/**
|
|
110
125
|
* Read and normalise a WAIT_FOR_CONDITION step's config. Fails closed: the
|
|
111
126
|
* predicate and the timeout are both mandatory, and the poll interval is
|
|
@@ -499,7 +514,7 @@ export async function evaluateWaitCondition(
|
|
|
499
514
|
|
|
500
515
|
const deadlineMs = Date.parse(options.deadlineAt)
|
|
501
516
|
const deadlinePassed = Number.isFinite(deadlineMs) ? nowMs >= deadlineMs : true
|
|
502
|
-
const attemptCapReached = attempts >=
|
|
517
|
+
const attemptCapReached = attempts >= resolveConditionAttemptCap(waiter.config)
|
|
503
518
|
|
|
504
519
|
if (deadlinePassed || attemptCapReached) {
|
|
505
520
|
const eventLogger = container.resolve<typeof eventLoggerModule>('eventLogger')
|
|
@@ -147,6 +147,31 @@ export function calculateDueDate(duration: string, from: Date = new Date()): Dat
|
|
|
147
147
|
return new Date(from.getTime() + parseDuration(duration))
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
+
/**
|
|
151
|
+
* Largest delay Node's `setTimeout` honours (a signed 32-bit millisecond count,
|
|
152
|
+
* ~24.86 days). A longer delay is not rejected — it fires after 1 ms with only a
|
|
153
|
+
* `TimeoutOverflowWarning` — so an in-process wait must refuse it explicitly.
|
|
154
|
+
*/
|
|
155
|
+
export const MAX_TIMER_DELAY_MS = 2_147_483_647
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* A WAIT longer than this is not slept inside the executing request: when it is
|
|
159
|
+
* the last activity of its transition it is handed to the activity queue (the
|
|
160
|
+
* path an `async: true` WAIT already takes). One minute keeps the short pauses
|
|
161
|
+
* people use for pacing inline while anything that would hold a request or a
|
|
162
|
+
* worker slot open for minutes becomes durable.
|
|
163
|
+
*/
|
|
164
|
+
export const MAX_INLINE_WAIT_MS = 60_000
|
|
165
|
+
|
|
166
|
+
export function assertInProcessWaitDelay(delayMs: number): number {
|
|
167
|
+
if (delayMs > MAX_TIMER_DELAY_MS) {
|
|
168
|
+
throw new Error(
|
|
169
|
+
`WAIT activity: a ${delayMs}ms delay cannot be slept in-process (limit ${MAX_TIMER_DELAY_MS}ms); make it the last activity of its transition, mark it async, or use a WAIT_FOR_TIMER step`
|
|
170
|
+
)
|
|
171
|
+
}
|
|
172
|
+
return delayMs
|
|
173
|
+
}
|
|
174
|
+
|
|
150
175
|
export function calculateWaitDelayMs(config: { duration?: string; until?: string }): number {
|
|
151
176
|
if (config.until) {
|
|
152
177
|
const targetDate = new Date(config.until)
|
|
@@ -222,6 +222,21 @@ async function emitStepMilestone(em: EntityManager, stepInstance: StepInstance):
|
|
|
222
222
|
}
|
|
223
223
|
}
|
|
224
224
|
|
|
225
|
+
/**
|
|
226
|
+
* `step_instances.execution_time_ms` is a Postgres `integer`, so a step that
|
|
227
|
+
* lasted 2^31 ms (~24.86 days) or longer cannot be stored there — the flush
|
|
228
|
+
* that completes the step would throw after its task already committed,
|
|
229
|
+
* stranding the run. Such a duration persists as `null` (every reader already
|
|
230
|
+
* falls back to `exitedAt - enteredAt`); the exact value still travels on the
|
|
231
|
+
* `STEP_EXITED` event, whose payload is jsonb.
|
|
232
|
+
*/
|
|
233
|
+
export const MAX_PERSISTED_EXECUTION_TIME_MS = 2_147_483_647
|
|
234
|
+
|
|
235
|
+
export function toPersistableExecutionTimeMs(executionTimeMs: number | null): number | null {
|
|
236
|
+
if (executionTimeMs === null || !Number.isFinite(executionTimeMs)) return null
|
|
237
|
+
return Math.abs(executionTimeMs) > MAX_PERSISTED_EXECUTION_TIME_MS ? null : executionTimeMs
|
|
238
|
+
}
|
|
239
|
+
|
|
225
240
|
export async function exitStep(
|
|
226
241
|
em: EntityManager,
|
|
227
242
|
stepInstance: StepInstance,
|
|
@@ -239,7 +254,7 @@ export async function exitStep(
|
|
|
239
254
|
stepInstance.status = 'COMPLETED'
|
|
240
255
|
stepInstance.outputData = outputData || null
|
|
241
256
|
stepInstance.exitedAt = now
|
|
242
|
-
stepInstance.executionTimeMs = executionTimeMs
|
|
257
|
+
stepInstance.executionTimeMs = toPersistableExecutionTimeMs(executionTimeMs)
|
|
243
258
|
stepInstance.updatedAt = now
|
|
244
259
|
|
|
245
260
|
await em.flush()
|