@podosoft/podokit 1.3.0 → 1.4.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 (145) hide show
  1. package/dist/add.d.ts +10 -1
  2. package/dist/add.js +20 -7
  3. package/dist/api-paths.d.ts +34 -0
  4. package/dist/api-paths.js +103 -0
  5. package/dist/deploy-compose-profile.d.ts +4 -0
  6. package/dist/deploy-compose-profile.js +13 -2
  7. package/dist/deploy-compose-render.d.ts +1 -1
  8. package/dist/deploy-compose-render.js +23 -7
  9. package/dist/deploy-compose.js +2 -1
  10. package/dist/deploy-profile.d.ts +3 -0
  11. package/dist/deploy-profile.js +12 -4
  12. package/dist/deploy-render.js +54 -8
  13. package/dist/deploy.js +21 -1
  14. package/dist/dev.d.ts +14 -0
  15. package/dist/dev.js +152 -10
  16. package/dist/index.js +15 -2
  17. package/dist/lib.d.ts +2 -0
  18. package/dist/lib.js +4 -2
  19. package/dist/lockfile.d.ts +3 -0
  20. package/dist/lockfile.js +9 -0
  21. package/dist/module-api-paths.d.ts +52 -0
  22. package/dist/module-api-paths.js +189 -0
  23. package/dist/remove.d.ts +3 -0
  24. package/dist/remove.js +12 -3
  25. package/dist/templates/fullstack/AGENTS.md +17 -2
  26. package/dist/templates/fullstack/README.md +19 -5
  27. package/dist/templates/fullstack/apps/api/package.json +1 -1
  28. package/dist/templates/fullstack/apps/api/src/app.extensions.ts +7 -0
  29. package/dist/templates/fullstack/apps/api/src/app.spec.ts +20 -1
  30. package/dist/templates/fullstack/apps/api/src/app.ts +11 -0
  31. package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +3 -0
  32. package/dist/templates/fullstack/apps/api/src/config/redis.connection.spec.ts +48 -2
  33. package/dist/templates/fullstack/apps/api/src/config/redis.connection.ts +45 -0
  34. package/dist/templates/fullstack/apps/api/src/config/server-options.spec.ts +46 -0
  35. package/dist/templates/fullstack/apps/api/src/config/server-options.ts +48 -0
  36. package/dist/templates/fullstack/apps/api/src/core/api-contract.spec.ts +1 -0
  37. package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +9 -1
  38. package/dist/templates/fullstack/apps/api/src/core/services.spec.ts +196 -0
  39. package/dist/templates/fullstack/apps/api/src/core/services.ts +181 -2
  40. package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +8 -1
  41. package/dist/templates/fullstack/apps/api/src/database/database.ts +29 -10
  42. package/dist/templates/fullstack/apps/api/src/database/pool-guard.spec.ts +188 -0
  43. package/dist/templates/fullstack/apps/api/src/database/pool-guard.ts +151 -0
  44. package/dist/templates/fullstack/apps/api/src/health/health.plugin.spec.ts +28 -4
  45. package/dist/templates/fullstack/apps/api/src/health/health.plugin.ts +10 -2
  46. package/dist/templates/fullstack/apps/api/src/main.ts +2 -1
  47. package/dist/templates/fullstack/apps/web/components.json +3 -1
  48. package/dist/templates/fullstack/apps/web/package.json +1 -1
  49. package/dist/templates/fullstack/apps/web/src/lib/components/ui/textarea/index.ts +7 -0
  50. package/dist/templates/fullstack/apps/web/src/lib/components/ui/textarea/textarea.svelte +22 -0
  51. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +276 -1
  52. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +194 -9
  53. package/dist/templates/fullstack/apps/web/src/lib/server/body-limits.ts +15 -0
  54. package/dist/templates/fullstack/apps/web/src/lib/server/proxy-headers.ts +12 -0
  55. package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +10 -3
  56. package/dist/templates/fullstack/dot-claude/skills/podokit-elysia-endpoint/SKILL.md +5 -0
  57. package/dist/templates/fullstack/dot-claude/skills/podokit-sveltekit-route/SKILL.md +7 -1
  58. package/dist/templates/fullstack/dot-devcontainer/devcontainer.json +1 -0
  59. package/dist/templates/fullstack/{dot-env.docker → dot-env.docker.example} +7 -4
  60. package/dist/templates/fullstack/dot-env.example +10 -0
  61. package/dist/templates/fullstack/dot-gitignore +1 -0
  62. package/dist/templates/fullstack/dot-podokit/dev.json +2 -1
  63. package/dist/templates/fullstack/infra/k3s/README.md +3 -1
  64. package/dist/templates/modules/admin-dashboard/files/apps/api/src/bootstrap-admin-runtime.ts +1 -1
  65. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +27 -9
  66. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/asset-paths.ts +11 -0
  67. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.test.ts +125 -0
  68. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.ts +107 -0
  69. package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +2 -1
  70. package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +2 -1
  71. package/dist/templates/modules/admin-dashboard/files/tests/helpers/disposable-users.ts +10 -10
  72. package/dist/templates/modules/admin-dashboard/files/tests/helpers/object-response.ts +12 -0
  73. package/dist/templates/modules/admin-dashboard/module.manifest.json +3 -2
  74. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.spec.ts +26 -0
  75. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +17 -2
  76. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +8 -4
  77. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +2 -2
  78. package/dist/templates/modules/auth/files/apps/api/src/auth/database-outage.spec.ts +114 -0
  79. package/dist/templates/modules/auth/files/apps/api/src/auth/database-outage.ts +50 -0
  80. package/dist/templates/modules/auth/files/apps/api/src/auth/db.spec.ts +4 -1
  81. package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +11 -2
  82. package/dist/templates/modules/auth/files/apps/api/src/auth/maintenance-command.ts +5 -0
  83. package/dist/templates/modules/auth/files/apps/api/src/auth/maintenance.ts +14 -0
  84. package/dist/templates/modules/auth/files/apps/api/src/auth/postgres-pool.spec.ts +136 -0
  85. package/dist/templates/modules/auth/files/apps/api/src/auth/postgres-pool.ts +40 -0
  86. package/dist/templates/modules/auth/files/apps/api/src/migrate.ts +3 -2
  87. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.spec.ts +121 -1
  88. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.ts +108 -10
  89. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/jobs.module.ts +3 -1
  90. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/queue.ts +31 -3
  91. package/dist/templates/modules/bullmq/module.manifest.json +5 -1
  92. package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/events-redis.module.ts +2 -4
  93. package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/redis-event.bus.spec.ts +224 -0
  94. package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/redis-event.bus.ts +124 -35
  95. package/dist/templates/modules/events-redis/module.manifest.json +4 -0
  96. package/dist/templates/modules/file-upload/files/apps/api/src/files/chunked-uploads.routes.ts +136 -0
  97. package/dist/templates/modules/file-upload/files/apps/api/src/files/chunked-uploads.spec.ts +636 -0
  98. package/dist/templates/modules/file-upload/files/apps/api/src/files/chunked-uploads.ts +673 -0
  99. package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.spec.ts +10 -4
  100. package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.ts +37 -8
  101. package/dist/templates/modules/file-upload/files/apps/api/src/files/upload-config.ts +99 -0
  102. package/dist/templates/modules/file-upload/files/apps/api/src/migrations/1792100000000-InitFileUploadSessions.ts +49 -0
  103. package/dist/templates/modules/file-upload/files/apps/web/src/lib/uploads.test.ts +456 -0
  104. package/dist/templates/modules/file-upload/files/apps/web/src/lib/uploads.ts +356 -0
  105. package/dist/templates/modules/file-upload/files/tests/api/chunked-uploads.api.spec.ts +147 -0
  106. package/dist/templates/modules/file-upload/module.manifest.json +34 -6
  107. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/jobs-local.module.ts +1 -1
  108. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.spec.ts +23 -0
  109. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.ts +30 -20
  110. package/dist/templates/modules/jobs-local/module.manifest.json +3 -0
  111. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/download-url.ts +20 -0
  112. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.spec.ts +79 -12
  113. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.ts +16 -1
  114. package/dist/templates/modules/object-storage-local/module.manifest.json +11 -2
  115. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-multipart.spec.ts +115 -0
  116. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-multipart.ts +171 -0
  117. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.spec.ts +222 -9
  118. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.ts +55 -19
  119. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-signer.spec.ts +71 -0
  120. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-signer.ts +98 -0
  121. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage-demo.spec.ts +6 -3
  122. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.config.ts +7 -2
  123. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.service.ts +187 -44
  124. package/dist/templates/modules/object-storage-s3/module.manifest.json +10 -2
  125. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +11 -22
  126. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.spec.ts +113 -0
  127. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.ts +122 -0
  128. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +24 -0
  129. package/dist/templates/modules/rate-limit/module.manifest.json +7 -0
  130. package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.spec.ts +14 -1
  131. package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.ts +9 -12
  132. package/dist/templates/modules/redis/files/apps/api/src/redis/redis.module.ts +16 -4
  133. package/dist/templates/modules/redis/files/apps/api/src/redis/redis.service.spec.ts +233 -5
  134. package/dist/templates/modules/redis/files/apps/api/src/redis/redis.service.ts +134 -32
  135. package/dist/templates/modules/redis/module.manifest.json +4 -0
  136. package/dist/templates/modules/sse/files/apps/api/src/events/event-stream.ts +11 -4
  137. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.spec.ts +33 -0
  138. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +7 -0
  139. package/dist/templates/todo/AGENTS.md +9 -1
  140. package/dist/templates/todo/README.md +18 -5
  141. package/dist/update.d.ts +5 -0
  142. package/dist/update.js +35 -0
  143. package/package.json +2 -2
  144. package/dist/templates/modules/auth/files/apps/api/src/auth/schema-validation.ts +0 -13
  145. package/dist/templates/modules/auth/files/apps/api/src/auth/skip-schema-validation.ts +0 -5
@@ -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,11 +174,12 @@ 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
 
@@ -4,6 +4,7 @@ import { mkdirSync } from "node:fs";
4
4
  import { dirname, resolve } from "node:path";
5
5
  import type { AppEnv } from "../config/env.validation";
6
6
  import { PROVIDERS } from "../config/providers";
7
+ import { answersOnNewConnection, PoolGuard } from "./pool-guard";
7
8
 
8
9
  function isSqliteUrl(url: string): boolean {
9
10
  return url === ":memory:" || /^(?:sqlite|file):/.test(url);
@@ -62,8 +63,9 @@ export function postgresPoolOptions(env: AppEnv): PostgresPoolOptions {
62
63
  };
63
64
  }
64
65
 
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.
66
+ // Bun SQL and pg report a database that cannot be reached, a connection that
67
+ // could not be opened or taken from a full pool in time, or a query the server
68
+ // did not answer in time, with these shapes.
67
69
  const UNAVAILABLE_SQL_CODES = new Set([
68
70
  "ERR_POSTGRES_CONNECTION_TIMEOUT",
69
71
  "ERR_POSTGRES_CONNECTION_REFUSED",
@@ -73,6 +75,7 @@ const UNAVAILABLE_SQL_CODES = new Set([
73
75
  const UNAVAILABLE_PG_MESSAGES = new Set([
74
76
  "timeout exceeded when trying to connect",
75
77
  "Connection terminated due to connection timeout",
78
+ "Query read timeout",
76
79
  ]);
77
80
 
78
81
  /** Whether an error, or one it wraps, means the database is unavailable rather than the request being wrong. */
@@ -86,20 +89,36 @@ export function isDatabaseUnavailable(error: unknown): boolean {
86
89
  return false;
87
90
  }
88
91
 
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
-
96
92
  export class Database {
97
93
  readonly provider: DatabaseProviderName = selectedDatabaseProvider();
98
94
  readonly sql: SQL;
95
+ private readonly guard?: PoolGuard;
99
96
  private ready?: Promise<void>;
100
97
 
101
98
  constructor(env: AppEnv, sql?: SQL) {
102
- this.sql = sql ?? createSql(this.provider, env);
99
+ if (sql) {
100
+ this.sql = sql;
101
+ return;
102
+ }
103
+ const url = databaseUrl(env);
104
+ if (this.provider === "sqlite") {
105
+ this.sql = new SQL(url, { adapter: "sqlite", create: true, readwrite: true, strict: true });
106
+ return;
107
+ }
108
+ this.guard = new PoolGuard(() => new SQL(url, postgresPoolOptions(env)), {
109
+ stallTimeoutMs: env.DB_QUERY_TIMEOUT_MS,
110
+ serverAnswers: () => answersOnNewConnection(url, env.READINESS_CHECK_TIMEOUT_MS),
111
+ });
112
+ // The supervisor's next connect() then checks the new pool.
113
+ this.guard.onLost(() => {
114
+ this.ready = undefined;
115
+ });
116
+ this.sql = this.guard.sql;
117
+ }
118
+
119
+ /** Takes the callback to run when the pool is replaced because PostgreSQL stopped answering. */
120
+ onLost(listener: (reason: unknown) => void): void {
121
+ this.guard?.onLost(listener);
103
122
  }
104
123
 
105
124
  connect(): Promise<void> {