@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.
- package/README.md +1 -1
- package/dist/add.d.ts +10 -1
- package/dist/add.js +20 -7
- package/dist/api-paths.d.ts +34 -0
- package/dist/api-paths.js +103 -0
- package/dist/deploy-compose-profile.d.ts +4 -0
- package/dist/deploy-compose-profile.js +13 -2
- package/dist/deploy-compose-render.d.ts +1 -1
- package/dist/deploy-compose-render.js +23 -7
- package/dist/deploy-compose.js +2 -1
- package/dist/deploy-profile.d.ts +3 -0
- package/dist/deploy-profile.js +12 -4
- package/dist/deploy-render.js +54 -8
- package/dist/deploy.js +21 -1
- package/dist/dev.d.ts +14 -0
- package/dist/dev.js +152 -10
- package/dist/index.js +15 -2
- package/dist/lib.d.ts +2 -0
- package/dist/lib.js +4 -2
- package/dist/lockfile.d.ts +3 -0
- package/dist/lockfile.js +11 -0
- package/dist/module-api-paths.d.ts +52 -0
- package/dist/module-api-paths.js +189 -0
- package/dist/remove.d.ts +3 -0
- package/dist/remove.js +12 -3
- package/dist/templates/fullstack/AGENTS.md +27 -5
- package/dist/templates/fullstack/README.md +28 -11
- package/dist/templates/fullstack/apps/api/package.json +2 -2
- package/dist/templates/fullstack/apps/api/scripts/run-migrations.mjs +25 -0
- package/dist/templates/fullstack/apps/api/src/app.extensions.ts +7 -0
- package/dist/templates/fullstack/apps/api/src/app.spec.ts +20 -1
- package/dist/templates/fullstack/apps/api/src/app.ts +11 -0
- package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +3 -0
- package/dist/templates/fullstack/apps/api/src/config/redis.connection.spec.ts +48 -2
- package/dist/templates/fullstack/apps/api/src/config/redis.connection.ts +45 -0
- package/dist/templates/fullstack/apps/api/src/config/server-options.spec.ts +46 -0
- package/dist/templates/fullstack/apps/api/src/config/server-options.ts +48 -0
- package/dist/templates/fullstack/apps/api/src/core/api-contract.spec.ts +1 -0
- package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +9 -1
- package/dist/templates/fullstack/apps/api/src/core/services.spec.ts +196 -0
- package/dist/templates/fullstack/apps/api/src/core/services.ts +181 -2
- package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +26 -1
- package/dist/templates/fullstack/apps/api/src/database/database.ts +52 -11
- package/dist/templates/fullstack/apps/api/src/database/pool-guard.spec.ts +188 -0
- package/dist/templates/fullstack/apps/api/src/database/pool-guard.ts +151 -0
- package/dist/templates/fullstack/apps/api/src/database/run-migrations.spec.ts +70 -0
- package/dist/templates/fullstack/apps/api/src/health/health.plugin.spec.ts +28 -4
- package/dist/templates/fullstack/apps/api/src/health/health.plugin.ts +10 -2
- package/dist/templates/fullstack/apps/api/src/main.ts +2 -1
- package/dist/templates/fullstack/apps/web/components.json +3 -1
- package/dist/templates/fullstack/apps/web/package.json +1 -1
- package/dist/templates/fullstack/apps/web/src/error.html +73 -0
- package/dist/templates/fullstack/apps/web/src/lib/components/ui/textarea/index.ts +7 -0
- package/dist/templates/fullstack/apps/web/src/lib/components/ui/textarea/textarea.svelte +22 -0
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +276 -1
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +194 -9
- package/dist/templates/fullstack/apps/web/src/lib/server/body-limits.ts +15 -0
- package/dist/templates/fullstack/apps/web/src/lib/server/proxy-headers.ts +12 -0
- package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +10 -3
- package/dist/templates/fullstack/dot-claude/skills/podokit-elysia-endpoint/SKILL.md +5 -0
- package/dist/templates/fullstack/dot-claude/skills/podokit-sveltekit-route/SKILL.md +7 -1
- package/dist/templates/fullstack/dot-devcontainer/devcontainer.json +1 -0
- package/dist/templates/fullstack/{dot-env.docker → dot-env.docker.example} +7 -4
- package/dist/templates/fullstack/dot-env.example +10 -0
- package/dist/templates/fullstack/dot-gitignore +1 -0
- package/dist/templates/fullstack/dot-podokit/dev.json +2 -1
- package/dist/templates/fullstack/infra/k3s/README.md +3 -1
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.migration.spec.ts +32 -3
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/bootstrap-admin-runtime.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/migrations/1792060000000-IndexSessionCreatedAt.ts +19 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +27 -9
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/asset-paths.ts +11 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.test.ts +125 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.ts +107 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +2 -1
- package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +2 -1
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/disposable-users.ts +10 -10
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/object-response.ts +12 -0
- package/dist/templates/modules/admin-dashboard/module.manifest.json +3 -2
- package/dist/templates/modules/audit-log/module.manifest.json +1 -1
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.spec.ts +26 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +17 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +8 -4
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +2 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/database-outage.spec.ts +114 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/database-outage.ts +50 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/db.spec.ts +4 -1
- package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +11 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/maintenance-command.ts +5 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/maintenance.ts +14 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/postgres-pool.spec.ts +136 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/postgres-pool.ts +40 -0
- package/dist/templates/modules/auth/files/apps/api/src/migrate.ts +3 -2
- package/dist/templates/modules/auth/module.manifest.json +0 -1
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.spec.ts +121 -1
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.ts +108 -10
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/jobs.module.ts +3 -1
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/queue.ts +31 -3
- package/dist/templates/modules/bullmq/module.manifest.json +5 -1
- package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/events-redis.module.ts +2 -4
- package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/redis-event.bus.spec.ts +224 -0
- package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/redis-event.bus.ts +124 -35
- package/dist/templates/modules/events-redis/module.manifest.json +4 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/files/chunked-uploads.routes.ts +136 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/files/chunked-uploads.spec.ts +636 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/files/chunked-uploads.ts +673 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.spec.ts +10 -4
- package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.ts +37 -8
- package/dist/templates/modules/file-upload/files/apps/api/src/files/upload-config.ts +99 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/migrations/1792100000000-InitFileUploadSessions.ts +49 -0
- package/dist/templates/modules/file-upload/files/apps/web/src/lib/uploads.test.ts +456 -0
- package/dist/templates/modules/file-upload/files/apps/web/src/lib/uploads.ts +356 -0
- package/dist/templates/modules/file-upload/files/tests/api/chunked-uploads.api.spec.ts +153 -0
- package/dist/templates/modules/file-upload/module.manifest.json +34 -6
- package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/jobs-local.module.ts +1 -1
- package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.spec.ts +23 -0
- package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.ts +30 -20
- package/dist/templates/modules/jobs-local/module.manifest.json +3 -0
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/download-url.ts +20 -0
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.spec.ts +79 -12
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.ts +16 -1
- package/dist/templates/modules/object-storage-local/module.manifest.json +11 -2
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-multipart.spec.ts +115 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-multipart.ts +171 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.spec.ts +222 -9
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.ts +55 -19
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-signer.spec.ts +71 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-signer.ts +98 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage-demo.spec.ts +6 -3
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.config.ts +7 -2
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.service.ts +187 -44
- package/dist/templates/modules/object-storage-s3/module.manifest.json +10 -2
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +11 -22
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.spec.ts +113 -0
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.ts +122 -0
- package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +24 -0
- package/dist/templates/modules/rate-limit/module.manifest.json +7 -0
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.spec.ts +14 -1
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.ts +9 -12
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis.module.ts +16 -4
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis.service.spec.ts +233 -5
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis.service.ts +134 -32
- package/dist/templates/modules/redis/module.manifest.json +4 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/event-stream.ts +11 -4
- package/dist/templates/modules/sse/files/apps/api/src/events/events.service.spec.ts +33 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +7 -0
- package/dist/templates/todo/AGENTS.md +19 -4
- package/dist/templates/todo/README.md +27 -12
- package/dist/update.d.ts +5 -0
- package/dist/update.js +35 -0
- package/package.json +2 -2
- package/dist/templates/modules/auth/files/apps/api/scripts/run-migrations.mjs +0 -14
- package/dist/templates/modules/auth/files/apps/api/src/auth/schema-validation.ts +0 -13
- 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": [
|
|
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
|
|
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.
|
|
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
|
|
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
|
});
|