@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
@@ -0,0 +1,114 @@
1
+ import { describe, expect, it, mock } from "bun:test";
2
+ import { APIError } from "better-auth/api";
3
+ import { Pool } from "pg";
4
+ import {
5
+ answersWithin,
6
+ checkAuthResponse,
7
+ rethrowAuthError,
8
+ sharedProbe,
9
+ } from "./database-outage";
10
+
11
+ const down = mock(async () => false);
12
+ const up = mock(async () => true);
13
+
14
+ async function rejection(promise: Promise<unknown>): Promise<unknown> {
15
+ return promise.then(() => undefined, (error: unknown) => error);
16
+ }
17
+
18
+ describe("checkAuthResponse", () => {
19
+ it("passes answers below 500 on without asking the database", async () => {
20
+ const probe = mock(async () => false);
21
+ for (const status of [200, 401, 403, 429]) {
22
+ const response = new Response(null, { status });
23
+ await expect(checkAuthResponse(response, probe)).resolves.toBe(response);
24
+ }
25
+ expect(probe).not.toHaveBeenCalled();
26
+ });
27
+
28
+ it("keeps a server error while the database answers", async () => {
29
+ const response = new Response(null, { status: 500 });
30
+ await expect(checkAuthResponse(response, up)).resolves.toBe(response);
31
+ });
32
+
33
+ it("reports a server error during a database outage as DATABASE_UNAVAILABLE", async () => {
34
+ const error = await rejection(checkAuthResponse(new Response(null, { status: 500 }), down));
35
+ expect(error).toMatchObject({ code: "DATABASE_UNAVAILABLE", statusCode: 503 });
36
+ });
37
+ });
38
+
39
+ describe("rethrowAuthError", () => {
40
+ it("rethrows client errors without asking the database", async () => {
41
+ const probe = mock(async () => false);
42
+ const unauthorized = new APIError("UNAUTHORIZED", { message: "Invalid session" });
43
+ await expect(rejection(rethrowAuthError(unauthorized, probe))).resolves.toBe(unauthorized);
44
+ expect(probe).not.toHaveBeenCalled();
45
+ });
46
+
47
+ it("reports a server error during a database outage as DATABASE_UNAVAILABLE", async () => {
48
+ const failed = APIError.from("INTERNAL_SERVER_ERROR", { message: "Failed to get session", code: "FAILED_TO_GET_SESSION" });
49
+ const error = await rejection(rethrowAuthError(failed, down));
50
+ expect(error).toMatchObject({ code: "DATABASE_UNAVAILABLE", statusCode: 503 });
51
+ expect(await rejection(rethrowAuthError(new Error("socket hang up"), down)))
52
+ .toMatchObject({ code: "DATABASE_UNAVAILABLE" });
53
+ });
54
+
55
+ it("rethrows other server errors unchanged while the database answers", async () => {
56
+ const failure = new Error("Unexpected");
57
+ await expect(rejection(rethrowAuthError(failure, up))).resolves.toBe(failure);
58
+ });
59
+ });
60
+
61
+ describe("answersWithin", () => {
62
+ it("reports success and failure of the query", async () => {
63
+ await expect(answersWithin(async () => [{ ok: 1 }], 100)).resolves.toBe(true);
64
+ await expect(answersWithin(async () => { throw new Error("refused"); }, 100)).resolves.toBe(false);
65
+ });
66
+
67
+ it("gives up on a query that does not answer", async () => {
68
+ const started = Date.now();
69
+ await expect(answersWithin(() => new Promise(() => undefined), 50)).resolves.toBe(false);
70
+ expect(Date.now() - started).toBeLessThan(1_000);
71
+ });
72
+
73
+ it("reports a PostgreSQL server that refuses or never answers as unreachable", async () => {
74
+ // Accepts the TCP connection and never answers, like a paused server.
75
+ const silent = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } });
76
+ const closed = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } });
77
+ closed.stop(true);
78
+ const pools = [silent.port, closed.port].map((port) =>
79
+ new Pool({
80
+ connectionString: `postgres://podokit:podokit@127.0.0.1:${port}/podokit`,
81
+ connectionTimeoutMillis: 2_000,
82
+ })
83
+ );
84
+ try {
85
+ for (const pool of pools) {
86
+ const started = Date.now();
87
+ await expect(answersWithin(() => pool.query("SELECT 1"), 200)).resolves.toBe(false);
88
+ expect(Date.now() - started).toBeLessThan(1_000);
89
+ }
90
+ } finally {
91
+ silent.stop(true);
92
+ await Promise.all(pools.map((pool) => pool.end().catch(() => undefined)));
93
+ }
94
+ });
95
+ });
96
+
97
+ describe("sharedProbe", () => {
98
+ it("lets concurrent callers share one probe and probes again afterwards", async () => {
99
+ let release: (value: boolean) => void = () => undefined;
100
+ const probe = mock(() => new Promise<boolean>((resolve) => { release = resolve; }));
101
+ const shared = sharedProbe(probe);
102
+
103
+ const first = shared();
104
+ const second = shared();
105
+ release(false);
106
+ await expect(Promise.all([first, second])).resolves.toEqual([false, false]);
107
+ expect(probe).toHaveBeenCalledTimes(1);
108
+
109
+ const third = shared();
110
+ release(true);
111
+ await expect(third).resolves.toBe(true);
112
+ expect(probe).toHaveBeenCalledTimes(2);
113
+ });
114
+ });
@@ -0,0 +1,50 @@
1
+ import { AppException } from "@podosoft/podokit-contracts";
2
+ import { APIError } from "better-auth/api";
3
+
4
+ /** Resolves whether the database answers now. */
5
+ export type DatabaseProbe = () => Promise<boolean>;
6
+
7
+ export function databaseUnavailable(): AppException {
8
+ return new AppException("DATABASE_UNAVAILABLE", "The database is temporarily unavailable", 503);
9
+ }
10
+
11
+ /** Whether `query` succeeds within `timeoutMs`; a query still waiting then counts as failed. */
12
+ export async function answersWithin(query: () => Promise<unknown>, timeoutMs: number): Promise<boolean> {
13
+ let timer: ReturnType<typeof setTimeout> | undefined;
14
+ const deadline = new Promise<boolean>((resolve) => {
15
+ timer = setTimeout(() => resolve(false), timeoutMs);
16
+ });
17
+ try {
18
+ return await Promise.race([query().then(() => true, () => false), deadline]);
19
+ } finally {
20
+ clearTimeout(timer);
21
+ }
22
+ }
23
+
24
+ /** Lets concurrent callers share one run of `probe`. */
25
+ export function sharedProbe(probe: DatabaseProbe): DatabaseProbe {
26
+ let running: Promise<boolean> | undefined;
27
+ return () => {
28
+ running ??= probe().finally(() => {
29
+ running = undefined;
30
+ });
31
+ return running;
32
+ };
33
+ }
34
+
35
+ // Better Auth answers a failed query with a bare 500, and its session lookup
36
+ // replaces the cause with INTERNAL_SERVER_ERROR. A server error is therefore
37
+ // checked against the database itself before it is reported.
38
+
39
+ /** Passes `response` on, unless it is a server error while the database does not answer. */
40
+ export async function checkAuthResponse(response: Response, reachable: DatabaseProbe): Promise<Response> {
41
+ if (response.status < 500 || (await reachable())) return response;
42
+ throw databaseUnavailable();
43
+ }
44
+
45
+ /** Rethrows `error`, as DATABASE_UNAVAILABLE when it is a server error while the database does not answer. */
46
+ export async function rethrowAuthError(error: unknown, reachable: DatabaseProbe): Promise<never> {
47
+ const clientError = error instanceof APIError && error.statusCode < 500;
48
+ if (!clientError && !(await reachable())) throw databaseUnavailable();
49
+ throw error;
50
+ }
@@ -3,22 +3,25 @@ import type { DatabaseProviderName } from "@podosoft/podokit-runtime";
3
3
  import { validateEnv } from "../config/env.validation";
4
4
  import { PROVIDERS } from "../config/providers";
5
5
  import { postgresPool } from "./db";
6
+ import { AuthPostgresPool } from "./postgres-pool";
6
7
 
7
8
  function databaseProvider(): DatabaseProviderName {
8
9
  return PROVIDERS.database;
9
10
  }
10
11
 
11
12
  describe("auth database pool", () => {
12
- it("applies the pool limits from the environment", () => {
13
+ it("applies the pool limits and the query timeout from the environment", () => {
13
14
  if (databaseProvider() !== "postgres") {
14
15
  expect(postgresPool).toBeNull();
15
16
  return;
16
17
  }
17
18
  const env = validateEnv(process.env);
19
+ expect(postgresPool).toBeInstanceOf(AuthPostgresPool);
18
20
  expect(postgresPool?.options).toMatchObject({
19
21
  max: env.DB_AUTH_POOL_MAX,
20
22
  connectionTimeoutMillis: env.DB_CONNECT_TIMEOUT_MS,
21
23
  idleTimeoutMillis: env.DB_IDLE_TIMEOUT_MS,
24
+ query_timeout: env.DB_QUERY_TIMEOUT_MS,
22
25
  });
23
26
  });
24
27
  });
@@ -1,11 +1,13 @@
1
1
  import { Database as SqliteDatabase } from "bun:sqlite";
2
2
  import type { SQL } from "bun";
3
- import { Pool } from "pg";
4
3
  import type { AuthConfigRow, ConfigVersions } from "@podosoft/podokit-auth";
5
4
  import type { DatabaseProviderName } from "@podosoft/podokit-runtime";
6
5
  import { validateEnv } from "../config/env.validation";
7
6
  import { PROVIDERS } from "../config/providers";
8
7
  import { databaseUrl, sqliteDatabasePath } from "../database/database";
8
+ import { answersWithin, sharedProbe, type DatabaseProbe } from "./database-outage";
9
+ import { isMaintenanceCommand } from "./maintenance";
10
+ import { AuthPostgresPool } from "./postgres-pool";
9
11
 
10
12
  const env = validateEnv(process.env);
11
13
  const url = databaseUrl(env);
@@ -15,11 +17,12 @@ function databaseProvider(): DatabaseProviderName {
15
17
  const provider = databaseProvider();
16
18
 
17
19
  export const postgresPool = provider === "postgres"
18
- ? new Pool({
20
+ ? new AuthPostgresPool({
19
21
  connectionString: url,
20
22
  max: env.DB_AUTH_POOL_MAX,
21
23
  connectionTimeoutMillis: env.DB_CONNECT_TIMEOUT_MS,
22
24
  idleTimeoutMillis: env.DB_IDLE_TIMEOUT_MS,
25
+ ...(isMaintenanceCommand() ? {} : { query_timeout: env.DB_QUERY_TIMEOUT_MS }),
23
26
  })
24
27
  : null;
25
28
 
@@ -30,6 +33,12 @@ export const sqliteDatabase = provider === "sqlite"
30
33
  export const authDatabase = postgresPool ?? sqliteDatabase;
31
34
  if (!authDatabase) throw new Error("No authentication database is configured");
32
35
 
36
+ /** Whether the authentication database answers `SELECT 1` within the readiness check timeout. */
37
+ export const authDatabaseReachable: DatabaseProbe = sharedProbe(async () => {
38
+ if (!postgresPool) return true;
39
+ return answersWithin(() => postgresPool.query("SELECT 1"), env.READINESS_CHECK_TIMEOUT_MS);
40
+ });
41
+
33
42
  let queryDatabase: SQL | undefined;
34
43
 
35
44
  export function configureAuthQueryDatabase(sql: SQL): void {
@@ -0,0 +1,5 @@
1
+ // Imported first by maintenance commands, before ./auth creates the Better Auth
2
+ // instance and ./db opens its pool.
3
+ import { startMaintenanceCommand } from "./maintenance";
4
+
5
+ startMaintenanceCommand();
@@ -0,0 +1,14 @@
1
+ // Servers validate Better Auth's database schema at startup and bound every auth
2
+ // query with DB_QUERY_TIMEOUT_MS. Maintenance commands, such as the migration
3
+ // runner and the admin bootstrap, build the same instance before the schema they
4
+ // change exists or matches, and may run statements for longer, so they opt out
5
+ // for their process.
6
+ let maintenance = false;
7
+
8
+ export function startMaintenanceCommand(): void {
9
+ maintenance = true;
10
+ }
11
+
12
+ export function isMaintenanceCommand(): boolean {
13
+ return maintenance;
14
+ }
@@ -0,0 +1,136 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import type { Socket } from "bun";
3
+ import type { PoolClient } from "pg";
4
+ import { AuthPostgresPool } from "./postgres-pool";
5
+
6
+ interface FakePostgres {
7
+ port: number;
8
+ /** While false, the server keeps every connection open and ignores queries, like a paused one. */
9
+ answering: boolean;
10
+ accepted: number;
11
+ closed: number;
12
+ stop: () => void;
13
+ }
14
+
15
+ function message(type: string, body: Buffer): Buffer {
16
+ const header = Buffer.alloc(5);
17
+ header.write(type, 0, "latin1");
18
+ header.writeInt32BE(body.length + 4, 1);
19
+ return Buffer.concat([header, body]);
20
+ }
21
+
22
+ const AUTHENTICATION_OK = message("R", Buffer.from([0, 0, 0, 0]));
23
+ const READY_FOR_QUERY = message("Z", Buffer.from("I"));
24
+ const SELECT_DONE = Buffer.concat([message("C", Buffer.from("SELECT 0\0")), READY_FOR_QUERY]);
25
+
26
+ /** Speaks enough of the PostgreSQL protocol for pg to connect and run simple queries. */
27
+ function fakePostgres(): FakePostgres {
28
+ const started = new WeakSet<Socket<undefined>>();
29
+ const server: FakePostgres = { port: 0, answering: true, accepted: 0, closed: 0, stop: () => undefined };
30
+ const listener = Bun.listen({
31
+ hostname: "127.0.0.1",
32
+ port: 0,
33
+ socket: {
34
+ open() {
35
+ server.accepted += 1;
36
+ },
37
+ close() {
38
+ server.closed += 1;
39
+ },
40
+ data(socket, data) {
41
+ if (!started.has(socket)) {
42
+ started.add(socket);
43
+ socket.write(Buffer.concat([AUTHENTICATION_OK, READY_FOR_QUERY]));
44
+ return;
45
+ }
46
+ if (server.answering && data[0] === "Q".charCodeAt(0)) socket.write(SELECT_DONE);
47
+ },
48
+ },
49
+ });
50
+ server.port = listener.port;
51
+ server.stop = () => listener.stop(true);
52
+ return server;
53
+ }
54
+
55
+ function poolFor(server: FakePostgres): AuthPostgresPool {
56
+ return new AuthPostgresPool({
57
+ host: "127.0.0.1",
58
+ port: server.port,
59
+ user: "podokit",
60
+ database: "podokit",
61
+ max: 1,
62
+ query_timeout: 50,
63
+ });
64
+ }
65
+
66
+ async function until(condition: () => boolean): Promise<void> {
67
+ const deadline = Date.now() + 2_000;
68
+ while (!condition()) {
69
+ if (Date.now() > deadline) throw new Error("Condition not met in time");
70
+ await new Promise((resolve) => setTimeout(resolve, 5));
71
+ }
72
+ }
73
+
74
+ describe("AuthPostgresPool", () => {
75
+ it("keeps a client whose queries were answered", async () => {
76
+ const server = fakePostgres();
77
+ const pool = poolFor(server);
78
+ try {
79
+ for (let round = 0; round < 2; round += 1) {
80
+ const client = await pool.connect();
81
+ await client.query("SELECT 1");
82
+ client.release();
83
+ }
84
+ expect(server.accepted).toBe(1);
85
+ expect(pool.idleCount).toBe(1);
86
+ } finally {
87
+ await pool.end();
88
+ server.stop();
89
+ }
90
+ });
91
+
92
+ it("discards a client released while its query still waits for the reply", async () => {
93
+ const server = fakePostgres();
94
+ const pool = poolFor(server);
95
+ try {
96
+ const client = await pool.connect();
97
+ server.answering = false;
98
+ await expect(client.query("SELECT 1")).rejects.toThrow("Query read timeout");
99
+ client.release();
100
+
101
+ expect(pool.totalCount).toBe(0);
102
+ await until(() => server.closed === 1);
103
+
104
+ server.answering = true;
105
+ const next = await pool.connect();
106
+ await next.query("SELECT 1");
107
+ next.release();
108
+ expect(server.accepted).toBe(2);
109
+ expect(pool.idleCount).toBe(1);
110
+ } finally {
111
+ await pool.end();
112
+ server.stop();
113
+ }
114
+ });
115
+
116
+ it("discards the client through the callback form and Pool.query", async () => {
117
+ const server = fakePostgres();
118
+ const pool = poolFor(server);
119
+ try {
120
+ server.answering = false;
121
+ await expect(pool.query("SELECT 1")).rejects.toThrow("Query read timeout");
122
+ expect(pool.totalCount).toBe(0);
123
+
124
+ const client = await new Promise<PoolClient>((resolve, reject) => {
125
+ pool.connect((error, connected) => (connected ? resolve(connected) : reject(error)));
126
+ });
127
+ await expect(client.query("SELECT 1")).rejects.toThrow("Query read timeout");
128
+ client.release();
129
+ expect(pool.totalCount).toBe(0);
130
+ await until(() => server.closed === 2);
131
+ } finally {
132
+ await pool.end();
133
+ server.stop();
134
+ }
135
+ });
136
+ });
@@ -0,0 +1,40 @@
1
+ import { Pool, type PoolClient } from "pg";
2
+
3
+ type ConnectCallback = Parameters<Pool["connect"]>[0];
4
+
5
+ /** Whether `client` still waits for the reply to a query; pg sets `readyForQuery` once the server is ready for the next one. */
6
+ function awaitingReply(client: PoolClient): boolean {
7
+ return Reflect.get(client, "readyForQuery") === false;
8
+ }
9
+
10
+ function discardWhileAwaitingReply(client: PoolClient): PoolClient {
11
+ const release = client.release;
12
+ client.release = (error?: Error | boolean): void => {
13
+ release.call(
14
+ client,
15
+ error || (awaitingReply(client) ? new Error("Released while waiting for a query reply") : undefined),
16
+ );
17
+ };
18
+ return client;
19
+ }
20
+
21
+ /**
22
+ * pg fails a query after `query_timeout` but leaves the connection waiting for
23
+ * the reply. `Pool.query` discards that client, but Kysely, which Better Auth
24
+ * runs on, releases it as healthy, so the pool would hand it out again and
25
+ * every later query on it would queue behind the missing reply. This pool
26
+ * discards a client released while it still waits for a reply: pg closes its
27
+ * socket, and the next query gets a new connection.
28
+ */
29
+ export class AuthPostgresPool extends Pool {
30
+ override connect(): Promise<PoolClient>;
31
+ override connect(callback: ConnectCallback): void;
32
+ override connect(callback?: ConnectCallback): Promise<PoolClient> | void {
33
+ if (!callback) return super.connect().then(discardWhileAwaitingReply);
34
+ super.connect((error, client, done) => {
35
+ if (!client) return callback(error, client, done);
36
+ const guarded = discardWhileAwaitingReply(client);
37
+ callback(error, guarded, guarded.release);
38
+ });
39
+ }
40
+ }
@@ -1,6 +1,7 @@
1
1
  // Keep this first: Better Auth checks the schema when ./auth/auth creates its
2
- // instance, and the migrations below are what make that schema match.
3
- import "./auth/skip-schema-validation";
2
+ // instance, the migrations below are what make that schema match, and they may
3
+ // run longer than the auth pool's query timeout.
4
+ import "./auth/maintenance-command";
4
5
  import { getMigrations } from "better-auth/db/migration";
5
6
  import { auth } from "./auth/auth";
6
7
  import { closeAuthDatabase, postgresPool, sqliteDatabase } from "./auth/db";
@@ -1,5 +1,53 @@
1
1
  import { describe, expect, it } from "bun:test";
2
- import { externalJobId, parseExternalJobId } from "./bullmq-job.queue";
2
+ import type { ConnectionOptions } from "bullmq";
3
+ import { BullMqJobQueue, externalJobId, parseExternalJobId } from "./bullmq-job.queue";
4
+ import { producerConnection, reconnectDelay, redisConnection } from "./queue";
5
+
6
+ function producerAt(port: number): ConnectionOptions {
7
+ return producerConnection({
8
+ REDIS_HOST: "127.0.0.1",
9
+ REDIS_PORT: String(port),
10
+ REDIS_COMMAND_TIMEOUT_MS: "100",
11
+ });
12
+ }
13
+
14
+ function closedPort(): number {
15
+ const server = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } });
16
+ const port = server.port;
17
+ server.stop(true);
18
+ return port;
19
+ }
20
+
21
+ /**
22
+ * Answers the connection handshake and INFO like Redis 7, but stops answering a
23
+ * connection, without closing it, once it sends any other command.
24
+ */
25
+ function stallingRedis(): { port: number; stop: () => void } {
26
+ const info = "# Server\r\nredis_version:7.2.0\r\nloading:0\r\n";
27
+ const stalled = new WeakSet<object>();
28
+ const server = Bun.listen({
29
+ hostname: "127.0.0.1",
30
+ port: 0,
31
+ socket: {
32
+ data(socket, data) {
33
+ // Each command is an array of bulk strings: "*<count>", "$<length>", "<name>", ...
34
+ const lines = data.toString().split("\r\n");
35
+ lines.forEach((line, index) => {
36
+ if (stalled.has(socket) || !line.startsWith("*")) return;
37
+ const command = lines[index + 2]?.toLowerCase();
38
+ if (command === "client") socket.write("+OK\r\n");
39
+ else if (command === "info") socket.write(`$${Buffer.byteLength(info)}\r\n${info}\r\n`);
40
+ else stalled.add(socket);
41
+ });
42
+ },
43
+ },
44
+ });
45
+ return { port: server.port, stop: () => server.stop(true) };
46
+ }
47
+
48
+ async function rejection(promise: Promise<unknown>): Promise<unknown> {
49
+ return promise.then(() => undefined, (error: unknown) => error);
50
+ }
3
51
 
4
52
  describe("BullMQ job identifiers", () => {
5
53
  it("round-trips queue names without exposing an ambiguous id", () => {
@@ -8,3 +56,75 @@ describe("BullMQ job identifiers", () => {
8
56
  expect(parseExternalJobId("invalid")).toBeNull();
9
57
  });
10
58
  });
59
+
60
+ describe("BullMQ connections", () => {
61
+ it("lets workers wait for replies and gives producers a bounded, supervised connection", () => {
62
+ expect(redisConnection({})).toMatchObject({ maxRetriesPerRequest: null });
63
+ const producer = producerConnection({ REDIS_COMMAND_TIMEOUT_MS: "750" });
64
+ expect(producer).toMatchObject({ enableOfflineQueue: false, commandTimeout: 750, lazyConnect: true });
65
+ expect("retryStrategy" in producer && producer.retryStrategy?.(1)).toBeNull();
66
+ });
67
+
68
+ it("backs off reconnects from 250 ms up to 5 seconds", () => {
69
+ expect([1, 2, 3, 4, 5, 6, 30].map(reconnectDelay)).toEqual([250, 500, 1_000, 2_000, 4_000, 5_000, 5_000]);
70
+ });
71
+ });
72
+
73
+ describe("BullMqJobQueue while Redis is unavailable", () => {
74
+ it("fails enqueues and reads at once before the producer has connected", async () => {
75
+ const queue = new BullMqJobQueue(producerAt(closedPort()));
76
+ try {
77
+ const started = Date.now();
78
+ expect(await rejection(queue.enqueue("demo", { text: "hello" })))
79
+ .toMatchObject({ code: "JOBS_UNAVAILABLE", statusCode: 503 });
80
+ expect(await rejection(queue.get(externalJobId("demo", "1"))))
81
+ .toMatchObject({ code: "JOBS_UNAVAILABLE" });
82
+ expect(Date.now() - started).toBeLessThan(100);
83
+ } finally {
84
+ await queue.close();
85
+ }
86
+ });
87
+
88
+ it("reports a refused connection to the supervisor and keeps failing enqueues at once", async () => {
89
+ const queue = new BullMqJobQueue(producerAt(closedPort()));
90
+ const lost: unknown[] = [];
91
+ queue.onLost((reason) => { lost.push(reason); });
92
+ try {
93
+ await expect(queue.connect()).rejects.toThrow();
94
+ await expect(queue.ready()).rejects.toMatchObject({ code: "JOBS_UNAVAILABLE" });
95
+ const started = Date.now();
96
+ expect(await rejection(queue.enqueue("demo", {}))).toMatchObject({ code: "JOBS_UNAVAILABLE" });
97
+ expect(Date.now() - started).toBeLessThan(100);
98
+ expect(lost.length).toBeGreaterThan(0);
99
+ } finally {
100
+ await queue.close();
101
+ }
102
+ });
103
+
104
+ it("drops a connection Redis stops answering and fails at once until the supervisor reopens it", async () => {
105
+ const redis = stallingRedis();
106
+ const queue = new BullMqJobQueue(producerAt(redis.port));
107
+ const lost: unknown[] = [];
108
+ queue.onLost((reason) => { lost.push(reason); });
109
+ try {
110
+ await queue.connect();
111
+ let started = Date.now();
112
+ expect(await rejection(queue.enqueue("demo", { text: "hello" })))
113
+ .toMatchObject({ code: "JOBS_UNAVAILABLE", statusCode: 503 });
114
+ expect(Date.now() - started).toBeLessThan(1_000);
115
+ expect(lost.length).toBe(1);
116
+
117
+ started = Date.now();
118
+ expect(await rejection(queue.enqueue("demo", {}))).toMatchObject({ code: "JOBS_UNAVAILABLE" });
119
+ expect(Date.now() - started).toBeLessThan(100);
120
+
121
+ await queue.connect();
122
+ await queue.ready();
123
+ await Bun.sleep(50);
124
+ expect(lost.length).toBe(1);
125
+ } finally {
126
+ await queue.close();
127
+ redis.stop();
128
+ }
129
+ });
130
+ });