@podosoft/podokit 1.2.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 (187) hide show
  1. package/dist/add.d.ts +13 -2
  2. package/dist/add.js +135 -20
  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 +23 -2
  17. package/dist/lib.d.ts +4 -2
  18. package/dist/lib.js +5 -2
  19. package/dist/lockfile.d.ts +6 -1
  20. package/dist/lockfile.js +26 -6
  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/scripts/build.mjs +1 -0
  29. package/dist/templates/fullstack/apps/api/src/app.extensions.ts +7 -0
  30. package/dist/templates/fullstack/apps/api/src/app.spec.ts +20 -1
  31. package/dist/templates/fullstack/apps/api/src/app.ts +11 -0
  32. package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +3 -0
  33. package/dist/templates/fullstack/apps/api/src/config/redis.connection.spec.ts +48 -2
  34. package/dist/templates/fullstack/apps/api/src/config/redis.connection.ts +45 -0
  35. package/dist/templates/fullstack/apps/api/src/config/server-options.spec.ts +46 -0
  36. package/dist/templates/fullstack/apps/api/src/config/server-options.ts +48 -0
  37. package/dist/templates/fullstack/apps/api/src/core/api-contract.spec.ts +1 -0
  38. package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +10 -1
  39. package/dist/templates/fullstack/apps/api/src/core/services.spec.ts +196 -0
  40. package/dist/templates/fullstack/apps/api/src/core/services.ts +181 -2
  41. package/dist/templates/fullstack/apps/api/src/database/data-source.spec.ts +15 -0
  42. package/dist/templates/fullstack/apps/api/src/database/data-source.ts +3 -0
  43. package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +8 -1
  44. package/dist/templates/fullstack/apps/api/src/database/database.ts +29 -10
  45. package/dist/templates/fullstack/apps/api/src/database/migrate.ts +1 -1
  46. package/dist/templates/fullstack/apps/api/src/database/pool-guard.spec.ts +188 -0
  47. package/dist/templates/fullstack/apps/api/src/database/pool-guard.ts +151 -0
  48. package/dist/templates/fullstack/apps/api/src/health/health.plugin.spec.ts +28 -4
  49. package/dist/templates/fullstack/apps/api/src/health/health.plugin.ts +10 -2
  50. package/dist/templates/fullstack/apps/api/src/main.ts +2 -1
  51. package/dist/templates/fullstack/apps/web/components.json +3 -1
  52. package/dist/templates/fullstack/apps/web/package.json +1 -1
  53. package/dist/templates/fullstack/apps/web/src/lib/components/ui/textarea/index.ts +7 -0
  54. package/dist/templates/fullstack/apps/web/src/lib/components/ui/textarea/textarea.svelte +22 -0
  55. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +276 -1
  56. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +194 -9
  57. package/dist/templates/fullstack/apps/web/src/lib/server/body-limits.ts +15 -0
  58. package/dist/templates/fullstack/apps/web/src/lib/server/proxy-headers.ts +12 -0
  59. package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +10 -3
  60. package/dist/templates/fullstack/dot-claude/skills/podokit-elysia-endpoint/SKILL.md +5 -0
  61. package/dist/templates/fullstack/dot-claude/skills/podokit-sveltekit-route/SKILL.md +14 -1
  62. package/dist/templates/fullstack/dot-devcontainer/devcontainer.json +1 -0
  63. package/dist/templates/fullstack/{dot-env.docker → dot-env.docker.example} +7 -4
  64. package/dist/templates/fullstack/dot-env.example +10 -0
  65. package/dist/templates/fullstack/dot-gitignore +1 -0
  66. package/dist/templates/fullstack/dot-podokit/dev.json +2 -1
  67. package/dist/templates/fullstack/infra/k3s/README.md +3 -1
  68. package/dist/templates/fullstack/tests/playwright.extensions.ts +2 -2
  69. package/dist/templates/modules/admin-dashboard/files/apps/api/scripts/bootstrap-admin.mjs +21 -26
  70. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.migration.spec.ts +74 -0
  71. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.routes.spec.ts +76 -0
  72. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.routes.ts +41 -0
  73. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.service.spec.ts +124 -0
  74. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.service.ts +153 -0
  75. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin.module.ts +5 -0
  76. package/dist/templates/modules/admin-dashboard/files/apps/api/src/bootstrap-admin-runtime.ts +60 -0
  77. package/dist/templates/modules/admin-dashboard/files/apps/api/src/migrations/1792060000000-IndexSessionCreatedAt.ts +35 -0
  78. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +27 -9
  79. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/users.test.ts +59 -32
  80. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/users.ts +81 -11
  81. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/asset-paths.ts +11 -0
  82. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/guards.test.ts +23 -0
  83. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/guards.ts +2 -0
  84. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/public-paths.ts +9 -0
  85. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.test.ts +125 -0
  86. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.ts +107 -0
  87. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/users/+page.svelte +13 -19
  88. package/dist/templates/modules/admin-dashboard/files/tests/api/admin-users.api.spec.ts +86 -0
  89. package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +1 -0
  90. package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +1 -1
  91. package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +2 -1
  92. package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +2 -1
  93. package/dist/templates/modules/admin-dashboard/files/tests/helpers/disposable-users.ts +15 -12
  94. package/dist/templates/modules/admin-dashboard/files/tests/helpers/object-response.ts +12 -0
  95. package/dist/templates/modules/admin-dashboard/files/tests/ui/data-table.ui.spec.ts +2 -2
  96. package/dist/templates/modules/admin-dashboard/files/tests/ui/email-admin.ui.spec.ts +0 -1
  97. package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +1 -3
  98. package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +3 -1
  99. package/dist/templates/modules/admin-dashboard/files/tests/ui/request-budget.ui.spec.ts +14 -15
  100. package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +13 -8
  101. package/dist/templates/modules/admin-dashboard/files/tests/ui/users-crud.ui.spec.ts +1 -1
  102. package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +14 -3
  103. package/dist/templates/modules/admin-dashboard/module.manifest.json +4 -2
  104. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.migration.spec.ts +53 -11
  105. package/dist/templates/modules/audit-log/files/apps/api/src/migrations/1792000000000-IndexAuditLogFilters.ts +26 -4
  106. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.spec.ts +26 -0
  107. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +17 -2
  108. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +8 -4
  109. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +2 -2
  110. package/dist/templates/modules/auth/files/apps/api/src/auth/database-outage.spec.ts +114 -0
  111. package/dist/templates/modules/auth/files/apps/api/src/auth/database-outage.ts +50 -0
  112. package/dist/templates/modules/auth/files/apps/api/src/auth/db.spec.ts +4 -1
  113. package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +11 -2
  114. package/dist/templates/modules/auth/files/apps/api/src/auth/maintenance-command.ts +5 -0
  115. package/dist/templates/modules/auth/files/apps/api/src/auth/maintenance.ts +14 -0
  116. package/dist/templates/modules/auth/files/apps/api/src/auth/postgres-pool.spec.ts +136 -0
  117. package/dist/templates/modules/auth/files/apps/api/src/auth/postgres-pool.ts +40 -0
  118. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.rows.spec.ts +53 -0
  119. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.rows.ts +65 -0
  120. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +16 -37
  121. package/dist/templates/modules/auth/files/apps/api/src/auth-config/session-expiry.spec.ts +33 -0
  122. package/dist/templates/modules/auth/files/apps/api/src/auth-config/session-expiry.ts +29 -0
  123. package/dist/templates/modules/auth/files/apps/api/src/migrate.ts +3 -2
  124. package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/references/bootstrap-admin.md +2 -0
  125. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.spec.ts +121 -1
  126. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.ts +108 -10
  127. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/jobs.module.ts +3 -1
  128. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/queue.ts +31 -3
  129. package/dist/templates/modules/bullmq/module.manifest.json +5 -1
  130. package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/events-redis.module.ts +2 -4
  131. package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/redis-event.bus.spec.ts +224 -0
  132. package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/redis-event.bus.ts +124 -35
  133. package/dist/templates/modules/events-redis/module.manifest.json +4 -0
  134. package/dist/templates/modules/file-upload/files/apps/api/src/files/chunked-uploads.routes.ts +136 -0
  135. package/dist/templates/modules/file-upload/files/apps/api/src/files/chunked-uploads.spec.ts +636 -0
  136. package/dist/templates/modules/file-upload/files/apps/api/src/files/chunked-uploads.ts +673 -0
  137. package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.spec.ts +10 -4
  138. package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.ts +37 -8
  139. package/dist/templates/modules/file-upload/files/apps/api/src/files/upload-config.ts +99 -0
  140. package/dist/templates/modules/file-upload/files/apps/api/src/migrations/1792100000000-InitFileUploadSessions.ts +49 -0
  141. package/dist/templates/modules/file-upload/files/apps/web/src/lib/uploads.test.ts +456 -0
  142. package/dist/templates/modules/file-upload/files/apps/web/src/lib/uploads.ts +356 -0
  143. package/dist/templates/modules/file-upload/files/tests/api/chunked-uploads.api.spec.ts +147 -0
  144. package/dist/templates/modules/file-upload/module.manifest.json +34 -6
  145. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/jobs-local.module.ts +1 -1
  146. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.spec.ts +23 -0
  147. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.ts +30 -20
  148. package/dist/templates/modules/jobs-local/module.manifest.json +3 -0
  149. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/download-url.ts +20 -0
  150. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.spec.ts +79 -12
  151. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.ts +16 -1
  152. package/dist/templates/modules/object-storage-local/module.manifest.json +11 -2
  153. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-multipart.spec.ts +115 -0
  154. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-multipart.ts +171 -0
  155. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.spec.ts +222 -9
  156. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.ts +55 -19
  157. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-signer.spec.ts +71 -0
  158. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-signer.ts +98 -0
  159. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage-demo.spec.ts +6 -3
  160. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.config.ts +7 -2
  161. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.service.ts +187 -44
  162. package/dist/templates/modules/object-storage-s3/module.manifest.json +10 -2
  163. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +11 -22
  164. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.spec.ts +113 -0
  165. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.ts +122 -0
  166. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +24 -0
  167. package/dist/templates/modules/rate-limit/module.manifest.json +7 -0
  168. package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.spec.ts +14 -1
  169. package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.ts +9 -12
  170. package/dist/templates/modules/redis/files/apps/api/src/redis/redis.module.ts +16 -4
  171. package/dist/templates/modules/redis/files/apps/api/src/redis/redis.service.spec.ts +233 -5
  172. package/dist/templates/modules/redis/files/apps/api/src/redis/redis.service.ts +134 -32
  173. package/dist/templates/modules/redis/module.manifest.json +4 -0
  174. package/dist/templates/modules/sse/files/apps/api/src/events/event-stream.ts +11 -4
  175. package/dist/templates/modules/sse/files/apps/api/src/events/events.module.spec.ts +48 -1
  176. package/dist/templates/modules/sse/files/apps/api/src/events/events.module.ts +15 -2
  177. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.spec.ts +68 -0
  178. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +25 -1
  179. package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +46 -0
  180. package/dist/templates/modules/sse/module.manifest.json +3 -1
  181. package/dist/templates/todo/AGENTS.md +9 -1
  182. package/dist/templates/todo/README.md +18 -5
  183. package/dist/update.d.ts +22 -0
  184. package/dist/update.js +68 -8
  185. package/package.json +2 -2
  186. package/dist/templates/modules/auth/files/apps/api/src/auth/schema-validation.ts +0 -13
  187. package/dist/templates/modules/auth/files/apps/api/src/auth/skip-schema-validation.ts +0 -5
@@ -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
+ }
@@ -0,0 +1,53 @@
1
+ import { afterEach, describe, expect, it } from "bun:test";
2
+ import { SQL } from "bun";
3
+ import type { QueryRunner } from "typeorm";
4
+ import { InitAuthConfig1720500000000 } from "../migrations/1720500000000-InitAuthConfig";
5
+ import { readAuthConfigRow, readAuthConfigRows, writeAuthConfigRow } from "./auth-config.rows";
6
+
7
+ let open: SQL | undefined;
8
+
9
+ afterEach(async () => {
10
+ await open?.close();
11
+ open = undefined;
12
+ });
13
+
14
+ async function sqliteDatabase(): Promise<SQL> {
15
+ const sql = new SQL(":memory:");
16
+ open = sql;
17
+ const runner = {
18
+ connection: { options: { type: "better-sqlite3" } },
19
+ query: (query: string): Promise<unknown> => sql.unsafe(query),
20
+ } as unknown as QueryRunner;
21
+ await new InitAuthConfig1720500000000().up(runner);
22
+ return sql;
23
+ }
24
+
25
+ describe("auth config rows on SQLite", () => {
26
+ it("reads the integer enabled column back as a boolean", async () => {
27
+ const sql = await sqliteDatabase();
28
+ await writeAuthConfigRow(sql, "sqlite", { key: "smtp", enabled: true, config: { host: "mail" }, secret: null });
29
+ await writeAuthConfigRow(sql, "sqlite", { key: "social.github", enabled: false, config: {}, secret: null });
30
+
31
+ expect(await readAuthConfigRow(sql, "smtp")).toEqual({
32
+ key: "smtp",
33
+ enabled: true,
34
+ config: JSON.stringify({ host: "mail" }),
35
+ secret: null,
36
+ });
37
+ const rows = await readAuthConfigRows(sql);
38
+ expect(rows.map((row) => [row.key, row.enabled])).toEqual([
39
+ ["smtp", true],
40
+ ["social.github", false],
41
+ ]);
42
+ expect(await readAuthConfigRow(sql, "server")).toBeNull();
43
+ });
44
+
45
+ it("keeps a boolean through a read and rewrite", async () => {
46
+ const sql = await sqliteDatabase();
47
+ await writeAuthConfigRow(sql, "sqlite", { key: "smtp", enabled: true, config: {}, secret: null });
48
+ const current = await readAuthConfigRow(sql, "smtp");
49
+ if (!current) throw new Error("expected the smtp row");
50
+ await writeAuthConfigRow(sql, "sqlite", { ...current, secret: "sealed" });
51
+ expect((await readAuthConfigRow(sql, "smtp"))?.enabled).toBe(true);
52
+ });
53
+ });
@@ -0,0 +1,65 @@
1
+ import type { SQL } from "bun";
2
+ import type { DatabaseProviderName } from "@podosoft/podokit-runtime";
3
+ import { authConfigParam } from "./config-param";
4
+
5
+ export interface AuthConfigRow {
6
+ key: string;
7
+ enabled: boolean;
8
+ config: unknown;
9
+ secret: string | null;
10
+ }
11
+
12
+ function configObject(value: unknown): Record<string, unknown> {
13
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) {
14
+ return value as Record<string, unknown>;
15
+ }
16
+ if (typeof value === "string") {
17
+ try {
18
+ return configObject(JSON.parse(value) as unknown);
19
+ } catch {
20
+ return {};
21
+ }
22
+ }
23
+ return {};
24
+ }
25
+
26
+ // SQLite stores booleans as 0 or 1.
27
+ interface StoredAuthConfigRow extends Omit<AuthConfigRow, "enabled"> {
28
+ enabled: boolean | number;
29
+ }
30
+
31
+ function authConfigRow(row: StoredAuthConfigRow): AuthConfigRow {
32
+ return { ...row, enabled: row.enabled === true || row.enabled === 1 };
33
+ }
34
+
35
+ export async function readAuthConfigRows(sql: SQL): Promise<AuthConfigRow[]> {
36
+ const rows = await sql<StoredAuthConfigRow[]>`
37
+ SELECT "key", "enabled", "config", "secret" FROM "auth_config"
38
+ `;
39
+ return rows.map(authConfigRow);
40
+ }
41
+
42
+ export async function readAuthConfigRow(sql: SQL, key: string): Promise<AuthConfigRow | null> {
43
+ const rows = await sql<StoredAuthConfigRow[]>`
44
+ SELECT "key", "enabled", "config", "secret"
45
+ FROM "auth_config" WHERE "key" = ${key}
46
+ `;
47
+ return rows[0] ? authConfigRow(rows[0]) : null;
48
+ }
49
+
50
+ export async function writeAuthConfigRow(
51
+ sql: SQL,
52
+ provider: DatabaseProviderName,
53
+ row: AuthConfigRow,
54
+ ): Promise<void> {
55
+ const config = authConfigParam(provider, configObject(row.config));
56
+ await sql`
57
+ INSERT INTO "auth_config" ("key", "enabled", "config", "secret", "updatedAt")
58
+ VALUES (${row.key}, ${row.enabled}, ${config}, ${row.secret}, CURRENT_TIMESTAMP)
59
+ ON CONFLICT ("key") DO UPDATE SET
60
+ "enabled" = EXCLUDED."enabled",
61
+ "config" = EXCLUDED."config",
62
+ "secret" = EXCLUDED."secret",
63
+ "updatedAt" = CURRENT_TIMESTAMP
64
+ `;
65
+ }
@@ -12,7 +12,13 @@ import {
12
12
  } from "@podosoft/podokit-auth";
13
13
  import type { DatabaseProviderName } from "@podosoft/podokit-runtime";
14
14
  import { refreshAuthNow } from "../auth/auth-provider";
15
- import { authConfigParam } from "./config-param";
15
+ import {
16
+ readAuthConfigRow,
17
+ readAuthConfigRows,
18
+ writeAuthConfigRow,
19
+ type AuthConfigRow,
20
+ } from "./auth-config.rows";
21
+ import { resetSessionExpirations } from "./session-expiry";
16
22
 
17
23
  export type SocialProviderView = {
18
24
  id: string;
@@ -73,13 +79,6 @@ export type AuthConfigUpdate = {
73
79
  };
74
80
  };
75
81
 
76
- interface AuthConfigRow {
77
- key: string;
78
- enabled: boolean;
79
- config: unknown;
80
- secret: string | null;
81
- }
82
-
83
82
  function object(value: unknown): Record<string, unknown> {
84
83
  if (value !== null && typeof value === "object" && !Array.isArray(value)) {
85
84
  return value as Record<string, unknown>;
@@ -110,30 +109,15 @@ export class AuthConfigService {
110
109
  ) {}
111
110
 
112
111
  private async rows(): Promise<AuthConfigRow[]> {
113
- return this.sql<AuthConfigRow[]>`
114
- SELECT "key", "enabled", "config", "secret" FROM "auth_config"
115
- `;
112
+ return readAuthConfigRows(this.sql);
116
113
  }
117
114
 
118
115
  private async row(key: string): Promise<AuthConfigRow | null> {
119
- const rows = await this.sql<AuthConfigRow[]>`
120
- SELECT "key", "enabled", "config", "secret"
121
- FROM "auth_config" WHERE "key" = ${key}
122
- `;
123
- return rows[0] ?? null;
116
+ return readAuthConfigRow(this.sql, key);
124
117
  }
125
118
 
126
119
  private async upsert(row: AuthConfigRow): Promise<void> {
127
- const config = authConfigParam(this.provider, object(row.config));
128
- await this.sql`
129
- INSERT INTO "auth_config" ("key", "enabled", "config", "secret", "updatedAt")
130
- VALUES (${row.key}, ${row.enabled}, ${config}, ${row.secret}, CURRENT_TIMESTAMP)
131
- ON CONFLICT ("key") DO UPDATE SET
132
- "enabled" = EXCLUDED."enabled",
133
- "config" = EXCLUDED."config",
134
- "secret" = EXCLUDED."secret",
135
- "updatedAt" = CURRENT_TIMESTAMP
136
- `;
120
+ await writeAuthConfigRow(this.sql, this.provider, row);
137
121
  }
138
122
 
139
123
  async describe(): Promise<AuthConfigView> {
@@ -216,7 +200,12 @@ export class AuthConfigService {
216
200
  if (update.server) await this.upsertServer(update.server);
217
201
  await refreshAuthNow();
218
202
  if (update.server?.sessionIdleTimeoutMinutes !== undefined) {
219
- await this.resetSessionExpirations(update.server.sessionIdleTimeoutMinutes);
203
+ const minutes = update.server.sessionIdleTimeoutMinutes;
204
+ await resetSessionExpirations(
205
+ this.sql,
206
+ this.provider,
207
+ minutes === null ? DEFAULT_SESSION_LIFETIME_SECONDS : minutes * 60,
208
+ );
220
209
  }
221
210
  return this.describe();
222
211
  }
@@ -285,14 +274,4 @@ export class AuthConfigService {
285
274
  }
286
275
  await this.upsert({ ...current, config });
287
276
  }
288
-
289
- private async resetSessionExpirations(minutes: number | null): Promise<void> {
290
- const lifetimeSeconds = minutes === null ? DEFAULT_SESSION_LIFETIME_SECONDS : minutes * 60;
291
- await this.sql`
292
- UPDATE "session"
293
- SET "expiresAt" = CURRENT_TIMESTAMP + (${lifetimeSeconds} * INTERVAL '1 second'),
294
- "updatedAt" = CURRENT_TIMESTAMP
295
- WHERE "expiresAt" > CURRENT_TIMESTAMP
296
- `;
297
- }
298
277
  }
@@ -0,0 +1,33 @@
1
+ import { afterEach, describe, expect, it } from "bun:test";
2
+ import { SQL } from "bun";
3
+ import { resetSessionExpirations } from "./session-expiry";
4
+
5
+ let open: SQL | undefined;
6
+
7
+ afterEach(async () => {
8
+ await open?.close();
9
+ open = undefined;
10
+ });
11
+
12
+ describe("session expiry reset on SQLite", () => {
13
+ it("moves active sessions to the new lifetime and leaves expired ones", async () => {
14
+ const sql = new SQL(":memory:");
15
+ open = sql;
16
+ // Better Auth stores SQLite dates as ISO 8601 text.
17
+ await sql`CREATE TABLE "session" ("id" text PRIMARY KEY, "expiresAt" date NOT NULL, "updatedAt" date NOT NULL)`;
18
+ const now = new Date("2026-10-15T10:00:00.000Z");
19
+ const active = new Date(now.getTime() + 3_600_000).toISOString();
20
+ const expired = new Date(now.getTime() - 1_000).toISOString();
21
+ await sql`INSERT INTO "session" VALUES (${"active"}, ${active}, ${active}), (${"expired"}, ${expired}, ${expired})`;
22
+
23
+ await resetSessionExpirations(sql, "sqlite", 1_800, now);
24
+
25
+ const rows = await sql<Array<{ id: string; expiresAt: string; updatedAt: string }>>`
26
+ SELECT "id", "expiresAt", "updatedAt" FROM "session" ORDER BY "id"
27
+ `;
28
+ expect(rows).toEqual([
29
+ { id: "active", expiresAt: "2026-10-15T10:30:00.000Z", updatedAt: now.toISOString() },
30
+ { id: "expired", expiresAt: expired, updatedAt: expired },
31
+ ]);
32
+ });
33
+ });
@@ -0,0 +1,29 @@
1
+ import type { SQL } from "bun";
2
+ import type { DatabaseProviderName } from "@podosoft/podokit-runtime";
3
+
4
+ /** Give every active session the configured lifetime from now. */
5
+ export async function resetSessionExpirations(
6
+ sql: SQL,
7
+ provider: DatabaseProviderName,
8
+ lifetimeSeconds: number,
9
+ now: Date = new Date(),
10
+ ): Promise<void> {
11
+ if (provider === "sqlite") {
12
+ // Better Auth stores SQLite dates as ISO 8601 text, which sorts in time
13
+ // order. SQLite has no interval arithmetic.
14
+ const current = now.toISOString();
15
+ const expiresAt = new Date(now.getTime() + lifetimeSeconds * 1000).toISOString();
16
+ await sql`
17
+ UPDATE "session"
18
+ SET "expiresAt" = ${expiresAt}, "updatedAt" = ${current}
19
+ WHERE "expiresAt" > ${current}
20
+ `;
21
+ return;
22
+ }
23
+ await sql`
24
+ UPDATE "session"
25
+ SET "expiresAt" = CURRENT_TIMESTAMP + (${lifetimeSeconds} * INTERVAL '1 second'),
26
+ "updatedAt" = CURRENT_TIMESTAMP
27
+ WHERE "expiresAt" > CURRENT_TIMESTAMP
28
+ `;
29
+ }
@@ -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,6 +1,8 @@
1
1
  # Bootstrap the initial administrator
2
2
 
3
3
  Use the generated database-backed command after the API build and migrations.
4
+ With `NODE_ENV=production` it loads the bundled `dist/bootstrap-admin-runtime.js`;
5
+ otherwise it runs the source entry. It supports PostgreSQL and SQLite.
4
6
  Do not create the first administrator through the public sign-up page.
5
7
 
6
8
  ## Set the persistent allowlist
@@ -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
+ });
@@ -1,4 +1,5 @@
1
- import { Queue, Worker } from "bullmq";
1
+ import { AppException } from "@podosoft/podokit-contracts";
2
+ import { Queue, RedisConnection, Worker, type ConnectionOptions, type RedisClient } from "bullmq";
2
3
  import type {
3
4
  EnqueueOptions,
4
5
  JobHandler,
@@ -8,10 +9,15 @@ import type {
8
9
  JobStatus,
9
10
  Unsubscribe,
10
11
  } from "@podosoft/podokit-runtime";
11
- import { redisConnection } from "./queue";
12
+ import type { SupervisedConnection } from "../core/services";
13
+ import { producerConnection, redisConnection } from "./queue";
12
14
 
13
15
  const ID_SEPARATOR = "~";
14
16
 
17
+ // BullMQ would otherwise wait for Redis and ask its version before the first
18
+ // command, which keeps a call pending while Redis is down instead of failing it.
19
+ const NO_STARTUP_ROUND_TRIP = { skipWaitingForReady: true, skipVersionCheck: true } as const;
20
+
15
21
  export function externalJobId(name: string, id: string): string {
16
22
  return `${encodeURIComponent(name)}${ID_SEPARATOR}${id}`;
17
23
  }
@@ -31,23 +37,57 @@ function statusOf(state: string): JobStatus {
31
37
  return "waiting";
32
38
  }
33
39
 
34
- export class BullMqJobQueue implements JobQueue {
40
+ function isReplyTimeout(error: unknown): boolean {
41
+ return error instanceof Error && error.message === "Command timed out";
42
+ }
43
+
44
+ export function jobsUnavailable(): AppException {
45
+ return new AppException("JOBS_UNAVAILABLE", "Background jobs are temporarily unavailable.", 503);
46
+ }
47
+
48
+ /**
49
+ * Adds and reads jobs over one producer connection that the service registry
50
+ * supervises, and runs in-process handlers on BullMQ workers, which keep their
51
+ * own blocking connections and reconnect by themselves.
52
+ */
53
+ export class BullMqJobQueue implements JobQueue, SupervisedConnection {
35
54
  private readonly queues = new Map<string, Queue>();
36
55
  private readonly workers = new Map<string, Worker>();
56
+ private producer: RedisConnection;
57
+ private lostListener?: (reason: unknown) => void;
58
+ private closed = false;
59
+
60
+ constructor(private readonly connection: ConnectionOptions = producerConnection()) {
61
+ this.producer = this.openProducer();
62
+ }
63
+
64
+ onLost(listener: (reason: unknown) => void): void {
65
+ this.lostListener = listener;
66
+ }
67
+
68
+ /** Opens the producer connection. */
69
+ async connect(): Promise<void> {
70
+ const client = await this.producer.client;
71
+ if (client.status !== "ready") await client.connect();
72
+ }
73
+
74
+ /** Rejects while Redis is unreachable or slower to answer than `REDIS_COMMAND_TIMEOUT_MS`. */
75
+ async ready(): Promise<void> {
76
+ await this.command((client) => client.info());
77
+ }
37
78
 
38
79
  async enqueue(
39
80
  name: string,
40
81
  payload: unknown,
41
82
  options: EnqueueOptions = {},
42
83
  ): Promise<JobReference> {
43
- const queue = this.queue(name);
44
- const job = await queue.add(name, payload, {
84
+ const job = await this.command((client) => this.queue(name, client).add(name, payload, {
45
85
  ...(options.delayMs === undefined ? {} : { delay: options.delayMs }),
46
86
  ...(options.attempts === undefined ? {} : { attempts: options.attempts }),
47
87
  ...(options.deduplicationKey === undefined
48
88
  ? {}
49
89
  : { deduplication: { id: options.deduplicationKey } }),
50
- });
90
+ }));
51
91
  if (job.id === undefined) throw new Error("BullMQ did not assign a job id");
52
92
  return { id: externalJobId(name, job.id), name };
53
93
  }
@@ -55,12 +95,13 @@ export class BullMqJobQueue implements JobQueue {
55
95
  async get(externalId: string): Promise<JobSnapshot | null> {
56
96
  const parsed = parseExternalJobId(externalId);
57
97
  if (!parsed) return null;
58
- const job = await this.queue(parsed.name).getJob(parsed.id);
98
+ const job = await this.command((client) => this.queue(parsed.name, client).getJob(parsed.id));
59
99
  if (!job) return null;
100
+ const state = await this.command(() => job.getState());
60
101
  return {
61
102
  id: externalId,
62
103
  name: parsed.name,
63
- status: statusOf(await job.getState()),
104
+ status: statusOf(state),
64
105
  attempts: job.attemptsMade,
65
106
  createdAt: job.timestamp,
66
107
  updatedAt: job.finishedOn ?? job.processedOn ?? job.timestamp,
@@ -89,18 +130,75 @@ export class BullMqJobQueue implements JobQueue {
89
130
  }
90
131
 
91
132
  async close(): Promise<void> {
133
+ this.closed = true;
92
134
  await Promise.all([
93
135
  ...[...this.workers.values()].map((worker) => worker.close()),
94
136
  ...[...this.queues.values()].map((queue) => queue.close()),
95
137
  ]);
96
138
  this.workers.clear();
97
139
  this.queues.clear();
140
+ await this.producer.close(true);
141
+ }
142
+
143
+ /**
144
+ * Runs `send` on the producer connection and fails it with JOBS_UNAVAILABLE
145
+ * while the connection is closed or when Redis does not answer in time.
146
+ */
147
+ private async command<T>(send: (client: RedisClient) => Promise<T>): Promise<T> {
148
+ const producer = this.producer;
149
+ const client = await producer.client;
150
+ if (client.status !== "ready") throw jobsUnavailable();
151
+ try {
152
+ return await send(client);
153
+ } catch (error) {
154
+ const timedOut = isReplyTimeout(error);
155
+ if (timedOut && producer === this.producer) this.replaceProducer(error);
156
+ if (timedOut || producer !== this.producer || client.status !== "ready") throw jobsUnavailable();
157
+ throw error;
158
+ }
159
+ }
160
+
161
+ private openProducer(): RedisConnection {
162
+ const producer = new RedisConnection(this.connection, {
163
+ shared: false,
164
+ blocking: false,
165
+ ...NO_STARTUP_ROUND_TRIP,
166
+ });
167
+ // Connection errors reach callers as JOBS_UNAVAILABLE and the supervisor's log.
168
+ producer.on("error", () => undefined);
169
+ producer.on("close", () => {
170
+ if (producer === this.producer) this.report(new Error("Connection closed"));
171
+ });
172
+ return producer;
173
+ }
174
+
175
+ /**
176
+ * Redis answers in order, so after a reply times out every later command
177
+ * would wait behind it, and a server that stopped answering never completes
178
+ * the close either. The connection is dropped for a new one that the
179
+ * supervisor opens.
180
+ */
181
+ private replaceProducer(reason: unknown): void {
182
+ const stale = this.producer;
183
+ const queues = [...this.queues.values()];
184
+ this.queues.clear();
185
+ this.producer = this.openProducer();
186
+ void Promise.all(queues.map((queue) => queue.close()))
187
+ .then(() => stale.close(true))
188
+ .catch(() => undefined);
189
+ this.report(reason);
190
+ }
191
+
192
+ private report(reason: unknown): void {
193
+ if (!this.closed) this.lostListener?.(reason);
98
194
  }
99
195
 
100
- private queue(name: string): Queue {
196
+ private queue(name: string, client: RedisClient): Queue {
101
197
  const current = this.queues.get(name);
102
198
  if (current) return current;
103
- const queue = new Queue(name, { connection: redisConnection() });
199
+ const queue = new Queue(name, { connection: client, ...NO_STARTUP_ROUND_TRIP });
200
+ // It reports the producer connection's errors, which `openProducer` already handles.
201
+ queue.on("error", () => undefined);
104
202
  this.queues.set(name, queue);
105
203
  return queue;
106
204
  }
@@ -2,7 +2,7 @@ import { AppException } from "@podosoft/podokit-contracts";
2
2
  import { JOBS } from "@podosoft/podokit-runtime";
3
3
  import { Elysia, t } from "elysia";
4
4
  import { PROVIDERS } from "../config/providers";
5
- import type { AppPlugin, PodokitModule } from "../core/services";
5
+ import { READINESS, type AppPlugin, type PodokitModule } from "../core/services";
6
6
  import { BullMqJobQueue } from "./bullmq-job.queue";
7
7
 
8
8
  const jobsPlugin: AppPlugin = ({ services }) => {
@@ -33,6 +33,8 @@ export const jobsModule: PodokitModule = {
33
33
  if (PROVIDERS.jobs !== "bullmq") return;
34
34
  const queue = new BullMqJobQueue();
35
35
  services.register(JOBS, queue, () => queue.close());
36
+ services.resolve(READINESS).register("jobs", () => queue.ready());
37
+ services.supervise("jobs", queue);
36
38
  },
37
39
  plugin: jobsPlugin,
38
40
  };