@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
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
3
|
-
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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(
|
|
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:
|
|
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
|
|
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
|
};
|