@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.
Files changed (245) hide show
  1. package/README.md +2 -1
  2. package/dist/app-proxy-timeouts.d.ts +27 -0
  3. package/dist/app-proxy-timeouts.js +180 -0
  4. package/dist/assemble.d.ts +5 -2
  5. package/dist/assemble.js +14 -4
  6. package/dist/deploy-checks.d.ts +44 -0
  7. package/dist/deploy-checks.js +267 -0
  8. package/dist/deploy-compose-profile.d.ts +6 -2
  9. package/dist/deploy-compose-profile.js +15 -6
  10. package/dist/deploy-compose-render.d.ts +12 -4
  11. package/dist/deploy-compose-render.js +47 -17
  12. package/dist/deploy-compose.d.ts +2 -5
  13. package/dist/deploy-compose.js +7 -2
  14. package/dist/deploy-profile.d.ts +6 -2
  15. package/dist/deploy-profile.js +11 -7
  16. package/dist/deploy-render.d.ts +4 -1
  17. package/dist/deploy-render.js +28 -20
  18. package/dist/deploy-schema.d.ts +61 -0
  19. package/dist/deploy-schema.js +157 -1
  20. package/dist/deploy.d.ts +2 -5
  21. package/dist/deploy.js +5 -2
  22. package/dist/dev.d.ts +21 -0
  23. package/dist/dev.js +78 -3
  24. package/dist/eject.js +2 -0
  25. package/dist/index.js +85 -5
  26. package/dist/lib.d.ts +2 -0
  27. package/dist/lib.js +5 -2
  28. package/dist/lockfile.d.ts +24 -0
  29. package/dist/lockfile.js +77 -5
  30. package/dist/provider.d.ts +1 -0
  31. package/dist/provider.js +12 -4
  32. package/dist/remove.js +1 -1
  33. package/dist/template-files.d.ts +42 -0
  34. package/dist/template-files.js +102 -0
  35. package/dist/templates/fullstack/AGENTS.md +44 -7
  36. package/dist/templates/fullstack/README.md +8 -7
  37. package/dist/templates/fullstack/apps/api/package.json +4 -4
  38. package/dist/templates/fullstack/apps/api/scripts/build.extensions.mjs +6 -0
  39. package/dist/templates/fullstack/apps/api/scripts/build.mjs +65 -16
  40. package/dist/templates/fullstack/apps/api/scripts/run-migrations.mjs +7 -3
  41. package/dist/templates/fullstack/apps/api/scripts/verify-api-contract.ts +5 -1
  42. package/dist/templates/fullstack/apps/api/src/app.extensions.ts +7 -4
  43. package/dist/templates/fullstack/apps/api/src/app.spec.ts +22 -0
  44. package/dist/templates/fullstack/apps/api/src/app.ts +47 -28
  45. package/dist/templates/fullstack/apps/api/src/build.spec.ts +118 -0
  46. package/dist/templates/fullstack/apps/api/src/config/env.extensions.ts +20 -0
  47. package/dist/templates/fullstack/apps/api/src/config/env.validation.spec.ts +76 -0
  48. package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +46 -3
  49. package/dist/templates/fullstack/apps/api/src/config/provider-selection.spec.ts +53 -0
  50. package/dist/templates/fullstack/apps/api/src/config/provider-selection.ts +79 -0
  51. package/dist/templates/fullstack/apps/api/src/config/providers.ts +8 -2
  52. package/dist/templates/fullstack/apps/api/src/config/server-options.spec.ts +17 -6
  53. package/dist/templates/fullstack/apps/api/src/config/server-options.ts +5 -8
  54. package/dist/templates/fullstack/apps/api/src/core/api-contract.extensions.ts +6 -0
  55. package/dist/templates/fullstack/apps/api/src/core/api-contract.spec.ts +29 -0
  56. package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +39 -2
  57. package/dist/templates/fullstack/apps/api/src/core/client-address.spec.ts +82 -0
  58. package/dist/templates/fullstack/apps/api/src/core/client-address.ts +68 -0
  59. package/dist/templates/fullstack/apps/api/src/core/services.ts +43 -0
  60. package/dist/templates/fullstack/apps/api/src/database/data-source.spec.ts +27 -0
  61. package/dist/templates/fullstack/apps/api/src/database/data-source.ts +7 -10
  62. package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +12 -0
  63. package/dist/templates/fullstack/apps/api/src/database/database.ts +2 -3
  64. package/dist/templates/fullstack/apps/api/src/database/entities.ts +12 -0
  65. package/dist/templates/fullstack/apps/api/src/database/migrate.ts +12 -2
  66. package/dist/templates/fullstack/apps/api/src/database/migration-files.spec.ts +116 -0
  67. package/dist/templates/fullstack/apps/api/src/database/migration-files.ts +134 -0
  68. package/dist/templates/fullstack/apps/api/src/database/migration-readiness.spec.ts +36 -0
  69. package/dist/templates/fullstack/apps/api/src/database/migration-readiness.ts +25 -0
  70. package/dist/templates/fullstack/apps/api/src/database/run-migrations.spec.ts +12 -1
  71. package/dist/templates/fullstack/apps/api/src/database/sqlite-migrator.ts +2 -36
  72. package/dist/templates/fullstack/apps/api/src/main.ts +2 -2
  73. package/dist/templates/fullstack/apps/api/tsconfig.json +2 -0
  74. package/dist/templates/fullstack/apps/web/Dockerfile +4 -0
  75. package/dist/templates/fullstack/apps/web/package.json +1 -1
  76. package/dist/templates/fullstack/apps/web/src/app.css +3 -0
  77. package/dist/templates/fullstack/apps/web/src/app.extensions.css +11 -0
  78. package/dist/templates/fullstack/apps/web/src/lib/components/site-runtime.svelte +3 -1
  79. package/dist/templates/fullstack/apps/web/src/lib/server/api.test.ts +19 -0
  80. package/dist/templates/fullstack/apps/web/src/lib/server/api.ts +4 -1
  81. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +370 -36
  82. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +250 -41
  83. package/dist/templates/fullstack/apps/web/src/lib/server/body-limits.ts +5 -2
  84. package/dist/templates/fullstack/apps/web/src/lib/server/proxy-timeouts.ts +23 -0
  85. package/dist/templates/fullstack/apps/web/tsconfig.json +1 -1
  86. package/dist/templates/fullstack/apps/web/vite.config.test.ts +33 -0
  87. package/dist/templates/fullstack/apps/web/vite.config.ts +55 -13
  88. package/dist/templates/fullstack/apps/web/vite.extensions.ts +23 -0
  89. package/dist/templates/fullstack/compose.dev.yaml +7 -4
  90. package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/SKILL.md +2 -2
  91. package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +12 -3
  92. package/dist/templates/fullstack/dot-claude/skills/podokit-update/SKILL.md +5 -2
  93. package/dist/templates/fullstack/dot-env.docker.example +7 -2
  94. package/dist/templates/fullstack/dot-env.example +21 -1
  95. package/dist/templates/fullstack/dot-podokit/dev.json +1 -1
  96. package/dist/templates/fullstack/infra/k3s/configmap.yaml +4 -0
  97. package/dist/templates/fullstack/tests/README.md +40 -1
  98. package/dist/templates/fullstack/tests/helpers/app-paths.ts +18 -0
  99. package/dist/templates/fullstack/tests/helpers/paths.ts +33 -0
  100. package/dist/templates/fullstack/tests/package.json +2 -2
  101. package/dist/templates/fullstack/tests/playwright.config.ts +7 -3
  102. package/dist/templates/fullstack/tests/playwright.extensions.ts +88 -1
  103. package/dist/templates/fullstack/tests/ui/smoke.ui.spec.ts +5 -4
  104. package/dist/templates/modules/admin-dashboard/files/apps/api/src/account-sessions/account-sessions.routes.spec.ts +152 -0
  105. package/dist/templates/modules/admin-dashboard/files/apps/api/src/account-sessions/account-sessions.routes.ts +112 -0
  106. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.revoker.ts +24 -0
  107. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.spec.ts +18 -0
  108. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.ts +36 -13
  109. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin.module.ts +13 -1
  110. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.test.ts +37 -1
  111. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +15 -6
  112. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-sessions.test.ts +33 -0
  113. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-sessions.ts +34 -0
  114. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-flag-settings.test.ts +31 -0
  115. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-flag-settings.ts +31 -0
  116. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-flags.ts +12 -0
  117. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-nav.ts +11 -0
  118. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/nav.test.ts +39 -0
  119. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/nav.ts +35 -0
  120. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/registry.svelte.ts +15 -0
  121. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +73 -39
  122. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/admin-sidebar.svelte +2 -23
  123. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/data-table.svelte +7 -2
  124. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/data-table.test.ts +30 -0
  125. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/impersonation-banner.svelte +3 -2
  126. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/settings/app-flags-settings.svelte +65 -0
  127. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/sidebar-user-menu.svelte +32 -23
  128. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/sidebar-user-menu.test.ts +27 -0
  129. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-footer.svelte +4 -1
  130. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-runtime.svelte +3 -1
  131. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/table-toolbar.svelte +3 -1
  132. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/en.json +6 -0
  133. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/ko.json +6 -0
  134. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/app-hooks.ts +11 -0
  135. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/non-indexable-paths.ts +8 -0
  136. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/search-indexing.test.ts +31 -0
  137. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/search-indexing.ts +5 -1
  138. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/settings/+page.svelte +1 -1
  139. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/account/+page.server.ts +2 -1
  140. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/account/+page.svelte +1 -5
  141. package/dist/templates/modules/admin-dashboard/files/dot-claude/skills/podokit-data-table/SKILL.md +2 -0
  142. package/dist/templates/modules/admin-dashboard/files/tests/api/account-sessions.api.spec.ts +55 -0
  143. package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +1 -1
  144. package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +159 -170
  145. package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +72 -81
  146. package/dist/templates/modules/admin-dashboard/files/tests/api/email.api.spec.ts +1 -6
  147. package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +1 -1
  148. package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +43 -53
  149. package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +29 -1
  150. package/dist/templates/modules/admin-dashboard/files/tests/helpers/accounts.ts +24 -6
  151. package/dist/templates/modules/admin-dashboard/files/tests/helpers/app-accounts.ts +24 -0
  152. package/dist/templates/modules/admin-dashboard/files/tests/helpers/devices.ts +55 -0
  153. package/dist/templates/modules/admin-dashboard/files/tests/helpers/disposable-users.ts +5 -3
  154. package/dist/templates/modules/admin-dashboard/files/tests/helpers/mailpit.ts +3 -9
  155. package/dist/templates/modules/admin-dashboard/files/tests/helpers/sms.ts +2 -4
  156. package/dist/templates/modules/admin-dashboard/files/tests/playwright.config.ts +11 -5
  157. package/dist/templates/modules/admin-dashboard/files/tests/seed.setup.ts +3 -10
  158. package/dist/templates/modules/admin-dashboard/files/tests/ui/account-flows.ui.spec.ts +4 -5
  159. package/dist/templates/modules/admin-dashboard/files/tests/ui/account-sessions.ui.spec.ts +46 -0
  160. package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +62 -59
  161. package/dist/templates/modules/admin-dashboard/files/tests/ui/app-flags.ui.spec.ts +63 -0
  162. package/dist/templates/modules/admin-dashboard/files/tests/ui/appearance.ui.spec.ts +12 -16
  163. package/dist/templates/modules/admin-dashboard/files/tests/ui/audit.ui.spec.ts +3 -2
  164. package/dist/templates/modules/admin-dashboard/files/tests/ui/auth.ui.spec.ts +32 -35
  165. package/dist/templates/modules/admin-dashboard/files/tests/ui/dashboard.ui.spec.ts +4 -2
  166. package/dist/templates/modules/admin-dashboard/files/tests/ui/email-admin.ui.spec.ts +1 -2
  167. package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +1 -2
  168. package/dist/templates/modules/admin-dashboard/files/tests/ui/general-effects.ui.spec.ts +82 -91
  169. package/dist/templates/modules/admin-dashboard/files/tests/ui/guards.ui.spec.ts +6 -2
  170. package/dist/templates/modules/admin-dashboard/files/tests/ui/i18n.ui.spec.ts +16 -20
  171. package/dist/templates/modules/admin-dashboard/files/tests/ui/impersonation.ui.spec.ts +6 -3
  172. package/dist/templates/modules/admin-dashboard/files/tests/ui/login-methods.ui.spec.ts +2 -3
  173. package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +2 -4
  174. package/dist/templates/modules/admin-dashboard/files/tests/ui/organizations.ui.spec.ts +5 -2
  175. package/dist/templates/modules/admin-dashboard/files/tests/ui/passkey.ui.spec.ts +7 -2
  176. package/dist/templates/modules/admin-dashboard/files/tests/ui/same-origin-requests.ui.spec.ts +89 -0
  177. package/dist/templates/modules/admin-dashboard/files/tests/ui/sessions.ui.spec.ts +5 -4
  178. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +67 -73
  179. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +84 -93
  180. package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +33 -7
  181. package/dist/templates/modules/admin-dashboard/files/tests/ui/theme-mode.ui.spec.ts +29 -0
  182. package/dist/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +51 -54
  183. package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +10 -9
  184. package/dist/templates/modules/admin-dashboard/module.manifest.json +7 -2
  185. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +2 -1
  186. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.module.ts +5 -1
  187. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.spec.ts +33 -0
  188. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +20 -1
  189. package/dist/templates/modules/audit-log/module.manifest.json +15 -3
  190. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-app-name.spec.ts +21 -0
  191. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-config-store.ts +13 -1
  192. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-environment.ts +9 -2
  193. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +19 -3
  194. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.module.ts +6 -1
  195. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.runtime.spec.ts +10 -0
  196. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.spec.ts +54 -6
  197. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +26 -4
  198. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +15 -8
  199. package/dist/templates/modules/auth/files/apps/api/src/auth/revoke-other-sessions.spec.ts +56 -0
  200. package/dist/templates/modules/auth/files/apps/api/src/auth/revoke-other-sessions.ts +45 -0
  201. package/dist/templates/modules/auth/files/apps/api/src/auth/schema-recheck.spec.ts +86 -0
  202. package/dist/templates/modules/auth/files/apps/api/src/auth/schema-recheck.ts +44 -0
  203. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +2 -1
  204. package/dist/templates/modules/auth/files/apps/api/src/migrate.ts +3 -1
  205. package/dist/templates/modules/auth/files/apps/api/src/settings/app-flags.ts +9 -0
  206. package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.spec.ts +43 -0
  207. package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.ts +30 -5
  208. package/dist/templates/modules/auth/module.manifest.json +8 -2
  209. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.spec.ts +57 -1
  210. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.ts +93 -9
  211. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/worker.module.spec.ts +40 -0
  212. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/worker.module.ts +52 -5
  213. package/dist/templates/modules/bullmq/files/apps/api/src/main-worker.ts +9 -7
  214. package/dist/templates/modules/bullmq/files/apps/api/src/worker.extensions.ts +13 -0
  215. package/dist/templates/modules/bullmq/module.manifest.json +5 -1
  216. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.worker.spec.ts +2 -0
  217. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.spec.ts +87 -1
  218. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.ts +93 -3
  219. package/dist/templates/modules/jobs-local/module.manifest.json +3 -0
  220. package/dist/templates/modules/logging/files/apps/api/src/logging/logging.module.spec.ts +16 -0
  221. package/dist/templates/modules/logging/files/apps/api/src/logging/logging.module.ts +15 -10
  222. package/dist/templates/modules/mailer/module.manifest.json +1 -1
  223. package/dist/templates/modules/object-storage-local/files/tests/api/storage-local.api.spec.ts +1 -1
  224. package/dist/templates/modules/object-storage-s3/files/infra/docker/minio.compose.yml +5 -4
  225. package/dist/templates/modules/object-storage-s3/files/tests/api/storage.api.spec.ts +1 -1
  226. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.spec.ts +2 -59
  227. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.ts +0 -65
  228. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.spec.ts +82 -21
  229. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.ts +54 -15
  230. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.spec.ts +43 -5
  231. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +10 -2
  232. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.spec.ts +61 -3
  233. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.ts +48 -15
  234. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +63 -27
  235. package/dist/templates/modules/rate-limit/module.manifest.json +6 -9
  236. package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +1 -1
  237. package/dist/templates/todo/AGENTS.md +46 -6
  238. package/dist/templates/todo/README.md +6 -4
  239. package/dist/text-diff.d.ts +12 -0
  240. package/dist/text-diff.js +111 -0
  241. package/dist/update.d.ts +61 -3
  242. package/dist/update.js +152 -27
  243. package/package.json +2 -2
  244. package/dist/templates/modules/admin-dashboard/files/tests/api/exclusive.api.spec.ts +0 -75
  245. 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 type {
4
- EnqueueOptions,
5
- JobHandler,
6
- JobQueue,
7
- JobReference,
8
- JobSnapshot,
9
- JobStatus,
10
- Unsubscribe,
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
- /** Opens the producer connection. */
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 function startWorkers(): Worker[] {
19
- return definitions.map(({ queue, processor }) =>
20
- new Worker(queue, processor, { connection: redisConnection() })
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 workers = startWorkers();
9
+ const runtime = startWorkers();
10
10
 
11
- for (const worker of workers) {
12
- worker.on("failed", (job, error) => {
13
- process.stderr.write(`Process job failed: ${job?.id ?? "unknown"} ${error.message}\n`);
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 Promise.all(workers.map((worker) => worker.close())).finally(() => process.exit(0));
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",
@@ -41,6 +41,8 @@ function fakeQueue(): FakeQueue {
41
41
  if (!handler) throw new Error(`no handler for ${job.name}`);
42
42
  await handler(payload, job);
43
43
  },
44
+ schedule: async () => undefined,
45
+ unschedule: async () => undefined,
44
46
  close: () => undefined,
45
47
  };
46
48
  }
@@ -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.schedule(0);
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 schedule(delay: number): void {
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.schedule(this.pollIntervalMs));
282
+ .finally(() => this.poll(this.pollIntervalMs));
193
283
  }, delay);
194
284
  this.timer.unref?.();
195
285
  }
@@ -15,6 +15,9 @@
15
15
  "apps/api/src/jobs-local/**"
16
16
  ],
17
17
  "targetApp": "api",
18
+ "dependencies": {
19
+ "cron-parser": "^4.9.0"
20
+ },
18
21
  "env": [
19
22
  "LOCAL_JOBS_POLL_INTERVAL_MS=250"
20
23
  ],
@@ -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: (_env, services): void => {
7
- const isProduction = process.env.NODE_ENV === "production";
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
  };
@@ -5,7 +5,7 @@
5
5
  "targetApp": "api",
6
6
  "dependencies": {
7
7
  "nodemailer": "^9.0.5",
8
- "@podosoft/podokit-auth": "^0.5.0"
8
+ "@podosoft/podokit-auth": "^0.6.0"
9
9
  },
10
10
  "devDependencies": {
11
11
  "@types/nodemailer": "^8.0.1"
@@ -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