@podosoft/podokit 1.0.2 → 1.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +74 -6
- package/dist/add.d.ts +14 -1
- package/dist/add.js +42 -5
- package/dist/create.d.ts +4 -0
- package/dist/create.js +9 -0
- package/dist/deploy-compose-profile.d.ts +3 -0
- package/dist/deploy-compose-profile.js +74 -8
- package/dist/deploy-compose-render.d.ts +1 -0
- package/dist/deploy-compose-render.js +46 -1
- package/dist/deploy-compose.js +2 -1
- package/dist/deploy-profile.js +18 -8
- package/dist/deploy.js +157 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +41 -0
- package/dist/lib.d.ts +2 -0
- package/dist/lib.js +8 -2
- package/dist/lockfile.d.ts +6 -1
- package/dist/lockfile.js +22 -4
- package/dist/provider.d.ts +24 -0
- package/dist/provider.js +142 -0
- package/dist/remove.js +6 -3
- package/dist/templates/base/AGENTS.md +9 -3
- package/dist/templates/base/README.md +12 -8
- package/dist/templates/fullstack/AGENTS.md +24 -5
- package/dist/templates/fullstack/README.md +75 -11
- package/dist/templates/fullstack/apps/api/package.json +4 -3
- package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +1 -0
- package/dist/templates/fullstack/apps/api/src/config/providers.ts +9 -0
- package/dist/templates/fullstack/apps/api/src/core/services.ts +7 -2
- package/dist/templates/fullstack/apps/api/src/database/data-source.ts +30 -8
- package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +58 -0
- package/dist/templates/fullstack/apps/api/src/database/database.ts +58 -2
- package/dist/templates/fullstack/apps/api/src/database/migrate.ts +24 -2
- package/dist/templates/fullstack/apps/api/src/database/sqlite-migrator.spec.ts +50 -0
- package/dist/templates/fullstack/apps/api/src/database/sqlite-migrator.ts +134 -0
- package/dist/templates/fullstack/compose.dev.yaml +2 -2
- package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +10 -3
- package/dist/templates/fullstack/dot-env.example +2 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin.module.ts +2 -2
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.spec.ts +10 -4
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.ts +10 -4
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.service.ts +10 -4
- package/dist/templates/modules/admin-dashboard/module.manifest.json +3 -5
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-enabled.ts +2 -5
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.migration.spec.ts +33 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.spec.ts +13 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +17 -8
- package/dist/templates/modules/audit-log/files/apps/api/src/migrations/1720300000000-InitAuditLogs.ts +5 -4
- package/dist/templates/modules/audit-log/module.manifest.json +1 -1
- package/dist/templates/modules/auth/files/apps/api/src/auth/account-issuer-migration.spec.ts +88 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/account-issuer-migration.ts +123 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-config-store.ts +2 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.module.ts +4 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +2 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +4 -4
- package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +55 -10
- package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.ts +10 -14
- package/dist/templates/modules/auth/files/apps/api/src/migrate.ts +24 -6
- package/dist/templates/modules/auth/files/apps/api/src/migrations/1720400000000-InitAppSettings.ts +2 -1
- package/dist/templates/modules/auth/files/apps/api/src/migrations/1720500000000-InitAuthConfig.ts +4 -3
- package/dist/templates/modules/auth/files/apps/api/src/settings/app-settings.migration.spec.ts +15 -4
- package/dist/templates/modules/auth/files/apps/api/src/settings/auth-config.migration.spec.ts +30 -0
- package/dist/templates/modules/auth/module.manifest.json +6 -6
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.spec.ts +10 -0
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.ts +107 -0
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/jobs.module.ts +12 -11
- package/dist/templates/modules/bullmq/files/apps/api/src/main-worker.ts +5 -0
- package/dist/templates/modules/bullmq/module.manifest.json +7 -0
- package/dist/templates/modules/cache-memory/files/apps/api/src/cache-memory/cache-memory.module.spec.ts +16 -0
- package/dist/templates/modules/cache-memory/files/apps/api/src/cache-memory/cache-memory.module.ts +12 -0
- package/dist/templates/modules/cache-memory/module.manifest.json +30 -0
- package/dist/templates/modules/events-memory/files/apps/api/src/events-provider-memory/events-memory.module.ts +14 -0
- package/dist/templates/modules/events-memory/module.manifest.json +30 -0
- package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/events-redis.module.ts +16 -0
- package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/redis-event.bus.ts +75 -0
- package/dist/templates/modules/events-redis/module.manifest.json +32 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.ts +9 -5
- package/dist/templates/modules/file-upload/files/tests/api/files.api.spec.ts +1 -1
- package/dist/templates/modules/file-upload/module.manifest.json +4 -3
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/job-progress.module.ts +36 -14
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.bridge.spec.ts +9 -32
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.processor.ts +16 -11
- package/dist/templates/modules/job-progress/module.manifest.json +7 -8
- package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/jobs-local.module.ts +42 -0
- package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.spec.ts +45 -0
- package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.ts +212 -0
- package/dist/templates/modules/jobs-local/module.manifest.json +34 -0
- package/dist/templates/modules/mailer/files/apps/api/src/mail/mailer.ts +7 -3
- package/dist/templates/modules/mailer/module.manifest.json +2 -4
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.spec.ts +25 -0
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.ts +60 -0
- package/dist/templates/modules/object-storage-local/module.manifest.json +34 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.ts +50 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.module.ts +8 -0
- package/dist/templates/modules/object-storage-s3/files/infra/docker/minio.compose.yml +4 -4
- package/dist/templates/modules/object-storage-s3/files/tests/api/storage.api.spec.ts +20 -12
- package/dist/templates/modules/object-storage-s3/module.manifest.json +4 -2
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.spec.ts +14 -1
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +18 -37
- package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +12 -0
- package/dist/templates/modules/rate-limit/module.manifest.json +8 -6
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.ts +82 -0
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis.module.ts +6 -0
- package/dist/templates/modules/redis/module.manifest.json +2 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/events.module.ts +5 -6
- package/dist/templates/modules/sse/files/apps/api/src/events/events.service.spec.ts +4 -4
- package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +6 -5
- package/dist/templates/modules/sse/module.manifest.json +4 -6
- package/dist/templates/todo/AGENTS.md +25 -6
- package/dist/templates/todo/README.md +72 -14
- package/dist/templates/todo/apps/api/src/migrations/1720100000000-InitTodos.ts +11 -0
- package/dist/templates/todo/apps/api/src/todos/todo.repository.spec.ts +76 -0
- package/dist/templates/todo/apps/api/src/todos/todo.repository.ts +9 -4
- package/dist/templates/todo/tests/api/todo.api.spec.ts +32 -0
- package/dist/templates/todo/tests/ui/todo.ui.spec.ts +31 -0
- package/package.json +2 -1
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.bridge.ts +0 -32
- package/dist/templates/modules/mailer/files/apps/api/src/mail/db.ts +0 -13
- package/dist/templates/modules/sse/files/apps/api/src/events/events.transport.ts +0 -111
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import type { Database as SqliteDatabase } from "bun:sqlite";
|
|
2
|
+
import type { Pool } from "pg";
|
|
3
|
+
|
|
4
|
+
type MigrationRow = Record<string, unknown>;
|
|
5
|
+
|
|
6
|
+
export interface AccountIssuerMigrationClient {
|
|
7
|
+
query<Row extends MigrationRow>(sql: string, values?: readonly unknown[]): Promise<readonly Row[]>;
|
|
8
|
+
release(): void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface AccountIssuerMigrationDatabase {
|
|
12
|
+
connect(): Promise<AccountIssuerMigrationClient>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type AccountIssuerMigrationResult = "absent" | "current" | "migrated";
|
|
16
|
+
|
|
17
|
+
function readCount(rows: readonly MigrationRow[]): number {
|
|
18
|
+
const value = rows[0]?.count;
|
|
19
|
+
if (typeof value === "number" && Number.isSafeInteger(value)) return value;
|
|
20
|
+
if (typeof value === "string" && /^\d+$/.test(value)) return Number(value);
|
|
21
|
+
throw new Error("Cannot read account identity collision count");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function assertNoCollisions(count: number): void {
|
|
25
|
+
if (count > 0) {
|
|
26
|
+
throw new Error("Cannot upgrade Better Auth: duplicate providerId and accountId identities require manual resolution");
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function postgresAccountIssuerMigrationDatabase(pool: Pool): AccountIssuerMigrationDatabase {
|
|
31
|
+
return {
|
|
32
|
+
connect: async (): Promise<AccountIssuerMigrationClient> => {
|
|
33
|
+
const client = await pool.connect();
|
|
34
|
+
return {
|
|
35
|
+
query: async <Row extends MigrationRow>(sql: string, values?: readonly unknown[]): Promise<readonly Row[]> => {
|
|
36
|
+
const result = await client.query<Row>(sql, values === undefined ? undefined : [...values]);
|
|
37
|
+
return result.rows;
|
|
38
|
+
},
|
|
39
|
+
release: (): void => client.release(),
|
|
40
|
+
};
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Relax the obsolete 1.7.0-1.7.2 issuer schema for Better Auth 1.7.3 and later. */
|
|
46
|
+
export async function migrateLegacyAccountIssuers(
|
|
47
|
+
database: AccountIssuerMigrationDatabase,
|
|
48
|
+
): Promise<AccountIssuerMigrationResult> {
|
|
49
|
+
const client = await database.connect();
|
|
50
|
+
let transactionOpen = false;
|
|
51
|
+
try {
|
|
52
|
+
await client.query("BEGIN");
|
|
53
|
+
transactionOpen = true;
|
|
54
|
+
await client.query("SELECT pg_advisory_xact_lock(1886350955, 1)");
|
|
55
|
+
const tableRows = await client.query<{ exists: boolean }>(`
|
|
56
|
+
SELECT to_regclass('"account"') IS NOT NULL AS "exists"
|
|
57
|
+
`);
|
|
58
|
+
if (tableRows[0]?.exists !== true) {
|
|
59
|
+
await client.query("COMMIT");
|
|
60
|
+
transactionOpen = false;
|
|
61
|
+
return "absent";
|
|
62
|
+
}
|
|
63
|
+
await client.query('LOCK TABLE "account" IN SHARE ROW EXCLUSIVE MODE');
|
|
64
|
+
const collisions = await client.query<MigrationRow>(`
|
|
65
|
+
SELECT COUNT(*)::int AS "count" FROM (
|
|
66
|
+
SELECT "providerId", "accountId" FROM "account"
|
|
67
|
+
GROUP BY "providerId", "accountId" HAVING COUNT(*) > 1
|
|
68
|
+
) AS collisions
|
|
69
|
+
`);
|
|
70
|
+
assertNoCollisions(readCount(collisions));
|
|
71
|
+
const columns = await client.query<{ exists: boolean }>(`
|
|
72
|
+
SELECT EXISTS (
|
|
73
|
+
SELECT 1 FROM pg_attribute
|
|
74
|
+
WHERE attrelid = '"account"'::regclass AND attname = 'issuer' AND NOT attisdropped
|
|
75
|
+
) AS "exists"
|
|
76
|
+
`);
|
|
77
|
+
await client.query('DROP TRIGGER IF EXISTS podokit_fill_legacy_account_issuer ON "account"');
|
|
78
|
+
await client.query('DROP INDEX IF EXISTS "account_issuer_accountId_uidx"');
|
|
79
|
+
if (columns[0]?.exists === true) {
|
|
80
|
+
await client.query('ALTER TABLE "account" ALTER COLUMN "issuer" DROP NOT NULL');
|
|
81
|
+
}
|
|
82
|
+
await client.query('CREATE UNIQUE INDEX IF NOT EXISTS "account_providerId_accountId_uidx" ON "account" ("providerId", "accountId")');
|
|
83
|
+
await client.query("COMMIT");
|
|
84
|
+
transactionOpen = false;
|
|
85
|
+
return columns[0]?.exists === true ? "migrated" : "current";
|
|
86
|
+
} catch (error: unknown) {
|
|
87
|
+
if (transactionOpen) await client.query("ROLLBACK");
|
|
88
|
+
throw error;
|
|
89
|
+
} finally {
|
|
90
|
+
client.release();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** SQLite follows the upstream cleanup: remove the unused issuer column. */
|
|
95
|
+
export function migrateSqliteLegacyAccountIssuers(database: SqliteDatabase): AccountIssuerMigrationResult {
|
|
96
|
+
database.run("BEGIN IMMEDIATE");
|
|
97
|
+
try {
|
|
98
|
+
const table = database.query<{ name: string }, []>(
|
|
99
|
+
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'account'",
|
|
100
|
+
).get();
|
|
101
|
+
if (!table) {
|
|
102
|
+
database.run("COMMIT");
|
|
103
|
+
return "absent";
|
|
104
|
+
}
|
|
105
|
+
const collision = database.query<{ count: number }, []>(`
|
|
106
|
+
SELECT COUNT(*) AS count FROM (
|
|
107
|
+
SELECT "providerId", "accountId" FROM "account"
|
|
108
|
+
GROUP BY "providerId", "accountId" HAVING COUNT(*) > 1
|
|
109
|
+
)
|
|
110
|
+
`).get();
|
|
111
|
+
assertNoCollisions(readCount(collision ? [collision] : []));
|
|
112
|
+
const hasIssuer = database.query<{ name: string }, []>('PRAGMA table_info("account")').all()
|
|
113
|
+
.some((column) => column.name === "issuer");
|
|
114
|
+
database.run('DROP INDEX IF EXISTS "account_issuer_accountId_uidx"');
|
|
115
|
+
if (hasIssuer) database.run('ALTER TABLE "account" DROP COLUMN "issuer"');
|
|
116
|
+
database.run('CREATE UNIQUE INDEX IF NOT EXISTS "account_providerId_accountId_uidx" ON "account" ("providerId", "accountId")');
|
|
117
|
+
database.run("COMMIT");
|
|
118
|
+
return hasIssuer ? "migrated" : "current";
|
|
119
|
+
} catch (error: unknown) {
|
|
120
|
+
database.run("ROLLBACK");
|
|
121
|
+
throw error;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createConfigStore } from "@podosoft/podokit-auth";
|
|
2
|
-
import {
|
|
2
|
+
import { readAuthConfig } from "./db";
|
|
3
3
|
|
|
4
4
|
// One process-wide store keeps the auth runtime and capabilities endpoint on the
|
|
5
5
|
// same cache snapshot. Admin writes invalidate this store before rebuilding auth.
|
|
6
|
-
export const authConfigStore = createConfigStore(
|
|
6
|
+
export const authConfigStore = createConfigStore({ list: readAuthConfig });
|
|
@@ -20,6 +20,8 @@ import { getAuth, authRuntime, primeAuth } from "./auth-provider";
|
|
|
20
20
|
import { generateAuthOpenApiDocument } from "./auth.openapi";
|
|
21
21
|
import { ROLE_NAMES } from "./permissions";
|
|
22
22
|
import { AuthService } from "./auth.service";
|
|
23
|
+
import { configureAuthQueryDatabase } from "./db";
|
|
24
|
+
import { setMailConfigStore } from "../mail/mailer";
|
|
23
25
|
|
|
24
26
|
export const AUTH = Symbol("auth") as ServiceKey<AuthService>;
|
|
25
27
|
export const SETTINGS = Symbol("settings") as ServiceKey<SettingsService>;
|
|
@@ -146,6 +148,8 @@ export const authModule: PodokitModule = {
|
|
|
146
148
|
name: "auth",
|
|
147
149
|
configure: (_env, services): void => {
|
|
148
150
|
const database = services.resolve(DATABASE);
|
|
151
|
+
configureAuthQueryDatabase(database.sql);
|
|
152
|
+
setMailConfigStore(authConfigStore);
|
|
149
153
|
const accessPolicy = services.resolve(ACCESS_POLICY);
|
|
150
154
|
const settings = new SettingsService(database.sql);
|
|
151
155
|
const authService = new AuthService(settings, accessPolicy);
|
|
@@ -2,7 +2,7 @@ import { AppException } from "@podosoft/podokit-contracts";
|
|
|
2
2
|
import type { AccessPolicy } from "../core/services";
|
|
3
3
|
import type { SettingsService } from "../settings/settings.service";
|
|
4
4
|
import { getAuth, refreshIfStale } from "./auth-provider";
|
|
5
|
-
import {
|
|
5
|
+
import { closeAuthDatabase } from "./db";
|
|
6
6
|
|
|
7
7
|
export interface AuthUser extends Record<string, unknown> {
|
|
8
8
|
id: string;
|
|
@@ -73,6 +73,6 @@ export class AuthService {
|
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
async close(): Promise<void> {
|
|
76
|
-
await
|
|
76
|
+
await closeAuthDatabase();
|
|
77
77
|
}
|
|
78
78
|
}
|
|
@@ -5,7 +5,7 @@ import { actionEmail, sendMail } from "../mail/mailer";
|
|
|
5
5
|
import { sendSms } from "../sms/sms";
|
|
6
6
|
import { assertUserCreationAllowed, createFeatureGate, createSignupOpenCheck } from "./feature-gate";
|
|
7
7
|
import { orgAc, orgRoles } from "./org-permissions";
|
|
8
|
-
import {
|
|
8
|
+
import { authDatabase, readAppSettings } from "./db";
|
|
9
9
|
import { type AuthConfig, envAuthConfig, sessionIdleOptions, SUPPORTED_PROVIDER_IDS } from "@podosoft/podokit-auth";
|
|
10
10
|
import { apiKey } from "@better-auth/api-key";
|
|
11
11
|
import { passkey } from "@better-auth/passkey";
|
|
@@ -46,7 +46,7 @@ function buildSocial(config: AuthConfig): NonNullable<BetterAuthOptions["socialP
|
|
|
46
46
|
// server-enforced toggles) apply without a restart. Everything below the injection
|
|
47
47
|
// markers is contributed by other PodoKit modules (admin-dashboard, audit-log).
|
|
48
48
|
export function buildAuth(config: AuthConfig) {
|
|
49
|
-
const isSignupOpen = createSignupOpenCheck(
|
|
49
|
+
const isSignupOpen = createSignupOpenCheck(readAppSettings);
|
|
50
50
|
const plugins: BetterAuthPlugin[] = [
|
|
51
51
|
twoFactor(),
|
|
52
52
|
magicLink({
|
|
@@ -176,7 +176,7 @@ export function buildAuth(config: AuthConfig) {
|
|
|
176
176
|
// real server boundary (disabled features 404). Other PodoKit modules add their
|
|
177
177
|
// own hooks below the marker.
|
|
178
178
|
const hooks: NonNullable<BetterAuthOptions["hooks"]> = {
|
|
179
|
-
before: createFeatureGate(
|
|
179
|
+
before: createFeatureGate(readAppSettings, isSignupOpen),
|
|
180
180
|
};
|
|
181
181
|
// podokit:begin:auth-hooks
|
|
182
182
|
// podokit:end:auth-hooks
|
|
@@ -184,7 +184,7 @@ export function buildAuth(config: AuthConfig) {
|
|
|
184
184
|
const session = sessionIdleOptions(config.sessionIdleTimeoutMinutes);
|
|
185
185
|
|
|
186
186
|
return betterAuth({
|
|
187
|
-
database:
|
|
187
|
+
database: authDatabase,
|
|
188
188
|
emailAndPassword: {
|
|
189
189
|
enabled: true,
|
|
190
190
|
// Pending email/password registrations should complete without creating a
|
|
@@ -1,12 +1,57 @@
|
|
|
1
|
+
import { Database as SqliteDatabase } from "bun:sqlite";
|
|
2
|
+
import type { SQL } from "bun";
|
|
1
3
|
import { Pool } from "pg";
|
|
4
|
+
import type { AuthConfigRow } from "@podosoft/podokit-auth";
|
|
5
|
+
import type { DatabaseProviderName } from "@podosoft/podokit-runtime";
|
|
6
|
+
import { validateEnv } from "../config/env.validation";
|
|
7
|
+
import { PROVIDERS } from "../config/providers";
|
|
8
|
+
import { databaseUrl, sqliteDatabasePath } from "../database/database";
|
|
2
9
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
const env = validateEnv(process.env);
|
|
11
|
+
const url = databaseUrl(env);
|
|
12
|
+
function databaseProvider(): DatabaseProviderName {
|
|
13
|
+
return PROVIDERS.database;
|
|
14
|
+
}
|
|
15
|
+
const provider = databaseProvider();
|
|
16
|
+
|
|
17
|
+
export const postgresPool = provider === "postgres"
|
|
18
|
+
? new Pool({ connectionString: url })
|
|
19
|
+
: null;
|
|
20
|
+
|
|
21
|
+
export const sqliteDatabase = provider === "sqlite"
|
|
22
|
+
? new SqliteDatabase(sqliteDatabasePath(url), { create: true, strict: true })
|
|
23
|
+
: null;
|
|
24
|
+
|
|
25
|
+
export const authDatabase = postgresPool ?? sqliteDatabase;
|
|
26
|
+
if (!authDatabase) throw new Error("No authentication database is configured");
|
|
27
|
+
|
|
28
|
+
let queryDatabase: SQL | undefined;
|
|
29
|
+
|
|
30
|
+
export function configureAuthQueryDatabase(sql: SQL): void {
|
|
31
|
+
queryDatabase = sql;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function queries(): SQL {
|
|
35
|
+
if (!queryDatabase) throw new Error("Authentication query database is not configured");
|
|
36
|
+
return queryDatabase;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function readAppSettings(): Promise<Array<{ key: string; value: string }>> {
|
|
40
|
+
const sql = queries();
|
|
41
|
+
return sql<Array<{ key: string; value: string }>>`
|
|
42
|
+
SELECT "key", "value" FROM "app_setting"
|
|
43
|
+
`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function readAuthConfig(): Promise<AuthConfigRow[]> {
|
|
47
|
+
const sql = queries();
|
|
48
|
+
const rows = await sql<Array<Omit<AuthConfigRow, "enabled"> & { enabled: boolean | number }>>`
|
|
49
|
+
SELECT "key", "enabled", "config", "secret", "updatedAt" FROM "auth_config"
|
|
50
|
+
`;
|
|
51
|
+
return rows.map((row) => ({ ...row, enabled: row.enabled === true || row.enabled === 1 }));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function closeAuthDatabase(): Promise<void> {
|
|
55
|
+
if (postgresPool) await postgresPool.end();
|
|
56
|
+
sqliteDatabase?.close();
|
|
57
|
+
}
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { APIError, createAuthMiddleware } from "better-auth/api";
|
|
2
|
-
import type { Pool } from "pg";
|
|
3
2
|
import { FLAG_DEFAULTS, type FeatureFlag } from "../settings/flag-defaults";
|
|
4
3
|
|
|
5
4
|
export const PUBLIC_SIGNUP_DISABLED = "PUBLIC_SIGNUP_DISABLED" as const;
|
|
@@ -22,22 +21,20 @@ const FEATURE_PATHS: Array<{ flag: FeatureFlag; prefixes: string[] }> = [
|
|
|
22
21
|
const CACHE_TTL_MS = 3_000;
|
|
23
22
|
|
|
24
23
|
export type SignupOpenCheck = () => Promise<boolean>;
|
|
24
|
+
export type AppSettingsReader = () => Promise<Array<{ key: string; value: string }>>;
|
|
25
25
|
|
|
26
26
|
/** Public registration is the default until an administrator explicitly closes
|
|
27
27
|
* it. The short cache keeps every auth flow on the same live site-setting
|
|
28
28
|
* policy without rebuilding the Better Auth instance. */
|
|
29
|
-
export function createSignupOpenCheck(
|
|
29
|
+
export function createSignupOpenCheck(readSettings: AppSettingsReader): SignupOpenCheck {
|
|
30
30
|
let signupOpen = true;
|
|
31
31
|
let fetchedAt = 0;
|
|
32
32
|
|
|
33
33
|
return async (): Promise<boolean> => {
|
|
34
34
|
if (Date.now() - fetchedAt < CACHE_TTL_MS) return signupOpen;
|
|
35
35
|
try {
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
["site.allowSignup"],
|
|
39
|
-
);
|
|
40
|
-
signupOpen = res.rows[0]?.value !== "false";
|
|
36
|
+
const rows = await readSettings();
|
|
37
|
+
signupOpen = rows.find((row) => row.key === "site.allowSignup")?.value !== "false";
|
|
41
38
|
} catch {
|
|
42
39
|
signupOpen = true;
|
|
43
40
|
}
|
|
@@ -67,8 +64,8 @@ export function assertUserCreationAllowed(signupOpen: boolean, requestPath?: str
|
|
|
67
64
|
* (a toggle takes effect within ~CACHE_TTL_MS); before the migration has run,
|
|
68
65
|
* the shipped defaults apply. */
|
|
69
66
|
export function createFeatureGate(
|
|
70
|
-
|
|
71
|
-
isSignupOpen: SignupOpenCheck = createSignupOpenCheck(
|
|
67
|
+
readSettings: AppSettingsReader,
|
|
68
|
+
isSignupOpen: SignupOpenCheck = createSignupOpenCheck(readSettings),
|
|
72
69
|
) {
|
|
73
70
|
let cache: Record<FeatureFlag, boolean> = { ...FLAG_DEFAULTS };
|
|
74
71
|
let fetchedAt = 0;
|
|
@@ -76,12 +73,11 @@ export function createFeatureGate(
|
|
|
76
73
|
async function flags(): Promise<Record<FeatureFlag, boolean>> {
|
|
77
74
|
if (Date.now() - fetchedAt < CACHE_TTL_MS) return cache;
|
|
78
75
|
try {
|
|
79
|
-
const
|
|
80
|
-
'SELECT "key", "value" FROM "app_setting" WHERE "key" = ANY($1)',
|
|
81
|
-
[Object.keys(FLAG_DEFAULTS)],
|
|
82
|
-
);
|
|
76
|
+
const rows = await readSettings();
|
|
83
77
|
const next = { ...FLAG_DEFAULTS };
|
|
84
|
-
for (const row of
|
|
78
|
+
for (const row of rows) {
|
|
79
|
+
if (row.key in FLAG_DEFAULTS) next[row.key as FeatureFlag] = row.value === "true";
|
|
80
|
+
}
|
|
85
81
|
cache = next;
|
|
86
82
|
} catch {
|
|
87
83
|
cache = { ...FLAG_DEFAULTS };
|
|
@@ -1,14 +1,33 @@
|
|
|
1
1
|
import { getMigrations } from "better-auth/db/migration";
|
|
2
2
|
import { auth } from "./auth/auth";
|
|
3
|
-
import {
|
|
4
|
-
import
|
|
3
|
+
import { closeAuthDatabase, postgresPool, sqliteDatabase } from "./auth/db";
|
|
4
|
+
import {
|
|
5
|
+
migrateLegacyAccountIssuers,
|
|
6
|
+
migrateSqliteLegacyAccountIssuers,
|
|
7
|
+
postgresAccountIssuerMigrationDatabase,
|
|
8
|
+
} from "./auth/account-issuer-migration";
|
|
9
|
+
import { runSqliteMigrations } from "./database/sqlite-migrator";
|
|
5
10
|
|
|
6
11
|
async function runMigrations(): Promise<void> {
|
|
12
|
+
if (postgresPool) {
|
|
13
|
+
await migrateLegacyAccountIssuers(postgresAccountIssuerMigrationDatabase(postgresPool));
|
|
14
|
+
}
|
|
15
|
+
if (sqliteDatabase) migrateSqliteLegacyAccountIssuers(sqliteDatabase);
|
|
16
|
+
|
|
7
17
|
const authMigrations = await getMigrations(auth.options);
|
|
8
18
|
await authMigrations.runMigrations();
|
|
9
19
|
|
|
10
|
-
|
|
11
|
-
|
|
20
|
+
if (sqliteDatabase) {
|
|
21
|
+
await runSqliteMigrations(sqliteDatabase);
|
|
22
|
+
} else {
|
|
23
|
+
const { appDataSource: dataSource } = await import("./database/data-source.js");
|
|
24
|
+
await dataSource.initialize();
|
|
25
|
+
try {
|
|
26
|
+
await dataSource.runMigrations();
|
|
27
|
+
} finally {
|
|
28
|
+
await dataSource.destroy();
|
|
29
|
+
}
|
|
30
|
+
}
|
|
12
31
|
}
|
|
13
32
|
|
|
14
33
|
runMigrations()
|
|
@@ -17,6 +36,5 @@ runMigrations()
|
|
|
17
36
|
process.exitCode = 1;
|
|
18
37
|
})
|
|
19
38
|
.finally(async (): Promise<void> => {
|
|
20
|
-
|
|
21
|
-
await pool.end();
|
|
39
|
+
await closeAuthDatabase();
|
|
22
40
|
});
|
package/dist/templates/modules/auth/files/apps/api/src/migrations/1720400000000-InitAppSettings.ts
CHANGED
|
@@ -4,11 +4,12 @@ export class InitAppSettings1720400000000 implements MigrationInterface {
|
|
|
4
4
|
name = "InitAppSettings1720400000000";
|
|
5
5
|
|
|
6
6
|
async up(queryRunner: QueryRunner): Promise<void> {
|
|
7
|
+
const sqlite = queryRunner.connection.options.type === "better-sqlite3";
|
|
7
8
|
await queryRunner.query(`
|
|
8
9
|
CREATE TABLE "app_setting" (
|
|
9
10
|
"key" text NOT NULL,
|
|
10
11
|
"value" text NOT NULL,
|
|
11
|
-
"updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
|
12
|
+
"updatedAt" ${sqlite ? "datetime" : "TIMESTAMP WITH TIME ZONE"} NOT NULL DEFAULT ${sqlite ? "CURRENT_TIMESTAMP" : "now()"},
|
|
12
13
|
CONSTRAINT "PK_app_setting_key" PRIMARY KEY ("key")
|
|
13
14
|
)
|
|
14
15
|
`);
|
package/dist/templates/modules/auth/files/apps/api/src/migrations/1720500000000-InitAuthConfig.ts
CHANGED
|
@@ -4,6 +4,7 @@ export class InitAuthConfig1720500000000 implements MigrationInterface {
|
|
|
4
4
|
name = "InitAuthConfig1720500000000";
|
|
5
5
|
|
|
6
6
|
async up(queryRunner: QueryRunner): Promise<void> {
|
|
7
|
+
const sqlite = queryRunner.connection.options.type === "better-sqlite3";
|
|
7
8
|
// Admin-managed auth configuration (OAuth providers, SMTP, server-enforced
|
|
8
9
|
// toggles). Secrets (OAuth client secret, SMTP password) are stored encrypted
|
|
9
10
|
// in "secret" (AES-256-GCM envelope; key derived from BETTER_AUTH_SECRET —
|
|
@@ -12,10 +13,10 @@ export class InitAuthConfig1720500000000 implements MigrationInterface {
|
|
|
12
13
|
await queryRunner.query(`
|
|
13
14
|
CREATE TABLE "auth_config" (
|
|
14
15
|
"key" text NOT NULL,
|
|
15
|
-
"enabled" boolean NOT NULL DEFAULT false,
|
|
16
|
-
"config" jsonb NOT NULL DEFAULT '{}',
|
|
16
|
+
"enabled" ${sqlite ? "integer" : "boolean"} NOT NULL DEFAULT ${sqlite ? "0" : "false"},
|
|
17
|
+
"config" ${sqlite ? "text" : "jsonb"} NOT NULL DEFAULT '{}',
|
|
17
18
|
"secret" text,
|
|
18
|
-
"updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
|
19
|
+
"updatedAt" ${sqlite ? "datetime" : "TIMESTAMP WITH TIME ZONE"} NOT NULL DEFAULT ${sqlite ? "CURRENT_TIMESTAMP" : "now()"},
|
|
19
20
|
CONSTRAINT "PK_auth_config_key" PRIMARY KEY ("key")
|
|
20
21
|
)
|
|
21
22
|
`);
|
package/dist/templates/modules/auth/files/apps/api/src/settings/app-settings.migration.spec.ts
CHANGED
|
@@ -3,16 +3,19 @@ import type { QueryRunner } from "typeorm";
|
|
|
3
3
|
import { InitAppSettings1720400000000 } from "../migrations/1720400000000-InitAppSettings";
|
|
4
4
|
|
|
5
5
|
describe("app setting migration", () => {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
function runner(type: "postgres" | "better-sqlite3", queries: string[]): QueryRunner {
|
|
7
|
+
return {
|
|
8
|
+
connection: { options: { type } },
|
|
9
9
|
query: (sql: string): Promise<unknown[]> => {
|
|
10
10
|
queries.push(sql);
|
|
11
11
|
return Promise.resolve([]);
|
|
12
12
|
},
|
|
13
13
|
} as unknown as QueryRunner;
|
|
14
|
+
}
|
|
14
15
|
|
|
15
|
-
|
|
16
|
+
it("seeds every authentication feature and policy default", async () => {
|
|
17
|
+
const queries: string[] = [];
|
|
18
|
+
await new InitAppSettings1720400000000().up(runner("postgres", queries));
|
|
16
19
|
|
|
17
20
|
const seed = queries.join("\n");
|
|
18
21
|
for (const key of [
|
|
@@ -31,4 +34,12 @@ describe("app setting migration", () => {
|
|
|
31
34
|
expect(seed).toContain(`('${key}',`);
|
|
32
35
|
}
|
|
33
36
|
});
|
|
37
|
+
|
|
38
|
+
it("uses SQLite-compatible timestamp syntax", async () => {
|
|
39
|
+
const queries: string[] = [];
|
|
40
|
+
await new InitAppSettings1720400000000().up(runner("better-sqlite3", queries));
|
|
41
|
+
expect(queries[0]).toContain("datetime");
|
|
42
|
+
expect(queries[0]).toContain("CURRENT_TIMESTAMP");
|
|
43
|
+
expect(queries[0]).not.toContain("TIMESTAMP WITH TIME ZONE");
|
|
44
|
+
});
|
|
34
45
|
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import type { QueryRunner } from "typeorm";
|
|
3
|
+
import { InitAuthConfig1720500000000 } from "../migrations/1720500000000-InitAuthConfig";
|
|
4
|
+
|
|
5
|
+
function runner(type: "postgres" | "better-sqlite3", queries: string[]): QueryRunner {
|
|
6
|
+
return {
|
|
7
|
+
connection: { options: { type } },
|
|
8
|
+
query: (sql: string): Promise<unknown[]> => {
|
|
9
|
+
queries.push(sql);
|
|
10
|
+
return Promise.resolve([]);
|
|
11
|
+
},
|
|
12
|
+
} as unknown as QueryRunner;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe("auth config migration", () => {
|
|
16
|
+
it("retains PostgreSQL boolean and JSONB columns", async () => {
|
|
17
|
+
const queries: string[] = [];
|
|
18
|
+
await new InitAuthConfig1720500000000().up(runner("postgres", queries));
|
|
19
|
+
expect(queries[0]).toContain("boolean NOT NULL DEFAULT false");
|
|
20
|
+
expect(queries[0]).toContain("jsonb NOT NULL");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("uses SQLite integer booleans and JSON text", async () => {
|
|
24
|
+
const queries: string[] = [];
|
|
25
|
+
await new InitAuthConfig1720500000000().up(runner("better-sqlite3", queries));
|
|
26
|
+
expect(queries[0]).toContain("integer NOT NULL DEFAULT 0");
|
|
27
|
+
expect(queries[0]).toContain("text NOT NULL DEFAULT '{}'");
|
|
28
|
+
expect(queries[0]).toContain("DEFAULT CURRENT_TIMESTAMP");
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -7,13 +7,13 @@
|
|
|
7
7
|
".claude/skills/podokit-configure-auth/**"
|
|
8
8
|
],
|
|
9
9
|
"dependencies": {
|
|
10
|
-
"@podosoft/podokit-auth": "^0.
|
|
10
|
+
"@podosoft/podokit-auth": "^0.4.0",
|
|
11
11
|
"@podosoft/podokit-contracts": "^0.4.0",
|
|
12
|
-
"better-auth": "^1.7.
|
|
12
|
+
"better-auth": "^1.7.7",
|
|
13
13
|
"pg": "^8.12.0",
|
|
14
|
-
"@better-auth/api-key": "^1.7.
|
|
15
|
-
"@better-auth/passkey": "^1.7.
|
|
16
|
-
"@better-auth/oauth-provider": "^1.7.
|
|
14
|
+
"@better-auth/api-key": "^1.7.7",
|
|
15
|
+
"@better-auth/passkey": "^1.7.7",
|
|
16
|
+
"@better-auth/oauth-provider": "^1.7.7"
|
|
17
17
|
},
|
|
18
18
|
"devDependencies": {
|
|
19
19
|
"@types/pg": "^8.11.10"
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
"file": "AGENTS.md",
|
|
59
59
|
"marker": "<!-- podokit:end:agents-modules -->",
|
|
60
60
|
"optional": true,
|
|
61
|
-
"text": "### auth (Better Auth + Elysia)\n- The API is secure by default: every application route needs a session except health, OpenAPI, `/api/auth/*`, and routes explicitly registered in `ACCESS_POLICY`.\n- Register a public route during module configuration with `services.resolve(ACCESS_POLICY).register(\"GET\", \"/path\", \"public\")`; use `AUTH` and `AuthService.requireSession()` or `requireAdmin()` when a handler needs the current user.\n- Configure OAuth providers, SMTP, sign-up approval, automatic logout, and server toggles on the admin Settings page. Only `BETTER_AUTH_SECRET` and `BETTER_AUTH_URL` are required environment variables.\n- Use `{{apiRun}} auth:configure{{apiWorkspaceArg}} -- ...` and `.claude/skills/podokit-configure-auth` to apply OAuth/SMTP credentials without committing secrets.\n- Run `bun run --cwd apps/api migrate:all` in development and production so Better Auth and application migrations use the versions installed by the app.\n"
|
|
61
|
+
"text": "### auth (Better Auth + Elysia)\n- The API is secure by default: every application route needs a session except health, OpenAPI, `/api/auth/*`, and routes explicitly registered in `ACCESS_POLICY`.\n- Register a public route during module configuration with `services.resolve(ACCESS_POLICY).register(\"GET\", \"/path\", \"public\")`; use `AUTH` and `AuthService.requireSession()` or `requireAdmin()` when a handler needs the current user.\n- Configure OAuth providers, SMTP, sign-up approval, automatic logout, and server toggles on the admin Settings page. Only `BETTER_AUTH_SECRET` and `BETTER_AUTH_URL` are required environment variables.\n- Use `{{apiRun}} auth:configure{{apiWorkspaceArg}} -- ...` and `.claude/skills/podokit-configure-auth` to apply OAuth/SMTP credentials without committing secrets.\n- Run `bun run --cwd apps/api migrate:all` in development and production so Better Auth and application migrations use the versions installed by the app.\n- Back up existing databases before upgrading authentication. Migrations check duplicate `(providerId, accountId)` identities and clean up obsolete issuer constraints; resolve reported collisions manually rather than guessing account ownership.\n"
|
|
62
62
|
}
|
|
63
63
|
],
|
|
64
64
|
"instructions": [
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { externalJobId, parseExternalJobId } from "./bullmq-job.queue";
|
|
3
|
+
|
|
4
|
+
describe("BullMQ job identifiers", () => {
|
|
5
|
+
it("round-trips queue names without exposing an ambiguous id", () => {
|
|
6
|
+
const id = externalJobId("email/delivery", "42");
|
|
7
|
+
expect(parseExternalJobId(id)).toEqual({ name: "email/delivery", id: "42" });
|
|
8
|
+
expect(parseExternalJobId("invalid")).toBeNull();
|
|
9
|
+
});
|
|
10
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { Queue, Worker } from "bullmq";
|
|
2
|
+
import type {
|
|
3
|
+
EnqueueOptions,
|
|
4
|
+
JobHandler,
|
|
5
|
+
JobQueue,
|
|
6
|
+
JobReference,
|
|
7
|
+
JobSnapshot,
|
|
8
|
+
JobStatus,
|
|
9
|
+
Unsubscribe,
|
|
10
|
+
} from "@podosoft/podokit-runtime";
|
|
11
|
+
import { redisConnection } from "./queue";
|
|
12
|
+
|
|
13
|
+
const ID_SEPARATOR = "~";
|
|
14
|
+
|
|
15
|
+
export function externalJobId(name: string, id: string): string {
|
|
16
|
+
return `${encodeURIComponent(name)}${ID_SEPARATOR}${id}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function parseExternalJobId(id: string): { name: string; id: string } | null {
|
|
20
|
+
const separator = id.indexOf(ID_SEPARATOR);
|
|
21
|
+
if (separator < 1 || separator === id.length - 1) return null;
|
|
22
|
+
try {
|
|
23
|
+
return { name: decodeURIComponent(id.slice(0, separator)), id: id.slice(separator + 1) };
|
|
24
|
+
} catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function statusOf(state: string): JobStatus {
|
|
30
|
+
if (state === "active" || state === "completed" || state === "failed") return state;
|
|
31
|
+
return "waiting";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export class BullMqJobQueue implements JobQueue {
|
|
35
|
+
private readonly queues = new Map<string, Queue>();
|
|
36
|
+
private readonly workers = new Map<string, Worker>();
|
|
37
|
+
|
|
38
|
+
async enqueue(
|
|
39
|
+
name: string,
|
|
40
|
+
payload: unknown,
|
|
41
|
+
options: EnqueueOptions = {},
|
|
42
|
+
): Promise<JobReference> {
|
|
43
|
+
const queue = this.queue(name);
|
|
44
|
+
const job = await queue.add(name, payload, {
|
|
45
|
+
...(options.delayMs === undefined ? {} : { delay: options.delayMs }),
|
|
46
|
+
...(options.attempts === undefined ? {} : { attempts: options.attempts }),
|
|
47
|
+
...(options.deduplicationKey === undefined
|
|
48
|
+
? {}
|
|
49
|
+
: { deduplication: { id: options.deduplicationKey } }),
|
|
50
|
+
});
|
|
51
|
+
if (job.id === undefined) throw new Error("BullMQ did not assign a job id");
|
|
52
|
+
return { id: externalJobId(name, job.id), name };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async get(externalId: string): Promise<JobSnapshot | null> {
|
|
56
|
+
const parsed = parseExternalJobId(externalId);
|
|
57
|
+
if (!parsed) return null;
|
|
58
|
+
const job = await this.queue(parsed.name).getJob(parsed.id);
|
|
59
|
+
if (!job) return null;
|
|
60
|
+
return {
|
|
61
|
+
id: externalId,
|
|
62
|
+
name: parsed.name,
|
|
63
|
+
status: statusOf(await job.getState()),
|
|
64
|
+
attempts: job.attemptsMade,
|
|
65
|
+
createdAt: job.timestamp,
|
|
66
|
+
updatedAt: job.finishedOn ?? job.processedOn ?? job.timestamp,
|
|
67
|
+
...(job.returnvalue === undefined ? {} : { result: job.returnvalue as unknown }),
|
|
68
|
+
...(job.failedReason ? { error: job.failedReason } : {}),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
process(name: string, handler: JobHandler): Promise<Unsubscribe> {
|
|
73
|
+
if (this.workers.has(name)) throw new Error(`A handler is already registered for job ${name}`);
|
|
74
|
+
const worker = new Worker(
|
|
75
|
+
name,
|
|
76
|
+
async (job): Promise<void> => {
|
|
77
|
+
await handler(job.data as unknown, {
|
|
78
|
+
id: externalJobId(name, String(job.id)),
|
|
79
|
+
name,
|
|
80
|
+
});
|
|
81
|
+
},
|
|
82
|
+
{ connection: redisConnection() },
|
|
83
|
+
);
|
|
84
|
+
this.workers.set(name, worker);
|
|
85
|
+
return Promise.resolve(async () => {
|
|
86
|
+
this.workers.delete(name);
|
|
87
|
+
await worker.close();
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async close(): Promise<void> {
|
|
92
|
+
await Promise.all([
|
|
93
|
+
...[...this.workers.values()].map((worker) => worker.close()),
|
|
94
|
+
...[...this.queues.values()].map((queue) => queue.close()),
|
|
95
|
+
]);
|
|
96
|
+
this.workers.clear();
|
|
97
|
+
this.queues.clear();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private queue(name: string): Queue {
|
|
101
|
+
const current = this.queues.get(name);
|
|
102
|
+
if (current) return current;
|
|
103
|
+
const queue = new Queue(name, { connection: redisConnection() });
|
|
104
|
+
this.queues.set(name, queue);
|
|
105
|
+
return queue;
|
|
106
|
+
}
|
|
107
|
+
}
|