@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.
- package/dist/add.d.ts +13 -2
- package/dist/add.js +135 -20
- 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 +23 -2
- package/dist/lib.d.ts +4 -2
- package/dist/lib.js +5 -2
- package/dist/lockfile.d.ts +6 -1
- package/dist/lockfile.js +26 -6
- 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 +17 -2
- package/dist/templates/fullstack/README.md +19 -5
- package/dist/templates/fullstack/apps/api/package.json +1 -1
- package/dist/templates/fullstack/apps/api/scripts/build.mjs +1 -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 +10 -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/data-source.spec.ts +15 -0
- package/dist/templates/fullstack/apps/api/src/database/data-source.ts +3 -0
- package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +8 -1
- package/dist/templates/fullstack/apps/api/src/database/database.ts +29 -10
- package/dist/templates/fullstack/apps/api/src/database/migrate.ts +1 -1
- 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/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/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 +14 -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/fullstack/tests/playwright.extensions.ts +2 -2
- package/dist/templates/modules/admin-dashboard/files/apps/api/scripts/bootstrap-admin.mjs +21 -26
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.migration.spec.ts +74 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.routes.spec.ts +76 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.routes.ts +41 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.service.spec.ts +124 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.service.ts +153 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin.module.ts +5 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/bootstrap-admin-runtime.ts +60 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/migrations/1792060000000-IndexSessionCreatedAt.ts +35 -0
- 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/admin/users.test.ts +59 -32
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/users.ts +81 -11
- 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/guards.test.ts +23 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/guards.ts +2 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/public-paths.ts +9 -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/apps/web/src/routes/(admin)/admin/users/+page.svelte +13 -19
- package/dist/templates/modules/admin-dashboard/files/tests/api/admin-users.api.spec.ts +86 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +1 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +1 -1
- 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 +15 -12
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/object-response.ts +12 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/data-table.ui.spec.ts +2 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/email-admin.ui.spec.ts +0 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +1 -3
- package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +3 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/request-budget.ui.spec.ts +14 -15
- package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +13 -8
- package/dist/templates/modules/admin-dashboard/files/tests/ui/users-crud.ui.spec.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +14 -3
- package/dist/templates/modules/admin-dashboard/module.manifest.json +4 -2
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.migration.spec.ts +53 -11
- package/dist/templates/modules/audit-log/files/apps/api/src/migrations/1792000000000-IndexAuditLogFilters.ts +26 -4
- 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/auth-config/auth-config.rows.spec.ts +53 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.rows.ts +65 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +16 -37
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/session-expiry.spec.ts +33 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/session-expiry.ts +29 -0
- package/dist/templates/modules/auth/files/apps/api/src/migrate.ts +3 -2
- package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/references/bootstrap-admin.md +2 -0
- 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 +147 -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.module.spec.ts +48 -1
- package/dist/templates/modules/sse/files/apps/api/src/events/events.module.ts +15 -2
- package/dist/templates/modules/sse/files/apps/api/src/events/events.service.spec.ts +68 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +25 -1
- package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +46 -0
- package/dist/templates/modules/sse/module.manifest.json +3 -1
- package/dist/templates/todo/AGENTS.md +9 -1
- package/dist/templates/todo/README.md +18 -5
- package/dist/update.d.ts +22 -0
- package/dist/update.js +68 -8
- package/package.json +2 -2
- 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
|
@@ -5,16 +5,24 @@ import { revertLatestSqliteMigration, runSqliteMigrations } from "../database/sq
|
|
|
5
5
|
import { InitAuditLogs1720300000000 } from "../migrations/1720300000000-InitAuditLogs";
|
|
6
6
|
import { IndexAuditLogFilters1792000000000 } from "../migrations/1792000000000-IndexAuditLogFilters";
|
|
7
7
|
|
|
8
|
-
function runner(
|
|
8
|
+
function runner(
|
|
9
|
+
type: "postgres" | "better-sqlite3",
|
|
10
|
+
queries: string[],
|
|
11
|
+
invalidIndexes: string[] = [],
|
|
12
|
+
): QueryRunner {
|
|
9
13
|
return {
|
|
10
14
|
connection: { options: { type } },
|
|
11
|
-
query: (sql: string): Promise<unknown[]> => {
|
|
15
|
+
query: (sql: string, parameters: unknown[] = []): Promise<unknown[]> => {
|
|
12
16
|
queries.push(sql);
|
|
13
|
-
|
|
17
|
+
const invalid = sql.includes("indisvalid") && invalidIndexes.some((name) => parameters[0] === `"${name}"`);
|
|
18
|
+
return Promise.resolve(invalid ? [{ invalid: 1 }] : []);
|
|
14
19
|
},
|
|
15
20
|
} as unknown as QueryRunner;
|
|
16
21
|
}
|
|
17
22
|
|
|
23
|
+
const INVALID_CHECK =
|
|
24
|
+
"SELECT 1 AS \"invalid\" FROM pg_index WHERE indexrelid = to_regclass($1) AND NOT indisvalid";
|
|
25
|
+
|
|
18
26
|
const FILTER_INDEXES = [
|
|
19
27
|
"IDX_audit_logs_action_createdAt",
|
|
20
28
|
"IDX_audit_logs_actorEmail_createdAt",
|
|
@@ -61,20 +69,41 @@ describe("audit log migration", () => {
|
|
|
61
69
|
});
|
|
62
70
|
|
|
63
71
|
describe("audit filter index migration", () => {
|
|
64
|
-
it("
|
|
72
|
+
it("builds and drops the PostgreSQL indexes concurrently outside a transaction", async () => {
|
|
65
73
|
const migration = new IndexAuditLogFilters1792000000000();
|
|
74
|
+
// A transaction block would reject CONCURRENTLY; the runner must use "each".
|
|
75
|
+
expect(migration.transaction).toBe(false);
|
|
66
76
|
const up: string[] = [];
|
|
67
77
|
await migration.up(runner("postgres", up));
|
|
68
78
|
expect(up).toEqual([
|
|
69
|
-
|
|
70
|
-
'CREATE INDEX IF NOT EXISTS "
|
|
71
|
-
|
|
72
|
-
'CREATE INDEX IF NOT EXISTS "
|
|
73
|
-
|
|
74
|
-
'
|
|
79
|
+
INVALID_CHECK,
|
|
80
|
+
'CREATE INDEX CONCURRENTLY IF NOT EXISTS "IDX_audit_logs_createdAt_id" ON "audit_logs" ("createdAt", "id")',
|
|
81
|
+
INVALID_CHECK,
|
|
82
|
+
'CREATE INDEX CONCURRENTLY IF NOT EXISTS "IDX_audit_logs_action_createdAt" ON "audit_logs" ("action", "createdAt")',
|
|
83
|
+
INVALID_CHECK,
|
|
84
|
+
'CREATE INDEX CONCURRENTLY IF NOT EXISTS "IDX_audit_logs_actorEmail_createdAt" ON "audit_logs" ("actorEmail", "createdAt")',
|
|
85
|
+
INVALID_CHECK,
|
|
86
|
+
'CREATE INDEX CONCURRENTLY IF NOT EXISTS "IDX_audit_logs_targetLabel_createdAt" ON "audit_logs" ("targetLabel", "createdAt")',
|
|
87
|
+
'DROP INDEX CONCURRENTLY IF EXISTS "IDX_audit_logs_action"',
|
|
88
|
+
'DROP INDEX CONCURRENTLY IF EXISTS "IDX_audit_logs_createdAt"',
|
|
75
89
|
]);
|
|
76
|
-
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("rebuilds an invalid index left by an interrupted concurrent build", async () => {
|
|
93
|
+
const up: string[] = [];
|
|
94
|
+
await new IndexAuditLogFilters1792000000000().up(
|
|
95
|
+
runner("postgres", up, ["IDX_audit_logs_action_createdAt"]),
|
|
96
|
+
);
|
|
97
|
+
const drop = up.indexOf('DROP INDEX CONCURRENTLY IF EXISTS "IDX_audit_logs_action_createdAt"');
|
|
98
|
+
expect(drop).toBeGreaterThan(-1);
|
|
99
|
+
expect(up[drop + 1]).toBe(
|
|
100
|
+
'CREATE INDEX CONCURRENTLY IF NOT EXISTS "IDX_audit_logs_action_createdAt" ON "audit_logs" ("action", "createdAt")',
|
|
101
|
+
);
|
|
102
|
+
expect(up.filter((sql) => sql.startsWith("DROP INDEX CONCURRENTLY"))).toHaveLength(3);
|
|
103
|
+
});
|
|
77
104
|
|
|
105
|
+
it("restores the single-column PostgreSQL indexes on revert inside its transaction", async () => {
|
|
106
|
+
const migration = new IndexAuditLogFilters1792000000000();
|
|
78
107
|
const down: string[] = [];
|
|
79
108
|
await migration.down(runner("postgres", down));
|
|
80
109
|
expect(down).toEqual([
|
|
@@ -87,6 +116,19 @@ describe("audit filter index migration", () => {
|
|
|
87
116
|
]);
|
|
88
117
|
});
|
|
89
118
|
|
|
119
|
+
it("keeps plain index statements on SQLite", async () => {
|
|
120
|
+
const up: string[] = [];
|
|
121
|
+
await new IndexAuditLogFilters1792000000000().up(runner("better-sqlite3", up));
|
|
122
|
+
expect(up).toEqual([
|
|
123
|
+
'CREATE INDEX IF NOT EXISTS "IDX_audit_logs_createdAt_id" ON "audit_logs" ("createdAt", "id")',
|
|
124
|
+
'CREATE INDEX IF NOT EXISTS "IDX_audit_logs_action_createdAt" ON "audit_logs" ("action", "createdAt")',
|
|
125
|
+
'CREATE INDEX IF NOT EXISTS "IDX_audit_logs_actorEmail_createdAt" ON "audit_logs" ("actorEmail", "createdAt")',
|
|
126
|
+
'CREATE INDEX IF NOT EXISTS "IDX_audit_logs_targetLabel_createdAt" ON "audit_logs" ("targetLabel", "createdAt")',
|
|
127
|
+
'DROP INDEX IF EXISTS "IDX_audit_logs_action"',
|
|
128
|
+
'DROP INDEX IF EXISTS "IDX_audit_logs_createdAt"',
|
|
129
|
+
]);
|
|
130
|
+
});
|
|
131
|
+
|
|
90
132
|
it("migrates up and down on SQLite", async () => {
|
|
91
133
|
const database = new Database(":memory:");
|
|
92
134
|
const migrations = [
|
|
@@ -2,8 +2,8 @@ import type { MigrationInterface, QueryRunner } from "typeorm";
|
|
|
2
2
|
|
|
3
3
|
// Indexes for the keyset list: ("createdAt", "id") serves the unfiltered pages and
|
|
4
4
|
// the retention prune, and each filter gets its column followed by "createdAt".
|
|
5
|
-
//
|
|
6
|
-
//
|
|
5
|
+
// PostgreSQL builds and drops them concurrently so audit writes continue on a
|
|
6
|
+
// large table. IF NOT EXISTS keeps indexes an operator already built.
|
|
7
7
|
const INDEXES: Array<[name: string, columns: string]> = [
|
|
8
8
|
["IDX_audit_logs_createdAt_id", `"createdAt", "id"`],
|
|
9
9
|
["IDX_audit_logs_action_createdAt", `"action", "createdAt"`],
|
|
@@ -18,14 +18,36 @@ const REPLACED: Array<[name: string, columns: string]> = [
|
|
|
18
18
|
|
|
19
19
|
export class IndexAuditLogFilters1792000000000 implements MigrationInterface {
|
|
20
20
|
name = "IndexAuditLogFilters1792000000000";
|
|
21
|
+
// CONCURRENTLY cannot run inside a transaction block. The runner therefore
|
|
22
|
+
// uses the "each" transaction mode. SQLite migrations always use one.
|
|
23
|
+
transaction = false;
|
|
21
24
|
|
|
22
25
|
async up(queryRunner: QueryRunner): Promise<void> {
|
|
26
|
+
if (queryRunner.connection.options.type !== "postgres") {
|
|
27
|
+
for (const [name, columns] of INDEXES) {
|
|
28
|
+
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "${name}" ON "audit_logs" (${columns})`);
|
|
29
|
+
}
|
|
30
|
+
for (const [name] of REPLACED) await queryRunner.query(`DROP INDEX IF EXISTS "${name}"`);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
23
33
|
for (const [name, columns] of INDEXES) {
|
|
24
|
-
|
|
34
|
+
// An interrupted concurrent build leaves an invalid index that
|
|
35
|
+
// IF NOT EXISTS would keep, so drop it and build again.
|
|
36
|
+
const invalid = await queryRunner.query(
|
|
37
|
+
`SELECT 1 AS "invalid" FROM pg_index WHERE indexrelid = to_regclass($1) AND NOT indisvalid`,
|
|
38
|
+
[`"${name}"`],
|
|
39
|
+
) as unknown[];
|
|
40
|
+
if (invalid.length > 0) await queryRunner.query(`DROP INDEX CONCURRENTLY IF EXISTS "${name}"`);
|
|
41
|
+
await queryRunner.query(
|
|
42
|
+
`CREATE INDEX CONCURRENTLY IF NOT EXISTS "${name}" ON "audit_logs" (${columns})`,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
for (const [name] of REPLACED) {
|
|
46
|
+
await queryRunner.query(`DROP INDEX CONCURRENTLY IF EXISTS "${name}"`);
|
|
25
47
|
}
|
|
26
|
-
for (const [name] of REPLACED) await queryRunner.query(`DROP INDEX IF EXISTS "${name}"`);
|
|
27
48
|
}
|
|
28
49
|
|
|
50
|
+
// A revert runs inside a transaction, so it uses plain statements.
|
|
29
51
|
async down(queryRunner: QueryRunner): Promise<void> {
|
|
30
52
|
for (const [name, columns] of REPLACED) {
|
|
31
53
|
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "${name}" ON "audit_logs" (${columns})`);
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { SQL } from "bun";
|
|
2
|
+
import { describe, expect, it } from "bun:test";
|
|
3
|
+
import { validateEnv } from "../config/env.validation";
|
|
4
|
+
import { refreshForRequest } from "./auth-provider";
|
|
5
|
+
import { configureAuthQueryDatabase } from "./db";
|
|
6
|
+
|
|
7
|
+
describe("refreshForRequest", () => {
|
|
8
|
+
it("stops waiting for a configuration refresh the database does not answer", async () => {
|
|
9
|
+
let queries = 0;
|
|
10
|
+
// Every query waits forever, like one on a paused server.
|
|
11
|
+
const silent = (): Promise<never> => {
|
|
12
|
+
queries += 1;
|
|
13
|
+
return new Promise(() => undefined);
|
|
14
|
+
};
|
|
15
|
+
configureAuthQueryDatabase(silent as unknown as SQL);
|
|
16
|
+
|
|
17
|
+
const waitMs = validateEnv(process.env).READINESS_CHECK_TIMEOUT_MS;
|
|
18
|
+
const started = Date.now();
|
|
19
|
+
await refreshForRequest();
|
|
20
|
+
const waited = Date.now() - started;
|
|
21
|
+
|
|
22
|
+
expect(queries).toBeGreaterThan(0);
|
|
23
|
+
expect(waited).toBeGreaterThanOrEqual(waitMs - 10);
|
|
24
|
+
expect(waited).toBeLessThan(waitMs + 500);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import type { AuthConfig } from "@podosoft/podokit-auth";
|
|
2
|
+
import { validateEnv } from "../config/env.validation";
|
|
2
3
|
import { auth as bootstrapAuth, buildAuth } from "./auth";
|
|
3
4
|
import { authConfigStore } from "./auth-config-store";
|
|
4
5
|
import { publishConfigChanged } from "./config-events";
|
|
6
|
+
import { answersWithin, checkAuthResponse } from "./database-outage";
|
|
7
|
+
import { authDatabaseReachable } from "./db";
|
|
8
|
+
|
|
9
|
+
const REFRESH_WAIT_MS = validateEnv(process.env).READINESS_CHECK_TIMEOUT_MS;
|
|
5
10
|
|
|
6
11
|
// Runtime auth instance that rebuilds when the DB config changes, so admin edits
|
|
7
12
|
// (OAuth credentials, SMTP, server-enforced toggles) apply WITHOUT a restart.
|
|
@@ -52,10 +57,20 @@ export async function refreshAuthNow(): Promise<void> {
|
|
|
52
57
|
await publishConfigChanged();
|
|
53
58
|
}
|
|
54
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Refreshes before a request is served. While PostgreSQL does not answer, the
|
|
62
|
+
* refresh lasts as long as a query may wait, and the request would wait for
|
|
63
|
+
* Better Auth's own queries after it. Past the readiness check timeout the
|
|
64
|
+
* current instance serves the request, and the refresh finishes in the background.
|
|
65
|
+
*/
|
|
66
|
+
export async function refreshForRequest(): Promise<void> {
|
|
67
|
+
await answersWithin(refreshIfStale, REFRESH_WAIT_MS);
|
|
68
|
+
}
|
|
69
|
+
|
|
55
70
|
// Stable handler reference the adapter captures once; redispatches per request.
|
|
56
71
|
const dynamicHandler: typeof bootstrapAuth.handler = async (request) => {
|
|
57
|
-
await
|
|
58
|
-
return current.handler(request);
|
|
72
|
+
await refreshForRequest();
|
|
73
|
+
return checkAuthResponse(await current.handler(request), authDatabaseReachable);
|
|
59
74
|
};
|
|
60
75
|
|
|
61
76
|
export const authRuntime: typeof bootstrapAuth = new Proxy(bootstrapAuth, {
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { AppException } from "@podosoft/podokit-contracts";
|
|
2
2
|
import type { AccessPolicy, RequestIdentity } from "../core/services";
|
|
3
3
|
import type { SettingsService } from "../settings/settings.service";
|
|
4
|
-
import { getAuth,
|
|
5
|
-
import {
|
|
4
|
+
import { getAuth, refreshForRequest } from "./auth-provider";
|
|
5
|
+
import { rethrowAuthError } from "./database-outage";
|
|
6
|
+
import { authDatabaseReachable, closeAuthDatabase } from "./db";
|
|
6
7
|
|
|
7
8
|
export interface AuthUser extends Record<string, unknown> {
|
|
8
9
|
id: string;
|
|
@@ -18,9 +19,12 @@ export interface AuthSession {
|
|
|
18
19
|
export type SessionResolver = (headers: Headers) => Promise<AuthSession | null>;
|
|
19
20
|
|
|
20
21
|
function defaultSessionResolver(headers: Headers): Promise<AuthSession | null> {
|
|
21
|
-
return
|
|
22
|
+
return refreshForRequest()
|
|
22
23
|
.then(() => getAuth().api.getSession({ headers }))
|
|
23
|
-
.then(
|
|
24
|
+
.then(
|
|
25
|
+
(session) => session as AuthSession | null,
|
|
26
|
+
(error: unknown) => rethrowAuthError(error, authDatabaseReachable),
|
|
27
|
+
);
|
|
24
28
|
}
|
|
25
29
|
|
|
26
30
|
export function rolesOf(user: AuthUser): string[] {
|
|
@@ -14,7 +14,7 @@ import { oauthProvider } from "@better-auth/oauth-provider";
|
|
|
14
14
|
import { SIGNUP_APPROVAL_REQUIRED } from "@podosoft/podokit-contracts";
|
|
15
15
|
import { runUserDeletedHandlers } from "./user-delete-handlers";
|
|
16
16
|
import { authBaseUrl, authSecret } from "./auth-environment";
|
|
17
|
-
import {
|
|
17
|
+
import { isMaintenanceCommand } from "./maintenance";
|
|
18
18
|
// podokit:begin:auth-imports
|
|
19
19
|
// podokit:end:auth-imports
|
|
20
20
|
|
|
@@ -233,7 +233,7 @@ export function buildAuth(config: AuthConfig) {
|
|
|
233
233
|
// resolved client IP as x-forwarded-for; trust it so sessions record an IP.
|
|
234
234
|
ipAddressHeaders: ["x-forwarded-for"],
|
|
235
235
|
},
|
|
236
|
-
...(
|
|
236
|
+
...(isMaintenanceCommand() ? { database: { validateSchema: false } } : {}),
|
|
237
237
|
},
|
|
238
238
|
user: {
|
|
239
239
|
additionalFields: {
|
|
@@ -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
|
|
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,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
|
+
});
|