@podosoft/podokit 1.5.0 → 1.6.1

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 (215) 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 -18
  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 +27 -21
  18. package/dist/deploy-schema.d.ts +60 -2
  19. package/dist/deploy-schema.js +141 -14
  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 +74 -4
  30. package/dist/provider.js +10 -5
  31. package/dist/remove.js +1 -1
  32. package/dist/template-files.d.ts +42 -0
  33. package/dist/template-files.js +102 -0
  34. package/dist/templates/fullstack/AGENTS.md +30 -4
  35. package/dist/templates/fullstack/README.md +8 -7
  36. package/dist/templates/fullstack/apps/api/package.json +4 -4
  37. package/dist/templates/fullstack/apps/api/scripts/build.extensions.mjs +6 -0
  38. package/dist/templates/fullstack/apps/api/scripts/build.mjs +65 -16
  39. package/dist/templates/fullstack/apps/api/scripts/run-migrations.mjs +7 -3
  40. package/dist/templates/fullstack/apps/api/scripts/verify-api-contract.ts +7 -2
  41. package/dist/templates/fullstack/apps/api/src/app.spec.ts +22 -0
  42. package/dist/templates/fullstack/apps/api/src/app.ts +41 -25
  43. package/dist/templates/fullstack/apps/api/src/build.spec.ts +121 -0
  44. package/dist/templates/fullstack/apps/api/src/config/env.extensions.ts +20 -0
  45. package/dist/templates/fullstack/apps/api/src/config/env.validation.spec.ts +76 -0
  46. package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +18 -3
  47. package/dist/templates/fullstack/apps/api/src/config/provider-selection.spec.ts +53 -0
  48. package/dist/templates/fullstack/apps/api/src/config/provider-selection.ts +79 -0
  49. package/dist/templates/fullstack/apps/api/src/config/providers.ts +7 -3
  50. package/dist/templates/fullstack/apps/api/src/core/api-contract.extensions.ts +6 -0
  51. package/dist/templates/fullstack/apps/api/src/core/api-contract.spec.ts +72 -4
  52. package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +78 -6
  53. package/dist/templates/fullstack/apps/api/src/core/services.ts +13 -0
  54. package/dist/templates/fullstack/apps/api/src/database/data-source.spec.ts +27 -0
  55. package/dist/templates/fullstack/apps/api/src/database/data-source.ts +7 -10
  56. package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +12 -0
  57. package/dist/templates/fullstack/apps/api/src/database/database.ts +2 -3
  58. package/dist/templates/fullstack/apps/api/src/database/entities.ts +12 -0
  59. package/dist/templates/fullstack/apps/api/src/database/migrate.ts +12 -2
  60. package/dist/templates/fullstack/apps/api/src/database/migration-files.spec.ts +116 -0
  61. package/dist/templates/fullstack/apps/api/src/database/migration-files.ts +134 -0
  62. package/dist/templates/fullstack/apps/api/src/database/migration-readiness.spec.ts +36 -0
  63. package/dist/templates/fullstack/apps/api/src/database/migration-readiness.ts +25 -0
  64. package/dist/templates/fullstack/apps/api/src/database/run-migrations.spec.ts +12 -1
  65. package/dist/templates/fullstack/apps/api/src/database/sqlite-migrator.ts +2 -36
  66. package/dist/templates/fullstack/apps/api/src/main.ts +2 -2
  67. package/dist/templates/fullstack/apps/api/tsconfig.json +2 -0
  68. package/dist/templates/fullstack/apps/web/package.json +1 -1
  69. package/dist/templates/fullstack/apps/web/src/app.css +3 -0
  70. package/dist/templates/fullstack/apps/web/src/app.extensions.css +11 -0
  71. package/dist/templates/fullstack/apps/web/src/lib/components/site-runtime.svelte +3 -1
  72. package/dist/templates/fullstack/apps/web/src/lib/server/api.test.ts +19 -0
  73. package/dist/templates/fullstack/apps/web/src/lib/server/api.ts +4 -1
  74. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +79 -0
  75. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +55 -1
  76. package/dist/templates/fullstack/apps/web/src/lib/server/proxy-timeouts.ts +2 -1
  77. package/dist/templates/fullstack/apps/web/tsconfig.json +1 -1
  78. package/dist/templates/fullstack/apps/web/vite.config.test.ts +33 -0
  79. package/dist/templates/fullstack/apps/web/vite.config.ts +55 -13
  80. package/dist/templates/fullstack/apps/web/vite.extensions.ts +23 -0
  81. package/dist/templates/fullstack/compose.dev.yaml +7 -4
  82. package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/SKILL.md +2 -2
  83. package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +13 -3
  84. package/dist/templates/fullstack/dot-claude/skills/podokit-update/SKILL.md +5 -2
  85. package/dist/templates/fullstack/dot-env.docker.example +5 -2
  86. package/dist/templates/fullstack/dot-env.example +4 -0
  87. package/dist/templates/fullstack/dot-podokit/dev.json +1 -1
  88. package/dist/templates/fullstack/infra/k3s/configmap.yaml +1 -0
  89. package/dist/templates/fullstack/tests/README.md +50 -1
  90. package/dist/templates/fullstack/tests/helpers/app-paths.ts +18 -0
  91. package/dist/templates/fullstack/tests/helpers/paths.ts +33 -0
  92. package/dist/templates/fullstack/tests/helpers/seeded-admin.ts +15 -0
  93. package/dist/templates/fullstack/tests/package.json +3 -3
  94. package/dist/templates/fullstack/tests/playwright.config.ts +10 -4
  95. package/dist/templates/fullstack/tests/playwright.extensions.ts +149 -1
  96. package/dist/templates/fullstack/tests/run-e2e.ts +28 -0
  97. package/dist/templates/fullstack/tests/ui/smoke.ui.spec.ts +5 -4
  98. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.test.ts +37 -1
  99. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +15 -6
  100. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-flag-settings.test.ts +31 -0
  101. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-flag-settings.ts +31 -0
  102. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-flags.ts +12 -0
  103. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-nav.ts +11 -0
  104. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/nav.test.ts +53 -0
  105. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/nav.ts +35 -0
  106. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/registry.svelte.ts +15 -0
  107. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +11 -1
  108. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/admin-sidebar.svelte +2 -23
  109. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/data-table.svelte +7 -2
  110. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/data-table.test.ts +30 -0
  111. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/impersonation-banner.svelte +3 -2
  112. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/settings/app-flags-settings.svelte +65 -0
  113. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/sidebar-user-menu.svelte +32 -23
  114. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/sidebar-user-menu.test.ts +27 -0
  115. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-footer.svelte +4 -1
  116. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-runtime.svelte +3 -1
  117. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/theme-toggle.svelte +1 -1
  118. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/en.json +6 -0
  119. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/ko.json +6 -0
  120. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/app-hooks.ts +11 -0
  121. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/non-indexable-paths.ts +8 -0
  122. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/search-indexing.test.ts +31 -0
  123. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/search-indexing.ts +5 -1
  124. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/settings/+page.svelte +1 -1
  125. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/account/+page.server.ts +2 -1
  126. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/account/+page.svelte +1 -5
  127. package/dist/templates/modules/admin-dashboard/files/dot-claude/skills/podokit-data-table/SKILL.md +2 -0
  128. package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +1 -1
  129. package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +159 -170
  130. package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +72 -81
  131. package/dist/templates/modules/admin-dashboard/files/tests/api/email.api.spec.ts +1 -6
  132. package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +1 -1
  133. package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +43 -53
  134. package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +3 -1
  135. package/dist/templates/modules/admin-dashboard/files/tests/helpers/accounts.ts +24 -6
  136. package/dist/templates/modules/admin-dashboard/files/tests/helpers/app-accounts.ts +24 -0
  137. package/dist/templates/modules/admin-dashboard/files/tests/helpers/devices.ts +41 -11
  138. package/dist/templates/modules/admin-dashboard/files/tests/helpers/disposable-users.ts +5 -3
  139. package/dist/templates/modules/admin-dashboard/files/tests/helpers/hydration.ts +13 -4
  140. package/dist/templates/modules/admin-dashboard/files/tests/helpers/mailpit.ts +3 -9
  141. package/dist/templates/modules/admin-dashboard/files/tests/helpers/sms.ts +2 -4
  142. package/dist/templates/modules/admin-dashboard/files/tests/playwright.config.ts +14 -6
  143. package/dist/templates/modules/admin-dashboard/files/tests/ui/account-flows.ui.spec.ts +4 -5
  144. package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +55 -55
  145. package/dist/templates/modules/admin-dashboard/files/tests/ui/app-flags.ui.spec.ts +63 -0
  146. package/dist/templates/modules/admin-dashboard/files/tests/ui/appearance.ui.spec.ts +12 -16
  147. package/dist/templates/modules/admin-dashboard/files/tests/ui/audit.ui.spec.ts +3 -2
  148. package/dist/templates/modules/admin-dashboard/files/tests/ui/auth.ui.spec.ts +32 -35
  149. package/dist/templates/modules/admin-dashboard/files/tests/ui/dashboard.ui.spec.ts +4 -2
  150. package/dist/templates/modules/admin-dashboard/files/tests/ui/email-admin.ui.spec.ts +1 -2
  151. package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +1 -2
  152. package/dist/templates/modules/admin-dashboard/files/tests/ui/general-effects.ui.spec.ts +82 -91
  153. package/dist/templates/modules/admin-dashboard/files/tests/ui/guards.ui.spec.ts +6 -2
  154. package/dist/templates/modules/admin-dashboard/files/tests/ui/i18n.ui.spec.ts +16 -20
  155. package/dist/templates/modules/admin-dashboard/files/tests/ui/impersonation.ui.spec.ts +6 -3
  156. package/dist/templates/modules/admin-dashboard/files/tests/ui/login-methods.ui.spec.ts +2 -3
  157. package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +2 -4
  158. package/dist/templates/modules/admin-dashboard/files/tests/ui/organizations.ui.spec.ts +5 -2
  159. package/dist/templates/modules/admin-dashboard/files/tests/ui/passkey.ui.spec.ts +7 -2
  160. package/dist/templates/modules/admin-dashboard/files/tests/ui/same-origin-requests.ui.spec.ts +89 -0
  161. package/dist/templates/modules/admin-dashboard/files/tests/ui/sessions.ui.spec.ts +5 -4
  162. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +67 -73
  163. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +84 -93
  164. package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +8 -7
  165. package/dist/templates/modules/admin-dashboard/files/tests/ui/theme-mode.ui.spec.ts +35 -0
  166. package/dist/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +51 -54
  167. package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +10 -9
  168. package/dist/templates/modules/admin-dashboard/module.manifest.json +7 -2
  169. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.module.ts +5 -1
  170. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.spec.ts +17 -0
  171. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +18 -0
  172. package/dist/templates/modules/audit-log/module.manifest.json +15 -3
  173. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-app-name.spec.ts +21 -0
  174. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-config-store.ts +13 -1
  175. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-environment.ts +9 -2
  176. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +19 -3
  177. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.module.ts +2 -0
  178. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.runtime.spec.ts +10 -0
  179. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +7 -5
  180. package/dist/templates/modules/auth/files/apps/api/src/auth/schema-recheck.spec.ts +86 -0
  181. package/dist/templates/modules/auth/files/apps/api/src/auth/schema-recheck.ts +44 -0
  182. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +2 -1
  183. package/dist/templates/modules/auth/files/apps/api/src/migrate.ts +3 -1
  184. package/dist/templates/modules/auth/files/apps/api/src/settings/app-flags.ts +9 -0
  185. package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.spec.ts +43 -0
  186. package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.ts +30 -5
  187. package/dist/templates/modules/auth/module.manifest.json +8 -2
  188. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.spec.ts +107 -1
  189. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.ts +113 -9
  190. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/worker.module.spec.ts +40 -0
  191. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/worker.module.ts +52 -5
  192. package/dist/templates/modules/bullmq/files/apps/api/src/main-worker.ts +9 -7
  193. package/dist/templates/modules/bullmq/files/apps/api/src/worker.extensions.ts +13 -0
  194. package/dist/templates/modules/bullmq/module.manifest.json +5 -1
  195. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.worker.spec.ts +2 -0
  196. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.spec.ts +174 -1
  197. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.ts +150 -3
  198. package/dist/templates/modules/jobs-local/module.manifest.json +3 -0
  199. package/dist/templates/modules/logging/files/apps/api/src/logging/logging.module.spec.ts +16 -0
  200. package/dist/templates/modules/logging/files/apps/api/src/logging/logging.module.ts +15 -10
  201. package/dist/templates/modules/mailer/module.manifest.json +1 -1
  202. package/dist/templates/modules/object-storage-local/files/tests/api/storage-local.api.spec.ts +3 -6
  203. package/dist/templates/modules/object-storage-s3/files/infra/docker/minio.compose.yml +5 -4
  204. package/dist/templates/modules/object-storage-s3/files/tests/api/storage.api.spec.ts +3 -6
  205. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +45 -20
  206. package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +3 -6
  207. package/dist/templates/todo/AGENTS.md +5 -1
  208. package/dist/templates/todo/README.md +6 -4
  209. package/dist/text-diff.d.ts +12 -0
  210. package/dist/text-diff.js +111 -0
  211. package/dist/update.d.ts +61 -3
  212. package/dist/update.js +152 -27
  213. package/package.json +2 -2
  214. package/dist/templates/modules/admin-dashboard/files/tests/api/exclusive.api.spec.ts +0 -75
  215. package/dist/templates/modules/admin-dashboard/files/tests/helpers/exclusive.ts +0 -80
@@ -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,176 @@ 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
+ await expect(queue.schedule("bad", "report", {}, { everyMs: 1_000 }, { keepCompleted: -1 })).rejects.toThrow(
153
+ "keepCompleted must be a whole number of at least 0",
154
+ );
155
+ queue.close();
156
+ });
157
+ });
158
+
159
+ /** Moves the clock to each time and runs the job the schedule added then. */
160
+ async function runScheduled(queue: LocalJobQueue, time: ReturnType<typeof manualTime>, times: number[]): Promise<void> {
161
+ for (const at of times) {
162
+ time.advance(at);
163
+ // The schedule adds its job without waiting; let the insert finish.
164
+ await Bun.sleep(1);
165
+ expect(await queue.runOnce()).toBe(true);
166
+ }
167
+ }
168
+
169
+ async function finishedJobs(database: SQL): Promise<string[]> {
170
+ const rows = await database<Array<{ id: string; status: string }>>`
171
+ SELECT "id", "status" FROM "podokit_job" ORDER BY "updatedAt", "id"
172
+ `;
173
+ return rows.map((row) => `${row.id} ${row.status}`);
174
+ }
175
+
176
+ describe("local job schedule cleanup", () => {
177
+ it("keeps the newest finished jobs of a scheduled run's name up to the schedule's limits", async () => {
178
+ sql = new SQL(":memory:");
179
+ const time = manualTime(0);
180
+ const queue = new LocalJobQueue(sql, { pollIntervalMs: 60_000, now: time.now, timer: time.timer });
181
+ await queue.process("report", (_payload, job) => {
182
+ if (job.id.endsWith(":3000") || job.id.endsWith(":5000")) throw new Error("expected failure");
183
+ });
184
+ await queue.process("other", () => undefined);
185
+ const other = await queue.enqueue("other", {});
186
+ const adHoc = await queue.enqueue("report", {});
187
+ while (await queue.runOnce());
188
+ await queue.schedule("tick", "report", {}, { everyMs: 1_000 }, { keepCompleted: 2, keepFailed: 1 });
189
+ await queue.start();
190
+ await Bun.sleep(5);
191
+
192
+ await runScheduled(queue, time, [1_000, 2_000, 3_000, 4_000, 5_000, 6_000]);
193
+
194
+ // As BullMQ trims a queue: the job enqueue() added goes too, another name's stays.
195
+ expect(await finishedJobs(sql)).toEqual([
196
+ `${other.id} completed`,
197
+ "report:schedule:tick:4000 completed",
198
+ "report:schedule:tick:5000 failed",
199
+ "report:schedule:tick:6000 completed",
200
+ ]);
201
+ expect(await queue.get(adHoc.id)).toBeNull();
202
+ queue.close();
203
+ });
204
+
205
+ it("removes only the run itself when a limit is 0", async () => {
206
+ sql = new SQL(":memory:");
207
+ const time = manualTime(0);
208
+ const queue = new LocalJobQueue(sql, { pollIntervalMs: 60_000, now: time.now, timer: time.timer });
209
+ await queue.process("report", (_payload, job) => {
210
+ if (job.id.endsWith(":2000")) throw new Error("expected failure");
211
+ });
212
+ const adHoc = await queue.enqueue("report", {});
213
+ while (await queue.runOnce());
214
+ await queue.schedule("tick", "report", {}, { everyMs: 1_000 }, { keepCompleted: 0, keepFailed: 0 });
215
+ await queue.start();
216
+ await Bun.sleep(5);
217
+
218
+ await runScheduled(queue, time, [1_000, 2_000, 3_000]);
219
+
220
+ expect(await finishedJobs(sql)).toEqual([`${adHoc.id} completed`]);
221
+ queue.close();
222
+ });
223
+
224
+ it("keeps 100 completed runs of a schedule by default", async () => {
225
+ sql = new SQL(":memory:");
226
+ const time = manualTime(0);
227
+ const queue = new LocalJobQueue(sql, { pollIntervalMs: 60_000, now: time.now, timer: time.timer });
228
+ await queue.process("report", () => undefined);
229
+ await queue.schedule("tick", "report", {}, { everyMs: 1_000 });
230
+ await queue.start();
231
+ await Bun.sleep(5);
232
+
233
+ await runScheduled(queue, time, Array.from({ length: 105 }, (_, index) => (index + 1) * 1_000));
234
+
235
+ const kept = await finishedJobs(sql);
236
+ expect(kept).toHaveLength(100);
237
+ expect(kept[0]).toBe("report:schedule:tick:6000 completed");
238
+ expect(kept.at(-1)).toBe("report:schedule:tick:105000 completed");
239
+ queue.close();
240
+ });
241
+ });
@@ -1,11 +1,17 @@
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,
12
+ type ScheduleOptions,
13
+ type ScheduleRetention,
14
+ scheduleRetention,
9
15
  type Unsubscribe,
10
16
  } from "@podosoft/podokit-runtime";
11
17
 
@@ -22,15 +28,58 @@ interface JobRow {
22
28
  error: string | null;
23
29
  }
24
30
 
31
+ export interface ScheduleTimer {
32
+ /** Runs `task` once after `delayMs`; the returned function cancels it. */
33
+ after(delayMs: number, task: () => void): () => void;
34
+ }
35
+
25
36
  export interface LocalJobQueueOptions {
26
37
  pollIntervalMs?: number;
27
38
  now?: () => number;
39
+ /** Waits for schedules; an unref'd setTimeout by default. */
40
+ timer?: ScheduleTimer;
41
+ }
42
+
43
+ interface Schedule {
44
+ id: string;
45
+ name: string;
46
+ payload: unknown;
47
+ repeat: RepeatOptions;
48
+ retention: ScheduleRetention;
49
+ cancel?: () => void;
50
+ }
51
+
52
+ type FinishedStatus = "completed" | "failed";
53
+
54
+ // A scheduled job's deduplication key: this prefix, the schedule id, ":", and the time.
55
+ const SCHEDULE_KEY = "schedule:";
56
+
57
+ // setTimeout fires at once for a delay above 2^31 - 1 ms, so longer waits are split.
58
+ const MAX_TIMER_DELAY_MS = 2 ** 31 - 1;
59
+
60
+ const unrefTimer: ScheduleTimer = {
61
+ after: (delayMs, task) => {
62
+ const timer = setTimeout(task, delayMs);
63
+ timer.unref?.();
64
+ return () => clearTimeout(timer);
65
+ },
66
+ };
67
+
68
+ /** The first time after `after` (epoch milliseconds) that `repeat` adds a job. */
69
+ export function nextScheduledTime(repeat: RepeatOptions, after: number): number {
70
+ if (repeat.everyMs !== undefined) return (Math.floor(after / repeat.everyMs) + 1) * repeat.everyMs;
71
+ return cronParser
72
+ .parseExpression(repeat.cron ?? "", { currentDate: new Date(after), tz: repeat.timezone ?? "UTC" })
73
+ .next()
74
+ .getTime();
28
75
  }
29
76
 
30
77
  export class LocalJobQueue implements JobQueue {
31
78
  private readonly handlers = new Map<string, JobHandler>();
79
+ private readonly schedules = new Map<string, Schedule>();
32
80
  private readonly pollIntervalMs: number;
33
81
  private readonly now: () => number;
82
+ private readonly scheduleTimer: ScheduleTimer;
34
83
  private timer?: ReturnType<typeof setTimeout>;
35
84
  private started = false;
36
85
  private initialized?: Promise<void>;
@@ -44,6 +93,7 @@ export class LocalJobQueue implements JobQueue {
44
93
  throw new Error("pollIntervalMs must be an integer of at least 10");
45
94
  }
46
95
  this.now = options.now ?? Date.now;
96
+ this.scheduleTimer = options.timer ?? unrefTimer;
47
97
  }
48
98
 
49
99
  initialize(): Promise<void> {
@@ -63,7 +113,8 @@ export class LocalJobQueue implements JobQueue {
63
113
  if (this.started) return;
64
114
  await this.initialize();
65
115
  this.started = true;
66
- this.schedule(0);
116
+ this.poll(0);
117
+ for (const schedule of this.schedules.values()) this.arm(schedule);
67
118
  }
68
119
 
69
120
  async enqueue(
@@ -118,6 +169,29 @@ export class LocalJobQueue implements JobQueue {
118
169
  });
119
170
  }
120
171
 
172
+ async schedule(
173
+ id: string,
174
+ name: string,
175
+ payload: unknown,
176
+ repeat: RepeatOptions,
177
+ options?: ScheduleOptions,
178
+ ): Promise<void> {
179
+ assertRepeatOptions(repeat);
180
+ const retention = scheduleRetention(options);
181
+ // Rejects an invalid cron expression or time zone now rather than at the first run.
182
+ nextScheduledTime(repeat, this.now());
183
+ if (JSON.stringify(payload) === undefined) throw new Error("Job payload must be JSON serializable");
184
+ this.schedules.get(id)?.cancel?.();
185
+ const schedule: Schedule = { id, name, payload, repeat, retention };
186
+ this.schedules.set(id, schedule);
187
+ if (this.started) this.arm(schedule);
188
+ }
189
+
190
+ async unschedule(id: string): Promise<void> {
191
+ this.schedules.get(id)?.cancel?.();
192
+ this.schedules.delete(id);
193
+ }
194
+
121
195
  async runOnce(): Promise<boolean> {
122
196
  await this.initialize();
123
197
  const now = this.now();
@@ -147,6 +221,7 @@ export class LocalJobQueue implements JobQueue {
147
221
  SET "status" = 'completed', "updatedAt" = ${completedAt}, "error" = NULL
148
222
  WHERE "id" = ${row.id}
149
223
  `;
224
+ await this.removeFinished(row, "completed");
150
225
  } catch (error) {
151
226
  await this.fail(row, error);
152
227
  }
@@ -157,9 +232,41 @@ export class LocalJobQueue implements JobQueue {
157
232
  this.started = false;
158
233
  if (this.timer) clearTimeout(this.timer);
159
234
  this.timer = undefined;
235
+ for (const schedule of this.schedules.values()) schedule.cancel?.();
160
236
  this.handlers.clear();
161
237
  }
162
238
 
239
+ /**
240
+ * Adds the schedule's next job when its time comes. The job id names the
241
+ * schedule and the time, so a job is added once for each time.
242
+ */
243
+ private arm(schedule: Schedule, after = this.now()): void {
244
+ const at = nextScheduledTime(schedule.repeat, after);
245
+ schedule.cancel = this.waitUntil(at, () => {
246
+ if (!this.started || this.schedules.get(schedule.id) !== schedule) return;
247
+ void this.enqueue(schedule.name, schedule.payload, { deduplicationKey: `${SCHEDULE_KEY}${schedule.id}:${at}` })
248
+ .catch((error: unknown) => {
249
+ const message = error instanceof Error ? error.message : String(error);
250
+ process.stderr.write(`Add scheduled job failed: ${schedule.id} ${message}\n`);
251
+ });
252
+ this.arm(schedule, Math.max(this.now(), at));
253
+ });
254
+ }
255
+
256
+ private waitUntil(at: number, task: () => void): () => void {
257
+ let cancel = (): void => undefined;
258
+ const step = (): void => {
259
+ const delay = at - this.now();
260
+ if (delay <= 0) {
261
+ task();
262
+ return;
263
+ }
264
+ cancel = this.scheduleTimer.after(Math.min(delay, MAX_TIMER_DELAY_MS), step);
265
+ };
266
+ cancel = this.scheduleTimer.after(Math.max(0, Math.min(at - this.now(), MAX_TIMER_DELAY_MS)), step);
267
+ return () => cancel();
268
+ }
269
+
163
270
  private async createTables(): Promise<void> {
164
271
  await this.sql`
165
272
  CREATE TABLE IF NOT EXISTS "podokit_job" (
@@ -179,9 +286,48 @@ export class LocalJobQueue implements JobQueue {
179
286
  CREATE INDEX IF NOT EXISTS "podokit_job_pending"
180
287
  ON "podokit_job" ("status", "availableAt", "createdAt")
181
288
  `;
289
+ await this.sql`
290
+ CREATE INDEX IF NOT EXISTS "podokit_job_finished"
291
+ ON "podokit_job" ("name", "status", "updatedAt")
292
+ `;
293
+ }
294
+
295
+ /** The schedule whose job id `row` has, while that schedule is kept. */
296
+ private scheduleOf(row: JobRow): Schedule | undefined {
297
+ for (const schedule of this.schedules.values()) {
298
+ const prefix = `${schedule.name}:${SCHEDULE_KEY}${schedule.id}:`;
299
+ if (schedule.name === row.name && row.id.startsWith(prefix) && /^\d+$/.test(row.id.slice(prefix.length))) {
300
+ return schedule;
301
+ }
302
+ }
303
+ return undefined;
304
+ }
305
+
306
+ /**
307
+ * After a scheduled run finishes, keeps the newest finished jobs of its name
308
+ * up to the schedule's limit, as BullMQ trims a queue, or removes only the
309
+ * run when the limit is 0.
310
+ */
311
+ private async removeFinished(row: JobRow, status: FinishedStatus): Promise<void> {
312
+ const schedule = this.scheduleOf(row);
313
+ if (!schedule) return;
314
+ const keep = status === "completed" ? schedule.retention.keepCompleted : schedule.retention.keepFailed;
315
+ if (keep === 0) {
316
+ await this.sql`DELETE FROM "podokit_job" WHERE "id" = ${row.id}`;
317
+ return;
318
+ }
319
+ await this.sql`
320
+ DELETE FROM "podokit_job"
321
+ WHERE "name" = ${row.name} AND "status" = ${status} AND "id" NOT IN (
322
+ SELECT "id" FROM "podokit_job"
323
+ WHERE "name" = ${row.name} AND "status" = ${status}
324
+ ORDER BY "updatedAt" DESC, "id" DESC
325
+ LIMIT ${keep}
326
+ )
327
+ `;
182
328
  }
183
329
 
184
- private schedule(delay: number): void {
330
+ private poll(delay: number): void {
185
331
  if (!this.started) return;
186
332
  this.timer = setTimeout(() => {
187
333
  void this.runOnce()
@@ -189,7 +335,7 @@ export class LocalJobQueue implements JobQueue {
189
335
  const message = error instanceof Error ? error.message : String(error);
190
336
  process.stderr.write(`Process local job failed: ${message}\n`);
191
337
  })
192
- .finally(() => this.schedule(this.pollIntervalMs));
338
+ .finally(() => this.poll(this.pollIntervalMs));
193
339
  }, delay);
194
340
  this.timer.unref?.();
195
341
  }
@@ -206,6 +352,7 @@ export class LocalJobQueue implements JobQueue {
206
352
  "error" = ${message.slice(0, 2_000)}
207
353
  WHERE "id" = ${row.id}
208
354
  `;
355
+ if (!retry) await this.removeFinished(row, "failed");
209
356
  }
210
357
 
211
358
  private snapshot(row: JobRow): JobSnapshot {
@@ -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"
@@ -1,13 +1,8 @@
1
1
  import { expect, test, type APIRequestContext, type PlaywrightWorkerArgs } from "@playwright/test";
2
+ import { seededAdmin } from "../helpers/seeded-admin";
2
3
 
3
4
  const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
4
5
  const origin = { origin: base };
5
- // The administrator seeded by the admin-dashboard Playwright setup. Inlined so this
6
- // spec also compiles in apps that do not install that module.
7
- const admin = {
8
- email: "admin@example.com",
9
- password: process.env.E2E_ADMIN_PASSWORD || "Podokit3e-Str0ng!pw",
10
- };
11
6
 
12
7
  type Playwright = PlaywrightWorkerArgs["playwright"];
13
8
 
@@ -36,6 +31,8 @@ async function signUp(ctx: APIRequestContext): Promise<boolean> {
36
31
 
37
32
  /** Resolves false when no administrator session is available. */
38
33
  async function signInAdmin(ctx: APIRequestContext): Promise<boolean> {
34
+ const admin = await seededAdmin();
35
+ if (!admin) return false;
39
36
  const response = await ctx.post("/api/auth/sign-in/email", { data: admin });
40
37
  return response.ok() && ((await sessionRoles(ctx)) ?? []).includes("admin");
41
38
  }
@@ -24,12 +24,13 @@ services:
24
24
  profiles: ["dev"]
25
25
  depends_on:
26
26
  - minio
27
+ # -e stops at the first failing command, so a bucket that cannot be
28
+ # created fails this service instead of leaving uploads to fail later.
27
29
  entrypoint: >
28
- /bin/sh -c "
30
+ /bin/sh -ec "
29
31
  until mc alias set local http://minio:9000 ${S3_ACCESS_KEY_ID:-podokit} ${S3_SECRET_ACCESS_KEY:-podokitsecret}; do echo 'waiting for minio'; sleep 1; done;
30
- mc mb -p local/${S3_BUCKET:-podokit};
31
- echo 'bucket ready';
32
- exit 0"
32
+ mc mb --ignore-existing local/${S3_BUCKET:-podokit};
33
+ echo 'bucket ready'"
33
34
 
34
35
  volumes:
35
36
  minio-data:
@@ -1,13 +1,8 @@
1
1
  import { expect, test, type APIRequestContext, type PlaywrightWorkerArgs } from "@playwright/test";
2
+ import { seededAdmin } from "../helpers/seeded-admin";
2
3
 
3
4
  const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
4
5
  const origin = { origin: base };
5
- // The administrator seeded by the admin-dashboard Playwright setup. Inlined so this
6
- // spec also compiles in apps that do not install that module.
7
- const admin = {
8
- email: "admin@example.com",
9
- password: process.env.E2E_ADMIN_PASSWORD || "Podokit3e-Str0ng!pw",
10
- };
11
6
 
12
7
  type Playwright = PlaywrightWorkerArgs["playwright"];
13
8
 
@@ -36,6 +31,8 @@ async function signUp(ctx: APIRequestContext): Promise<boolean> {
36
31
 
37
32
  /** Resolves false when no administrator session is available. */
38
33
  async function signInAdmin(ctx: APIRequestContext): Promise<boolean> {
34
+ const admin = await seededAdmin();
35
+ if (!admin) return false;
39
36
  const response = await ctx.post("/api/auth/sign-in/email", { data: admin });
40
37
  return response.ok() && ((await sessionRoles(ctx)) ?? []).includes("admin");
41
38
  }
@@ -1,4 +1,10 @@
1
- import { expect, test, type APIResponse } from "@playwright/test";
1
+ import {
2
+ expect,
3
+ test,
4
+ type APIRequestContext,
5
+ type APIResponse,
6
+ type PlaywrightWorkerArgs,
7
+ } from "@playwright/test";
2
8
 
3
9
  const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
4
10
  const origin = { origin: base };
@@ -19,20 +25,42 @@ const limit = Number(process.env.RATE_LIMIT_MAX ?? 300);
19
25
  const authLimit = Number(process.env.RATE_LIMIT_AUTH_MAX ?? 20);
20
26
  const runtimeLimit = Number(process.env.RATE_LIMIT_RUNTIME_MAX ?? 1000);
21
27
 
22
- async function session(
23
- playwright: import("@playwright/test").PlaywrightWorkerArgs["playwright"],
24
- ) {
25
- const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
26
- const email = `rate-limit-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@example.com`;
27
- const signup = await ctx.post("/api/auth/sign-up/email", {
28
- data: {
29
- email,
30
- password: "Podokit3e-Str0ng!pw",
31
- name: "Rate Limit",
32
- },
28
+ type Playwright = PlaywrightWorkerArgs["playwright"];
29
+ const password = "Podokit3e-Str0ng!pw";
30
+
31
+ function uniqueEmail(): string {
32
+ return `rate-limit-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@example.com`;
33
+ }
34
+
35
+ // Sign-ups and sign-ins are authentication writes. From an anonymous client the
36
+ // limiter counts them against the per-address budget that the setup project's
37
+ // and every other spec's sign-ins share. Only this worker's first one is
38
+ // anonymous: the later ones carry its session cookie, so the limiter counts them
39
+ // against that session instead.
40
+ let carrier: string | undefined;
41
+
42
+ /** Run one authentication write and return a context holding the session it set. */
43
+ async function authenticated(
44
+ playwright: Playwright,
45
+ path: "/api/auth/sign-up/email" | "/api/auth/sign-in/email",
46
+ data: Record<string, string>,
47
+ ): Promise<APIRequestContext> {
48
+ const writer = await playwright.request.newContext({
49
+ baseURL: base,
50
+ extraHTTPHeaders: { ...origin, ...(carrier ? { cookie: carrier } : {}) },
33
51
  });
34
- expect(signup.ok()).toBeTruthy();
35
- return ctx;
52
+ const response = await writer.post(path, { data });
53
+ expect(response.ok(), `${path}: ${response.status()}`).toBeTruthy();
54
+ const storageState = await writer.storageState();
55
+ await writer.dispose();
56
+ const token = storageState.cookies.find((cookie) => cookie.name.endsWith("session_token"));
57
+ carrier ??= token ? `${token.name}=${token.value}` : undefined;
58
+ return playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin, storageState });
59
+ }
60
+
61
+ /** A new account, signed in. */
62
+ async function session(playwright: Playwright, email = uniqueEmail()): Promise<APIRequestContext> {
63
+ return authenticated(playwright, "/api/auth/sign-up/email", { email, password, name: "Rate Limit" });
36
64
  }
37
65
 
38
66
  test("rate limit: health probes stay available while ordinary routes return 429 @smoke", async ({
@@ -73,10 +101,8 @@ test("rate limit: health probes stay available while ordinary routes return 429
73
101
  test("rate limit: a user's sessions share one counter and forged sessions do not get one", async ({
74
102
  playwright,
75
103
  }) => {
76
- const email = `rate-limit-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@example.com`;
77
- const password = "Podokit3e-Str0ng!pw";
78
- const first = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
79
- expect((await first.post("/api/auth/sign-up/email", { data: { email, password, name: "Rate Limit" } })).ok()).toBeTruthy();
104
+ const email = uniqueEmail();
105
+ const first = await session(playwright, email);
80
106
  const probe = await first.get("/api/cache/rate-limit-probe");
81
107
  test.skip(probe.status() >= 500, "throttler storage (redis) not reachable");
82
108
  const remaining = (response: APIResponse): number => Number(response.headers()["ratelimit-remaining"]);
@@ -86,8 +112,7 @@ test("rate limit: a user's sessions share one counter and forged sessions do not
86
112
  // session. The probe requires a session, so each request below resolves it; a
87
113
  // session counts under its user from the request after the API first resolves
88
114
  // it (or from that request when authentication runs before the limiter).
89
- const second = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
90
- expect((await second.post("/api/auth/sign-in/email", { data: { email, password } })).ok()).toBeTruthy();
115
+ const second = await authenticated(playwright, "/api/auth/sign-in/email", { email, password });
91
116
  const cookie = (await first.storageState()).cookies.find((c) => c.name.endsWith("session_token"));
92
117
  expect(cookie, "sign-up set a session cookie").toBeTruthy();
93
118
  const bearer = await playwright.request.newContext({