@podosoft/podokit 1.3.0 → 1.4.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 (154) hide show
  1. package/README.md +1 -1
  2. package/dist/add.d.ts +10 -1
  3. package/dist/add.js +20 -7
  4. package/dist/api-paths.d.ts +34 -0
  5. package/dist/api-paths.js +103 -0
  6. package/dist/deploy-compose-profile.d.ts +4 -0
  7. package/dist/deploy-compose-profile.js +13 -2
  8. package/dist/deploy-compose-render.d.ts +1 -1
  9. package/dist/deploy-compose-render.js +23 -7
  10. package/dist/deploy-compose.js +2 -1
  11. package/dist/deploy-profile.d.ts +3 -0
  12. package/dist/deploy-profile.js +12 -4
  13. package/dist/deploy-render.js +54 -8
  14. package/dist/deploy.js +21 -1
  15. package/dist/dev.d.ts +14 -0
  16. package/dist/dev.js +152 -10
  17. package/dist/index.js +15 -2
  18. package/dist/lib.d.ts +2 -0
  19. package/dist/lib.js +4 -2
  20. package/dist/lockfile.d.ts +3 -0
  21. package/dist/lockfile.js +11 -0
  22. package/dist/module-api-paths.d.ts +52 -0
  23. package/dist/module-api-paths.js +189 -0
  24. package/dist/remove.d.ts +3 -0
  25. package/dist/remove.js +12 -3
  26. package/dist/templates/fullstack/AGENTS.md +27 -5
  27. package/dist/templates/fullstack/README.md +28 -11
  28. package/dist/templates/fullstack/apps/api/package.json +2 -2
  29. package/dist/templates/fullstack/apps/api/scripts/run-migrations.mjs +25 -0
  30. package/dist/templates/fullstack/apps/api/src/app.extensions.ts +7 -0
  31. package/dist/templates/fullstack/apps/api/src/app.spec.ts +20 -1
  32. package/dist/templates/fullstack/apps/api/src/app.ts +11 -0
  33. package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +3 -0
  34. package/dist/templates/fullstack/apps/api/src/config/redis.connection.spec.ts +48 -2
  35. package/dist/templates/fullstack/apps/api/src/config/redis.connection.ts +45 -0
  36. package/dist/templates/fullstack/apps/api/src/config/server-options.spec.ts +46 -0
  37. package/dist/templates/fullstack/apps/api/src/config/server-options.ts +48 -0
  38. package/dist/templates/fullstack/apps/api/src/core/api-contract.spec.ts +1 -0
  39. package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +9 -1
  40. package/dist/templates/fullstack/apps/api/src/core/services.spec.ts +196 -0
  41. package/dist/templates/fullstack/apps/api/src/core/services.ts +181 -2
  42. package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +26 -1
  43. package/dist/templates/fullstack/apps/api/src/database/database.ts +52 -11
  44. package/dist/templates/fullstack/apps/api/src/database/pool-guard.spec.ts +188 -0
  45. package/dist/templates/fullstack/apps/api/src/database/pool-guard.ts +151 -0
  46. package/dist/templates/fullstack/apps/api/src/database/run-migrations.spec.ts +70 -0
  47. package/dist/templates/fullstack/apps/api/src/health/health.plugin.spec.ts +28 -4
  48. package/dist/templates/fullstack/apps/api/src/health/health.plugin.ts +10 -2
  49. package/dist/templates/fullstack/apps/api/src/main.ts +2 -1
  50. package/dist/templates/fullstack/apps/web/components.json +3 -1
  51. package/dist/templates/fullstack/apps/web/package.json +1 -1
  52. package/dist/templates/fullstack/apps/web/src/error.html +73 -0
  53. package/dist/templates/fullstack/apps/web/src/lib/components/ui/textarea/index.ts +7 -0
  54. package/dist/templates/fullstack/apps/web/src/lib/components/ui/textarea/textarea.svelte +22 -0
  55. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +276 -1
  56. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +194 -9
  57. package/dist/templates/fullstack/apps/web/src/lib/server/body-limits.ts +15 -0
  58. package/dist/templates/fullstack/apps/web/src/lib/server/proxy-headers.ts +12 -0
  59. package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +10 -3
  60. package/dist/templates/fullstack/dot-claude/skills/podokit-elysia-endpoint/SKILL.md +5 -0
  61. package/dist/templates/fullstack/dot-claude/skills/podokit-sveltekit-route/SKILL.md +7 -1
  62. package/dist/templates/fullstack/dot-devcontainer/devcontainer.json +1 -0
  63. package/dist/templates/fullstack/{dot-env.docker → dot-env.docker.example} +7 -4
  64. package/dist/templates/fullstack/dot-env.example +10 -0
  65. package/dist/templates/fullstack/dot-gitignore +1 -0
  66. package/dist/templates/fullstack/dot-podokit/dev.json +2 -1
  67. package/dist/templates/fullstack/infra/k3s/README.md +3 -1
  68. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.migration.spec.ts +32 -3
  69. package/dist/templates/modules/admin-dashboard/files/apps/api/src/bootstrap-admin-runtime.ts +1 -1
  70. package/dist/templates/modules/admin-dashboard/files/apps/api/src/migrations/1792060000000-IndexSessionCreatedAt.ts +19 -1
  71. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +27 -9
  72. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/asset-paths.ts +11 -0
  73. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.test.ts +125 -0
  74. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.ts +107 -0
  75. package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +2 -1
  76. package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +2 -1
  77. package/dist/templates/modules/admin-dashboard/files/tests/helpers/disposable-users.ts +10 -10
  78. package/dist/templates/modules/admin-dashboard/files/tests/helpers/object-response.ts +12 -0
  79. package/dist/templates/modules/admin-dashboard/module.manifest.json +3 -2
  80. package/dist/templates/modules/audit-log/module.manifest.json +1 -1
  81. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.spec.ts +26 -0
  82. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +17 -2
  83. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +8 -4
  84. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +2 -2
  85. package/dist/templates/modules/auth/files/apps/api/src/auth/database-outage.spec.ts +114 -0
  86. package/dist/templates/modules/auth/files/apps/api/src/auth/database-outage.ts +50 -0
  87. package/dist/templates/modules/auth/files/apps/api/src/auth/db.spec.ts +4 -1
  88. package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +11 -2
  89. package/dist/templates/modules/auth/files/apps/api/src/auth/maintenance-command.ts +5 -0
  90. package/dist/templates/modules/auth/files/apps/api/src/auth/maintenance.ts +14 -0
  91. package/dist/templates/modules/auth/files/apps/api/src/auth/postgres-pool.spec.ts +136 -0
  92. package/dist/templates/modules/auth/files/apps/api/src/auth/postgres-pool.ts +40 -0
  93. package/dist/templates/modules/auth/files/apps/api/src/migrate.ts +3 -2
  94. package/dist/templates/modules/auth/module.manifest.json +0 -1
  95. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.spec.ts +121 -1
  96. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.ts +108 -10
  97. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/jobs.module.ts +3 -1
  98. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/queue.ts +31 -3
  99. package/dist/templates/modules/bullmq/module.manifest.json +5 -1
  100. package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/events-redis.module.ts +2 -4
  101. package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/redis-event.bus.spec.ts +224 -0
  102. package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/redis-event.bus.ts +124 -35
  103. package/dist/templates/modules/events-redis/module.manifest.json +4 -0
  104. package/dist/templates/modules/file-upload/files/apps/api/src/files/chunked-uploads.routes.ts +136 -0
  105. package/dist/templates/modules/file-upload/files/apps/api/src/files/chunked-uploads.spec.ts +636 -0
  106. package/dist/templates/modules/file-upload/files/apps/api/src/files/chunked-uploads.ts +673 -0
  107. package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.spec.ts +10 -4
  108. package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.ts +37 -8
  109. package/dist/templates/modules/file-upload/files/apps/api/src/files/upload-config.ts +99 -0
  110. package/dist/templates/modules/file-upload/files/apps/api/src/migrations/1792100000000-InitFileUploadSessions.ts +49 -0
  111. package/dist/templates/modules/file-upload/files/apps/web/src/lib/uploads.test.ts +456 -0
  112. package/dist/templates/modules/file-upload/files/apps/web/src/lib/uploads.ts +356 -0
  113. package/dist/templates/modules/file-upload/files/tests/api/chunked-uploads.api.spec.ts +153 -0
  114. package/dist/templates/modules/file-upload/module.manifest.json +34 -6
  115. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/jobs-local.module.ts +1 -1
  116. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.spec.ts +23 -0
  117. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.ts +30 -20
  118. package/dist/templates/modules/jobs-local/module.manifest.json +3 -0
  119. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/download-url.ts +20 -0
  120. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.spec.ts +79 -12
  121. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.ts +16 -1
  122. package/dist/templates/modules/object-storage-local/module.manifest.json +11 -2
  123. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-multipart.spec.ts +115 -0
  124. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-multipart.ts +171 -0
  125. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.spec.ts +222 -9
  126. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.ts +55 -19
  127. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-signer.spec.ts +71 -0
  128. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-signer.ts +98 -0
  129. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage-demo.spec.ts +6 -3
  130. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.config.ts +7 -2
  131. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.service.ts +187 -44
  132. package/dist/templates/modules/object-storage-s3/module.manifest.json +10 -2
  133. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +11 -22
  134. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.spec.ts +113 -0
  135. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.ts +122 -0
  136. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +24 -0
  137. package/dist/templates/modules/rate-limit/module.manifest.json +7 -0
  138. package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.spec.ts +14 -1
  139. package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.ts +9 -12
  140. package/dist/templates/modules/redis/files/apps/api/src/redis/redis.module.ts +16 -4
  141. package/dist/templates/modules/redis/files/apps/api/src/redis/redis.service.spec.ts +233 -5
  142. package/dist/templates/modules/redis/files/apps/api/src/redis/redis.service.ts +134 -32
  143. package/dist/templates/modules/redis/module.manifest.json +4 -0
  144. package/dist/templates/modules/sse/files/apps/api/src/events/event-stream.ts +11 -4
  145. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.spec.ts +33 -0
  146. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +7 -0
  147. package/dist/templates/todo/AGENTS.md +19 -4
  148. package/dist/templates/todo/README.md +27 -12
  149. package/dist/update.d.ts +5 -0
  150. package/dist/update.js +35 -0
  151. package/package.json +2 -2
  152. package/dist/templates/modules/auth/files/apps/api/scripts/run-migrations.mjs +0 -14
  153. package/dist/templates/modules/auth/files/apps/api/src/auth/schema-validation.ts +0 -13
  154. package/dist/templates/modules/auth/files/apps/api/src/auth/skip-schema-validation.ts +0 -5
@@ -0,0 +1,46 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { Elysia } from "elysia";
3
+ import { validateEnv } from "./env.validation";
4
+ import { apiServerOptions, DEFAULT_MAX_REQUEST_BODY_SIZE } from "./server-options";
5
+
6
+ describe("apiServerOptions", () => {
7
+ it("defaults to Bun's request body limit and leaves the idle timeout to Elysia", () => {
8
+ const env = validateEnv({});
9
+ expect(env.API_MAX_REQUEST_BODY_SIZE).toBe(DEFAULT_MAX_REQUEST_BODY_SIZE);
10
+ expect(apiServerOptions(env, {})).toEqual({ maxRequestBodySize: DEFAULT_MAX_REQUEST_BODY_SIZE });
11
+ });
12
+
13
+ it("reads API_MAX_REQUEST_BODY_SIZE and rejects values that are not byte counts", () => {
14
+ expect(apiServerOptions(validateEnv({ API_MAX_REQUEST_BODY_SIZE: "1073741824" }), {}))
15
+ .toEqual({ maxRequestBodySize: 1_073_741_824 });
16
+ for (const value of ["0", "-1", "1.5", "lots"]) {
17
+ expect(() => validateEnv({ API_MAX_REQUEST_BODY_SIZE: value })).toThrow("API_MAX_REQUEST_BODY_SIZE");
18
+ }
19
+ });
20
+
21
+ it("lets the application's options win over the environment", () => {
22
+ const env = validateEnv({ API_MAX_REQUEST_BODY_SIZE: "1024" });
23
+ expect(apiServerOptions(env, { maxRequestBodySize: 4096, idleTimeout: 120 }))
24
+ .toEqual({ maxRequestBodySize: 4096, idleTimeout: 120 });
25
+ expect(apiServerOptions(env, (current) => ({ maxRequestBodySize: current.API_MAX_REQUEST_BODY_SIZE * 2 })))
26
+ .toEqual({ maxRequestBodySize: 2048 });
27
+ expect(() => apiServerOptions(env, { maxRequestBodySize: 0 })).toThrow("maxRequestBodySize");
28
+ expect(() => apiServerOptions(env, { idleTimeout: 256 })).toThrow("idleTimeout");
29
+ expect(() => apiServerOptions(env, { idleTimeout: -1 })).toThrow("idleTimeout");
30
+ });
31
+
32
+ it("makes the server refuse a body above the limit with 413", async () => {
33
+ const app = new Elysia()
34
+ .post("/upload", async ({ request }) => ({ size: (await request.arrayBuffer()).byteLength }))
35
+ .listen({ ...apiServerOptions(validateEnv({}), { maxRequestBodySize: 1024 }), hostname: "127.0.0.1", port: 0 });
36
+ try {
37
+ const url = `http://127.0.0.1:${app.server?.port}/upload`;
38
+ const accepted = await fetch(url, { method: "POST", body: new Uint8Array(1024) });
39
+ expect(await accepted.json()).toEqual({ size: 1024 });
40
+ const refused = await fetch(url, { method: "POST", body: new Uint8Array(1025) });
41
+ expect(refused.status).toBe(413);
42
+ } finally {
43
+ await app.stop();
44
+ }
45
+ });
46
+ });
@@ -0,0 +1,48 @@
1
+ import * as extensions from "../app.extensions";
2
+ import type { AppEnv } from "./env.validation";
3
+
4
+ /** Bun's default request body limit. */
5
+ export const DEFAULT_MAX_REQUEST_BODY_SIZE = 128 * 1024 * 1024;
6
+ const MAX_IDLE_TIMEOUT_SECONDS = 255;
7
+
8
+ /** Bun server options an application sets from `app.extensions.ts`. */
9
+ export interface ApiServerOptions {
10
+ /** The largest request body in bytes. Defaults to `API_MAX_REQUEST_BODY_SIZE`. */
11
+ maxRequestBodySize?: number;
12
+ /**
13
+ * Seconds a connection may stay idle before it is closed, from 0 (never) to
14
+ * 255. Defaults to Elysia's 30 seconds.
15
+ */
16
+ idleTimeout?: number;
17
+ }
18
+
19
+ /** A fixed set of options, or a function of the validated environment. */
20
+ export type ApiServerOptionsSource = ApiServerOptions | ((env: AppEnv) => ApiServerOptions);
21
+
22
+ // `apiServerOptions` is an optional export of the application-owned
23
+ // app.extensions.ts. The index signature keeps extension files written before
24
+ // it existed assignable, while a wrongly typed export still fails to compile.
25
+ const owned: { readonly [name: string]: unknown; readonly apiServerOptions?: ApiServerOptionsSource } = extensions;
26
+
27
+ /**
28
+ * The Bun serve options for the API. Values from `apiServerOptions` win over
29
+ * the environment; the function form receives the environment to combine both.
30
+ */
31
+ export function apiServerOptions(
32
+ env: AppEnv,
33
+ source: ApiServerOptionsSource | undefined = owned.apiServerOptions,
34
+ ): ApiServerOptions & { maxRequestBodySize: number } {
35
+ const options = typeof source === "function" ? source(env) : source ?? {};
36
+ const maxRequestBodySize = options.maxRequestBodySize ?? env.API_MAX_REQUEST_BODY_SIZE;
37
+ if (!Number.isSafeInteger(maxRequestBodySize) || maxRequestBodySize < 1) {
38
+ throw new Error("apiServerOptions.maxRequestBodySize must be a positive integer of bytes");
39
+ }
40
+ const { idleTimeout } = options;
41
+ if (
42
+ idleTimeout !== undefined &&
43
+ (!Number.isInteger(idleTimeout) || idleTimeout < 0 || idleTimeout > MAX_IDLE_TIMEOUT_SECONDS)
44
+ ) {
45
+ throw new Error(`apiServerOptions.idleTimeout must be an integer from 0 to ${MAX_IDLE_TIMEOUT_SECONDS} seconds`);
46
+ }
47
+ return { maxRequestBodySize, ...(idleTimeout === undefined ? {} : { idleTimeout }) };
48
+ }
@@ -16,6 +16,7 @@ describe("API contract inventory", () => {
16
16
  expect(expected).toContain("POST /api/auth/sign-in/email");
17
17
  expect(expected).toContain("PUT /cache/{key}");
18
18
  expect(expected).toContain("POST /files");
19
+ expect(expected).toContain("PUT /files/uploads/{id}/parts/{partNumber}");
19
20
  });
20
21
 
21
22
  it("uses one structural parameter name for every public blog post route", () => {
@@ -71,7 +71,15 @@ const MODULE_ROUTES: Record<string, string[]> = {
71
71
  "DELETE /admin/blog/{id}",
72
72
  ],
73
73
  bullmq: ["POST /jobs", "GET /jobs/{id}"],
74
- "file-upload": ["POST /files", "GET /files/{key}/url"],
74
+ "file-upload": [
75
+ "POST /files",
76
+ "GET /files/{key}/url",
77
+ "POST /files/uploads",
78
+ "GET /files/uploads/{id}",
79
+ "PUT /files/uploads/{id}/parts/{partNumber}",
80
+ "POST /files/uploads/{id}/complete",
81
+ "DELETE /files/uploads/{id}",
82
+ ],
75
83
  "job-progress": ["POST /progress"],
76
84
  "object-storage-local": [
77
85
  "GET /files/content/*",
@@ -4,11 +4,15 @@ import { validateEnv } from "../config/env.validation";
4
4
  import { PROVIDERS } from "../config/providers";
5
5
  import {
6
6
  AccessPolicy,
7
+ ConnectionSupervisor,
7
8
  createCoreServices,
8
9
  OpenApiRegistry,
9
10
  REQUEST_IDENTITY,
10
11
  RequestGuardRegistry,
11
12
  ServiceRegistry,
13
+ type ConnectionEvents,
14
+ type RetryTimer,
15
+ type SupervisedConnection,
12
16
  } from "./services";
13
17
 
14
18
  function databaseProvider(): DatabaseProviderName {
@@ -50,6 +54,180 @@ describe("ServiceRegistry", () => {
50
54
  });
51
55
  });
52
56
 
57
+ describe("ServiceRegistry supervision", () => {
58
+ test("starts and runs start hooks while a supervised dependency is down", async () => {
59
+ const registry = new ServiceRegistry();
60
+ let attempts = 0;
61
+ let hookRan = false;
62
+ registry.supervise("redis", {
63
+ connect: () => {
64
+ attempts += 1;
65
+ return Promise.reject(new Error("Connection closed"));
66
+ },
67
+ });
68
+ registry.onStart(() => { hookRan = true; });
69
+ registry.freeze();
70
+
71
+ await registry.start();
72
+ expect(hookRan).toBe(true);
73
+ expect(attempts).toBe(1);
74
+ await registry.close();
75
+ await Bun.sleep(400);
76
+ expect(attempts).toBe(1);
77
+ });
78
+
79
+ test("connects a reachable dependency before the start hooks run", async () => {
80
+ const registry = new ServiceRegistry();
81
+ const order: string[] = [];
82
+ registry.supervise("database", {
83
+ connect: async () => {
84
+ await Bun.sleep(5);
85
+ order.push("connected");
86
+ },
87
+ });
88
+ registry.onStart(() => { order.push("hook"); });
89
+ registry.freeze();
90
+
91
+ await registry.start();
92
+ expect(order).toEqual(["connected", "hook"]);
93
+ await registry.close();
94
+ });
95
+ });
96
+
97
+ interface FakeTimer extends RetryTimer {
98
+ delays: number[];
99
+ pending(): number;
100
+ fire(): Promise<void>;
101
+ }
102
+
103
+ function fakeTimer(): FakeTimer {
104
+ const tasks: Array<{ task: () => void; cancelled: boolean }> = [];
105
+ const delays: number[] = [];
106
+ return {
107
+ delays,
108
+ after: (delayMs, task) => {
109
+ const entry = { task, cancelled: false };
110
+ delays.push(delayMs);
111
+ tasks.push(entry);
112
+ return () => { entry.cancelled = true; };
113
+ },
114
+ pending: () => tasks.filter((entry) => !entry.cancelled).length,
115
+ fire: async () => {
116
+ const entry = tasks.shift();
117
+ if (entry && !entry.cancelled) entry.task();
118
+ await Bun.sleep(0);
119
+ },
120
+ };
121
+ }
122
+
123
+ function recordingEvents(): ConnectionEvents & { log: string[] } {
124
+ const log: string[] = [];
125
+ return {
126
+ log,
127
+ unavailable: (name, reason) => { log.push(`unavailable ${name}: ${reason}`); },
128
+ restored: (name) => { log.push(`restored ${name}`); },
129
+ };
130
+ }
131
+
132
+ class FlakyConnection implements SupervisedConnection {
133
+ attempts = 0;
134
+ failuresLeft = 0;
135
+ private listener?: (reason: unknown) => void;
136
+
137
+ connect(): Promise<void> {
138
+ this.attempts += 1;
139
+ if (this.failuresLeft > 0) {
140
+ this.failuresLeft -= 1;
141
+ return Promise.reject(new Error("Connection closed"));
142
+ }
143
+ return Promise.resolve();
144
+ }
145
+
146
+ onLost(listener: (reason: unknown) => void): void {
147
+ this.listener = listener;
148
+ }
149
+
150
+ drop(): void {
151
+ this.listener?.(new Error("Connection reset"));
152
+ }
153
+ }
154
+
155
+ describe("ConnectionSupervisor", () => {
156
+ const policy = { initialDelayMs: 100, maxDelayMs: 400 };
157
+
158
+ test("retries with doubling delays up to the cap and reports each state change once", async () => {
159
+ const connection = new FlakyConnection();
160
+ connection.failuresLeft = 5;
161
+ const timer = fakeTimer();
162
+ const events = recordingEvents();
163
+ const supervisor = new ConnectionSupervisor("redis", connection, events, { policy, timer });
164
+
165
+ await supervisor.start();
166
+ expect(supervisor.connected).toBe(false);
167
+ while (timer.pending() > 0) await timer.fire();
168
+
169
+ expect(connection.attempts).toBe(6);
170
+ expect(timer.delays).toEqual([100, 200, 400, 400, 400]);
171
+ expect(events.log).toEqual(["unavailable redis: Connection closed", "restored redis"]);
172
+ expect(supervisor.connected).toBe(true);
173
+ });
174
+
175
+ test("reopens a lost connection and starts the backoff over", async () => {
176
+ const connection = new FlakyConnection();
177
+ const timer = fakeTimer();
178
+ const events = recordingEvents();
179
+ const supervisor = new ConnectionSupervisor("redis", connection, events, { policy, timer });
180
+ await supervisor.start();
181
+ expect(supervisor.connected).toBe(true);
182
+
183
+ connection.failuresLeft = 1;
184
+ connection.drop();
185
+ connection.drop();
186
+ expect(supervisor.connected).toBe(false);
187
+ expect(timer.pending()).toBe(1);
188
+ while (timer.pending() > 0) await timer.fire();
189
+
190
+ expect(timer.delays).toEqual([100, 200]);
191
+ expect(connection.attempts).toBe(3);
192
+ expect(events.log).toEqual(["unavailable redis: Connection reset", "restored redis"]);
193
+ expect(supervisor.connected).toBe(true);
194
+ });
195
+
196
+ test("tries again when the connection drops while an attempt runs", async () => {
197
+ const connection = new FlakyConnection();
198
+ const timer = fakeTimer();
199
+ const supervisor = new ConnectionSupervisor("events", {
200
+ connect: async () => {
201
+ await connection.connect();
202
+ if (connection.attempts === 1) connection.drop();
203
+ },
204
+ onLost: (listener) => connection.onLost(listener),
205
+ }, recordingEvents(), { policy, timer });
206
+
207
+ await supervisor.start();
208
+ expect(supervisor.connected).toBe(false);
209
+ await timer.fire();
210
+ expect(connection.attempts).toBe(2);
211
+ expect(supervisor.connected).toBe(true);
212
+ });
213
+
214
+ test("stops retrying once stopped", async () => {
215
+ const connection = new FlakyConnection();
216
+ connection.failuresLeft = 10;
217
+ const timer = fakeTimer();
218
+ const supervisor = new ConnectionSupervisor("redis", connection, recordingEvents(), { policy, timer });
219
+ await supervisor.start();
220
+ expect(timer.pending()).toBe(1);
221
+
222
+ supervisor.stop();
223
+ expect(timer.pending()).toBe(0);
224
+ connection.drop();
225
+ await timer.fire();
226
+ expect(connection.attempts).toBe(1);
227
+ expect(timer.pending()).toBe(0);
228
+ });
229
+ });
230
+
53
231
  describe("AccessPolicy", () => {
54
232
  test("defaults to session access", () => {
55
233
  const policy = new AccessPolicy();
@@ -66,6 +244,24 @@ describe("AccessPolicy", () => {
66
244
  expect(policy.resolve(new Request("http://localhost/api/auth/sign-in/email"))).toBe("public");
67
245
  });
68
246
 
247
+ test("applies GET rules to HEAD requests and leaves other methods alone", () => {
248
+ const policy = new AccessPolicy();
249
+ policy.register("GET", "/profile-images/:fileName", "public");
250
+ policy.register("POST", "/uploads", "public");
251
+ policy.register("GET", "/reports/:id", "api-key");
252
+
253
+ const head = (path: string): Request => new Request(`http://localhost${path}`, { method: "HEAD" });
254
+ expect(policy.resolve(head("/profile-images/a.png"))).toBe("public");
255
+ expect(policy.resolve(head("/reports/1"))).toBe("api-key");
256
+ expect(policy.resolve(head("/uploads"))).toBe("session");
257
+ expect(policy.resolve(head("/todos"))).toBe("session");
258
+ expect(policy.resolve(new Request("http://localhost/profile-images/a.png", { method: "DELETE" }))).toBe("session");
259
+
260
+ policy.register("HEAD", "/profile-images/:fileName", "session");
261
+ expect(policy.resolve(head("/profile-images/a.png"))).toBe("session");
262
+ expect(policy.resolve(new Request("http://localhost/profile-images/a.png"))).toBe("public");
263
+ });
264
+
69
265
  test("uses the most recently registered matching rule", () => {
70
266
  const policy = new AccessPolicy();
71
267
  policy.register("*", "/content/*", "public");
@@ -10,9 +10,151 @@ import { DATABASE_READINESS_CHECK, ReadinessService } from "../health/readiness.
10
10
 
11
11
  export type { ServiceKey } from "@podosoft/podokit-runtime";
12
12
 
13
+ /** A dependency that `ServiceRegistry.supervise` keeps connected. */
14
+ export interface SupervisedConnection {
15
+ /**
16
+ * Opens whatever is closed and restores what depends on it, such as
17
+ * subscriptions. Rejects while the dependency is unreachable.
18
+ */
19
+ connect(): Promise<void>;
20
+ /** Takes the callback to run whenever an open connection drops. */
21
+ onLost?(listener: (reason: unknown) => void): void;
22
+ }
23
+
24
+ export interface ReconnectPolicy {
25
+ /** Delay before the first retry. Each further failure doubles it. */
26
+ initialDelayMs: number;
27
+ /** Longest delay between two attempts. */
28
+ maxDelayMs: number;
29
+ }
30
+
31
+ export const DEFAULT_RECONNECT_POLICY: ReconnectPolicy = { initialDelayMs: 250, maxDelayMs: 5_000 };
32
+
33
+ export interface ConnectionEvents {
34
+ unavailable(name: string, reason: string): void;
35
+ restored(name: string, downtimeMs: number): void;
36
+ }
37
+
38
+ export interface RetryTimer {
39
+ /** Runs `task` once after `delayMs`; the returned function cancels it. */
40
+ after(delayMs: number, task: () => void): () => void;
41
+ }
42
+
43
+ const unrefTimer: RetryTimer = {
44
+ after: (delayMs, task) => {
45
+ const timer = setTimeout(task, delayMs);
46
+ timer.unref?.();
47
+ return () => clearTimeout(timer);
48
+ },
49
+ };
50
+
51
+ export interface SupervisorOptions {
52
+ policy?: ReconnectPolicy;
53
+ timer?: RetryTimer;
54
+ }
55
+
56
+ function reasonOf(error: unknown): string {
57
+ return error instanceof Error ? error.message : String(error);
58
+ }
59
+
60
+ /**
61
+ * Connects in the background and keeps the connection open: a failed attempt is
62
+ * retried with capped exponential backoff, and a lost connection is reopened.
63
+ * Events fire when the state changes, not on every attempt.
64
+ */
65
+ export class ConnectionSupervisor {
66
+ private up = false;
67
+ private stopped = false;
68
+ private failures = 0;
69
+ private drops = 0;
70
+ private lastDrop: unknown;
71
+ private downSince?: number;
72
+ private cancelRetry?: () => void;
73
+ private firstAttempt?: Promise<void>;
74
+ private readonly policy: ReconnectPolicy;
75
+ private readonly timer: RetryTimer;
76
+
77
+ constructor(
78
+ readonly name: string,
79
+ private readonly connection: SupervisedConnection,
80
+ private readonly events: ConnectionEvents,
81
+ options: SupervisorOptions = {},
82
+ ) {
83
+ this.policy = options.policy ?? DEFAULT_RECONNECT_POLICY;
84
+ this.timer = options.timer ?? unrefTimer;
85
+ connection.onLost?.((reason) => this.lost(reason));
86
+ }
87
+
88
+ get connected(): boolean {
89
+ return this.up;
90
+ }
91
+
92
+ /** Resolves after the first attempt, whether or not it connected. */
93
+ start(): Promise<void> {
94
+ this.firstAttempt ??= this.attempt();
95
+ return this.firstAttempt;
96
+ }
97
+
98
+ stop(): void {
99
+ this.stopped = true;
100
+ this.up = false;
101
+ this.cancelRetry?.();
102
+ this.cancelRetry = undefined;
103
+ }
104
+
105
+ private lost(reason: unknown): void {
106
+ if (this.stopped) return;
107
+ this.drops += 1;
108
+ this.lastDrop = reason;
109
+ // While an attempt is pending it sees the changed count and tries again.
110
+ if (!this.up) return;
111
+ this.up = false;
112
+ this.retry(reason);
113
+ }
114
+
115
+ private async attempt(): Promise<void> {
116
+ this.cancelRetry = undefined;
117
+ if (this.stopped) return;
118
+ const drops = this.drops;
119
+ try {
120
+ await this.connection.connect();
121
+ } catch (error) {
122
+ if (!this.stopped) this.retry(error);
123
+ return;
124
+ }
125
+ if (this.stopped) return;
126
+ // Bun reports a failed reconnect through the same close callback, so a drop
127
+ // during the attempt can mean a part of the connection is closed again.
128
+ if (drops !== this.drops) {
129
+ this.retry(this.lastDrop);
130
+ return;
131
+ }
132
+ this.up = true;
133
+ this.failures = 0;
134
+ if (this.downSince !== undefined) {
135
+ this.events.restored(this.name, Date.now() - this.downSince);
136
+ this.downSince = undefined;
137
+ }
138
+ }
139
+
140
+ private retry(reason: unknown): void {
141
+ if (this.downSince === undefined) {
142
+ this.downSince = Date.now();
143
+ this.events.unavailable(this.name, reasonOf(reason));
144
+ }
145
+ const delay = Math.min(
146
+ this.policy.maxDelayMs,
147
+ this.policy.initialDelayMs * 2 ** Math.min(this.failures, 30),
148
+ );
149
+ this.failures += 1;
150
+ this.cancelRetry = this.timer.after(delay, () => void this.attempt());
151
+ }
152
+ }
153
+
13
154
  export class ServiceRegistry {
14
155
  private readonly values = new Map<symbol, unknown>();
15
156
  private readonly starters: Array<() => void | Promise<void>> = [];
157
+ private readonly supervisors: ConnectionSupervisor[] = [];
16
158
  private readonly closers: Array<() => void | Promise<void>> = [];
17
159
  private startPromise?: Promise<void>;
18
160
  private closePromise?: Promise<void>;
@@ -41,17 +183,44 @@ export class ServiceRegistry {
41
183
  return this.values.get(key) as T | undefined;
42
184
  }
43
185
 
186
+ /** Runs `start` before the server listens; a rejection fails startup. */
44
187
  onStart(start: () => void | Promise<void>): void {
45
188
  if (this.frozen) throw new Error("Service registry is frozen");
46
189
  this.starters.push(start);
47
190
  }
48
191
 
192
+ /**
193
+ * Connects `connection` when the registry starts and keeps it connected.
194
+ * Startup waits only for the first attempt, so the server listens while the
195
+ * dependency is down; register a readiness check that reports it.
196
+ */
197
+ supervise(name: string, connection: SupervisedConnection): void {
198
+ if (this.frozen) throw new Error("Service registry is frozen");
199
+ this.supervisors.push(new ConnectionSupervisor(name, connection, {
200
+ // Resolved on use: a module such as logging may replace the logger.
201
+ unavailable: (dependency, reason) => {
202
+ this.tryResolve(LOGGER)?.warn(
203
+ { dependency, reason },
204
+ "Dependency connection failed; retrying in the background",
205
+ );
206
+ },
207
+ restored: (dependency, downtimeMs) => {
208
+ this.tryResolve(LOGGER)?.info({ dependency, downtimeMs }, "Dependency connection restored");
209
+ },
210
+ }));
211
+ }
212
+
49
213
  freeze(): void {
50
214
  this.frozen = true;
51
215
  }
52
216
 
217
+ /**
218
+ * Makes a first connection attempt for every supervised dependency at once,
219
+ * so those that are up are connected before the start hooks run in order.
220
+ */
53
221
  start(): Promise<void> {
54
222
  this.startPromise ??= (async () => {
223
+ await Promise.all(this.supervisors.map((supervisor) => supervisor.start()));
55
224
  for (const start of this.starters) await start();
56
225
  })();
57
226
  return this.startPromise;
@@ -59,6 +228,7 @@ export class ServiceRegistry {
59
228
 
60
229
  close(): Promise<void> {
61
230
  this.closePromise ??= (async () => {
231
+ for (const supervisor of this.supervisors) supervisor.stop();
62
232
  for (const close of [...this.closers].reverse()) await close();
63
233
  })();
64
234
  return this.closePromise;
@@ -90,13 +260,19 @@ export class AccessPolicy {
90
260
  this.rules.push({ method: method.toUpperCase(), matcher: routeMatcher(pattern), level });
91
261
  }
92
262
 
263
+ /**
264
+ * The level of the most recently registered matching rule. A HEAD request
265
+ * also matches GET rules, because Elysia answers it with the GET handler.
266
+ */
93
267
  resolve(request: Request): AccessLevel {
94
268
  const path = new URL(request.url).pathname;
95
269
  const method = request.method.toUpperCase();
96
270
  const rule = [...this.rules]
97
271
  .reverse()
98
272
  .find((candidate) =>
99
- (candidate.method === "*" || candidate.method === method) && candidate.matcher.test(path)
273
+ (candidate.method === "*" || candidate.method === method ||
274
+ (method === "HEAD" && candidate.method === "GET")) &&
275
+ candidate.matcher.test(path)
100
276
  );
101
277
  return rule?.level ?? "session";
102
278
  }
@@ -199,7 +375,10 @@ export function createCoreServices(env: AppEnv): ServiceRegistry {
199
375
  accessPolicy.register("*", "/api-docs-json", "public");
200
376
  accessPolicy.register("*", "/api-docs-elysia-json", "public");
201
377
  services.register(DATABASE, database, () => database.close());
202
- services.onStart(() => database.connect());
378
+ services.supervise(DATABASE_READINESS_CHECK, {
379
+ connect: () => services.resolve(DATABASE).connect(),
380
+ onLost: (listener) => database.onLost(listener),
381
+ });
203
382
  services.register(READINESS, readiness);
204
383
  readiness.register(DATABASE_READINESS_CHECK, () => services.resolve(DATABASE).ping());
205
384
  services.register(LOGGER, logger);
@@ -153,6 +153,12 @@ describe("PostgreSQL pool settings", () => {
153
153
  expect(() => validateEnv({ DB_POOL_MAX: "0" })).toThrow("DB_POOL_MAX");
154
154
  expect(() => validateEnv({ DB_AUTH_POOL_MAX: "-1" })).toThrow("DB_AUTH_POOL_MAX");
155
155
  expect(() => validateEnv({ DB_CONNECT_TIMEOUT_MS: "0" })).toThrow("DB_CONNECT_TIMEOUT_MS");
156
+ expect(() => validateEnv({ DB_QUERY_TIMEOUT_MS: "0" })).toThrow("DB_QUERY_TIMEOUT_MS");
157
+ });
158
+
159
+ it("bounds a query on a server that stopped answering after ten seconds by default", () => {
160
+ expect(env.DB_QUERY_TIMEOUT_MS).toBe(10_000);
161
+ expect(validateEnv({ DB_QUERY_TIMEOUT_MS: "2500" }).DB_QUERY_TIMEOUT_MS).toBe(2_500);
156
162
  });
157
163
  });
158
164
 
@@ -168,14 +174,31 @@ describe("isDatabaseUnavailable", () => {
168
174
  }
169
175
  });
170
176
 
171
- it("recognizes pg pool and connection timeouts, also when wrapped", () => {
177
+ it("recognizes pg pool, connection, and query timeouts, also when wrapped", () => {
172
178
  const acquire = new Error("timeout exceeded when trying to connect");
173
179
  const connect = new Error("Connection terminated due to connection timeout");
174
180
  expect(isDatabaseUnavailable(acquire)).toBe(true);
175
181
  expect(isDatabaseUnavailable(connect)).toBe(true);
182
+ expect(isDatabaseUnavailable(new Error("Query read timeout"))).toBe(true);
176
183
  expect(isDatabaseUnavailable(new Error("Failed to get session", { cause: acquire }))).toBe(true);
177
184
  });
178
185
 
186
+ it("recognizes refused, dropped, and shut-down connections from pg and Bun SQL", () => {
187
+ const socket = (code: string): Error => Object.assign(new Error(`connect ${code}`), { code });
188
+ const server = (code: string): Error => Object.assign(new Error("server error"), { code });
189
+ for (const code of ["ECONNREFUSED", "ECONNRESET", "ETIMEDOUT", "EHOSTUNREACH", "ENETUNREACH", "EPIPE"]) {
190
+ expect(isDatabaseUnavailable(socket(code))).toBe(true);
191
+ }
192
+ expect(isDatabaseUnavailable(new Error("Connection terminated unexpectedly"))).toBe(true);
193
+ for (const code of ["57P01", "57P02", "57P03", "08006", "08P01"]) {
194
+ expect(isDatabaseUnavailable(server(code))).toBe(true);
195
+ expect(
196
+ isDatabaseUnavailable(new SQL.PostgresError("server error", { code: "ERR_POSTGRES_SERVER_ERROR", errno: code })),
197
+ ).toBe(true);
198
+ }
199
+ expect(isDatabaseUnavailable(new Error("Failed to save", { cause: socket("ECONNREFUSED") }))).toBe(true);
200
+ });
201
+
179
202
  it("leaves query and application errors alone", () => {
180
203
  expect(isDatabaseUnavailable(new SQL.PostgresError("syntax error", { code: "ERR_POSTGRES_SYNTAX_ERROR" })))
181
204
  .toBe(false);
@@ -183,6 +206,8 @@ describe("isDatabaseUnavailable", () => {
183
206
  isDatabaseUnavailable(new SQL.PostgresError("duplicate key", { code: "ERR_POSTGRES_SERVER_ERROR", errno: "23505" })),
184
207
  ).toBe(false);
185
208
  expect(isDatabaseUnavailable(new Error("timeout"))).toBe(false);
209
+ expect(isDatabaseUnavailable(Object.assign(new Error("duplicate key"), { code: "23505" }))).toBe(false);
210
+ expect(isDatabaseUnavailable(Object.assign(new Error("not found"), { code: "ENOENT" }))).toBe(false);
186
211
  expect(isDatabaseUnavailable("ERR_POSTGRES_CONNECTION_TIMEOUT")).toBe(false);
187
212
  expect(isDatabaseUnavailable(undefined)).toBe(false);
188
213
  });