@podosoft/podokit 1.4.2 → 1.6.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/README.md +2 -1
- package/dist/app-proxy-timeouts.d.ts +27 -0
- package/dist/app-proxy-timeouts.js +180 -0
- package/dist/assemble.d.ts +5 -2
- package/dist/assemble.js +14 -4
- package/dist/deploy-checks.d.ts +44 -0
- package/dist/deploy-checks.js +267 -0
- package/dist/deploy-compose-profile.d.ts +6 -2
- package/dist/deploy-compose-profile.js +15 -6
- package/dist/deploy-compose-render.d.ts +12 -4
- package/dist/deploy-compose-render.js +47 -17
- package/dist/deploy-compose.d.ts +2 -5
- package/dist/deploy-compose.js +7 -2
- package/dist/deploy-profile.d.ts +6 -2
- package/dist/deploy-profile.js +11 -7
- package/dist/deploy-render.d.ts +4 -1
- package/dist/deploy-render.js +28 -20
- package/dist/deploy-schema.d.ts +61 -0
- package/dist/deploy-schema.js +157 -1
- package/dist/deploy.d.ts +2 -5
- package/dist/deploy.js +5 -2
- package/dist/dev.d.ts +21 -0
- package/dist/dev.js +78 -3
- package/dist/eject.js +2 -0
- package/dist/index.js +85 -5
- package/dist/lib.d.ts +2 -0
- package/dist/lib.js +5 -2
- package/dist/lockfile.d.ts +24 -0
- package/dist/lockfile.js +77 -5
- package/dist/provider.d.ts +1 -0
- package/dist/provider.js +12 -4
- package/dist/remove.js +1 -1
- package/dist/template-files.d.ts +42 -0
- package/dist/template-files.js +102 -0
- package/dist/templates/fullstack/AGENTS.md +44 -7
- package/dist/templates/fullstack/README.md +8 -7
- package/dist/templates/fullstack/apps/api/package.json +4 -4
- package/dist/templates/fullstack/apps/api/scripts/build.extensions.mjs +6 -0
- package/dist/templates/fullstack/apps/api/scripts/build.mjs +65 -16
- package/dist/templates/fullstack/apps/api/scripts/run-migrations.mjs +7 -3
- package/dist/templates/fullstack/apps/api/scripts/verify-api-contract.ts +5 -1
- package/dist/templates/fullstack/apps/api/src/app.extensions.ts +7 -4
- package/dist/templates/fullstack/apps/api/src/app.spec.ts +22 -0
- package/dist/templates/fullstack/apps/api/src/app.ts +47 -28
- package/dist/templates/fullstack/apps/api/src/build.spec.ts +118 -0
- package/dist/templates/fullstack/apps/api/src/config/env.extensions.ts +20 -0
- package/dist/templates/fullstack/apps/api/src/config/env.validation.spec.ts +76 -0
- package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +46 -3
- package/dist/templates/fullstack/apps/api/src/config/provider-selection.spec.ts +53 -0
- package/dist/templates/fullstack/apps/api/src/config/provider-selection.ts +79 -0
- package/dist/templates/fullstack/apps/api/src/config/providers.ts +8 -2
- package/dist/templates/fullstack/apps/api/src/config/server-options.spec.ts +17 -6
- package/dist/templates/fullstack/apps/api/src/config/server-options.ts +5 -8
- package/dist/templates/fullstack/apps/api/src/core/api-contract.extensions.ts +6 -0
- package/dist/templates/fullstack/apps/api/src/core/api-contract.spec.ts +29 -0
- package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +39 -2
- package/dist/templates/fullstack/apps/api/src/core/client-address.spec.ts +82 -0
- package/dist/templates/fullstack/apps/api/src/core/client-address.ts +68 -0
- package/dist/templates/fullstack/apps/api/src/core/services.ts +43 -0
- package/dist/templates/fullstack/apps/api/src/database/data-source.spec.ts +27 -0
- package/dist/templates/fullstack/apps/api/src/database/data-source.ts +7 -10
- package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +12 -0
- package/dist/templates/fullstack/apps/api/src/database/database.ts +2 -3
- package/dist/templates/fullstack/apps/api/src/database/entities.ts +12 -0
- package/dist/templates/fullstack/apps/api/src/database/migrate.ts +12 -2
- package/dist/templates/fullstack/apps/api/src/database/migration-files.spec.ts +116 -0
- package/dist/templates/fullstack/apps/api/src/database/migration-files.ts +134 -0
- package/dist/templates/fullstack/apps/api/src/database/migration-readiness.spec.ts +36 -0
- package/dist/templates/fullstack/apps/api/src/database/migration-readiness.ts +25 -0
- package/dist/templates/fullstack/apps/api/src/database/run-migrations.spec.ts +12 -1
- package/dist/templates/fullstack/apps/api/src/database/sqlite-migrator.ts +2 -36
- package/dist/templates/fullstack/apps/api/src/main.ts +2 -2
- package/dist/templates/fullstack/apps/api/tsconfig.json +2 -0
- package/dist/templates/fullstack/apps/web/Dockerfile +4 -0
- package/dist/templates/fullstack/apps/web/package.json +1 -1
- package/dist/templates/fullstack/apps/web/src/app.css +3 -0
- package/dist/templates/fullstack/apps/web/src/app.extensions.css +11 -0
- package/dist/templates/fullstack/apps/web/src/lib/components/site-runtime.svelte +3 -1
- package/dist/templates/fullstack/apps/web/src/lib/server/api.test.ts +19 -0
- package/dist/templates/fullstack/apps/web/src/lib/server/api.ts +4 -1
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +370 -36
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +250 -41
- package/dist/templates/fullstack/apps/web/src/lib/server/body-limits.ts +5 -2
- package/dist/templates/fullstack/apps/web/src/lib/server/proxy-timeouts.ts +23 -0
- package/dist/templates/fullstack/apps/web/tsconfig.json +1 -1
- package/dist/templates/fullstack/apps/web/vite.config.test.ts +33 -0
- package/dist/templates/fullstack/apps/web/vite.config.ts +55 -13
- package/dist/templates/fullstack/apps/web/vite.extensions.ts +23 -0
- package/dist/templates/fullstack/compose.dev.yaml +7 -4
- package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/SKILL.md +2 -2
- package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +12 -3
- package/dist/templates/fullstack/dot-claude/skills/podokit-update/SKILL.md +5 -2
- package/dist/templates/fullstack/dot-env.docker.example +7 -2
- package/dist/templates/fullstack/dot-env.example +21 -1
- package/dist/templates/fullstack/dot-podokit/dev.json +1 -1
- package/dist/templates/fullstack/infra/k3s/configmap.yaml +4 -0
- package/dist/templates/fullstack/tests/README.md +40 -1
- package/dist/templates/fullstack/tests/helpers/app-paths.ts +18 -0
- package/dist/templates/fullstack/tests/helpers/paths.ts +33 -0
- package/dist/templates/fullstack/tests/package.json +2 -2
- package/dist/templates/fullstack/tests/playwright.config.ts +7 -3
- package/dist/templates/fullstack/tests/playwright.extensions.ts +88 -1
- package/dist/templates/fullstack/tests/ui/smoke.ui.spec.ts +5 -4
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/account-sessions/account-sessions.routes.spec.ts +152 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/account-sessions/account-sessions.routes.ts +112 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.revoker.ts +24 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.spec.ts +18 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.ts +36 -13
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin.module.ts +13 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.test.ts +37 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +15 -6
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-sessions.test.ts +33 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-sessions.ts +34 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-flag-settings.test.ts +31 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-flag-settings.ts +31 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-flags.ts +12 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-nav.ts +11 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/nav.test.ts +39 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/nav.ts +35 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/registry.svelte.ts +15 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +73 -39
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/admin-sidebar.svelte +2 -23
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/data-table.svelte +7 -2
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/data-table.test.ts +30 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/impersonation-banner.svelte +3 -2
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/settings/app-flags-settings.svelte +65 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/sidebar-user-menu.svelte +32 -23
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/sidebar-user-menu.test.ts +27 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-footer.svelte +4 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-runtime.svelte +3 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/table-toolbar.svelte +3 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/en.json +6 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/ko.json +6 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/app-hooks.ts +11 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/non-indexable-paths.ts +8 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/search-indexing.test.ts +31 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/search-indexing.ts +5 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/settings/+page.svelte +1 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/account/+page.server.ts +2 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/account/+page.svelte +1 -5
- package/dist/templates/modules/admin-dashboard/files/dot-claude/skills/podokit-data-table/SKILL.md +2 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/account-sessions.api.spec.ts +55 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +159 -170
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +72 -81
- package/dist/templates/modules/admin-dashboard/files/tests/api/email.api.spec.ts +1 -6
- package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +43 -53
- package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +29 -1
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/accounts.ts +24 -6
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/app-accounts.ts +24 -0
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/devices.ts +55 -0
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/disposable-users.ts +5 -3
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/mailpit.ts +3 -9
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/sms.ts +2 -4
- package/dist/templates/modules/admin-dashboard/files/tests/playwright.config.ts +11 -5
- package/dist/templates/modules/admin-dashboard/files/tests/seed.setup.ts +3 -10
- package/dist/templates/modules/admin-dashboard/files/tests/ui/account-flows.ui.spec.ts +4 -5
- package/dist/templates/modules/admin-dashboard/files/tests/ui/account-sessions.ui.spec.ts +46 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +62 -59
- package/dist/templates/modules/admin-dashboard/files/tests/ui/app-flags.ui.spec.ts +63 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/appearance.ui.spec.ts +12 -16
- package/dist/templates/modules/admin-dashboard/files/tests/ui/audit.ui.spec.ts +3 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/auth.ui.spec.ts +32 -35
- package/dist/templates/modules/admin-dashboard/files/tests/ui/dashboard.ui.spec.ts +4 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/email-admin.ui.spec.ts +1 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +1 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/general-effects.ui.spec.ts +82 -91
- package/dist/templates/modules/admin-dashboard/files/tests/ui/guards.ui.spec.ts +6 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/i18n.ui.spec.ts +16 -20
- package/dist/templates/modules/admin-dashboard/files/tests/ui/impersonation.ui.spec.ts +6 -3
- package/dist/templates/modules/admin-dashboard/files/tests/ui/login-methods.ui.spec.ts +2 -3
- package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +2 -4
- package/dist/templates/modules/admin-dashboard/files/tests/ui/organizations.ui.spec.ts +5 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/passkey.ui.spec.ts +7 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/same-origin-requests.ui.spec.ts +89 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/sessions.ui.spec.ts +5 -4
- package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +67 -73
- package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +84 -93
- package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +33 -7
- package/dist/templates/modules/admin-dashboard/files/tests/ui/theme-mode.ui.spec.ts +29 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +51 -54
- package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +10 -9
- package/dist/templates/modules/admin-dashboard/module.manifest.json +7 -2
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +2 -1
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.module.ts +5 -1
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.spec.ts +33 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +20 -1
- package/dist/templates/modules/audit-log/module.manifest.json +15 -3
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-app-name.spec.ts +21 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-config-store.ts +13 -1
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-environment.ts +9 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +19 -3
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.module.ts +6 -1
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.runtime.spec.ts +10 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.spec.ts +54 -6
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +26 -4
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +15 -8
- package/dist/templates/modules/auth/files/apps/api/src/auth/revoke-other-sessions.spec.ts +56 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/revoke-other-sessions.ts +45 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/schema-recheck.spec.ts +86 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/schema-recheck.ts +44 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +2 -1
- package/dist/templates/modules/auth/files/apps/api/src/migrate.ts +3 -1
- package/dist/templates/modules/auth/files/apps/api/src/settings/app-flags.ts +9 -0
- package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.spec.ts +43 -0
- package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.ts +30 -5
- package/dist/templates/modules/auth/module.manifest.json +8 -2
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.spec.ts +57 -1
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.ts +93 -9
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/worker.module.spec.ts +40 -0
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/worker.module.ts +52 -5
- package/dist/templates/modules/bullmq/files/apps/api/src/main-worker.ts +9 -7
- package/dist/templates/modules/bullmq/files/apps/api/src/worker.extensions.ts +13 -0
- package/dist/templates/modules/bullmq/module.manifest.json +5 -1
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.worker.spec.ts +2 -0
- package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.spec.ts +87 -1
- package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.ts +93 -3
- package/dist/templates/modules/jobs-local/module.manifest.json +3 -0
- package/dist/templates/modules/logging/files/apps/api/src/logging/logging.module.spec.ts +16 -0
- package/dist/templates/modules/logging/files/apps/api/src/logging/logging.module.ts +15 -10
- package/dist/templates/modules/mailer/module.manifest.json +1 -1
- package/dist/templates/modules/object-storage-local/files/tests/api/storage-local.api.spec.ts +1 -1
- package/dist/templates/modules/object-storage-s3/files/infra/docker/minio.compose.yml +5 -4
- package/dist/templates/modules/object-storage-s3/files/tests/api/storage.api.spec.ts +1 -1
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.spec.ts +2 -59
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.ts +0 -65
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.spec.ts +82 -21
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.ts +54 -15
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.spec.ts +43 -5
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +10 -2
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.spec.ts +61 -3
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.ts +48 -15
- package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +63 -27
- package/dist/templates/modules/rate-limit/module.manifest.json +6 -9
- package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +1 -1
- package/dist/templates/todo/AGENTS.md +46 -6
- package/dist/templates/todo/README.md +6 -4
- package/dist/text-diff.d.ts +12 -0
- package/dist/text-diff.js +111 -0
- package/dist/update.d.ts +61 -3
- package/dist/update.js +152 -27
- package/package.json +2 -2
- package/dist/templates/modules/admin-dashboard/files/tests/api/exclusive.api.spec.ts +0 -75
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/exclusive.ts +0 -80
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import { AppException } from "@podosoft/podokit-contracts";
|
|
2
2
|
import { Queue, RedisConnection, Worker, type ConnectionOptions, type RedisClient } from "bullmq";
|
|
3
|
-
import
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
3
|
+
import cronParser from "cron-parser";
|
|
4
|
+
import {
|
|
5
|
+
assertRepeatOptions,
|
|
6
|
+
type EnqueueOptions,
|
|
7
|
+
type JobHandler,
|
|
8
|
+
type JobQueue,
|
|
9
|
+
type JobReference,
|
|
10
|
+
type JobSnapshot,
|
|
11
|
+
type JobStatus,
|
|
12
|
+
type RepeatOptions,
|
|
13
|
+
type Unsubscribe,
|
|
11
14
|
} from "@podosoft/podokit-runtime";
|
|
12
15
|
import type { SupervisedConnection } from "../core/services";
|
|
13
16
|
import { producerConnection, redisConnection } from "./queue";
|
|
@@ -45,6 +48,24 @@ export function jobsUnavailable(): AppException {
|
|
|
45
48
|
return new AppException("JOBS_UNAVAILABLE", "Background jobs are temporarily unavailable.", 503);
|
|
46
49
|
}
|
|
47
50
|
|
|
51
|
+
function isJobsUnavailable(error: unknown): boolean {
|
|
52
|
+
return error instanceof AppException && error.code === "JOBS_UNAVAILABLE";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface Schedule {
|
|
56
|
+
name: string;
|
|
57
|
+
payload: unknown;
|
|
58
|
+
repeat: RepeatOptions;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Rejects a schedule BullMQ could not run, before it is kept. */
|
|
62
|
+
function assertSchedule(repeat: RepeatOptions): void {
|
|
63
|
+
assertRepeatOptions(repeat);
|
|
64
|
+
if (repeat.cron !== undefined) {
|
|
65
|
+
cronParser.parseExpression(repeat.cron, { tz: repeat.timezone ?? "UTC" }).next();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
48
69
|
/**
|
|
49
70
|
* Adds and reads jobs over one producer connection that the service registry
|
|
50
71
|
* supervises, and runs in-process handlers on BullMQ workers, which keep their
|
|
@@ -53,6 +74,9 @@ export function jobsUnavailable(): AppException {
|
|
|
53
74
|
export class BullMqJobQueue implements JobQueue, SupervisedConnection {
|
|
54
75
|
private readonly queues = new Map<string, Queue>();
|
|
55
76
|
private readonly workers = new Map<string, Worker>();
|
|
77
|
+
private readonly schedules = new Map<string, Schedule>();
|
|
78
|
+
// Schedules to delete from Redis once it is reachable: id to queue name.
|
|
79
|
+
private readonly removals = new Map<string, string>();
|
|
56
80
|
private producer: RedisConnection;
|
|
57
81
|
private lostListener?: (reason: unknown) => void;
|
|
58
82
|
private closed = false;
|
|
@@ -65,10 +89,17 @@ export class BullMqJobQueue implements JobQueue, SupervisedConnection {
|
|
|
65
89
|
this.lostListener = listener;
|
|
66
90
|
}
|
|
67
91
|
|
|
68
|
-
/**
|
|
92
|
+
/**
|
|
93
|
+
* Opens the producer connection and applies every schedule, so a Redis that
|
|
94
|
+
* lost them, such as one restarted without persistence, gets them back.
|
|
95
|
+
*/
|
|
69
96
|
async connect(): Promise<void> {
|
|
70
97
|
const client = await this.producer.client;
|
|
71
98
|
if (client.status !== "ready") await client.connect();
|
|
99
|
+
await this.command(async (ready) => {
|
|
100
|
+
for (const id of [...this.removals.keys()]) await this.removeSchedule(ready, id);
|
|
101
|
+
for (const id of this.schedules.keys()) await this.applySchedule(ready, id);
|
|
102
|
+
});
|
|
72
103
|
}
|
|
73
104
|
|
|
74
105
|
/** Rejects while Redis is unreachable or slower to answer than `REDIS_COMMAND_TIMEOUT_MS`. */
|
|
@@ -110,6 +141,27 @@ export class BullMqJobQueue implements JobQueue, SupervisedConnection {
|
|
|
110
141
|
};
|
|
111
142
|
}
|
|
112
143
|
|
|
144
|
+
async schedule(id: string, name: string, payload: unknown, repeat: RepeatOptions): Promise<void> {
|
|
145
|
+
assertSchedule(repeat);
|
|
146
|
+
const previous = this.schedules.get(id);
|
|
147
|
+
// A scheduler lives in its queue, so a schedule that moves leaves the old one.
|
|
148
|
+
if (previous && previous.name !== name) this.removals.set(id, previous.name);
|
|
149
|
+
if (this.removals.get(id) === name) this.removals.delete(id);
|
|
150
|
+
this.schedules.set(id, { name, payload, repeat });
|
|
151
|
+
await this.whenConnected(async (client) => {
|
|
152
|
+
if (this.removals.has(id)) await this.removeSchedule(client, id);
|
|
153
|
+
await this.applySchedule(client, id);
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async unschedule(id: string): Promise<void> {
|
|
158
|
+
const schedule = this.schedules.get(id);
|
|
159
|
+
if (!schedule) return;
|
|
160
|
+
this.schedules.delete(id);
|
|
161
|
+
this.removals.set(id, schedule.name);
|
|
162
|
+
await this.whenConnected((client) => this.removeSchedule(client, id));
|
|
163
|
+
}
|
|
164
|
+
|
|
113
165
|
process(name: string, handler: JobHandler): Promise<Unsubscribe> {
|
|
114
166
|
if (this.workers.has(name)) throw new Error(`A handler is already registered for job ${name}`);
|
|
115
167
|
const worker = new Worker(
|
|
@@ -140,6 +192,38 @@ export class BullMqJobQueue implements JobQueue, SupervisedConnection {
|
|
|
140
192
|
await this.producer.close(true);
|
|
141
193
|
}
|
|
142
194
|
|
|
195
|
+
private async applySchedule(client: RedisClient, id: string): Promise<void> {
|
|
196
|
+
const schedule = this.schedules.get(id);
|
|
197
|
+
if (!schedule) return;
|
|
198
|
+
const { cron, everyMs, timezone } = schedule.repeat;
|
|
199
|
+
await this.queue(schedule.name, client).upsertJobScheduler(
|
|
200
|
+
id,
|
|
201
|
+
cron !== undefined ? { pattern: cron, tz: timezone ?? "UTC" } : { every: everyMs },
|
|
202
|
+
{ name: schedule.name, data: schedule.payload },
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
private async removeSchedule(client: RedisClient, id: string): Promise<void> {
|
|
207
|
+
const name = this.removals.get(id);
|
|
208
|
+
if (name === undefined) return;
|
|
209
|
+
await this.queue(name, client).removeJobScheduler(id);
|
|
210
|
+
if (this.removals.get(id) === name) this.removals.delete(id);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Runs `send` while the producer is connected. Otherwise, or when Redis goes
|
|
215
|
+
* away during it, the next connect() applies the change instead.
|
|
216
|
+
*/
|
|
217
|
+
private async whenConnected(send: (client: RedisClient) => Promise<void>): Promise<void> {
|
|
218
|
+
const client = await this.producer.client;
|
|
219
|
+
if (client.status !== "ready") return;
|
|
220
|
+
try {
|
|
221
|
+
await this.command(send);
|
|
222
|
+
} catch (error) {
|
|
223
|
+
if (!isJobsUnavailable(error)) throw error;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
143
227
|
/**
|
|
144
228
|
* Runs `send` on the producer connection and fails it with JOBS_UNAVAILABLE
|
|
145
229
|
* while the connection is closed or when Redis does not answer in time.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import type { Processor } from "bullmq";
|
|
3
|
+
import { DEMO_QUEUE } from "./queue";
|
|
4
|
+
import { collectWorkers, type WorkerDefinition } from "./worker.module";
|
|
5
|
+
|
|
6
|
+
const processor: Processor = async () => undefined;
|
|
7
|
+
|
|
8
|
+
describe("worker definitions", () => {
|
|
9
|
+
it("adds the application's queues after PodoKit's and releases them last first", async () => {
|
|
10
|
+
const released: string[] = [];
|
|
11
|
+
const collected = await collectWorkers(async (setup) => {
|
|
12
|
+
setup.onClose(() => { released.push("database"); });
|
|
13
|
+
setup.onClose(async () => { released.push("cache"); });
|
|
14
|
+
return [{ queue: "reports", processor, options: { concurrency: 2 } }];
|
|
15
|
+
});
|
|
16
|
+
expect(collected.definitions.map((definition) => definition.queue)).toEqual([DEMO_QUEUE, "reports"]);
|
|
17
|
+
expect(collected.definitions[1]?.options).toEqual({ concurrency: 2 });
|
|
18
|
+
await collected.release();
|
|
19
|
+
expect(released).toEqual(["cache", "database"]);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("refuses two workers for one queue and releases what was opened", async () => {
|
|
23
|
+
const released: string[] = [];
|
|
24
|
+
const duplicate: WorkerDefinition = { queue: DEMO_QUEUE, processor };
|
|
25
|
+
await expect(collectWorkers(async (setup) => {
|
|
26
|
+
setup.onClose(() => { released.push("database"); });
|
|
27
|
+
return [duplicate];
|
|
28
|
+
})).rejects.toThrow(`More than one worker for queue ${DEMO_QUEUE}`);
|
|
29
|
+
expect(released).toEqual(["database"]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("releases what was opened when the application setup fails", async () => {
|
|
33
|
+
const released: string[] = [];
|
|
34
|
+
await expect(collectWorkers(async (setup) => {
|
|
35
|
+
setup.onClose(() => { released.push("database"); });
|
|
36
|
+
throw new Error("no database");
|
|
37
|
+
})).rejects.toThrow("no database");
|
|
38
|
+
expect(released).toEqual(["database"]);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -1,12 +1,25 @@
|
|
|
1
|
-
import { Worker, type Processor } from "bullmq";
|
|
1
|
+
import { Worker, type Processor, type WorkerOptions } from "bullmq";
|
|
2
|
+
import { configureWorkers } from "../worker.extensions";
|
|
2
3
|
import { processDemoJob } from "./demo.processor";
|
|
3
4
|
import { DEMO_QUEUE, redisConnection } from "./queue";
|
|
4
5
|
// podokit:begin:worker-imports
|
|
5
6
|
// podokit:end:worker-imports
|
|
6
7
|
|
|
7
|
-
interface WorkerDefinition {
|
|
8
|
+
export interface WorkerDefinition {
|
|
8
9
|
queue: string;
|
|
9
10
|
processor: Processor;
|
|
11
|
+
/** BullMQ worker options other than the connection, such as `concurrency`. */
|
|
12
|
+
options?: Omit<WorkerOptions, "connection">;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface WorkerSetup {
|
|
16
|
+
/** Runs `close` when the worker process stops, after its workers; the last one registered runs first. */
|
|
17
|
+
onClose(close: () => void | Promise<void>): void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface WorkerRuntime {
|
|
21
|
+
workers: Worker[];
|
|
22
|
+
close(): Promise<void>;
|
|
10
23
|
}
|
|
11
24
|
|
|
12
25
|
const definitions: WorkerDefinition[] = [
|
|
@@ -15,8 +28,42 @@ const definitions: WorkerDefinition[] = [
|
|
|
15
28
|
// podokit:end:worker-processors
|
|
16
29
|
];
|
|
17
30
|
|
|
18
|
-
export
|
|
19
|
-
|
|
20
|
-
|
|
31
|
+
export interface CollectedWorkers {
|
|
32
|
+
definitions: WorkerDefinition[];
|
|
33
|
+
/** Releases what `configure` opened. */
|
|
34
|
+
release(): Promise<void>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** PodoKit's queues followed by the application's from worker.extensions.ts; one worker per queue. */
|
|
38
|
+
export async function collectWorkers(
|
|
39
|
+
configure: (setup: WorkerSetup) => Promise<WorkerDefinition[]> = configureWorkers,
|
|
40
|
+
): Promise<CollectedWorkers> {
|
|
41
|
+
const closers: Array<() => void | Promise<void>> = [];
|
|
42
|
+
const release = async (): Promise<void> => {
|
|
43
|
+
for (const close of closers.splice(0).reverse()) await close();
|
|
44
|
+
};
|
|
45
|
+
try {
|
|
46
|
+
const all = [...definitions, ...await configure({ onClose: (close) => closers.push(close) })];
|
|
47
|
+
const queues = all.map((definition) => definition.queue);
|
|
48
|
+
const repeated = queues.filter((queue, index) => queues.indexOf(queue) !== index);
|
|
49
|
+
if (repeated.length > 0) throw new Error(`More than one worker for queue ${[...new Set(repeated)].join(", ")}`);
|
|
50
|
+
return { definitions: all, release };
|
|
51
|
+
} catch (error) {
|
|
52
|
+
await release();
|
|
53
|
+
throw error;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function startWorkers(): Promise<WorkerRuntime> {
|
|
58
|
+
const { definitions: all, release } = await collectWorkers();
|
|
59
|
+
const workers = all.map(({ queue, processor, options }) =>
|
|
60
|
+
new Worker(queue, processor, { ...options, connection: redisConnection() })
|
|
21
61
|
);
|
|
62
|
+
return {
|
|
63
|
+
workers,
|
|
64
|
+
close: async (): Promise<void> => {
|
|
65
|
+
await Promise.all(workers.map((worker) => worker.close()));
|
|
66
|
+
await release();
|
|
67
|
+
},
|
|
68
|
+
};
|
|
22
69
|
}
|
|
@@ -6,16 +6,18 @@ if (PROVIDERS.jobs !== "bullmq") {
|
|
|
6
6
|
throw new Error("The BullMQ worker cannot start unless jobs=bullmq is selected");
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
-
const
|
|
9
|
+
const runtime = startWorkers();
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
}
|
|
11
|
+
void runtime.then(({ workers }) => {
|
|
12
|
+
for (const worker of workers) {
|
|
13
|
+
worker.on("failed", (job, error) => {
|
|
14
|
+
process.stderr.write(`Process job failed: ${job?.id ?? "unknown"} ${error.message}\n`);
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
});
|
|
16
18
|
|
|
17
19
|
for (const signal of ["SIGINT", "SIGTERM"] as const) {
|
|
18
20
|
process.once(signal, () => {
|
|
19
|
-
void
|
|
21
|
+
void runtime.then((started) => started.close()).finally(() => process.exit(0));
|
|
20
22
|
});
|
|
21
23
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { WorkerDefinition, WorkerSetup } from "./jobs/worker.module";
|
|
2
|
+
|
|
3
|
+
// This owned file is never overwritten by `podo update`. Return the queues the
|
|
4
|
+
// BullMQ worker process (`main-worker.ts`) consumes for the application, next to
|
|
5
|
+
// PodoKit's. Open what the processors need here and release it with
|
|
6
|
+
// `setup.onClose`, which runs after the workers stop. For example:
|
|
7
|
+
//
|
|
8
|
+
// const database = new Database(validateEnv(process.env));
|
|
9
|
+
// setup.onClose(() => database.close());
|
|
10
|
+
// return [{ queue: "reports", processor: (job) => buildReport(database, job.data), options: { concurrency: 2 } }];
|
|
11
|
+
export async function configureWorkers(_setup: WorkerSetup): Promise<WorkerDefinition[]> {
|
|
12
|
+
return [];
|
|
13
|
+
}
|
|
@@ -16,9 +16,13 @@
|
|
|
16
16
|
"apps/api/src/jobs/**",
|
|
17
17
|
"apps/api/src/main-worker.ts"
|
|
18
18
|
],
|
|
19
|
+
"ownedGlobs": [
|
|
20
|
+
"apps/api/src/worker.extensions.ts"
|
|
21
|
+
],
|
|
19
22
|
"targetApp": "api",
|
|
20
23
|
"dependencies": {
|
|
21
|
-
"bullmq": "^5.57.0"
|
|
24
|
+
"bullmq": "^5.57.0",
|
|
25
|
+
"cron-parser": "^4.9.0"
|
|
22
26
|
},
|
|
23
27
|
"scripts": {
|
|
24
28
|
"start:worker": "bun dist/main-worker.js",
|
package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.spec.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { afterEach, describe, expect, it } from "bun:test";
|
|
2
2
|
import { SQL } from "bun";
|
|
3
|
-
import { LocalJobQueue } from "./local-job.queue";
|
|
3
|
+
import { LocalJobQueue, nextScheduledTime, type ScheduleTimer } from "./local-job.queue";
|
|
4
4
|
|
|
5
5
|
let sql: SQL | undefined;
|
|
6
6
|
|
|
@@ -66,3 +66,89 @@ describe("local job queue", () => {
|
|
|
66
66
|
queue.close();
|
|
67
67
|
});
|
|
68
68
|
});
|
|
69
|
+
|
|
70
|
+
/** A clock and timer the test moves by hand. */
|
|
71
|
+
function manualTime(start: number): { now: () => number; timer: ScheduleTimer; advance: (to: number) => void; waiting: () => number } {
|
|
72
|
+
let now = start;
|
|
73
|
+
const timers: Array<{ at: number; task: () => void; done: boolean }> = [];
|
|
74
|
+
return {
|
|
75
|
+
now: () => now,
|
|
76
|
+
timer: {
|
|
77
|
+
after: (delayMs, task) => {
|
|
78
|
+
const entry = { at: now + delayMs, task, done: false };
|
|
79
|
+
timers.push(entry);
|
|
80
|
+
return () => { entry.done = true; };
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
advance: (to) => {
|
|
84
|
+
for (;;) {
|
|
85
|
+
const due = timers.filter((entry) => !entry.done && entry.at <= to).sort((a, b) => a.at - b.at)[0];
|
|
86
|
+
if (!due) break;
|
|
87
|
+
due.done = true;
|
|
88
|
+
now = due.at;
|
|
89
|
+
due.task();
|
|
90
|
+
}
|
|
91
|
+
now = to;
|
|
92
|
+
},
|
|
93
|
+
waiting: () => timers.filter((entry) => !entry.done).length,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function scheduledJobs(database: SQL): Promise<string[]> {
|
|
98
|
+
// Scheduled jobs are added without waiting; let those inserts finish.
|
|
99
|
+
await Bun.sleep(5);
|
|
100
|
+
const rows = await database<Array<{ id: string }>>`SELECT "id" FROM "podokit_job" ORDER BY "createdAt", "id"`;
|
|
101
|
+
return rows.map((row) => row.id);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
describe("local job schedules", () => {
|
|
105
|
+
it("adds one job for each time of an interval and keeps going", async () => {
|
|
106
|
+
sql = new SQL(":memory:");
|
|
107
|
+
const time = manualTime(10_500);
|
|
108
|
+
const queue = new LocalJobQueue(sql, { pollIntervalMs: 60_000, now: time.now, timer: time.timer });
|
|
109
|
+
await queue.schedule("nightly", "report", { scope: "all" }, { everyMs: 1_000 });
|
|
110
|
+
await queue.start();
|
|
111
|
+
time.advance(11_000);
|
|
112
|
+
expect(await scheduledJobs(sql)).toEqual(["report:schedule:nightly:11000"]);
|
|
113
|
+
time.advance(13_200);
|
|
114
|
+
expect(await scheduledJobs(sql)).toEqual([
|
|
115
|
+
"report:schedule:nightly:11000",
|
|
116
|
+
"report:schedule:nightly:12000",
|
|
117
|
+
"report:schedule:nightly:13000",
|
|
118
|
+
]);
|
|
119
|
+
await expect(queue.get("report:schedule:nightly:12000")).resolves.toMatchObject({ name: "report", status: "waiting" });
|
|
120
|
+
queue.close();
|
|
121
|
+
expect(time.waiting()).toBe(0);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("replaces a schedule with the same id and stops one on unschedule", async () => {
|
|
125
|
+
sql = new SQL(":memory:");
|
|
126
|
+
const time = manualTime(0);
|
|
127
|
+
const queue = new LocalJobQueue(sql, { pollIntervalMs: 60_000, now: time.now, timer: time.timer });
|
|
128
|
+
await queue.start();
|
|
129
|
+
await queue.schedule("sync", "sync", {}, { everyMs: 1_000 });
|
|
130
|
+
await queue.schedule("sync", "sync", {}, { everyMs: 5_000 });
|
|
131
|
+
time.advance(5_000);
|
|
132
|
+
expect(await scheduledJobs(sql)).toEqual(["sync:schedule:sync:5000"]);
|
|
133
|
+
await queue.unschedule("sync");
|
|
134
|
+
time.advance(20_000);
|
|
135
|
+
expect(await scheduledJobs(sql)).toEqual(["sync:schedule:sync:5000"]);
|
|
136
|
+
queue.close();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("reads cron expressions in the schedule's time zone, UTC by default", () => {
|
|
140
|
+
const after = Date.UTC(2026, 0, 1, 0, 0);
|
|
141
|
+
expect(nextScheduledTime({ cron: "0 3 * * *" }, after)).toBe(Date.UTC(2026, 0, 1, 3, 0));
|
|
142
|
+
expect(nextScheduledTime({ cron: "0 3 * * *", timezone: "Asia/Seoul" }, after)).toBe(Date.UTC(2026, 0, 1, 18, 0));
|
|
143
|
+
expect(nextScheduledTime({ everyMs: 60_000 }, after + 1)).toBe(after + 60_000);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("rejects an invalid schedule before it is kept", async () => {
|
|
147
|
+
sql = new SQL(":memory:");
|
|
148
|
+
const queue = new LocalJobQueue(sql);
|
|
149
|
+
await expect(queue.schedule("bad", "report", {}, { cron: "61 * * * *" })).rejects.toThrow();
|
|
150
|
+
await expect(queue.schedule("bad", "report", {}, { cron: "0 3 * * *", timezone: "Mars/Olympus" })).rejects.toThrow();
|
|
151
|
+
await expect(queue.schedule("bad", "report", {}, { everyMs: 10 })).rejects.toThrow("at least 1000");
|
|
152
|
+
queue.close();
|
|
153
|
+
});
|
|
154
|
+
});
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import type { SQL } from "bun";
|
|
2
|
+
import cronParser from "cron-parser";
|
|
2
3
|
import {
|
|
4
|
+
assertRepeatOptions,
|
|
3
5
|
type EnqueueOptions,
|
|
4
6
|
type JobHandler,
|
|
5
7
|
type JobQueue,
|
|
6
8
|
type JobReference,
|
|
7
9
|
type JobSnapshot,
|
|
8
10
|
type JobStatus,
|
|
11
|
+
type RepeatOptions,
|
|
9
12
|
type Unsubscribe,
|
|
10
13
|
} from "@podosoft/podokit-runtime";
|
|
11
14
|
|
|
@@ -22,15 +25,52 @@ interface JobRow {
|
|
|
22
25
|
error: string | null;
|
|
23
26
|
}
|
|
24
27
|
|
|
28
|
+
export interface ScheduleTimer {
|
|
29
|
+
/** Runs `task` once after `delayMs`; the returned function cancels it. */
|
|
30
|
+
after(delayMs: number, task: () => void): () => void;
|
|
31
|
+
}
|
|
32
|
+
|
|
25
33
|
export interface LocalJobQueueOptions {
|
|
26
34
|
pollIntervalMs?: number;
|
|
27
35
|
now?: () => number;
|
|
36
|
+
/** Waits for schedules; an unref'd setTimeout by default. */
|
|
37
|
+
timer?: ScheduleTimer;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface Schedule {
|
|
41
|
+
id: string;
|
|
42
|
+
name: string;
|
|
43
|
+
payload: unknown;
|
|
44
|
+
repeat: RepeatOptions;
|
|
45
|
+
cancel?: () => void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// setTimeout fires at once for a delay above 2^31 - 1 ms, so longer waits are split.
|
|
49
|
+
const MAX_TIMER_DELAY_MS = 2 ** 31 - 1;
|
|
50
|
+
|
|
51
|
+
const unrefTimer: ScheduleTimer = {
|
|
52
|
+
after: (delayMs, task) => {
|
|
53
|
+
const timer = setTimeout(task, delayMs);
|
|
54
|
+
timer.unref?.();
|
|
55
|
+
return () => clearTimeout(timer);
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** The first time after `after` (epoch milliseconds) that `repeat` adds a job. */
|
|
60
|
+
export function nextScheduledTime(repeat: RepeatOptions, after: number): number {
|
|
61
|
+
if (repeat.everyMs !== undefined) return (Math.floor(after / repeat.everyMs) + 1) * repeat.everyMs;
|
|
62
|
+
return cronParser
|
|
63
|
+
.parseExpression(repeat.cron ?? "", { currentDate: new Date(after), tz: repeat.timezone ?? "UTC" })
|
|
64
|
+
.next()
|
|
65
|
+
.getTime();
|
|
28
66
|
}
|
|
29
67
|
|
|
30
68
|
export class LocalJobQueue implements JobQueue {
|
|
31
69
|
private readonly handlers = new Map<string, JobHandler>();
|
|
70
|
+
private readonly schedules = new Map<string, Schedule>();
|
|
32
71
|
private readonly pollIntervalMs: number;
|
|
33
72
|
private readonly now: () => number;
|
|
73
|
+
private readonly scheduleTimer: ScheduleTimer;
|
|
34
74
|
private timer?: ReturnType<typeof setTimeout>;
|
|
35
75
|
private started = false;
|
|
36
76
|
private initialized?: Promise<void>;
|
|
@@ -44,6 +84,7 @@ export class LocalJobQueue implements JobQueue {
|
|
|
44
84
|
throw new Error("pollIntervalMs must be an integer of at least 10");
|
|
45
85
|
}
|
|
46
86
|
this.now = options.now ?? Date.now;
|
|
87
|
+
this.scheduleTimer = options.timer ?? unrefTimer;
|
|
47
88
|
}
|
|
48
89
|
|
|
49
90
|
initialize(): Promise<void> {
|
|
@@ -63,7 +104,8 @@ export class LocalJobQueue implements JobQueue {
|
|
|
63
104
|
if (this.started) return;
|
|
64
105
|
await this.initialize();
|
|
65
106
|
this.started = true;
|
|
66
|
-
this.
|
|
107
|
+
this.poll(0);
|
|
108
|
+
for (const schedule of this.schedules.values()) this.arm(schedule);
|
|
67
109
|
}
|
|
68
110
|
|
|
69
111
|
async enqueue(
|
|
@@ -118,6 +160,22 @@ export class LocalJobQueue implements JobQueue {
|
|
|
118
160
|
});
|
|
119
161
|
}
|
|
120
162
|
|
|
163
|
+
async schedule(id: string, name: string, payload: unknown, repeat: RepeatOptions): Promise<void> {
|
|
164
|
+
assertRepeatOptions(repeat);
|
|
165
|
+
// Rejects an invalid cron expression or time zone now rather than at the first run.
|
|
166
|
+
nextScheduledTime(repeat, this.now());
|
|
167
|
+
if (JSON.stringify(payload) === undefined) throw new Error("Job payload must be JSON serializable");
|
|
168
|
+
this.schedules.get(id)?.cancel?.();
|
|
169
|
+
const schedule: Schedule = { id, name, payload, repeat };
|
|
170
|
+
this.schedules.set(id, schedule);
|
|
171
|
+
if (this.started) this.arm(schedule);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async unschedule(id: string): Promise<void> {
|
|
175
|
+
this.schedules.get(id)?.cancel?.();
|
|
176
|
+
this.schedules.delete(id);
|
|
177
|
+
}
|
|
178
|
+
|
|
121
179
|
async runOnce(): Promise<boolean> {
|
|
122
180
|
await this.initialize();
|
|
123
181
|
const now = this.now();
|
|
@@ -157,9 +215,41 @@ export class LocalJobQueue implements JobQueue {
|
|
|
157
215
|
this.started = false;
|
|
158
216
|
if (this.timer) clearTimeout(this.timer);
|
|
159
217
|
this.timer = undefined;
|
|
218
|
+
for (const schedule of this.schedules.values()) schedule.cancel?.();
|
|
160
219
|
this.handlers.clear();
|
|
161
220
|
}
|
|
162
221
|
|
|
222
|
+
/**
|
|
223
|
+
* Adds the schedule's next job when its time comes. The job id names the
|
|
224
|
+
* schedule and the time, so a job is added once for each time.
|
|
225
|
+
*/
|
|
226
|
+
private arm(schedule: Schedule, after = this.now()): void {
|
|
227
|
+
const at = nextScheduledTime(schedule.repeat, after);
|
|
228
|
+
schedule.cancel = this.waitUntil(at, () => {
|
|
229
|
+
if (!this.started || this.schedules.get(schedule.id) !== schedule) return;
|
|
230
|
+
void this.enqueue(schedule.name, schedule.payload, { deduplicationKey: `schedule:${schedule.id}:${at}` })
|
|
231
|
+
.catch((error: unknown) => {
|
|
232
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
233
|
+
process.stderr.write(`Add scheduled job failed: ${schedule.id} ${message}\n`);
|
|
234
|
+
});
|
|
235
|
+
this.arm(schedule, Math.max(this.now(), at));
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
private waitUntil(at: number, task: () => void): () => void {
|
|
240
|
+
let cancel = (): void => undefined;
|
|
241
|
+
const step = (): void => {
|
|
242
|
+
const delay = at - this.now();
|
|
243
|
+
if (delay <= 0) {
|
|
244
|
+
task();
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
cancel = this.scheduleTimer.after(Math.min(delay, MAX_TIMER_DELAY_MS), step);
|
|
248
|
+
};
|
|
249
|
+
cancel = this.scheduleTimer.after(Math.max(0, Math.min(at - this.now(), MAX_TIMER_DELAY_MS)), step);
|
|
250
|
+
return () => cancel();
|
|
251
|
+
}
|
|
252
|
+
|
|
163
253
|
private async createTables(): Promise<void> {
|
|
164
254
|
await this.sql`
|
|
165
255
|
CREATE TABLE IF NOT EXISTS "podokit_job" (
|
|
@@ -181,7 +271,7 @@ export class LocalJobQueue implements JobQueue {
|
|
|
181
271
|
`;
|
|
182
272
|
}
|
|
183
273
|
|
|
184
|
-
private
|
|
274
|
+
private poll(delay: number): void {
|
|
185
275
|
if (!this.started) return;
|
|
186
276
|
this.timer = setTimeout(() => {
|
|
187
277
|
void this.runOnce()
|
|
@@ -189,7 +279,7 @@ export class LocalJobQueue implements JobQueue {
|
|
|
189
279
|
const message = error instanceof Error ? error.message : String(error);
|
|
190
280
|
process.stderr.write(`Process local job failed: ${message}\n`);
|
|
191
281
|
})
|
|
192
|
-
.finally(() => this.
|
|
282
|
+
.finally(() => this.poll(this.pollIntervalMs));
|
|
193
283
|
}, delay);
|
|
194
284
|
this.timer.unref?.();
|
|
195
285
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { validateEnv } from "../config/env.validation";
|
|
3
|
+
import { loggingOptions } from "./logging.module";
|
|
4
|
+
|
|
5
|
+
describe("logging options", () => {
|
|
6
|
+
test("writes JSON without the development transport in production", () => {
|
|
7
|
+
expect(loggingOptions(validateEnv({ NODE_ENV: "production" })).transport).toBeUndefined();
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test("pretty-prints single lines in development", () => {
|
|
11
|
+
expect(loggingOptions(validateEnv({})).transport).toEqual({
|
|
12
|
+
target: "pino-pretty",
|
|
13
|
+
options: { singleLine: true },
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
});
|
|
@@ -1,16 +1,21 @@
|
|
|
1
|
-
import pino from "pino";
|
|
1
|
+
import pino, { type LoggerOptions } from "pino";
|
|
2
|
+
import type { AppEnv } from "../config/env.validation";
|
|
2
3
|
import { LOGGER, type PodokitModule } from "../core/services";
|
|
3
4
|
|
|
5
|
+
/** Pretty single-line logs in development, JSON in production. */
|
|
6
|
+
export function loggingOptions(env: Pick<AppEnv, "NODE_ENV">): LoggerOptions {
|
|
7
|
+
return {
|
|
8
|
+
level: process.env.LOG_LEVEL ?? "info",
|
|
9
|
+
// pino-pretty is a development dependency, so production must not ask for it.
|
|
10
|
+
transport: env.NODE_ENV === "production"
|
|
11
|
+
? undefined
|
|
12
|
+
: { target: "pino-pretty", options: { singleLine: true } },
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
4
16
|
export const loggingModule: PodokitModule = {
|
|
5
17
|
name: "logging",
|
|
6
|
-
configure: (
|
|
7
|
-
|
|
8
|
-
const logger = pino({
|
|
9
|
-
level: process.env.LOG_LEVEL ?? "info",
|
|
10
|
-
transport: isProduction
|
|
11
|
-
? undefined
|
|
12
|
-
: { target: "pino-pretty", options: { singleLine: true } },
|
|
13
|
-
});
|
|
14
|
-
services.override(LOGGER, logger);
|
|
18
|
+
configure: (env, services): void => {
|
|
19
|
+
services.override(LOGGER, pino(loggingOptions(env)));
|
|
15
20
|
},
|
|
16
21
|
};
|
package/dist/templates/modules/object-storage-local/files/tests/api/storage-local.api.spec.ts
CHANGED
|
@@ -5,7 +5,7 @@ const origin = { origin: base };
|
|
|
5
5
|
// The administrator seeded by the admin-dashboard Playwright setup. Inlined so this
|
|
6
6
|
// spec also compiles in apps that do not install that module.
|
|
7
7
|
const admin = {
|
|
8
|
-
email: "admin@example.com",
|
|
8
|
+
email: process.env.E2E_ADMIN_EMAIL || "admin@example.com",
|
|
9
9
|
password: process.env.E2E_ADMIN_PASSWORD || "Podokit3e-Str0ng!pw",
|
|
10
10
|
};
|
|
11
11
|
|