@azurioh/discord-kernel 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +77 -0
- package/dist/authz/authorizer.d.ts +45 -0
- package/dist/authz/authorizer.d.ts.map +1 -0
- package/dist/authz/authorizer.js +31 -0
- package/dist/authz/authorizer.js.map +1 -0
- package/dist/authz/require-level-guard.d.ts +23 -0
- package/dist/authz/require-level-guard.d.ts.map +1 -0
- package/dist/authz/require-level-guard.js +37 -0
- package/dist/authz/require-level-guard.js.map +1 -0
- package/dist/clock.d.ts +13 -0
- package/dist/clock.d.ts.map +1 -0
- package/dist/clock.js +13 -0
- package/dist/clock.js.map +1 -0
- package/dist/concurrency/keyed-queue.d.ts +18 -0
- package/dist/concurrency/keyed-queue.d.ts.map +1 -0
- package/dist/concurrency/keyed-queue.js +37 -0
- package/dist/concurrency/keyed-queue.js.map +1 -0
- package/dist/concurrency/run-with-concurrency.d.ts +15 -0
- package/dist/concurrency/run-with-concurrency.d.ts.map +1 -0
- package/dist/concurrency/run-with-concurrency.js +40 -0
- package/dist/concurrency/run-with-concurrency.js.map +1 -0
- package/dist/config/env.d.ts +17 -0
- package/dist/config/env.d.ts.map +1 -0
- package/dist/config/env.js +49 -0
- package/dist/config/env.js.map +1 -0
- package/dist/config/errors.d.ts +14 -0
- package/dist/config/errors.d.ts.map +1 -0
- package/dist/config/errors.js +25 -0
- package/dist/config/errors.js.map +1 -0
- package/dist/datetime.d.ts +34 -0
- package/dist/datetime.d.ts.map +1 -0
- package/dist/datetime.js +188 -0
- package/dist/datetime.js.map +1 -0
- package/dist/discord/api-errors.d.ts +20 -0
- package/dist/discord/api-errors.d.ts.map +1 -0
- package/dist/discord/api-errors.js +90 -0
- package/dist/discord/api-errors.js.map +1 -0
- package/dist/discord/channel-exporter.d.ts +42 -0
- package/dist/discord/channel-exporter.d.ts.map +1 -0
- package/dist/discord/channel-exporter.js +3 -0
- package/dist/discord/channel-exporter.js.map +1 -0
- package/dist/discord/command/autocomplete-dispatcher.d.ts +26 -0
- package/dist/discord/command/autocomplete-dispatcher.d.ts.map +1 -0
- package/dist/discord/command/autocomplete-dispatcher.js +41 -0
- package/dist/discord/command/autocomplete-dispatcher.js.map +1 -0
- package/dist/discord/command/autocomplete.d.ts +18 -0
- package/dist/discord/command/autocomplete.d.ts.map +1 -0
- package/dist/discord/command/autocomplete.js +65 -0
- package/dist/discord/command/autocomplete.js.map +1 -0
- package/dist/discord/command/context.d.ts +33 -0
- package/dist/discord/command/context.d.ts.map +1 -0
- package/dist/discord/command/context.js +48 -0
- package/dist/discord/command/context.js.map +1 -0
- package/dist/discord/command/cooldown-guard.d.ts +25 -0
- package/dist/discord/command/cooldown-guard.d.ts.map +1 -0
- package/dist/discord/command/cooldown-guard.js +60 -0
- package/dist/discord/command/cooldown-guard.js.map +1 -0
- package/dist/discord/command/create-command.d.ts +100 -0
- package/dist/discord/command/create-command.d.ts.map +1 -0
- package/dist/discord/command/create-command.js +245 -0
- package/dist/discord/command/create-command.js.map +1 -0
- package/dist/discord/command/create-context-menu-command.d.ts +51 -0
- package/dist/discord/command/create-context-menu-command.d.ts.map +1 -0
- package/dist/discord/command/create-context-menu-command.js +128 -0
- package/dist/discord/command/create-context-menu-command.js.map +1 -0
- package/dist/discord/command/errors.d.ts +19 -0
- package/dist/discord/command/errors.d.ts.map +1 -0
- package/dist/discord/command/errors.js +27 -0
- package/dist/discord/command/errors.js.map +1 -0
- package/dist/discord/command/guard.d.ts +30 -0
- package/dist/discord/command/guard.d.ts.map +1 -0
- package/dist/discord/command/guard.js +34 -0
- package/dist/discord/command/guard.js.map +1 -0
- package/dist/discord/command/localization.d.ts +9 -0
- package/dist/discord/command/localization.d.ts.map +1 -0
- package/dist/discord/command/localization.js +13 -0
- package/dist/discord/command/localization.js.map +1 -0
- package/dist/discord/command/options.d.ts +113 -0
- package/dist/discord/command/options.d.ts.map +1 -0
- package/dist/discord/command/options.js +509 -0
- package/dist/discord/command/options.js.map +1 -0
- package/dist/discord/command/permission-guard.d.ts +21 -0
- package/dist/discord/command/permission-guard.d.ts.map +1 -0
- package/dist/discord/command/permission-guard.js +69 -0
- package/dist/discord/command/permission-guard.js.map +1 -0
- package/dist/discord/command/router.d.ts +48 -0
- package/dist/discord/command/router.d.ts.map +1 -0
- package/dist/discord/command/router.js +118 -0
- package/dist/discord/command/router.js.map +1 -0
- package/dist/discord/command/types.d.ts +51 -0
- package/dist/discord/command/types.d.ts.map +1 -0
- package/dist/discord/command/types.js +3 -0
- package/dist/discord/command/types.js.map +1 -0
- package/dist/discord/components/component-router.d.ts +116 -0
- package/dist/discord/components/component-router.d.ts.map +1 -0
- package/dist/discord/components/component-router.js +123 -0
- package/dist/discord/components/component-router.js.map +1 -0
- package/dist/discord/components/interactive-message/index.d.ts +3 -0
- package/dist/discord/components/interactive-message/index.d.ts.map +1 -0
- package/dist/discord/components/interactive-message/index.js +11 -0
- package/dist/discord/components/interactive-message/index.js.map +1 -0
- package/dist/discord/components/interactive-message/interactive-message-collector.d.ts +195 -0
- package/dist/discord/components/interactive-message/interactive-message-collector.d.ts.map +1 -0
- package/dist/discord/components/interactive-message/interactive-message-collector.js +151 -0
- package/dist/discord/components/interactive-message/interactive-message-collector.js.map +1 -0
- package/dist/discord/components/interactive-message/mount-interactive-message.d.ts +30 -0
- package/dist/discord/components/interactive-message/mount-interactive-message.d.ts.map +1 -0
- package/dist/discord/components/interactive-message/mount-interactive-message.js +44 -0
- package/dist/discord/components/interactive-message/mount-interactive-message.js.map +1 -0
- package/dist/discord/components/paginator/index.d.ts +5 -0
- package/dist/discord/components/paginator/index.d.ts.map +1 -0
- package/dist/discord/components/paginator/index.js +15 -0
- package/dist/discord/components/paginator/index.js.map +1 -0
- package/dist/discord/components/paginator/mount-paginator.d.ts +25 -0
- package/dist/discord/components/paginator/mount-paginator.d.ts.map +1 -0
- package/dist/discord/components/paginator/mount-paginator.js +41 -0
- package/dist/discord/components/paginator/mount-paginator.js.map +1 -0
- package/dist/discord/components/paginator/page-state.d.ts +21 -0
- package/dist/discord/components/paginator/page-state.d.ts.map +1 -0
- package/dist/discord/components/paginator/page-state.js +44 -0
- package/dist/discord/components/paginator/page-state.js.map +1 -0
- package/dist/discord/components/paginator/paginator-buttons.d.ts +17 -0
- package/dist/discord/components/paginator/paginator-buttons.d.ts.map +1 -0
- package/dist/discord/components/paginator/paginator-buttons.js +45 -0
- package/dist/discord/components/paginator/paginator-buttons.js.map +1 -0
- package/dist/discord/components/paginator/paginator-view.d.ts +26 -0
- package/dist/discord/components/paginator/paginator-view.d.ts.map +1 -0
- package/dist/discord/components/paginator/paginator-view.js +28 -0
- package/dist/discord/components/paginator/paginator-view.js.map +1 -0
- package/dist/discord/components/persistent-paginator.d.ts +47 -0
- package/dist/discord/components/persistent-paginator.d.ts.map +1 -0
- package/dist/discord/components/persistent-paginator.js +137 -0
- package/dist/discord/components/persistent-paginator.js.map +1 -0
- package/dist/discord/components/settings-editor/index.d.ts +7 -0
- package/dist/discord/components/settings-editor/index.d.ts.map +1 -0
- package/dist/discord/components/settings-editor/index.js +22 -0
- package/dist/discord/components/settings-editor/index.js.map +1 -0
- package/dist/discord/components/settings-editor/mount-settings-editor.d.ts +140 -0
- package/dist/discord/components/settings-editor/mount-settings-editor.d.ts.map +1 -0
- package/dist/discord/components/settings-editor/mount-settings-editor.js +344 -0
- package/dist/discord/components/settings-editor/mount-settings-editor.js.map +1 -0
- package/dist/discord/components/settings-editor/navigation.d.ts +49 -0
- package/dist/discord/components/settings-editor/navigation.d.ts.map +1 -0
- package/dist/discord/components/settings-editor/navigation.js +39 -0
- package/dist/discord/components/settings-editor/navigation.js.map +1 -0
- package/dist/discord/components/settings-editor/settings-editor-card.view.d.ts +56 -0
- package/dist/discord/components/settings-editor/settings-editor-card.view.d.ts.map +1 -0
- package/dist/discord/components/settings-editor/settings-editor-card.view.js +284 -0
- package/dist/discord/components/settings-editor/settings-editor-card.view.js.map +1 -0
- package/dist/discord/components/settings-editor/settings-editor-field-modal.d.ts +69 -0
- package/dist/discord/components/settings-editor/settings-editor-field-modal.d.ts.map +1 -0
- package/dist/discord/components/settings-editor/settings-editor-field-modal.js +353 -0
- package/dist/discord/components/settings-editor/settings-editor-field-modal.js.map +1 -0
- package/dist/discord/components/settings-editor/settings-editor-fields.d.ts +216 -0
- package/dist/discord/components/settings-editor/settings-editor-fields.d.ts.map +1 -0
- package/dist/discord/components/settings-editor/settings-editor-fields.js +66 -0
- package/dist/discord/components/settings-editor/settings-editor-fields.js.map +1 -0
- package/dist/discord/components/settings-editor/settings-editor.view.d.ts +303 -0
- package/dist/discord/components/settings-editor/settings-editor.view.d.ts.map +1 -0
- package/dist/discord/components/settings-editor/settings-editor.view.js +316 -0
- package/dist/discord/components/settings-editor/settings-editor.view.js.map +1 -0
- package/dist/discord/events/create-event.d.ts +5 -0
- package/dist/discord/events/create-event.d.ts.map +1 -0
- package/dist/discord/events/create-event.js +8 -0
- package/dist/discord/events/create-event.js.map +1 -0
- package/dist/discord/events/event-router.d.ts +16 -0
- package/dist/discord/events/event-router.d.ts.map +1 -0
- package/dist/discord/events/event-router.js +51 -0
- package/dist/discord/events/event-router.js.map +1 -0
- package/dist/discord/events/types.d.ts +16 -0
- package/dist/discord/events/types.d.ts.map +1 -0
- package/dist/discord/events/types.js +3 -0
- package/dist/discord/events/types.js.map +1 -0
- package/dist/discord/i18n.d.ts +58 -0
- package/dist/discord/i18n.d.ts.map +1 -0
- package/dist/discord/i18n.js +173 -0
- package/dist/discord/i18n.js.map +1 -0
- package/dist/discord/interaction/button.d.ts +39 -0
- package/dist/discord/interaction/button.d.ts.map +1 -0
- package/dist/discord/interaction/button.js +36 -0
- package/dist/discord/interaction/button.js.map +1 -0
- package/dist/discord/interaction/interaction-router.d.ts +21 -0
- package/dist/discord/interaction/interaction-router.d.ts.map +1 -0
- package/dist/discord/interaction/interaction-router.js +24 -0
- package/dist/discord/interaction/interaction-router.js.map +1 -0
- package/dist/discord/interaction/modal.d.ts +171 -0
- package/dist/discord/interaction/modal.d.ts.map +1 -0
- package/dist/discord/interaction/modal.js +264 -0
- package/dist/discord/interaction/modal.js.map +1 -0
- package/dist/discord/interaction/select-menu.d.ts +104 -0
- package/dist/discord/interaction/select-menu.d.ts.map +1 -0
- package/dist/discord/interaction/select-menu.js +126 -0
- package/dist/discord/interaction/select-menu.js.map +1 -0
- package/dist/discord/permissions.d.ts +32 -0
- package/dist/discord/permissions.d.ts.map +1 -0
- package/dist/discord/permissions.js +40 -0
- package/dist/discord/permissions.js.map +1 -0
- package/dist/discord/placeholder-registry-errors.d.ts +10 -0
- package/dist/discord/placeholder-registry-errors.d.ts.map +1 -0
- package/dist/discord/placeholder-registry-errors.js +18 -0
- package/dist/discord/placeholder-registry-errors.js.map +1 -0
- package/dist/discord/placeholder-registry.d.ts +53 -0
- package/dist/discord/placeholder-registry.d.ts.map +1 -0
- package/dist/discord/placeholder-registry.js +52 -0
- package/dist/discord/placeholder-registry.js.map +1 -0
- package/dist/discord/presenter.d.ts +26 -0
- package/dist/discord/presenter.d.ts.map +1 -0
- package/dist/discord/presenter.js +3 -0
- package/dist/discord/presenter.js.map +1 -0
- package/dist/discord/ui/card.d.ts +114 -0
- package/dist/discord/ui/card.d.ts.map +1 -0
- package/dist/discord/ui/card.js +162 -0
- package/dist/discord/ui/card.js.map +1 -0
- package/dist/discord/ui/color-input-errors.d.ts +12 -0
- package/dist/discord/ui/color-input-errors.d.ts.map +1 -0
- package/dist/discord/ui/color-input-errors.js +21 -0
- package/dist/discord/ui/color-input-errors.js.map +1 -0
- package/dist/discord/ui/color-input.d.ts +37 -0
- package/dist/discord/ui/color-input.d.ts.map +1 -0
- package/dist/discord/ui/color-input.js +166 -0
- package/dist/discord/ui/color-input.js.map +1 -0
- package/dist/discord/ui/colors.d.ts +44 -0
- package/dist/discord/ui/colors.d.ts.map +1 -0
- package/dist/discord/ui/colors.js +45 -0
- package/dist/discord/ui/colors.js.map +1 -0
- package/dist/discord/ui/embed.d.ts +16 -0
- package/dist/discord/ui/embed.d.ts.map +1 -0
- package/dist/discord/ui/embed.js +53 -0
- package/dist/discord/ui/embed.js.map +1 -0
- package/dist/discord/ui/message-attachments.d.ts +40 -0
- package/dist/discord/ui/message-attachments.d.ts.map +1 -0
- package/dist/discord/ui/message-attachments.js +27 -0
- package/dist/discord/ui/message-attachments.js.map +1 -0
- package/dist/errors/business-error.d.ts +41 -0
- package/dist/errors/business-error.d.ts.map +1 -0
- package/dist/errors/business-error.js +39 -0
- package/dist/errors/business-error.js.map +1 -0
- package/dist/errors/describe-error.d.ts +29 -0
- package/dist/errors/describe-error.d.ts.map +1 -0
- package/dist/errors/describe-error.js +49 -0
- package/dist/errors/describe-error.js.map +1 -0
- package/dist/errors/incident-ref.d.ts +7 -0
- package/dist/errors/incident-ref.d.ts.map +1 -0
- package/dist/errors/incident-ref.js +13 -0
- package/dist/errors/incident-ref.js.map +1 -0
- package/dist/errors/index.d.ts +4 -0
- package/dist/errors/index.d.ts.map +1 -0
- package/dist/errors/index.js +15 -0
- package/dist/errors/index.js.map +1 -0
- package/dist/i18n/business-message.d.ts +14 -0
- package/dist/i18n/business-message.d.ts.map +1 -0
- package/dist/i18n/business-message.js +19 -0
- package/dist/i18n/business-message.js.map +1 -0
- package/dist/i18n/catalog.d.ts +27 -0
- package/dist/i18n/catalog.d.ts.map +1 -0
- package/dist/i18n/catalog.js +25 -0
- package/dist/i18n/catalog.js.map +1 -0
- package/dist/i18n/errors.d.ts +10 -0
- package/dist/i18n/errors.d.ts.map +1 -0
- package/dist/i18n/errors.js +18 -0
- package/dist/i18n/errors.js.map +1 -0
- package/dist/i18n/index.d.ts +6 -0
- package/dist/i18n/index.d.ts.map +1 -0
- package/dist/i18n/index.js +16 -0
- package/dist/i18n/index.js.map +1 -0
- package/dist/i18n/locale.d.ts +22 -0
- package/dist/i18n/locale.d.ts.map +1 -0
- package/dist/i18n/locale.js +40 -0
- package/dist/i18n/locale.js.map +1 -0
- package/dist/i18n/translator.d.ts +35 -0
- package/dist/i18n/translator.d.ts.map +1 -0
- package/dist/i18n/translator.js +40 -0
- package/dist/i18n/translator.js.map +1 -0
- package/dist/logger.d.ts +28 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +3 -0
- package/dist/logger.js.map +1 -0
- package/dist/module/module.d.ts +42 -0
- package/dist/module/module.d.ts.map +1 -0
- package/dist/module/module.js +3 -0
- package/dist/module/module.js.map +1 -0
- package/dist/persistence/database.d.ts +24 -0
- package/dist/persistence/database.d.ts.map +1 -0
- package/dist/persistence/database.js +3 -0
- package/dist/persistence/database.js.map +1 -0
- package/dist/resilience/retry.d.ts +36 -0
- package/dist/resilience/retry.d.ts.map +1 -0
- package/dist/resilience/retry.js +78 -0
- package/dist/resilience/retry.js.map +1 -0
- package/dist/scheduler/errors.d.ts +25 -0
- package/dist/scheduler/errors.d.ts.map +1 -0
- package/dist/scheduler/errors.js +45 -0
- package/dist/scheduler/errors.js.map +1 -0
- package/dist/scheduler/scheduler.d.ts +68 -0
- package/dist/scheduler/scheduler.d.ts.map +1 -0
- package/dist/scheduler/scheduler.js +162 -0
- package/dist/scheduler/scheduler.js.map +1 -0
- package/package.json +89 -0
- package/src/authz/authorizer.ts +76 -0
- package/src/authz/require-level-guard.ts +42 -0
- package/src/clock.ts +18 -0
- package/src/concurrency/keyed-queue.ts +50 -0
- package/src/concurrency/run-with-concurrency.ts +44 -0
- package/src/config/env.ts +58 -0
- package/src/config/errors.ts +21 -0
- package/src/datetime.ts +221 -0
- package/src/discord/api-errors.ts +91 -0
- package/src/discord/channel-exporter.ts +48 -0
- package/src/discord/command/autocomplete-dispatcher.ts +54 -0
- package/src/discord/command/autocomplete.ts +76 -0
- package/src/discord/command/context.ts +85 -0
- package/src/discord/command/cooldown-guard.ts +81 -0
- package/src/discord/command/create-command.ts +431 -0
- package/src/discord/command/create-context-menu-command.ts +227 -0
- package/src/discord/command/errors.ts +22 -0
- package/src/discord/command/guard.ts +54 -0
- package/src/discord/command/localization.ts +11 -0
- package/src/discord/command/options.ts +847 -0
- package/src/discord/command/permission-guard.ts +74 -0
- package/src/discord/command/router.ts +142 -0
- package/src/discord/command/types.ts +52 -0
- package/src/discord/components/component-router.ts +213 -0
- package/src/discord/components/interactive-message/index.ts +18 -0
- package/src/discord/components/interactive-message/interactive-message-collector.ts +374 -0
- package/src/discord/components/interactive-message/mount-interactive-message.ts +71 -0
- package/src/discord/components/paginator/index.ts +17 -0
- package/src/discord/components/paginator/mount-paginator.ts +69 -0
- package/src/discord/components/paginator/page-state.ts +45 -0
- package/src/discord/components/paginator/paginator-buttons.ts +60 -0
- package/src/discord/components/paginator/paginator-view.ts +61 -0
- package/src/discord/components/persistent-paginator.ts +197 -0
- package/src/discord/components/settings-editor/index.ts +41 -0
- package/src/discord/components/settings-editor/mount-settings-editor.ts +646 -0
- package/src/discord/components/settings-editor/navigation.ts +67 -0
- package/src/discord/components/settings-editor/settings-editor-card.view.ts +344 -0
- package/src/discord/components/settings-editor/settings-editor-field-modal.ts +538 -0
- package/src/discord/components/settings-editor/settings-editor-fields.ts +290 -0
- package/src/discord/components/settings-editor/settings-editor.view.ts +654 -0
- package/src/discord/events/create-event.ts +7 -0
- package/src/discord/events/event-router.ts +53 -0
- package/src/discord/events/types.ts +17 -0
- package/src/discord/i18n.ts +173 -0
- package/src/discord/interaction/button.ts +75 -0
- package/src/discord/interaction/interaction-router.ts +28 -0
- package/src/discord/interaction/modal.ts +505 -0
- package/src/discord/interaction/select-menu.ts +246 -0
- package/src/discord/permissions.ts +57 -0
- package/src/discord/placeholder-registry-errors.ts +11 -0
- package/src/discord/placeholder-registry.ts +77 -0
- package/src/discord/presenter.ts +26 -0
- package/src/discord/ui/card.ts +303 -0
- package/src/discord/ui/color-input-errors.ts +17 -0
- package/src/discord/ui/color-input.ts +179 -0
- package/src/discord/ui/colors.ts +58 -0
- package/src/discord/ui/embed.ts +64 -0
- package/src/discord/ui/message-attachments.ts +57 -0
- package/src/errors/business-error.ts +49 -0
- package/src/errors/describe-error.ts +73 -0
- package/src/errors/incident-ref.ts +10 -0
- package/src/errors/index.ts +12 -0
- package/src/i18n/business-message.ts +23 -0
- package/src/i18n/catalog.ts +41 -0
- package/src/i18n/errors.ts +11 -0
- package/src/i18n/index.ts +11 -0
- package/src/i18n/locale.ts +41 -0
- package/src/i18n/translator.ts +78 -0
- package/src/logger.ts +28 -0
- package/src/module/module.ts +42 -0
- package/src/persistence/database.ts +23 -0
- package/src/resilience/retry.ts +102 -0
- package/src/scheduler/errors.ts +40 -0
- package/src/scheduler/scheduler.ts +248 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export type ErrorSeverity = "warning" | "error";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Optional catalog reference carried by a {@link BusinessError}. When present,
|
|
5
|
+
* the command pipeline renders the translated text for the interaction's
|
|
6
|
+
* locale; `message` stays the English source used for logs and as fallback.
|
|
7
|
+
* Declared structurally (not imported from `core/i18n`'s `LocalizedText`) to
|
|
8
|
+
* keep this file a pure error declaration; the shapes are checked compatible
|
|
9
|
+
* where the pipeline resolves it.
|
|
10
|
+
*/
|
|
11
|
+
export interface ErrorTranslation {
|
|
12
|
+
readonly key: string;
|
|
13
|
+
readonly params?: Readonly<Record<string, string | number>>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Base class for expected, user-facing errors. Anything that is NOT a
|
|
18
|
+
* {@link BusinessError} is treated as unexpected: it is logged with an incident
|
|
19
|
+
* reference and the user sees a generic message.
|
|
20
|
+
*/
|
|
21
|
+
export abstract class BusinessError extends Error {
|
|
22
|
+
abstract readonly severity: ErrorSeverity;
|
|
23
|
+
|
|
24
|
+
constructor(
|
|
25
|
+
message: string,
|
|
26
|
+
readonly translation?: ErrorTranslation,
|
|
27
|
+
) {
|
|
28
|
+
super(message);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Benign business error (invalid input, missing resource, conflict). */
|
|
33
|
+
export class WarningError extends BusinessError {
|
|
34
|
+
override readonly severity = "warning" as const;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Serious business error that still carries a user-facing message. */
|
|
38
|
+
export class CriticalError extends BusinessError {
|
|
39
|
+
override readonly severity = "error" as const;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The referenced resource does not exist. */
|
|
43
|
+
export class NotFoundError extends WarningError {}
|
|
44
|
+
|
|
45
|
+
/** User input failed validation. */
|
|
46
|
+
export class ValidationError extends WarningError {}
|
|
47
|
+
|
|
48
|
+
/** The action conflicts with the current state (e.g. a duplicate). */
|
|
49
|
+
export class ConflictError extends WarningError {}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An error as a log record keeps it: what it says, where it came from, and
|
|
3
|
+
* whatever it is wrapping.
|
|
4
|
+
*
|
|
5
|
+
* `message` alone is not enough for the errors this bot actually hits. A
|
|
6
|
+
* builder that refuses a payload throws `CombinedPropertyError`, whose own
|
|
7
|
+
* message is the constant "Received one or more errors" — everything that
|
|
8
|
+
* names the offending field lives in the errors nested under it. Logging the
|
|
9
|
+
* message and dropping the rest turns a precise complaint into a sentence that
|
|
10
|
+
* says nothing.
|
|
11
|
+
*/
|
|
12
|
+
export interface DescribedError {
|
|
13
|
+
readonly message: string;
|
|
14
|
+
readonly name?: string;
|
|
15
|
+
readonly stack?: string;
|
|
16
|
+
/**
|
|
17
|
+
* What this error wraps: the entries of an aggregate, or the `cause` a
|
|
18
|
+
* rethrow attached.
|
|
19
|
+
*/
|
|
20
|
+
readonly causes?: DescribedError[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* How deep the wrapping is followed. Aggregates nest two or three levels in
|
|
25
|
+
* practice; the bound is there so a cyclic `cause` cannot spin.
|
|
26
|
+
*/
|
|
27
|
+
const MAX_DEPTH = 5;
|
|
28
|
+
|
|
29
|
+
/** How many entries of one aggregate are kept, so a wide failure stays readable. */
|
|
30
|
+
const MAX_CAUSES = 10;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The errors nested inside one, whatever shape it wraps them in.
|
|
34
|
+
*
|
|
35
|
+
* `AggregateError` and shapeshift's `CombinedError` both expose `errors` as a
|
|
36
|
+
* plain array; shapeshift's `CombinedPropertyError` uses the same field but
|
|
37
|
+
* pairs each error with the property it came from, as `[key, error]`. Taking
|
|
38
|
+
* the last element of an entry reads both, and keeping the key out of the way
|
|
39
|
+
* costs nothing — the nested message names the constraint that failed.
|
|
40
|
+
*/
|
|
41
|
+
function aggregated(error: object): unknown[] {
|
|
42
|
+
const { errors } = error as { errors?: unknown };
|
|
43
|
+
if (!Array.isArray(errors)) {
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
return errors
|
|
47
|
+
.slice(0, MAX_CAUSES)
|
|
48
|
+
.map((entry) => (Array.isArray(entry) ? entry[entry.length - 1] : entry));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Render an unknown thrown value for a structured log.
|
|
53
|
+
*
|
|
54
|
+
* Use it wherever an error is caught to be reported rather than handled:
|
|
55
|
+
* `logger.error({ err: describeError(error) }, "…")`.
|
|
56
|
+
*/
|
|
57
|
+
export function describeError(error: unknown, depth = 0): DescribedError {
|
|
58
|
+
if (!(error instanceof Error)) {
|
|
59
|
+
return { message: String(error) };
|
|
60
|
+
}
|
|
61
|
+
const causes =
|
|
62
|
+
depth >= MAX_DEPTH
|
|
63
|
+
? []
|
|
64
|
+
: [...aggregated(error), ...(error.cause === undefined ? [] : [error.cause])].map((nested) =>
|
|
65
|
+
describeError(nested, depth + 1),
|
|
66
|
+
);
|
|
67
|
+
return {
|
|
68
|
+
message: error.message,
|
|
69
|
+
name: error.name,
|
|
70
|
+
...(error.stack === undefined ? {} : { stack: error.stack }),
|
|
71
|
+
...(causes.length === 0 ? {} : { causes }),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Generate a unique incident reference attached to unexpected errors. The same
|
|
5
|
+
* value is logged server-side and shown to the user, so a support request can be
|
|
6
|
+
* traced back to a single log line.
|
|
7
|
+
*/
|
|
8
|
+
export function createIncidentRef(): string {
|
|
9
|
+
return randomUUID();
|
|
10
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export {
|
|
2
|
+
BusinessError,
|
|
3
|
+
ConflictError,
|
|
4
|
+
CriticalError,
|
|
5
|
+
type ErrorSeverity,
|
|
6
|
+
type ErrorTranslation,
|
|
7
|
+
NotFoundError,
|
|
8
|
+
ValidationError,
|
|
9
|
+
WarningError,
|
|
10
|
+
} from "@/errors/business-error";
|
|
11
|
+
export { type DescribedError, describeError } from "@/errors/describe-error";
|
|
12
|
+
export { createIncidentRef } from "@/errors/incident-ref";
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { BusinessError } from "@/errors";
|
|
2
|
+
import type { Locale } from "@/i18n/locale";
|
|
3
|
+
import type { Translator } from "@/i18n/translator";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The user-facing wording of an expected failure: the catalog entry it points
|
|
7
|
+
* at, or its English source when it carries none.
|
|
8
|
+
*
|
|
9
|
+
* Lives beside the translator rather than with any one surface: the command
|
|
10
|
+
* pipeline, the `ComponentRouter` and every collector-scoped screen owe the same
|
|
11
|
+
* answer to the same failure, and a screen that has to translate its own
|
|
12
|
+
* refusals must not reach into another module to do it.
|
|
13
|
+
*/
|
|
14
|
+
export function resolveBusinessMessage(
|
|
15
|
+
error: BusinessError,
|
|
16
|
+
translator: Translator,
|
|
17
|
+
locale: Locale,
|
|
18
|
+
): string {
|
|
19
|
+
if (error.translation === undefined) {
|
|
20
|
+
return error.message;
|
|
21
|
+
}
|
|
22
|
+
return translator.translate(locale, error.translation.key, error.translation.params);
|
|
23
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { DuplicateTranslationKeyError } from "@/i18n/errors";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One translatable message. `en` is mandatory by construction — it is the last
|
|
5
|
+
* link of every fallback chain, so a missing translation can only ever degrade
|
|
6
|
+
* to English, never to a broken reply. Templates carry `{name}` placeholders
|
|
7
|
+
* interpolated by the translator.
|
|
8
|
+
*/
|
|
9
|
+
export interface CatalogEntry {
|
|
10
|
+
readonly en: string;
|
|
11
|
+
readonly fr?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A set of keyed message templates. Keys are namespaced `<owner>.<area>.<name>`
|
|
16
|
+
* (e.g. `core.presenter.success-title`, `comms.event.created`) so two modules
|
|
17
|
+
* can never collide accidentally.
|
|
18
|
+
*/
|
|
19
|
+
export type Catalog = Readonly<Record<string, CatalogEntry>>;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Accumulates the core catalog and every module's catalog at boot. Registration
|
|
23
|
+
* is fail-fast: a duplicate key throws {@link DuplicateTranslationKeyError}
|
|
24
|
+
* instead of silently letting one module's copy shadow another's.
|
|
25
|
+
*/
|
|
26
|
+
export class TranslationRegistry {
|
|
27
|
+
private readonly entries = new Map<string, CatalogEntry>();
|
|
28
|
+
|
|
29
|
+
register(catalog: Catalog): void {
|
|
30
|
+
for (const [key, entry] of Object.entries(catalog)) {
|
|
31
|
+
if (this.entries.has(key)) {
|
|
32
|
+
throw new DuplicateTranslationKeyError(key);
|
|
33
|
+
}
|
|
34
|
+
this.entries.set(key, entry);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
get(key: string): CatalogEntry | undefined {
|
|
39
|
+
return this.entries.get(key);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Two catalogs declared the same translation key. Raised while modules register
|
|
3
|
+
* their catalogs at boot — a wiring mistake that must stop the boot, never a
|
|
4
|
+
* runtime condition.
|
|
5
|
+
*/
|
|
6
|
+
export class DuplicateTranslationKeyError extends Error {
|
|
7
|
+
constructor(readonly key: string) {
|
|
8
|
+
super(`Translation key "${key}" is registered twice`);
|
|
9
|
+
this.name = "DuplicateTranslationKeyError";
|
|
10
|
+
}
|
|
11
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { resolveBusinessMessage } from "@/i18n/business-message";
|
|
2
|
+
export { type Catalog, type CatalogEntry, TranslationRegistry } from "@/i18n/catalog";
|
|
3
|
+
export { DuplicateTranslationKeyError } from "@/i18n/errors";
|
|
4
|
+
export { type Locale, normalizeLocale, resolveLocale, SUPPORTED_LOCALES } from "@/i18n/locale";
|
|
5
|
+
export {
|
|
6
|
+
createTranslator,
|
|
7
|
+
type LocalizedText,
|
|
8
|
+
type TranslationParams,
|
|
9
|
+
type Translator,
|
|
10
|
+
type TranslatorOptions,
|
|
11
|
+
} from "@/i18n/translator";
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The languages the framework can render replies in. `en` is structurally the
|
|
3
|
+
* fallback source: every catalog entry must provide it (see `catalog.ts`), so
|
|
4
|
+
* adding a language here can degrade gracefully instead of failing lookups.
|
|
5
|
+
*/
|
|
6
|
+
export const SUPPORTED_LOCALES = ["en", "fr"] as const;
|
|
7
|
+
|
|
8
|
+
export type Locale = (typeof SUPPORTED_LOCALES)[number];
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Map a raw Discord locale string (`en-US`, `en-GB`, `fr`, `de`…) to a
|
|
12
|
+
* supported base language. Regional variants collapse to their base tag, so
|
|
13
|
+
* both English variants resolve to `en` without duplicating catalog entries.
|
|
14
|
+
* Returns `undefined` for unsupported languages so callers can keep walking
|
|
15
|
+
* their fallback chain.
|
|
16
|
+
*/
|
|
17
|
+
export function normalizeLocale(raw: string | null | undefined): Locale | undefined {
|
|
18
|
+
if (!raw) {
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
const base = raw.split("-")[0]?.toLowerCase();
|
|
22
|
+
return SUPPORTED_LOCALES.find((locale) => locale === base);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Pick the reply language for one message: the first supported candidate wins,
|
|
27
|
+
* otherwise the configured default. Callers list candidates in priority order
|
|
28
|
+
* (user locale, then guild locale).
|
|
29
|
+
*/
|
|
30
|
+
export function resolveLocale(
|
|
31
|
+
candidates: readonly (string | null | undefined)[],
|
|
32
|
+
defaultLocale: Locale,
|
|
33
|
+
): Locale {
|
|
34
|
+
for (const candidate of candidates) {
|
|
35
|
+
const normalized = normalizeLocale(candidate);
|
|
36
|
+
if (normalized !== undefined) {
|
|
37
|
+
return normalized;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return defaultLocale;
|
|
41
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { TranslationRegistry } from "@/i18n/catalog";
|
|
2
|
+
import type { Locale } from "@/i18n/locale";
|
|
3
|
+
import type { Logger } from "@/logger";
|
|
4
|
+
|
|
5
|
+
/** Values interpolated into a template's `{name}` placeholders. */
|
|
6
|
+
export type TranslationParams = Readonly<Record<string, string | number>>;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A message that may be translated: either a plain string delivered verbatim
|
|
10
|
+
* (ad-hoc dynamic text, backward-compatible call sites) or a catalog key with
|
|
11
|
+
* its interpolation parameters. Accepted by guard denials, fallback errors and
|
|
12
|
+
* business errors, and resolved by the dispatch pipeline with the locale of the
|
|
13
|
+
* interaction being answered.
|
|
14
|
+
*/
|
|
15
|
+
export type LocalizedText = string | { readonly key: string; readonly params?: TranslationParams };
|
|
16
|
+
|
|
17
|
+
/** Translates catalog keys for a resolved locale; never throws on lookup. */
|
|
18
|
+
export interface Translator {
|
|
19
|
+
readonly defaultLocale: Locale;
|
|
20
|
+
translate(locale: Locale, key: string, params?: TranslationParams): string;
|
|
21
|
+
/** Resolve a {@link LocalizedText}: plain strings pass through untranslated. */
|
|
22
|
+
resolve(locale: Locale, text: LocalizedText): string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface TranslatorOptions {
|
|
26
|
+
readonly defaultLocale: Locale;
|
|
27
|
+
readonly logger: Logger;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const PLACEHOLDER_PATTERN = /\{(\w+)\}/g;
|
|
31
|
+
|
|
32
|
+
function interpolate(
|
|
33
|
+
template: string,
|
|
34
|
+
params: TranslationParams | undefined,
|
|
35
|
+
key: string,
|
|
36
|
+
logger: Logger,
|
|
37
|
+
): string {
|
|
38
|
+
return template.replace(PLACEHOLDER_PATTERN, (placeholder, name: string) => {
|
|
39
|
+
const value = params?.[name];
|
|
40
|
+
if (value === undefined) {
|
|
41
|
+
// A missing value is a call-site bug, but the reply must still go out:
|
|
42
|
+
// keep the placeholder visible and leave a trace for the operator.
|
|
43
|
+
logger.warn({ key, placeholder: name }, "Missing interpolation parameter");
|
|
44
|
+
return placeholder;
|
|
45
|
+
}
|
|
46
|
+
return String(value);
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Build the translator over a filled registry. Lookup order per call:
|
|
52
|
+
* requested locale → configured default → `en`. A key absent from the registry
|
|
53
|
+
* renders as the key itself (logged) — a lookup can degrade but never prevent
|
|
54
|
+
* a reply from being delivered.
|
|
55
|
+
*/
|
|
56
|
+
export function createTranslator(
|
|
57
|
+
registry: TranslationRegistry,
|
|
58
|
+
options: TranslatorOptions,
|
|
59
|
+
): Translator {
|
|
60
|
+
const { defaultLocale, logger } = options;
|
|
61
|
+
|
|
62
|
+
const translate = (locale: Locale, key: string, params?: TranslationParams): string => {
|
|
63
|
+
const entry = registry.get(key);
|
|
64
|
+
if (entry === undefined) {
|
|
65
|
+
logger.warn({ key }, "Unknown translation key");
|
|
66
|
+
return key;
|
|
67
|
+
}
|
|
68
|
+
const template = entry[locale] ?? entry[defaultLocale] ?? entry.en;
|
|
69
|
+
return interpolate(template, params, key, logger);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
defaultLocale,
|
|
74
|
+
translate,
|
|
75
|
+
resolve: (locale, text) =>
|
|
76
|
+
typeof text === "string" ? text : translate(locale, text.key, text.params),
|
|
77
|
+
};
|
|
78
|
+
}
|
package/src/logger.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A single log call. Mirrors the pino calling convention so call sites can pass
|
|
3
|
+
* either a bare message or a structured record followed by a message:
|
|
4
|
+
* `logger.error({ err }, "Failed to do the thing")`.
|
|
5
|
+
*/
|
|
6
|
+
export interface LogFn {
|
|
7
|
+
(record: unknown, message?: string, ...args: unknown[]): void;
|
|
8
|
+
(message: string, ...args: unknown[]): void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Structured application logger port.
|
|
13
|
+
*
|
|
14
|
+
* Declared structurally on purpose: `core` owns the contract and stays free of
|
|
15
|
+
* any vendor SDK, so the concrete implementation (pino, Sentry, a test double)
|
|
16
|
+
* lives in `infrastructure/logging` and can be swapped without touching call
|
|
17
|
+
* sites.
|
|
18
|
+
*/
|
|
19
|
+
export interface Logger {
|
|
20
|
+
trace: LogFn;
|
|
21
|
+
debug: LogFn;
|
|
22
|
+
info: LogFn;
|
|
23
|
+
warn: LogFn;
|
|
24
|
+
error: LogFn;
|
|
25
|
+
fatal: LogFn;
|
|
26
|
+
/** Derive a logger that stamps `bindings` onto every record it emits. */
|
|
27
|
+
child(bindings: Record<string, unknown>): Logger;
|
|
28
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { ContextMenuCommand, SlashCommand } from "@/discord/command/types";
|
|
2
|
+
import type { ComponentHandler } from "@/discord/components/component-router";
|
|
3
|
+
import type { DiscordEvent } from "@/discord/events/types";
|
|
4
|
+
import type { Catalog } from "@/i18n";
|
|
5
|
+
import type { ScheduledJob } from "@/scheduler/scheduler";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A self-contained vertical feature slice. A module bundles everything it
|
|
9
|
+
* contributes to the bot — commands, gateway events, scheduled jobs — and an
|
|
10
|
+
* optional async `setup` for one-off work (creating indexes, migrations…).
|
|
11
|
+
*
|
|
12
|
+
* Modules are produced by factories that receive the application container (see
|
|
13
|
+
* `bootstrap/container.ts`), so all dependencies are injected — never imported
|
|
14
|
+
* as singletons. The core depends only on this output contract, never on how a
|
|
15
|
+
* module is wired.
|
|
16
|
+
*/
|
|
17
|
+
export interface BotModule {
|
|
18
|
+
/** Stable identifier, used in logs. */
|
|
19
|
+
readonly name: string;
|
|
20
|
+
readonly commands?: readonly SlashCommand[];
|
|
21
|
+
/** Right-click entries on a member or a message (the Apps submenu). */
|
|
22
|
+
readonly contextMenuCommands?: readonly ContextMenuCommand[];
|
|
23
|
+
/** Persistent component/modal handlers (routed by `customId`). */
|
|
24
|
+
readonly components?: readonly ComponentHandler[];
|
|
25
|
+
readonly events?: readonly DiscordEvent[];
|
|
26
|
+
readonly jobs?: readonly ScheduledJob[];
|
|
27
|
+
/**
|
|
28
|
+
* The module's translation catalog (keys namespaced by module name). The
|
|
29
|
+
* composition root registers it into the shared registry at boot — a
|
|
30
|
+
* duplicate key across modules fails the boot, never a runtime lookup.
|
|
31
|
+
*/
|
|
32
|
+
readonly translations?: Catalog;
|
|
33
|
+
/** One-off initialisation run once at boot, after the client is ready. */
|
|
34
|
+
setup?(): Promise<void> | void;
|
|
35
|
+
/**
|
|
36
|
+
* Symmetric counterpart of {@link setup}, awaited during graceful shutdown
|
|
37
|
+
* before the client and the database are closed. Release what the module owns
|
|
38
|
+
* and the framework cannot see — timers, collectors, third-party connections.
|
|
39
|
+
* A rejecting teardown is logged and never aborts the shutdown of the others.
|
|
40
|
+
*/
|
|
41
|
+
teardown?(): Promise<void> | void;
|
|
42
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Driver-agnostic persistence port. It exists so the framework (boot, graceful
|
|
3
|
+
* shutdown) can own a database lifecycle without ever naming a vendor: adding a
|
|
4
|
+
* SQL or Redis driver must not require touching this file, only adding an
|
|
5
|
+
* adapter that satisfies this contract.
|
|
6
|
+
*
|
|
7
|
+
* Deliberately narrow: querying is not part of it. Query APIs differ too much
|
|
8
|
+
* between engines to be usefully abstracted here, so each adapter exposes its
|
|
9
|
+
* own (Mongo collections, SQL statements…) and repositories in the module
|
|
10
|
+
* `infrastructure/` layer are the only code allowed to use it.
|
|
11
|
+
*/
|
|
12
|
+
export interface DatabaseConnection {
|
|
13
|
+
/**
|
|
14
|
+
* Discriminant identifying the concrete driver. Adapters narrow it to a
|
|
15
|
+
* string literal so consumers can select an implementation by comparison
|
|
16
|
+
* rather than by `instanceof`, which would force a vendor import.
|
|
17
|
+
*/
|
|
18
|
+
readonly driver: string;
|
|
19
|
+
/** Open the connection. Idempotent: safe to call from several call sites. */
|
|
20
|
+
connect(): Promise<void>;
|
|
21
|
+
/** Release the connection on graceful shutdown. */
|
|
22
|
+
close(): Promise<void>;
|
|
23
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { BusinessError } from "@/errors";
|
|
2
|
+
|
|
3
|
+
const MIN_ATTEMPTS = 1;
|
|
4
|
+
const DEFAULT_ATTEMPTS = 3;
|
|
5
|
+
const DEFAULT_INITIAL_DELAY_MS = 200;
|
|
6
|
+
const DEFAULT_MAX_DELAY_MS = 5_000;
|
|
7
|
+
const DEFAULT_BACKOFF_FACTOR = 2;
|
|
8
|
+
/** Share of the computed delay kept fixed; the rest is randomised (full jitter on 1 - RATIO). */
|
|
9
|
+
const DEFAULT_JITTER_RATIO = 0.5;
|
|
10
|
+
|
|
11
|
+
export interface RetryOptions {
|
|
12
|
+
/** Total number of tries, first attempt included. */
|
|
13
|
+
attempts?: number;
|
|
14
|
+
initialDelayMs?: number;
|
|
15
|
+
maxDelayMs?: number;
|
|
16
|
+
backoffFactor?: number;
|
|
17
|
+
jitterRatio?: number;
|
|
18
|
+
/** Decide whether a failure is worth another try. Default: anything but a permanent failure. */
|
|
19
|
+
shouldRetry?: (error: unknown, attempt: number) => boolean;
|
|
20
|
+
/** Injected so tests advance instantly instead of waiting on real timers. */
|
|
21
|
+
sleep?: (delayMs: number) => Promise<void>;
|
|
22
|
+
/** Injected randomness keeps the jitter deterministic under test. */
|
|
23
|
+
random?: () => number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Everything the backoff needs once caller defaults have been resolved. */
|
|
27
|
+
export interface BackoffOptions {
|
|
28
|
+
initialDelayMs: number;
|
|
29
|
+
maxDelayMs: number;
|
|
30
|
+
backoffFactor: number;
|
|
31
|
+
jitterRatio: number;
|
|
32
|
+
random: () => number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A {@link BusinessError} describes an expected, user-facing failure (bad input,
|
|
37
|
+
* missing entity, conflict): repeating the call cannot change the outcome, so it
|
|
38
|
+
* is never retried. Everything else is assumed transient until proven otherwise.
|
|
39
|
+
*/
|
|
40
|
+
function isTransient(error: unknown): boolean {
|
|
41
|
+
return !(error instanceof BusinessError);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function defaultSleep(delayMs: number): Promise<void> {
|
|
45
|
+
return new Promise((resolve) => {
|
|
46
|
+
setTimeout(resolve, delayMs);
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Exponential backoff with jitter. The jitter matters more than the curve itself:
|
|
52
|
+
* without it every caller that failed during the same outage retries in lockstep
|
|
53
|
+
* and hammers the dependency back down.
|
|
54
|
+
*/
|
|
55
|
+
export function computeBackoffDelay(attempt: number, options: BackoffOptions): number {
|
|
56
|
+
const exponential = options.initialDelayMs * options.backoffFactor ** (attempt - 1);
|
|
57
|
+
const capped = Math.min(exponential, options.maxDelayMs);
|
|
58
|
+
const fixed = capped * options.jitterRatio;
|
|
59
|
+
return Math.round(fixed + (capped - fixed) * options.random());
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Run `task`, retrying transient failures until the attempt budget is spent, then
|
|
64
|
+
* rethrowing the last error. Vendor-free by design: callers pass their own
|
|
65
|
+
* `shouldRetry` (e.g. built on the Discord API predicates) so this stays usable
|
|
66
|
+
* for any I/O.
|
|
67
|
+
*/
|
|
68
|
+
export async function withRetry<T>(task: () => Promise<T>, options: RetryOptions = {}): Promise<T> {
|
|
69
|
+
// `Math.max` propagates NaN, and `attempt <= NaN` is false on the first pass:
|
|
70
|
+
// an unusable budget would skip the task entirely and reject with `undefined`.
|
|
71
|
+
// Floor to a valid count so a bad config still runs the task once.
|
|
72
|
+
const requested = options.attempts ?? DEFAULT_ATTEMPTS;
|
|
73
|
+
const attempts = Number.isFinite(requested)
|
|
74
|
+
? Math.max(MIN_ATTEMPTS, Math.floor(requested))
|
|
75
|
+
: DEFAULT_ATTEMPTS;
|
|
76
|
+
const shouldRetry = options.shouldRetry ?? isTransient;
|
|
77
|
+
const sleep = options.sleep ?? defaultSleep;
|
|
78
|
+
const backoff: BackoffOptions = {
|
|
79
|
+
initialDelayMs: options.initialDelayMs ?? DEFAULT_INITIAL_DELAY_MS,
|
|
80
|
+
maxDelayMs: options.maxDelayMs ?? DEFAULT_MAX_DELAY_MS,
|
|
81
|
+
backoffFactor: options.backoffFactor ?? DEFAULT_BACKOFF_FACTOR,
|
|
82
|
+
jitterRatio: options.jitterRatio ?? DEFAULT_JITTER_RATIO,
|
|
83
|
+
random: options.random ?? Math.random,
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
let lastError: unknown;
|
|
87
|
+
for (let attempt = MIN_ATTEMPTS; attempt <= attempts; attempt += 1) {
|
|
88
|
+
try {
|
|
89
|
+
return await task();
|
|
90
|
+
} catch (error) {
|
|
91
|
+
lastError = error;
|
|
92
|
+
const isLastAttempt = attempt === attempts;
|
|
93
|
+
if (isLastAttempt || !shouldRetry(error, attempt)) {
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
96
|
+
await sleep(computeBackoffDelay(attempt, backoff));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
// Unreachable — the loop always returns or throws — but the compiler cannot
|
|
100
|
+
// prove the budget is non-empty, and a silent `undefined` would be worse.
|
|
101
|
+
throw lastError;
|
|
102
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Failures raised while a job's schedule is resolved. They live apart from the
|
|
3
|
+
* scheduler so a composition root can catch or re-map them without importing the
|
|
4
|
+
* scheduling logic, and so the scheduler file stays focused on behaviour.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Base class for every malformed-schedule failure, so callers can catch the family. */
|
|
8
|
+
export abstract class JobScheduleError extends Error {}
|
|
9
|
+
|
|
10
|
+
/** Raised when a job declares both a cron expression and a fixed interval. */
|
|
11
|
+
export class AmbiguousJobScheduleError extends JobScheduleError {
|
|
12
|
+
constructor(jobName: string) {
|
|
13
|
+
super(`Job ${jobName} declares both "cron" and "intervalMs": pick exactly one`);
|
|
14
|
+
this.name = "AmbiguousJobScheduleError";
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Raised when a job declares neither a cron expression nor a fixed interval. */
|
|
19
|
+
export class MissingJobScheduleError extends JobScheduleError {
|
|
20
|
+
constructor(jobName: string) {
|
|
21
|
+
super(`Job ${jobName} declares no schedule: it needs either "cron" or "intervalMs"`);
|
|
22
|
+
this.name = "MissingJobScheduleError";
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Raised when a fixed interval is not a usable delay (non-finite or non-positive). */
|
|
27
|
+
export class InvalidJobIntervalError extends JobScheduleError {
|
|
28
|
+
constructor(jobName: string, intervalMs: number) {
|
|
29
|
+
super(`Job ${jobName} declares an invalid "intervalMs" (${intervalMs}): expected > 0`);
|
|
30
|
+
this.name = "InvalidJobIntervalError";
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Raised when two jobs declare the same stable identifier. */
|
|
35
|
+
export class DuplicateJobNameError extends JobScheduleError {
|
|
36
|
+
constructor(jobName: string) {
|
|
37
|
+
super(`Job ${jobName} is declared more than once: scheduled job names must be unique`);
|
|
38
|
+
this.name = "DuplicateJobNameError";
|
|
39
|
+
}
|
|
40
|
+
}
|