@podosoft/podokit 1.1.6 → 1.3.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 (203) hide show
  1. package/dist/add.d.ts +3 -1
  2. package/dist/add.js +116 -14
  3. package/dist/deploy-render.js +5 -0
  4. package/dist/index.js +8 -0
  5. package/dist/lib.d.ts +2 -2
  6. package/dist/lib.js +3 -2
  7. package/dist/lockfile.d.ts +3 -1
  8. package/dist/lockfile.js +17 -6
  9. package/dist/templates/fullstack/apps/api/package.json +2 -2
  10. package/dist/templates/fullstack/apps/api/scripts/build.mjs +1 -0
  11. package/dist/templates/fullstack/apps/api/src/app.spec.ts +81 -0
  12. package/dist/templates/fullstack/apps/api/src/app.ts +53 -5
  13. package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +6 -0
  14. package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +10 -1
  15. package/dist/templates/fullstack/apps/api/src/core/services.spec.ts +30 -0
  16. package/dist/templates/fullstack/apps/api/src/core/services.ts +19 -3
  17. package/dist/templates/fullstack/apps/api/src/database/data-source.spec.ts +15 -0
  18. package/dist/templates/fullstack/apps/api/src/database/data-source.ts +3 -0
  19. package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +149 -4
  20. package/dist/templates/fullstack/apps/api/src/database/database.ts +66 -13
  21. package/dist/templates/fullstack/apps/api/src/database/migrate.ts +1 -1
  22. package/dist/templates/fullstack/apps/api/src/health/health.plugin.spec.ts +86 -0
  23. package/dist/templates/fullstack/apps/api/src/health/health.plugin.ts +4 -9
  24. package/dist/templates/fullstack/apps/api/src/health/readiness.service.spec.ts +141 -1
  25. package/dist/templates/fullstack/apps/api/src/health/readiness.service.ts +86 -12
  26. package/dist/templates/fullstack/apps/web/package.json +1 -1
  27. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +188 -1
  28. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +30 -0
  29. package/dist/templates/fullstack/apps/web/src/routes/api/[...path]/+server.ts +3 -3
  30. package/dist/templates/fullstack/apps/web/src/routes/api/auth/[...all]/+server.ts +3 -3
  31. package/dist/templates/fullstack/compose.dev.yaml +21 -0
  32. package/dist/templates/fullstack/dot-claude/skills/podokit-sveltekit-route/SKILL.md +8 -1
  33. package/dist/templates/fullstack/dot-env.docker +3 -0
  34. package/dist/templates/fullstack/dot-env.example +14 -0
  35. package/dist/templates/fullstack/infra/k3s/api-deployment.yaml +2 -0
  36. package/dist/templates/fullstack/tests/playwright.extensions.ts +2 -2
  37. package/dist/templates/modules/admin-dashboard/files/apps/api/scripts/bootstrap-admin.mjs +21 -26
  38. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.migration.spec.ts +74 -0
  39. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.revoker.spec.ts +25 -0
  40. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.revoker.ts +26 -0
  41. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.routes.spec.ts +122 -0
  42. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.routes.ts +46 -0
  43. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.spec.ts +187 -0
  44. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.ts +160 -0
  45. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.routes.spec.ts +76 -0
  46. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.routes.ts +41 -0
  47. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.service.spec.ts +124 -0
  48. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.service.ts +153 -0
  49. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin.module.ts +22 -28
  50. package/dist/templates/modules/admin-dashboard/files/apps/api/src/bootstrap-admin-runtime.ts +60 -0
  51. package/dist/templates/modules/admin-dashboard/files/apps/api/src/migrations/1792060000000-IndexSessionCreatedAt.ts +35 -0
  52. package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.spec.ts +3 -5
  53. package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.ts +6 -17
  54. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-assets/site-assets.routes.spec.ts +167 -0
  55. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-assets/site-assets.routes.ts +50 -0
  56. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.service.spec.ts +158 -0
  57. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.service.ts +78 -28
  58. package/dist/templates/modules/admin-dashboard/files/apps/web/src/app.d.ts +3 -0
  59. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.test.ts +124 -0
  60. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +19 -11
  61. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-data.server.ts +6 -32
  62. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/sessions.test.ts +74 -0
  63. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/sessions.ts +73 -0
  64. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/users.test.ts +102 -0
  65. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/users.ts +141 -0
  66. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/audit-log.test.ts +68 -0
  67. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/audit-log.ts +108 -0
  68. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +14 -8
  69. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/session-idle-timeout.svelte +74 -49
  70. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/en.json +6 -1
  71. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/ko.json +6 -1
  72. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.test.ts +29 -0
  73. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.ts +23 -6
  74. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/oauth-query.test.ts +21 -0
  75. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/oauth-query.ts +15 -0
  76. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/capabilities.test.ts +98 -0
  77. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/capabilities.ts +54 -0
  78. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/guards.test.ts +23 -0
  79. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/guards.ts +2 -0
  80. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/protected-layout.ts +7 -50
  81. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/public-paths.ts +9 -0
  82. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/audit/+page.svelte +88 -45
  83. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/sessions/+page.svelte +76 -75
  84. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/settings/+page.server.ts +15 -14
  85. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/settings/+page.svelte +2 -2
  86. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/users/+page.svelte +79 -37
  87. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.server.ts +10 -17
  88. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.ts +10 -0
  89. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/oauth2/consent/+page.svelte +4 -3
  90. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/setup-2fa/+page.svelte +14 -8
  91. package/dist/templates/modules/admin-dashboard/files/dot-claude/skills/podokit-data-table/SKILL.md +2 -1
  92. package/dist/templates/modules/admin-dashboard/files/tests/api/admin-sessions.api.spec.ts +124 -0
  93. package/dist/templates/modules/admin-dashboard/files/tests/api/admin-users.api.spec.ts +86 -0
  94. package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +75 -12
  95. package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +1 -0
  96. package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +35 -1
  97. package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +7 -0
  98. package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +72 -0
  99. package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +36 -2
  100. package/dist/templates/modules/admin-dashboard/files/tests/helpers/disposable-users.ts +5 -2
  101. package/dist/templates/modules/admin-dashboard/files/tests/ui/audit.ui.spec.ts +41 -0
  102. package/dist/templates/modules/admin-dashboard/files/tests/ui/data-table.ui.spec.ts +32 -13
  103. package/dist/templates/modules/admin-dashboard/files/tests/ui/email-admin.ui.spec.ts +0 -1
  104. package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +1 -3
  105. package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +16 -5
  106. package/dist/templates/modules/admin-dashboard/files/tests/ui/request-budget.ui.spec.ts +45 -0
  107. package/dist/templates/modules/admin-dashboard/files/tests/ui/sessions.ui.spec.ts +64 -1
  108. package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +29 -0
  109. package/dist/templates/modules/admin-dashboard/files/tests/ui/users-crud.ui.spec.ts +8 -1
  110. package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +32 -1
  111. package/dist/templates/modules/admin-dashboard/module.manifest.json +3 -2
  112. package/dist/templates/modules/audit-log/files/apps/api/src/audit/README.md +46 -5
  113. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-events.ts +19 -7
  114. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +2 -0
  115. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.migration.spec.ts +126 -3
  116. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.module.ts +13 -11
  117. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.routes.spec.ts +115 -0
  118. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.routes.ts +44 -0
  119. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.spec.ts +421 -43
  120. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +382 -35
  121. package/dist/templates/modules/audit-log/files/apps/api/src/migrations/1792000000000-IndexAuditLogFilters.ts +57 -0
  122. package/dist/templates/modules/audit-log/module.manifest.json +7 -4
  123. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-config-store.ts +20 -5
  124. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +24 -29
  125. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.module.ts +28 -13
  126. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.openapi.spec.ts +56 -2
  127. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.openapi.ts +22 -0
  128. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.spec.ts +81 -5
  129. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +26 -2
  130. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +15 -9
  131. package/dist/templates/modules/auth/files/apps/api/src/auth/config-events.spec.ts +38 -0
  132. package/dist/templates/modules/auth/files/apps/api/src/auth/config-events.ts +43 -0
  133. package/dist/templates/modules/auth/files/apps/api/src/auth/db.spec.ts +24 -0
  134. package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +31 -2
  135. package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.spec.ts +58 -0
  136. package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.ts +46 -57
  137. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.rows.spec.ts +53 -0
  138. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.rows.ts +65 -0
  139. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +21 -37
  140. package/dist/templates/modules/auth/files/apps/api/src/auth-config/config-param.spec.ts +23 -0
  141. package/dist/templates/modules/auth/files/apps/api/src/auth-config/config-param.ts +10 -0
  142. package/dist/templates/modules/auth/files/apps/api/src/auth-config/session-expiry.spec.ts +33 -0
  143. package/dist/templates/modules/auth/files/apps/api/src/auth-config/session-expiry.ts +29 -0
  144. package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.ts +10 -0
  145. package/dist/templates/modules/auth/files/apps/api/src/settings/settings.service.spec.ts +81 -0
  146. package/dist/templates/modules/auth/files/apps/api/src/settings/settings.service.ts +37 -34
  147. package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/references/bootstrap-admin.md +2 -0
  148. package/dist/templates/modules/auth/module.manifest.json +2 -2
  149. package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.spec.ts +136 -0
  150. package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.ts +34 -13
  151. package/dist/templates/modules/file-upload/files/apps/api/src/files/upload-keys.spec.ts +60 -0
  152. package/dist/templates/modules/file-upload/files/apps/api/src/files/upload-keys.ts +51 -0
  153. package/dist/templates/modules/file-upload/files/tests/api/files.api.spec.ts +70 -9
  154. package/dist/templates/modules/file-upload/module.manifest.json +9 -3
  155. package/dist/templates/modules/job-progress/files/apps/api/src/progress/job-progress.module.ts +26 -32
  156. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.bridge.spec.ts +16 -1
  157. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.processor.ts +31 -4
  158. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.worker.spec.ts +145 -0
  159. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.worker.ts +37 -0
  160. package/dist/templates/modules/job-progress/files/tests/api/progress.api.spec.ts +94 -4
  161. package/dist/templates/modules/job-progress/module.manifest.json +3 -3
  162. package/dist/templates/modules/mailer/files/apps/api/src/mail/mailer.spec.ts +54 -0
  163. package/dist/templates/modules/mailer/files/apps/api/src/mail/mailer.ts +47 -25
  164. package/dist/templates/modules/mailer/module.manifest.json +1 -1
  165. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/download-url.spec.ts +86 -0
  166. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/download-url.ts +159 -0
  167. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-demo.ts +38 -0
  168. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.spec.ts +172 -4
  169. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.ts +110 -33
  170. package/dist/templates/modules/object-storage-local/files/tests/api/storage-local.api.spec.ts +88 -0
  171. package/dist/templates/modules/object-storage-local/module.manifest.json +6 -1
  172. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.spec.ts +80 -0
  173. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.ts +13 -7
  174. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage-demo.spec.ts +156 -0
  175. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage-demo.ts +38 -0
  176. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.module.ts +39 -21
  177. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.service.ts +101 -2
  178. package/dist/templates/modules/object-storage-s3/files/tests/api/storage.api.spec.ts +84 -9
  179. package/dist/templates/modules/object-storage-s3/module.manifest.json +8 -3
  180. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.spec.ts +9 -0
  181. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.ts +7 -0
  182. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.spec.ts +139 -21
  183. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.ts +80 -11
  184. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.spec.ts +12 -6
  185. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +15 -2
  186. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +46 -0
  187. package/dist/templates/modules/rate-limit/module.manifest.json +4 -3
  188. package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.spec.ts +125 -0
  189. package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.ts +27 -13
  190. package/dist/templates/modules/sse/files/apps/api/src/events/event-envelope.ts +123 -0
  191. package/dist/templates/modules/sse/files/apps/api/src/events/event-stream.ts +191 -0
  192. package/dist/templates/modules/sse/files/apps/api/src/events/event-topics.ts +64 -0
  193. package/dist/templates/modules/sse/files/apps/api/src/events/events.module.spec.ts +250 -0
  194. package/dist/templates/modules/sse/files/apps/api/src/events/events.module.ts +121 -15
  195. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.spec.ts +407 -18
  196. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +153 -40
  197. package/dist/templates/modules/sse/files/apps/web/src/lib/events.test.ts +216 -0
  198. package/dist/templates/modules/sse/files/apps/web/src/lib/events.ts +207 -0
  199. package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +227 -70
  200. package/dist/templates/modules/sse/module.manifest.json +20 -5
  201. package/dist/update.d.ts +17 -0
  202. package/dist/update.js +33 -8
  203. package/package.json +2 -2
@@ -6,7 +6,7 @@ import {
6
6
  } from "@podosoft/podokit-runtime";
7
7
  import type { AppEnv } from "../config/env.validation";
8
8
  import { Database } from "../database/database";
9
- import { ReadinessService } from "../health/readiness.service";
9
+ import { DATABASE_READINESS_CHECK, ReadinessService } from "../health/readiness.service";
10
10
 
11
11
  export type { ServiceKey } from "@podosoft/podokit-runtime";
12
12
 
@@ -124,6 +124,9 @@ export class RequestGuardRegistry {
124
124
 
125
125
  export interface RequestIdentity {
126
126
  userId(request: Request): Promise<string | undefined>;
127
+ isAdmin(request: Request): Promise<boolean>;
128
+ roles(request: Request): Promise<string[]>;
129
+ activeOrganizationId(request: Request): Promise<string | undefined>;
127
130
  }
128
131
 
129
132
  export interface OpenApiContribution {
@@ -177,7 +180,14 @@ export interface PodokitModule {
177
180
  export function createCoreServices(env: AppEnv): ServiceRegistry {
178
181
  const services = new ServiceRegistry();
179
182
  const database = new Database(env);
180
- const readiness = new ReadinessService();
183
+ const readiness = new ReadinessService({
184
+ timeoutMs: env.READINESS_CHECK_TIMEOUT_MS,
185
+ cacheMs: env.READINESS_CACHE_MS,
186
+ // Resolved on use: a module such as logging may replace the logger after this point.
187
+ onCheckFailure: (check, reason) => {
188
+ services.resolve(LOGGER).warn({ check, reason }, "Readiness check failed");
189
+ },
190
+ });
181
191
  const logger = pino({ level: process.env.LOG_LEVEL ?? "info" });
182
192
  const accessPolicy = new AccessPolicy();
183
193
  const requestGuards = new RequestGuardRegistry();
@@ -191,10 +201,16 @@ export function createCoreServices(env: AppEnv): ServiceRegistry {
191
201
  services.register(DATABASE, database, () => database.close());
192
202
  services.onStart(() => database.connect());
193
203
  services.register(READINESS, readiness);
204
+ readiness.register(DATABASE_READINESS_CHECK, () => services.resolve(DATABASE).ping());
194
205
  services.register(LOGGER, logger);
195
206
  services.register(ACCESS_POLICY, accessPolicy);
196
207
  services.register(REQUEST_GUARDS, requestGuards);
197
- services.register(REQUEST_IDENTITY, { userId: async () => undefined });
208
+ services.register(REQUEST_IDENTITY, {
209
+ userId: async () => undefined,
210
+ isAdmin: async () => false,
211
+ roles: async () => [],
212
+ activeOrganizationId: async () => undefined,
213
+ });
198
214
  services.register(OPENAPI, openapiRegistry);
199
215
  return services;
200
216
  }
@@ -0,0 +1,15 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import type { DatabaseProviderName } from "@podosoft/podokit-runtime";
3
+ import { PROVIDERS } from "../config/providers";
4
+
5
+ function databaseProvider(): DatabaseProviderName {
6
+ return PROVIDERS.database;
7
+ }
8
+
9
+ // SQLite applications migrate with the Bun SQLite migrator, not this TypeORM source.
10
+ describe.skipIf(databaseProvider() !== "postgres")("migration data source", () => {
11
+ it("runs each migration in its own transaction so one can build indexes concurrently", async () => {
12
+ const { dataSourceOptions } = await import("./data-source.js");
13
+ expect(dataSourceOptions.migrationsTransactionMode).toBe("each");
14
+ });
15
+ });
@@ -15,6 +15,9 @@ const migrations = existsSync(compiledMigrations)
15
15
  const commonOptions = {
16
16
  entities: [],
17
17
  migrations,
18
+ // One transaction per migration, so a migration can set `transaction = false`
19
+ // for statements such as CREATE INDEX CONCURRENTLY.
20
+ migrationsTransactionMode: "each" as const,
18
21
  synchronize: false,
19
22
  };
20
23
 
@@ -1,13 +1,20 @@
1
+ import { SQL } from "bun";
1
2
  import { describe, expect, it } from "bun:test";
2
3
  import { existsSync, mkdtempSync, rmSync } from "node:fs";
3
4
  import { tmpdir } from "node:os";
4
5
  import { dirname, join } from "node:path";
5
- import type { AppEnv } from "../config/env.validation";
6
+ import { validateEnv } from "../config/env.validation";
6
7
  import { PROVIDERS } from "../config/providers";
7
- import { databaseUrl, sqliteDatabasePath } from "./database";
8
+ import {
9
+ Database,
10
+ databaseUrl,
11
+ isDatabaseUnavailable,
12
+ postgresPoolOptions,
13
+ sqliteDatabasePath,
14
+ } from "./database";
8
15
  import type { DatabaseProviderName } from "@podosoft/podokit-runtime";
9
16
 
10
- const env: AppEnv = {
17
+ const env = validateEnv({
11
18
  NODE_ENV: "test",
12
19
  PORT: 5002,
13
20
  POSTGRES_HOST: "localhost",
@@ -15,12 +22,44 @@ const env: AppEnv = {
15
22
  POSTGRES_USER: "podokit",
16
23
  POSTGRES_PASSWORD: "podokit",
17
24
  POSTGRES_DB: "podokit",
18
- };
25
+ });
19
26
 
20
27
  function databaseProvider(): DatabaseProviderName {
21
28
  return PROVIDERS.database;
22
29
  }
23
30
 
31
+ interface ProbedSql {
32
+ sql: SQL;
33
+ probes: () => number;
34
+ setAvailable: (available: boolean) => void;
35
+ close: () => Promise<void>;
36
+ }
37
+
38
+ // Counts `SELECT 1` probes against an in-memory database and can simulate an outage.
39
+ function probedSql(): ProbedSql {
40
+ const target = new SQL(":memory:", { adapter: "sqlite" });
41
+ let probes = 0;
42
+ let available = true;
43
+ const sql = new Proxy(target, {
44
+ apply(query, thisArg: unknown, args: unknown[]): unknown {
45
+ const [strings] = args;
46
+ if (Array.isArray(strings) && strings.join("").trim() === "SELECT 1") {
47
+ probes += 1;
48
+ if (!available) return Promise.reject(new Error("database unavailable"));
49
+ }
50
+ return Reflect.apply(query, thisArg, args);
51
+ },
52
+ });
53
+ return {
54
+ sql,
55
+ probes: () => probes,
56
+ setAvailable: (value) => {
57
+ available = value;
58
+ },
59
+ close: () => target.close(),
60
+ };
61
+ }
62
+
24
63
  describe("database provider", () => {
25
64
  it("uses DATABASE_URL as the canonical connection setting", () => {
26
65
  const provider = databaseProvider();
@@ -56,3 +95,109 @@ describe("database provider", () => {
56
95
  }
57
96
  });
58
97
  });
98
+
99
+ describe("Database ping", () => {
100
+ it("queries the database on every ping", async () => {
101
+ const probe = probedSql();
102
+ const database = new Database(env, probe.sql);
103
+ try {
104
+ await database.ping();
105
+ const afterFirstPing = probe.probes();
106
+ await database.ping();
107
+ await database.ping();
108
+ expect(probe.probes()).toBe(afterFirstPing + 2);
109
+ } finally {
110
+ await probe.close();
111
+ }
112
+ });
113
+
114
+ it("reports an outage that starts after a successful connect", async () => {
115
+ const probe = probedSql();
116
+ const database = new Database(env, probe.sql);
117
+ try {
118
+ await database.connect();
119
+ probe.setAvailable(false);
120
+ await expect(database.ping()).rejects.toThrow("database unavailable");
121
+ probe.setAvailable(true);
122
+ await expect(database.ping()).resolves.toBeUndefined();
123
+ } finally {
124
+ await probe.close();
125
+ }
126
+ });
127
+
128
+ it("retries the connection after a failed attempt", async () => {
129
+ const probe = probedSql();
130
+ const database = new Database(env, probe.sql);
131
+ try {
132
+ probe.setAvailable(false);
133
+ await expect(database.connect()).rejects.toThrow("database unavailable");
134
+ probe.setAvailable(true);
135
+ await expect(database.ping()).resolves.toBeUndefined();
136
+ await expect(database.connect()).resolves.toBeUndefined();
137
+ } finally {
138
+ await probe.close();
139
+ }
140
+ });
141
+ });
142
+
143
+ describe("PostgreSQL pool settings", () => {
144
+ it("bounds connections and converts the millisecond settings to Bun seconds", () => {
145
+ expect(postgresPoolOptions(env)).toEqual({ max: 20, connectionTimeout: 10, idleTimeout: 30 });
146
+ expect(env.DB_AUTH_POOL_MAX).toBe(10);
147
+ expect(
148
+ postgresPoolOptions(validateEnv({ DB_POOL_MAX: "5", DB_CONNECT_TIMEOUT_MS: "2500", DB_IDLE_TIMEOUT_MS: "0" })),
149
+ ).toEqual({ max: 5, connectionTimeout: 2.5, idleTimeout: 0 });
150
+ });
151
+
152
+ it("rejects pool settings that would disable the limits", () => {
153
+ expect(() => validateEnv({ DB_POOL_MAX: "0" })).toThrow("DB_POOL_MAX");
154
+ expect(() => validateEnv({ DB_AUTH_POOL_MAX: "-1" })).toThrow("DB_AUTH_POOL_MAX");
155
+ expect(() => validateEnv({ DB_CONNECT_TIMEOUT_MS: "0" })).toThrow("DB_CONNECT_TIMEOUT_MS");
156
+ });
157
+ });
158
+
159
+ describe("isDatabaseUnavailable", () => {
160
+ it("recognizes Bun SQL connection failures", () => {
161
+ for (const code of [
162
+ "ERR_POSTGRES_CONNECTION_TIMEOUT",
163
+ "ERR_POSTGRES_CONNECTION_REFUSED",
164
+ "ERR_POSTGRES_CONNECTION_FAILED",
165
+ "ERR_POSTGRES_CONNECTION_CLOSED",
166
+ ]) {
167
+ expect(isDatabaseUnavailable(new SQL.PostgresError("Connection failed", { code }))).toBe(true);
168
+ }
169
+ });
170
+
171
+ it("recognizes pg pool and connection timeouts, also when wrapped", () => {
172
+ const acquire = new Error("timeout exceeded when trying to connect");
173
+ const connect = new Error("Connection terminated due to connection timeout");
174
+ expect(isDatabaseUnavailable(acquire)).toBe(true);
175
+ expect(isDatabaseUnavailable(connect)).toBe(true);
176
+ expect(isDatabaseUnavailable(new Error("Failed to get session", { cause: acquire }))).toBe(true);
177
+ });
178
+
179
+ it("leaves query and application errors alone", () => {
180
+ expect(isDatabaseUnavailable(new SQL.PostgresError("syntax error", { code: "ERR_POSTGRES_SYNTAX_ERROR" })))
181
+ .toBe(false);
182
+ expect(
183
+ isDatabaseUnavailable(new SQL.PostgresError("duplicate key", { code: "ERR_POSTGRES_SERVER_ERROR", errno: "23505" })),
184
+ ).toBe(false);
185
+ expect(isDatabaseUnavailable(new Error("timeout"))).toBe(false);
186
+ expect(isDatabaseUnavailable("ERR_POSTGRES_CONNECTION_TIMEOUT")).toBe(false);
187
+ expect(isDatabaseUnavailable(undefined)).toBe(false);
188
+ });
189
+
190
+ it("recognizes a real Bun SQL connection timeout", async () => {
191
+ // Accepts the TCP connection and never answers, like an overloaded server.
192
+ const server = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } });
193
+ const sql = new SQL(`postgres://podokit:podokit@127.0.0.1:${server.port}/podokit`, { connectionTimeout: 0.2 });
194
+ try {
195
+ const error: unknown = await sql`SELECT 1`.then(() => undefined, (reason: unknown) => reason);
196
+ expect(error).toBeInstanceOf(SQL.PostgresError);
197
+ expect(isDatabaseUnavailable(error)).toBe(true);
198
+ } finally {
199
+ await sql.close();
200
+ server.stop(true);
201
+ }
202
+ });
203
+ });
@@ -47,35 +47,88 @@ export function sqliteDatabasePath(url: string): string {
47
47
  return path;
48
48
  }
49
49
 
50
+ export interface PostgresPoolOptions {
51
+ max: number;
52
+ connectionTimeout: number;
53
+ idleTimeout: number;
54
+ }
55
+
56
+ /** Bun SQL takes these timeouts in seconds; the environment sets milliseconds. */
57
+ export function postgresPoolOptions(env: AppEnv): PostgresPoolOptions {
58
+ return {
59
+ max: env.DB_POOL_MAX,
60
+ connectionTimeout: env.DB_CONNECT_TIMEOUT_MS / 1000,
61
+ idleTimeout: env.DB_IDLE_TIMEOUT_MS / 1000,
62
+ };
63
+ }
64
+
65
+ // Bun SQL and pg report a database that cannot be reached, or a connection that
66
+ // could not be opened or taken from a full pool in time, with these shapes.
67
+ const UNAVAILABLE_SQL_CODES = new Set([
68
+ "ERR_POSTGRES_CONNECTION_TIMEOUT",
69
+ "ERR_POSTGRES_CONNECTION_REFUSED",
70
+ "ERR_POSTGRES_CONNECTION_FAILED",
71
+ "ERR_POSTGRES_CONNECTION_CLOSED",
72
+ ]);
73
+ const UNAVAILABLE_PG_MESSAGES = new Set([
74
+ "timeout exceeded when trying to connect",
75
+ "Connection terminated due to connection timeout",
76
+ ]);
77
+
78
+ /** Whether an error, or one it wraps, means the database is unavailable rather than the request being wrong. */
79
+ export function isDatabaseUnavailable(error: unknown): boolean {
80
+ let current: unknown = error;
81
+ for (let depth = 0; depth < 5 && current instanceof Error; depth += 1) {
82
+ if (current instanceof SQL.PostgresError && UNAVAILABLE_SQL_CODES.has(current.code)) return true;
83
+ if (UNAVAILABLE_PG_MESSAGES.has(current.message)) return true;
84
+ current = current.cause;
85
+ }
86
+ return false;
87
+ }
88
+
89
+ function createSql(provider: DatabaseProviderName, env: AppEnv): SQL {
90
+ const url = databaseUrl(env);
91
+ return provider === "sqlite"
92
+ ? new SQL(url, { adapter: "sqlite", create: true, readwrite: true, strict: true })
93
+ : new SQL(url, postgresPoolOptions(env));
94
+ }
95
+
50
96
  export class Database {
51
97
  readonly provider: DatabaseProviderName = selectedDatabaseProvider();
52
98
  readonly sql: SQL;
53
99
  private ready?: Promise<void>;
54
100
 
55
- constructor(env: AppEnv) {
56
- const url = databaseUrl(env);
57
- this.sql = this.provider === "sqlite"
58
- ? new SQL(url, { adapter: "sqlite", create: true, readwrite: true, strict: true })
59
- : new SQL(url, { max: 20 });
101
+ constructor(env: AppEnv, sql?: SQL) {
102
+ this.sql = sql ?? createSql(this.provider, env);
60
103
  }
61
104
 
62
105
  connect(): Promise<void> {
63
- this.ready ??= (async () => {
64
- if (this.provider === "sqlite") {
65
- await this.sql`PRAGMA journal_mode = WAL`;
66
- await this.sql`PRAGMA foreign_keys = ON`;
67
- await this.sql`PRAGMA busy_timeout = 5000`;
68
- }
69
- await this.sql`SELECT 1`;
70
- })();
106
+ if (!this.ready) {
107
+ const attempt = this.initialize();
108
+ this.ready = attempt;
109
+ // Forget a failed start so the next call retries instead of replaying the failure.
110
+ void attempt.catch(() => {
111
+ if (this.ready === attempt) this.ready = undefined;
112
+ });
113
+ }
71
114
  return this.ready;
72
115
  }
73
116
 
74
117
  async ping(): Promise<void> {
75
118
  await this.connect();
119
+ await this.sql`SELECT 1`;
76
120
  }
77
121
 
78
122
  async close(): Promise<void> {
79
123
  await this.sql.close();
80
124
  }
125
+
126
+ private async initialize(): Promise<void> {
127
+ if (this.provider === "sqlite") {
128
+ await this.sql`PRAGMA journal_mode = WAL`;
129
+ await this.sql`PRAGMA foreign_keys = ON`;
130
+ await this.sql`PRAGMA busy_timeout = 5000`;
131
+ }
132
+ await this.sql`SELECT 1`;
133
+ }
81
134
  }
@@ -25,7 +25,7 @@ async function migrate(): Promise<void> {
25
25
  await dataSource.initialize();
26
26
  try {
27
27
  if (process.argv.includes("--revert")) await dataSource.undoLastMigration({ transaction: "all" });
28
- else await dataSource.runMigrations({ transaction: "all" });
28
+ else await dataSource.runMigrations();
29
29
  } finally {
30
30
  await dataSource.destroy();
31
31
  }
@@ -0,0 +1,86 @@
1
+ import { SQL } from "bun";
2
+ import { afterEach, describe, expect, it } from "bun:test";
3
+ import type { DatabaseProviderName } from "@podosoft/podokit-runtime";
4
+ import { Elysia } from "elysia";
5
+ import { AppException } from "../common/app-exception";
6
+ import { validateEnv } from "../config/env.validation";
7
+ import { PROVIDERS } from "../config/providers";
8
+ import {
9
+ appContext,
10
+ createCoreServices,
11
+ DATABASE,
12
+ READINESS,
13
+ type ServiceRegistry,
14
+ } from "../core/services";
15
+ import { Database } from "../database/database";
16
+ import type { ReadinessCheck } from "./readiness.service";
17
+ import { healthPlugin } from "./health.plugin";
18
+
19
+ const opened: ServiceRegistry[] = [];
20
+
21
+ function databaseProvider(): DatabaseProviderName {
22
+ return PROVIDERS.database;
23
+ }
24
+
25
+ afterEach(async () => {
26
+ for (const services of opened.splice(0)) await services.close();
27
+ });
28
+
29
+ async function readiness(
30
+ options: { databaseUp: boolean; checks?: Record<string, ReadinessCheck> },
31
+ ): Promise<{ status: number; body: unknown }> {
32
+ const env = validateEnv({
33
+ NODE_ENV: "test",
34
+ DATABASE_URL: databaseProvider() === "sqlite"
35
+ ? "sqlite://:memory:"
36
+ : "postgres://podokit:podokit@localhost:5432/podokit",
37
+ READINESS_CHECK_TIMEOUT_MS: "20",
38
+ READINESS_CACHE_MS: "0",
39
+ });
40
+ const services = createCoreServices(env);
41
+ opened.push(services);
42
+ const sql = new SQL(":memory:", { adapter: "sqlite" });
43
+ if (!options.databaseUp) await sql.close();
44
+ const database = new Database(env, sql);
45
+ services.override(DATABASE, database, () => database.close());
46
+ for (const [name, check] of Object.entries(options.checks ?? {})) {
47
+ services.resolve(READINESS).register(name, check);
48
+ }
49
+ const app = new Elysia()
50
+ .onError(({ error, set }) => {
51
+ set.status = error instanceof AppException ? error.statusCode : 500;
52
+ return { code: error instanceof AppException ? error.code : "INTERNAL_ERROR" };
53
+ })
54
+ .use(healthPlugin(appContext(env, services)));
55
+ const response = await app.handle(new Request("http://localhost/health/ready"));
56
+ return { status: response.status, body: await response.json() };
57
+ }
58
+
59
+ describe("GET /health/ready", () => {
60
+ it("reports the database as db and module checks separately", async () => {
61
+ const result = await readiness({
62
+ databaseUp: true,
63
+ checks: { cache: () => Promise.resolve() },
64
+ });
65
+
66
+ expect(result).toEqual({
67
+ status: 200,
68
+ body: { status: "ready", db: "up", checks: { cache: "up" } },
69
+ });
70
+ });
71
+
72
+ it("fails when the database is down", async () => {
73
+ const result = await readiness({ databaseUp: false });
74
+
75
+ expect(result).toEqual({ status: 503, body: { code: "READINESS_FAILED" } });
76
+ });
77
+
78
+ it("fails when a module check exceeds its timeout", async () => {
79
+ const result = await readiness({
80
+ databaseUp: true,
81
+ checks: { storage: () => new Promise<void>(() => {}) },
82
+ });
83
+
84
+ expect(result).toEqual({ status: 503, body: { code: "READINESS_FAILED" } });
85
+ });
86
+ });
@@ -1,8 +1,9 @@
1
1
  import { Elysia } from "elysia";
2
2
  import { AppException } from "../common/app-exception";
3
3
  import type { AppPlugin } from "../core/services";
4
+ import { DATABASE_READINESS_CHECK } from "./readiness.service";
4
5
 
5
- export const healthPlugin: AppPlugin = ({ database, readiness }) =>
6
+ export const healthPlugin: AppPlugin = ({ readiness }) =>
6
7
  new Elysia({ name: "podokit.health" })
7
8
  .get(
8
9
  "/health",
@@ -12,14 +13,8 @@ export const healthPlugin: AppPlugin = ({ database, readiness }) =>
12
13
  .get(
13
14
  "/health/ready",
14
15
  async () => {
15
- let db: "up" | "down" = "up";
16
- try {
17
- await database.ping();
18
- } catch {
19
- db = "down";
20
- }
21
- const checks = await readiness.run();
22
- if (db === "down" || Object.values(checks).includes("down")) {
16
+ const { [DATABASE_READINESS_CHECK]: db, ...checks } = await readiness.run();
17
+ if (db !== "up" || Object.values(checks).includes("down")) {
23
18
  throw new AppException("READINESS_FAILED", "One or more dependencies are unavailable", 503);
24
19
  }
25
20
  return { status: "ready" as const, db: "up" as const, checks };
@@ -1,6 +1,23 @@
1
- import { describe, expect, it } from "bun:test";
1
+ import { afterEach, describe, expect, it, setSystemTime } from "bun:test";
2
2
  import { ReadinessService } from "./readiness.service";
3
3
 
4
+ interface Deferred {
5
+ promise: Promise<void>;
6
+ resolve: () => void;
7
+ }
8
+
9
+ function deferred(): Deferred {
10
+ let resolve: () => void = () => {};
11
+ const promise = new Promise<void>((done) => {
12
+ resolve = done;
13
+ });
14
+ return { promise, resolve };
15
+ }
16
+
17
+ afterEach(() => {
18
+ setSystemTime();
19
+ });
20
+
4
21
  describe("ReadinessService", () => {
5
22
  it("runs registered checks and removes them with the returned callback", async () => {
6
23
  const service = new ReadinessService();
@@ -19,4 +36,127 @@ describe("ReadinessService", () => {
19
36
  'Readiness check "cache" is already registered',
20
37
  );
21
38
  });
39
+
40
+ it("reports a check that exceeds the timeout as down with the reason", async () => {
41
+ const failures: Array<[string, string]> = [];
42
+ const service = new ReadinessService({
43
+ timeoutMs: 20,
44
+ cacheMs: 0,
45
+ onCheckFailure: (name, reason) => failures.push([name, reason]),
46
+ });
47
+ service.register("storage", () => new Promise<void>(() => {}));
48
+ service.register("cache", () => Promise.resolve());
49
+
50
+ const started = performance.now();
51
+ await expect(service.run()).resolves.toEqual({ storage: "down", cache: "up" });
52
+ expect(performance.now() - started).toBeLessThan(1_000);
53
+ expect(failures).toEqual([["storage", 'Readiness check "storage" timed out after 20 ms']]);
54
+ });
55
+
56
+ it("reports the error message of a failed check", async () => {
57
+ const failures: Array<[string, string]> = [];
58
+ const service = new ReadinessService({
59
+ cacheMs: 0,
60
+ onCheckFailure: (name, reason) => failures.push([name, reason]),
61
+ });
62
+ service.register("events", () => Promise.reject(new Error("connection refused")));
63
+ service.register("cache", () => {
64
+ throw new Error("not connected");
65
+ });
66
+
67
+ await expect(service.run()).resolves.toEqual({ events: "down", cache: "down" });
68
+ expect(Object.fromEntries(failures)).toEqual({
69
+ events: "connection refused",
70
+ cache: "not connected",
71
+ });
72
+ });
73
+
74
+ it("reuses a finished evaluation until the cache window ends", async () => {
75
+ setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
76
+ let calls = 0;
77
+ const service = new ReadinessService({ cacheMs: 2_000 });
78
+ service.register("cache", async () => {
79
+ calls += 1;
80
+ });
81
+
82
+ await service.run();
83
+ setSystemTime(new Date("2026-01-01T00:00:01.999Z"));
84
+ await service.run();
85
+ expect(calls).toBe(1);
86
+
87
+ setSystemTime(new Date("2026-01-01T00:00:02.000Z"));
88
+ await service.run();
89
+ expect(calls).toBe(2);
90
+ });
91
+
92
+ it("caches a failed evaluation for the same window", async () => {
93
+ setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
94
+ let available = false;
95
+ const service = new ReadinessService({ cacheMs: 2_000 });
96
+ service.register("cache", async () => {
97
+ if (!available) throw new Error("unavailable");
98
+ });
99
+
100
+ await expect(service.run()).resolves.toEqual({ cache: "down" });
101
+ available = true;
102
+ await expect(service.run()).resolves.toEqual({ cache: "down" });
103
+ setSystemTime(new Date("2026-01-01T00:00:02.000Z"));
104
+ await expect(service.run()).resolves.toEqual({ cache: "up" });
105
+ });
106
+
107
+ it("evaluates on every run when the cache is disabled", async () => {
108
+ let calls = 0;
109
+ const service = new ReadinessService({ cacheMs: 0 });
110
+ service.register("cache", async () => {
111
+ calls += 1;
112
+ });
113
+
114
+ await service.run();
115
+ await service.run();
116
+ await service.run();
117
+ expect(calls).toBe(3);
118
+ });
119
+
120
+ it("shares one evaluation between concurrent runs", async () => {
121
+ let calls = 0;
122
+ const gate = deferred();
123
+ const service = new ReadinessService({ cacheMs: 0 });
124
+ service.register("cache", async () => {
125
+ calls += 1;
126
+ await gate.promise;
127
+ });
128
+
129
+ const runs = [service.run(), service.run(), service.run()];
130
+ gate.resolve();
131
+ const results = await Promise.all(runs);
132
+
133
+ expect(calls).toBe(1);
134
+ expect(results).toEqual([{ cache: "up" }, { cache: "up" }, { cache: "up" }]);
135
+ await service.run();
136
+ expect(calls).toBe(2);
137
+ });
138
+
139
+ it("does not share or cache an evaluation across a registration change", async () => {
140
+ const gate = deferred();
141
+ const service = new ReadinessService({ cacheMs: 60_000 });
142
+ service.register("cache", () => gate.promise);
143
+
144
+ const before = service.run();
145
+ service.register("events", () => Promise.resolve());
146
+ const after = service.run();
147
+ gate.resolve();
148
+
149
+ await expect(before).resolves.toEqual({ cache: "up" });
150
+ await expect(after).resolves.toEqual({ cache: "up", events: "up" });
151
+ await expect(service.run()).resolves.toEqual({ cache: "up", events: "up" });
152
+ });
153
+
154
+ it("returns a copy that callers cannot use to change the cached result", async () => {
155
+ const service = new ReadinessService({ cacheMs: 60_000 });
156
+ service.register("cache", () => Promise.resolve());
157
+
158
+ const first = await service.run();
159
+ first.cache = "down";
160
+ await expect(service.run()).resolves.toEqual({ cache: "up" });
161
+ });
22
162
  });