@tatara/cli 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/dist/bin.mjs +3001 -0
- package/package.json +32 -3
- package/templates/features/admin/files/src/apps/admin/app.ts +4 -0
- package/templates/features/admin/files/src/apps/admin/server.ts +5 -0
- package/templates/features/admin/files/src/modules/example/routes/admin-greeting.routes.ts +24 -0
- package/templates/features/admin/files/src/modules/example/tests/admin.test.ts +20 -0
- package/templates/features/auth/files/src/core/auth/middleware.ts +29 -0
- package/templates/features/auth/files/src/core/auth/routes/me.routes.ts +36 -0
- package/templates/features/auth/files/src/core/auth/tests/me.test.ts +25 -0
- package/templates/features/auth-orgs/files+drizzle+mysql/src/core/auth/db/organization.schema.ts +38 -0
- package/templates/features/auth-orgs/files+drizzle+postgres/src/core/auth/db/organization.schema.ts +38 -0
- package/templates/features/auth-orgs/files+prisma/prisma/models/organization.prisma +41 -0
- package/templates/features/authz/files/src/core/authz/cache.ts +31 -0
- package/templates/features/authz/files/src/core/authz/catalog.ts +85 -0
- package/templates/features/authz/files/src/core/authz/commands/grant-role.ts +21 -0
- package/templates/features/authz/files/src/core/authz/commands/sync-permissions.ts +11 -0
- package/templates/features/authz/files/src/core/authz/constants.ts +32 -0
- package/templates/features/authz/files/src/core/authz/db/seed.ts +10 -0
- package/templates/features/authz/files/src/core/authz/middleware.ts +21 -0
- package/templates/features/authz/files/src/core/authz/permissions.ts +31 -0
- package/templates/features/authz/files/src/core/authz/resolver.ts +98 -0
- package/templates/features/authz/files/src/core/authz/routes/authorization.routes.ts +281 -0
- package/templates/features/authz/files/src/core/authz/services/audit.ts +24 -0
- package/templates/features/authz/files/src/core/authz/services/grants.ts +29 -0
- package/templates/features/authz/files/src/core/authz/services/members.ts +84 -0
- package/templates/features/authz/files/src/core/authz/services/roles.ts +73 -0
- package/templates/features/authz/files/src/core/authz/services/sync.ts +34 -0
- package/templates/features/authz/files/src/core/authz/system-roles.ts +9 -0
- package/templates/features/authz/files/src/core/authz/tests/authz.test.ts +146 -0
- package/templates/features/authz/files/src/core/authz/tests/error-messages.test.ts +34 -0
- package/templates/features/authz/files/src/core/authz/tests/request-body.test.ts +27 -0
- package/templates/features/authz/files/src/core/authz/types.ts +87 -0
- package/templates/features/authz/files/src/core/authz/validators.ts +99 -0
- package/templates/features/authz/files/tests/helpers/authz.ts +30 -0
- package/templates/features/authz/files+drizzle/src/core/authz/db/store.ts +275 -0
- package/templates/features/authz/files+drizzle+mysql/src/core/authz/db/authz.schema.ts +90 -0
- package/templates/features/authz/files+drizzle+postgres/src/core/authz/db/authz.schema.ts +88 -0
- package/templates/features/authz/files+prisma/prisma/models/authz.prisma +75 -0
- package/templates/features/authz/files+prisma/src/core/authz/db/store.ts +236 -0
- package/templates/features/base/files/AGENTS.md +71 -0
- package/templates/features/base/files/CLAUDE.md +1 -0
- package/templates/features/base/files/Dockerfile +16 -0
- package/templates/features/base/files/README.md +31 -0
- package/templates/features/base/files/_github/workflows/ci.yml +26 -0
- package/templates/features/base/files/biome.template.json +20 -0
- package/templates/features/base/files/dockerignore +8 -0
- package/templates/features/base/files/gitignore +9 -0
- package/templates/features/base/files/src/apps/api/app.ts +3 -0
- package/templates/features/base/files/src/apps/api/server.ts +5 -0
- package/templates/features/base/files/src/apps/cli/index.ts +6 -0
- package/templates/features/base/files/src/core/commands/define.ts +33 -0
- package/templates/features/base/files/src/core/commands/runner.ts +54 -0
- package/templates/features/base/files/src/core/commands/tests/flags.test.ts +16 -0
- package/templates/features/base/files/src/core/config/tests/env.test.ts +40 -0
- package/templates/features/base/files/src/core/http/app.ts +57 -0
- package/templates/features/base/files/src/core/http/context.ts +18 -0
- package/templates/features/base/files/src/core/http/errors.ts +79 -0
- package/templates/features/base/files/src/core/http/health.ts +25 -0
- package/templates/features/base/files/src/core/http/middleware/error-handler.ts +36 -0
- package/templates/features/base/files/src/core/http/middleware/request-context.ts +24 -0
- package/templates/features/base/files/src/core/http/middleware/validation.ts +17 -0
- package/templates/features/base/files/src/core/http/response.ts +72 -0
- package/templates/features/base/files/src/core/http/routes.ts +28 -0
- package/templates/features/base/files/src/core/http/server.ts +15 -0
- package/templates/features/base/files/src/core/logging/logger.ts +17 -0
- package/templates/features/base/files/src/core/process/lifecycle.ts +34 -0
- package/templates/features/base/files/src/modules/example/routes/greeting.routes.ts +27 -0
- package/templates/features/base/files/src/modules/example/services/greeting.service.ts +3 -0
- package/templates/features/base/files/src/modules/example/tests/greeting.test.ts +21 -0
- package/templates/features/base/files/src/modules/example/validators/greeting.validators.ts +9 -0
- package/templates/features/base/files/tests/helpers/api.ts +54 -0
- package/templates/features/base/files/tests/setup.ts +2 -0
- package/templates/features/base/files/tsconfig.json +20 -0
- package/templates/features/base/files/tsdown.config.ts +15 -0
- package/templates/features/base/files/vitest.config.ts +16 -0
- package/templates/features/better-auth/files/src/core/auth/auth.ts +26 -0
- package/templates/features/better-auth/files/src/core/auth/db/seed.ts +24 -0
- package/templates/features/better-auth/files/src/core/auth/delivery.ts +48 -0
- package/templates/features/better-auth/files/src/core/auth/openapi.ts +60 -0
- package/templates/features/better-auth/files/src/core/auth/principal.ts +40 -0
- package/templates/features/better-auth/files/src/core/auth/sms.ts +14 -0
- package/templates/features/better-auth/files/src/core/auth/tests/openapi.test.ts +45 -0
- package/templates/features/better-auth/files/tests/helpers/auth.ts +40 -0
- package/templates/features/better-auth/files+admin/src/core/auth/tests/openapi-admin.test.ts +8 -0
- package/templates/features/better-auth/files+auth-orgs/src/core/auth/tests/principal.test.ts +44 -0
- package/templates/features/better-auth/files+auth-orgs+authz/src/core/auth/tests/principal-authz.test.ts +40 -0
- package/templates/features/better-auth/files+drizzle+mysql/src/core/auth/adapter.ts +15 -0
- package/templates/features/better-auth/files+drizzle+mysql/src/core/auth/db/auth.schema.ts +57 -0
- package/templates/features/better-auth/files+drizzle+postgres/src/core/auth/adapter.ts +15 -0
- package/templates/features/better-auth/files+drizzle+postgres/src/core/auth/db/auth.schema.ts +56 -0
- package/templates/features/better-auth/files+prisma/prisma/models/auth.prisma +62 -0
- package/templates/features/better-auth/files+prisma+mysql/src/core/auth/adapter.ts +4 -0
- package/templates/features/better-auth/files+prisma+postgres/src/core/auth/adapter.ts +4 -0
- package/templates/features/db/files/src/core/db/commands/migrate.ts +12 -0
- package/templates/features/db/files/src/core/db/commands/reset.ts +19 -0
- package/templates/features/db/files/src/core/db/commands/seed.ts +13 -0
- package/templates/features/db/files/src/core/db/id.ts +13 -0
- package/templates/features/db/files/src/core/db/seed.ts +14 -0
- package/templates/features/db/files/src/core/db/tests/reset.test.ts +7 -0
- package/templates/features/db/files/tests/global-setup.ts +27 -0
- package/templates/features/db/files/tests/helpers/database-url.ts +9 -0
- package/templates/features/db/files/tests/helpers/db.ts +7 -0
- package/templates/features/db/files+mysql+drizzle/src/core/db/admin.ts +53 -0
- package/templates/features/db/files+mysql+prisma/src/core/db/admin.ts +78 -0
- package/templates/features/db/files+postgres/src/core/db/admin.ts +50 -0
- package/templates/features/drizzle/files+mysql/drizzle.config.ts +12 -0
- package/templates/features/drizzle/files+mysql/src/core/db/client.ts +20 -0
- package/templates/features/drizzle/files+mysql/src/core/db/migrate.ts +13 -0
- package/templates/features/drizzle/files+postgres/drizzle.config.ts +12 -0
- package/templates/features/drizzle/files+postgres/src/core/db/client.ts +20 -0
- package/templates/features/drizzle/files+postgres/src/core/db/migrate.ts +13 -0
- package/templates/features/email/files/src/core/email/commands/send-test.ts +13 -0
- package/templates/features/email/files/src/core/email/send.ts +22 -0
- package/templates/features/email/files/src/core/email/tests/email.test.ts +8 -0
- package/templates/features/i18n/files/src/core/i18n/index.ts +41 -0
- package/templates/features/i18n/files/src/core/i18n/tests/i18n.test.ts +21 -0
- package/templates/features/i18n/files/src/locales/ar.json +10 -0
- package/templates/features/i18n/files/src/locales/en.json +10 -0
- package/templates/features/jobs/files/src/apps/cron/index.ts +25 -0
- package/templates/features/jobs/files/src/apps/worker/index.ts +24 -0
- package/templates/features/jobs/files/src/core/jobs/commands/enqueue.ts +13 -0
- package/templates/features/jobs/files/src/core/jobs/define.ts +37 -0
- package/templates/features/jobs/files/src/core/jobs/lock.ts +17 -0
- package/templates/features/jobs/files/src/core/jobs/process.ts +19 -0
- package/templates/features/jobs/files/src/core/jobs/queue.ts +29 -0
- package/templates/features/jobs/files/src/core/jobs/redis.ts +17 -0
- package/templates/features/jobs/files/src/modules/example/consumers/send-welcome.ts +11 -0
- package/templates/features/jobs/files/src/modules/example/schedules/heartbeat.ts +9 -0
- package/templates/features/jobs/files/src/modules/example/tests/jobs.test.ts +29 -0
- package/templates/features/keycloak/files/keycloak/realm.json +83 -0
- package/templates/features/keycloak/files/src/core/auth/commands/keycloak-token.ts +32 -0
- package/templates/features/keycloak/files/src/core/auth/keycloak.ts +58 -0
- package/templates/features/keycloak/files/src/core/auth/principal.ts +27 -0
- package/templates/features/keycloak/files/tests/helpers/auth.ts +52 -0
- package/templates/features/keycloak/files+drizzle/src/core/auth/users.ts +18 -0
- package/templates/features/keycloak/files+drizzle+mysql/src/core/auth/db/user.schema.ts +10 -0
- package/templates/features/keycloak/files+drizzle+postgres/src/core/auth/db/user.schema.ts +10 -0
- package/templates/features/keycloak/files+prisma/prisma/models/user.prisma +10 -0
- package/templates/features/keycloak/files+prisma/src/core/auth/users.ts +13 -0
- package/templates/features/prisma/files/prisma/models/.gitkeep +0 -0
- package/templates/features/prisma/files/prisma.config.ts +10 -0
- package/templates/features/prisma/files/src/core/db/migrate.ts +13 -0
- package/templates/features/prisma/files+mysql/prisma/schema.prisma +11 -0
- package/templates/features/prisma/files+mysql/src/core/db/client.ts +23 -0
- package/templates/features/prisma/files+postgres/prisma/schema.prisma +11 -0
- package/templates/features/prisma/files+postgres/src/core/db/client.ts +19 -0
- package/templates/features/storage/files/src/core/storage/commands/check.ts +18 -0
- package/templates/features/storage/files/src/core/storage/index.ts +8 -0
- package/templates/features/storage/files/src/core/storage/local.ts +34 -0
- package/templates/features/storage/files/src/core/storage/s3.ts +66 -0
- package/templates/features/storage/files/src/core/storage/storage.ts +21 -0
- package/templates/features/storage/files/src/core/storage/tests/storage.test.ts +22 -0
- package/templates/generators/command/files/src/modules/__moduleKebab__/commands/__nameKebab__.ts +11 -0
- package/templates/generators/consumer/files/src/modules/__moduleKebab__/consumers/__nameKebab__.ts +14 -0
- package/templates/generators/consumer/files/src/modules/__moduleKebab__/tests/__nameKebab__.consumer.test.ts +12 -0
- package/templates/generators/module/files/src/modules/__moduleKebab__/routes/__moduleKebab__.routes.ts +20 -0
- package/templates/generators/module/files/src/modules/__moduleKebab__/services/__moduleKebab__.service.ts +4 -0
- package/templates/generators/module/files/src/modules/__moduleKebab__/tests/__moduleKebab__.test.ts +10 -0
- package/templates/generators/module/files/src/modules/__moduleKebab__/validators/__moduleKebab__.validators.ts +5 -0
- package/templates/generators/permission/files/src/modules/__moduleKebab__/permissions/__nameKebab__.permissions.ts +18 -0
- package/templates/generators/resource/files/src/modules/__moduleKebab__/routes/__nameKebab__.routes.ts +127 -0
- package/templates/generators/resource/files/src/modules/__moduleKebab__/tests/__nameKebab__.routes.test.ts +59 -0
- package/templates/generators/resource/files/src/modules/__moduleKebab__/validators/__nameKebab__.validators.ts +30 -0
- package/templates/generators/resource/files+authz/src/modules/__moduleKebab__/permissions/__nameKebab__.permissions.ts +21 -0
- package/templates/generators/resource/files+drizzle/src/modules/__moduleKebab__/services/__nameKebab__.service.ts +78 -0
- package/templates/generators/resource/files+drizzle+mysql/src/modules/__moduleKebab__/db/__nameKebab__.schema.ts +9 -0
- package/templates/generators/resource/files+drizzle+postgres/src/modules/__moduleKebab__/db/__nameKebab__.schema.ts +9 -0
- package/templates/generators/resource/files+prisma/prisma/models/__nameKebab__.prisma +9 -0
- package/templates/generators/resource/files+prisma/src/modules/__moduleKebab__/services/__nameKebab__.service.ts +73 -0
- package/templates/generators/route/files/src/modules/__moduleKebab__/routes/__nameKebab__.routes.ts +29 -0
- package/templates/generators/route/files/src/modules/__moduleKebab__/validators/__nameKebab__.validators.ts +9 -0
- package/templates/generators/route/files+authz/src/modules/__moduleKebab__/permissions/__nameKebab__.permissions.ts +13 -0
- package/templates/generators/schedule/files/src/modules/__moduleKebab__/schedules/__nameKebab__.ts +11 -0
- package/templates/generators/schema/files+drizzle+mysql/src/modules/__moduleKebab__/db/__nameKebab__.schema.ts +9 -0
- package/templates/generators/schema/files+drizzle+postgres/src/modules/__moduleKebab__/db/__nameKebab__.schema.ts +9 -0
- package/templates/generators/schema/files+prisma/prisma/models/__nameKebab__.prisma +9 -0
- package/templates/generators/service/files/src/modules/__moduleKebab__/services/__nameKebab__.service.ts +4 -0
- package/templates/generators/service/files/src/modules/__moduleKebab__/tests/__nameKebab__.service.test.ts +8 -0
- package/templates/versions.json +34 -0
- package/README.md +0 -3
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { defineCommand } from "#core/commands/define";
|
|
2
|
+
import { env } from "#core/config/env";
|
|
3
|
+
import { migrateDatabase } from "#core/db/migrate";
|
|
4
|
+
|
|
5
|
+
export default defineCommand({
|
|
6
|
+
name: "db:migrate",
|
|
7
|
+
description: "Apply pending database migrations",
|
|
8
|
+
async run(_args, { logger }) {
|
|
9
|
+
await migrateDatabase(env.DATABASE_URL);
|
|
10
|
+
logger.info("migrations applied");
|
|
11
|
+
},
|
|
12
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { defineCommand, flag } from "#core/commands/define";
|
|
3
|
+
import { env } from "#core/config/env";
|
|
4
|
+
import { dropAllTables } from "#core/db/admin";
|
|
5
|
+
import { migrateDatabase } from "#core/db/migrate";
|
|
6
|
+
|
|
7
|
+
export default defineCommand({
|
|
8
|
+
name: "db:reset",
|
|
9
|
+
description: "Drop every table and re-apply migrations (refuses to run in production)",
|
|
10
|
+
args: z.object({ force: flag() }),
|
|
11
|
+
async run({ force }, { logger }) {
|
|
12
|
+
if (env.NODE_ENV === "production" && !force) {
|
|
13
|
+
throw new Error("db:reset refuses to run with NODE_ENV=production (pass --force to override)");
|
|
14
|
+
}
|
|
15
|
+
await dropAllTables(env.DATABASE_URL);
|
|
16
|
+
await migrateDatabase(env.DATABASE_URL);
|
|
17
|
+
logger.info("database reset");
|
|
18
|
+
},
|
|
19
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { defineCommand } from "#core/commands/define";
|
|
2
|
+
import { seeds } from "#generated/seeds";
|
|
3
|
+
|
|
4
|
+
export default defineCommand({
|
|
5
|
+
name: "db:seed",
|
|
6
|
+
description: "Run every idempotent seed (core first, then modules)",
|
|
7
|
+
async run(_args, { logger }) {
|
|
8
|
+
for (const seed of [...seeds].sort((a, b) => (a.order ?? 100) - (b.order ?? 100))) {
|
|
9
|
+
await seed.run({ logger: logger.child({ seed: seed.name }) });
|
|
10
|
+
logger.info({ seed: seed.name }, "seeded");
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { randomFillSync } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
/** UUIDv7: random but time-ordered, so sorting by id is sorting by creation time. */
|
|
4
|
+
export function newId(): string {
|
|
5
|
+
const bytes = new Uint8Array(16);
|
|
6
|
+
randomFillSync(bytes);
|
|
7
|
+
const ms = BigInt(Date.now());
|
|
8
|
+
for (let i = 0; i < 6; i++) bytes[i] = Number((ms >> BigInt(8 * (5 - i))) & 0xffn);
|
|
9
|
+
bytes[6] = ((bytes[6] ?? 0) & 0x0f) | 0x70;
|
|
10
|
+
bytes[8] = ((bytes[8] ?? 0) & 0x3f) | 0x80;
|
|
11
|
+
const hex = [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
12
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
13
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Logger } from "#core/logging/logger";
|
|
2
|
+
|
|
3
|
+
export interface SeedDefinition {
|
|
4
|
+
name: string;
|
|
5
|
+
/** lower runs first */
|
|
6
|
+
order?: number;
|
|
7
|
+
/** must be idempotent: seeds run on every `tatara dev` */
|
|
8
|
+
run(ctx: { logger: Logger }): Promise<void>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Default export of every `db/seed.ts`; discovered by `tatara sync`. */
|
|
12
|
+
export function defineSeed(definition: SeedDefinition): SeedDefinition {
|
|
13
|
+
return definition;
|
|
14
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { expect, it } from "vitest";
|
|
2
|
+
import { parseFlags } from "#core/commands/runner";
|
|
3
|
+
import reset from "#core/db/commands/reset";
|
|
4
|
+
|
|
5
|
+
it("does not treat --force false as forcing db:reset", () => {
|
|
6
|
+
expect(reset.args?.parse(parseFlags(["--force", "false"]))).toEqual({ force: false });
|
|
7
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { parseEnv } from "node:util";
|
|
3
|
+
import { ensureDatabase } from "#core/db/admin";
|
|
4
|
+
import { migrateDatabase } from "#core/db/migrate";
|
|
5
|
+
import { TEST_WORKERS, workerDatabaseUrl } from "#tests/helpers/database-url";
|
|
6
|
+
|
|
7
|
+
/** Creates and migrates one database per test worker, once per run. */
|
|
8
|
+
export default async function setup() {
|
|
9
|
+
const fileEnv = existsSync(".env.test")
|
|
10
|
+
? (parseEnv(readFileSync(".env.test", "utf8")) as Record<string, string>)
|
|
11
|
+
: {};
|
|
12
|
+
const base = process.env.DATABASE_URL ?? fileEnv.DATABASE_URL;
|
|
13
|
+
if (!base) throw new Error("DATABASE_URL is not set for tests (.env.test)");
|
|
14
|
+
try {
|
|
15
|
+
await Promise.all(
|
|
16
|
+
Array.from({ length: TEST_WORKERS }, async (_, i) => {
|
|
17
|
+
const url = workerDatabaseUrl(base, i + 1);
|
|
18
|
+
await ensureDatabase(url);
|
|
19
|
+
await migrateDatabase(url);
|
|
20
|
+
}),
|
|
21
|
+
);
|
|
22
|
+
} catch (error) {
|
|
23
|
+
throw new Error(
|
|
24
|
+
`Could not prepare the test databases. Is the database running? (\`docker compose up -d --wait\`)\n${String(error)}`,
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Number of Vitest workers; global setup prepares one database per worker. */
|
|
2
|
+
export const TEST_WORKERS = 4;
|
|
3
|
+
|
|
4
|
+
/** postgres://…/app_test → postgres://…/app_test_<worker> */
|
|
5
|
+
export function workerDatabaseUrl(baseUrl: string, worker: number | string): string {
|
|
6
|
+
const url = new URL(baseUrl);
|
|
7
|
+
url.pathname = `${url.pathname}_${worker}`;
|
|
8
|
+
return url.toString();
|
|
9
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { env } from "#core/config/env";
|
|
2
|
+
import { truncateAllTables } from "#core/db/admin";
|
|
3
|
+
|
|
4
|
+
/** Empties every application table in this worker's test database. Use in beforeEach. */
|
|
5
|
+
export async function resetDatabase(): Promise<void> {
|
|
6
|
+
await truncateAllTables(env.DATABASE_URL);
|
|
7
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import mysql from "mysql2/promise";
|
|
2
|
+
|
|
3
|
+
const MIGRATION_TABLES = ["__drizzle_migrations", "_prisma_migrations"];
|
|
4
|
+
|
|
5
|
+
function databaseName(url: string): string {
|
|
6
|
+
return decodeURIComponent(new URL(url).pathname.slice(1));
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
async function withConnection<T>(url: string, fn: (c: mysql.Connection) => Promise<T>): Promise<T> {
|
|
10
|
+
const connection = await mysql.createConnection({ uri: url, multipleStatements: true });
|
|
11
|
+
try {
|
|
12
|
+
return await fn(connection);
|
|
13
|
+
} finally {
|
|
14
|
+
await connection.end();
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Creates the database named in `url` if it does not exist. */
|
|
19
|
+
export async function ensureDatabase(url: string): Promise<void> {
|
|
20
|
+
const name = databaseName(url);
|
|
21
|
+
const server = new URL(url);
|
|
22
|
+
server.pathname = "/";
|
|
23
|
+
await withConnection(server.toString(), async (c) => {
|
|
24
|
+
await c.query(`create database if not exists \`${name.replaceAll("`", "``")}\``);
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function tables(c: mysql.Connection): Promise<string[]> {
|
|
29
|
+
const [rows] = await c.query<mysql.RowDataPacket[]>(
|
|
30
|
+
"select table_name as name from information_schema.tables where table_schema = database() and table_type = 'BASE TABLE'",
|
|
31
|
+
);
|
|
32
|
+
return rows.map((r) => String(r.name));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function dropAllTables(url: string): Promise<void> {
|
|
36
|
+
await withConnection(url, async (c) => {
|
|
37
|
+
const names = await tables(c);
|
|
38
|
+
if (names.length === 0) return;
|
|
39
|
+
await c.query(
|
|
40
|
+
`set foreign_key_checks = 0; drop table ${names.map((t) => `\`${t}\``).join(", ")}; set foreign_key_checks = 1;`,
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function truncateAllTables(url: string): Promise<void> {
|
|
46
|
+
await withConnection(url, async (c) => {
|
|
47
|
+
const names = (await tables(c)).filter((t) => !MIGRATION_TABLES.includes(t));
|
|
48
|
+
if (names.length === 0) return;
|
|
49
|
+
await c.query(
|
|
50
|
+
`set foreign_key_checks = 0; ${names.map((t) => `truncate table \`${t}\`;`).join(" ")} set foreign_key_checks = 1;`,
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import * as mariadb from "mariadb";
|
|
2
|
+
|
|
3
|
+
const MIGRATION_TABLES = ["__drizzle_migrations", "_prisma_migrations"];
|
|
4
|
+
|
|
5
|
+
const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* mariadb connection options from a mysql:// URL. Public-key retrieval (needed by MySQL's
|
|
9
|
+
* caching_sha2_password without TLS) is only enabled for local hosts or when the URL asks
|
|
10
|
+
* for it; use `?ssl=true` for remote databases.
|
|
11
|
+
*/
|
|
12
|
+
export function connectionConfig(url: string): mariadb.ConnectionConfig {
|
|
13
|
+
const u = new URL(url);
|
|
14
|
+
const param = (name: string) => u.searchParams.get(name);
|
|
15
|
+
return {
|
|
16
|
+
host: u.hostname,
|
|
17
|
+
port: Number(u.port || 3306),
|
|
18
|
+
user: decodeURIComponent(u.username),
|
|
19
|
+
password: decodeURIComponent(u.password),
|
|
20
|
+
database: decodeURIComponent(u.pathname.slice(1)) || undefined,
|
|
21
|
+
allowPublicKeyRetrieval:
|
|
22
|
+
param("allowPublicKeyRetrieval") === "true" || LOCAL_HOSTS.has(u.hostname),
|
|
23
|
+
...(param("ssl") === "true" ? { ssl: { rejectUnauthorized: true } } : {}),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const adminConfig = (url: string): mariadb.ConnectionConfig => ({
|
|
28
|
+
...connectionConfig(url),
|
|
29
|
+
multipleStatements: true,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
async function withConnection<T>(
|
|
33
|
+
config: mariadb.ConnectionConfig,
|
|
34
|
+
fn: (c: mariadb.Connection) => Promise<T>,
|
|
35
|
+
): Promise<T> {
|
|
36
|
+
const connection = await mariadb.createConnection(config);
|
|
37
|
+
try {
|
|
38
|
+
return await fn(connection);
|
|
39
|
+
} finally {
|
|
40
|
+
await connection.end();
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Creates the database named in `url` if it does not exist. */
|
|
45
|
+
export async function ensureDatabase(url: string): Promise<void> {
|
|
46
|
+
const config = adminConfig(url);
|
|
47
|
+
const name = String(config.database);
|
|
48
|
+
await withConnection({ ...config, database: undefined }, async (c) => {
|
|
49
|
+
await c.query(`create database if not exists \`${name.replaceAll("`", "``")}\``);
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function tables(c: mariadb.Connection): Promise<string[]> {
|
|
54
|
+
const rows: { name: string }[] = await c.query(
|
|
55
|
+
"select table_name as name from information_schema.tables where table_schema = database() and table_type = 'BASE TABLE'",
|
|
56
|
+
);
|
|
57
|
+
return rows.map((r) => String(r.name));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function dropAllTables(url: string): Promise<void> {
|
|
61
|
+
await withConnection(adminConfig(url), async (c) => {
|
|
62
|
+
const names = await tables(c);
|
|
63
|
+
if (names.length === 0) return;
|
|
64
|
+
await c.query(
|
|
65
|
+
`set foreign_key_checks = 0; drop table ${names.map((t) => `\`${t}\``).join(", ")}; set foreign_key_checks = 1;`,
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function truncateAllTables(url: string): Promise<void> {
|
|
71
|
+
await withConnection(adminConfig(url), async (c) => {
|
|
72
|
+
const names = (await tables(c)).filter((t) => !MIGRATION_TABLES.includes(t));
|
|
73
|
+
if (names.length === 0) return;
|
|
74
|
+
await c.query(
|
|
75
|
+
`set foreign_key_checks = 0; ${names.map((t) => `truncate table \`${t}\`;`).join(" ")} set foreign_key_checks = 1;`,
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import pg from "pg";
|
|
2
|
+
|
|
3
|
+
const MIGRATION_TABLES = ["__drizzle_migrations", "_prisma_migrations"];
|
|
4
|
+
|
|
5
|
+
function databaseName(url: string): string {
|
|
6
|
+
return decodeURIComponent(new URL(url).pathname.slice(1));
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
async function withClient<T>(url: string, fn: (client: pg.Client) => Promise<T>): Promise<T> {
|
|
10
|
+
const client = new pg.Client({ connectionString: url });
|
|
11
|
+
await client.connect();
|
|
12
|
+
try {
|
|
13
|
+
return await fn(client);
|
|
14
|
+
} finally {
|
|
15
|
+
await client.end();
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Creates the database named in `url` if it does not exist. */
|
|
20
|
+
export async function ensureDatabase(url: string): Promise<void> {
|
|
21
|
+
const name = databaseName(url);
|
|
22
|
+
const admin = new URL(url);
|
|
23
|
+
admin.pathname = "/postgres";
|
|
24
|
+
await withClient(admin.toString(), async (client) => {
|
|
25
|
+
const found = await client.query("select 1 from pg_database where datname = $1", [name]);
|
|
26
|
+
if (found.rowCount === 0) await client.query(`create database "${name.replaceAll('"', '""')}"`);
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function dropAllTables(url: string): Promise<void> {
|
|
31
|
+
await withClient(url, async (client) => {
|
|
32
|
+
await client.query("drop schema if exists drizzle cascade");
|
|
33
|
+
await client.query("drop schema public cascade");
|
|
34
|
+
await client.query("create schema public");
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function truncateAllTables(url: string): Promise<void> {
|
|
39
|
+
await withClient(url, async (client) => {
|
|
40
|
+
const { rows } = await client.query<{ tablename: string }>(
|
|
41
|
+
"select tablename from pg_tables where schemaname = 'public'",
|
|
42
|
+
);
|
|
43
|
+
const tables = rows.map((r) => r.tablename).filter((t) => !MIGRATION_TABLES.includes(t));
|
|
44
|
+
if (tables.length > 0) {
|
|
45
|
+
await client.query(
|
|
46
|
+
`truncate table ${tables.map((t) => `"${t}"`).join(", ")} restart identity cascade`,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { defineConfig } from "drizzle-kit";
|
|
3
|
+
|
|
4
|
+
if (existsSync(".env")) process.loadEnvFile(".env");
|
|
5
|
+
|
|
6
|
+
export default defineConfig({
|
|
7
|
+
dialect: "mysql",
|
|
8
|
+
schema: ["./src/core/*/db/*.schema.ts", "./src/modules/*/db/*.schema.ts"],
|
|
9
|
+
out: "./drizzle",
|
|
10
|
+
casing: "snake_case",
|
|
11
|
+
dbCredentials: { url: process.env.DATABASE_URL ?? "" },
|
|
12
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { drizzle } from "drizzle-orm/mysql2";
|
|
2
|
+
import mysql from "mysql2/promise";
|
|
3
|
+
import { env } from "#core/config/env";
|
|
4
|
+
import { registerReadinessCheck } from "#core/http/health";
|
|
5
|
+
import { onShutdown } from "#core/process/lifecycle";
|
|
6
|
+
import * as schema from "#generated/schema";
|
|
7
|
+
|
|
8
|
+
export const pool = mysql.createPool({ uri: env.DATABASE_URL, connectionLimit: env.DATABASE_POOL_MAX });
|
|
9
|
+
|
|
10
|
+
export const db = drizzle({ client: pool, schema, mode: "default", casing: "snake_case" });
|
|
11
|
+
|
|
12
|
+
export type Database = typeof db;
|
|
13
|
+
export type Transaction = Parameters<Parameters<Database["transaction"]>[0]>[0];
|
|
14
|
+
/** `db` or an open transaction; services take an Executor so callers can compose them. */
|
|
15
|
+
export type Executor = Database | Transaction;
|
|
16
|
+
|
|
17
|
+
registerReadinessCheck("database", async () => {
|
|
18
|
+
await pool.query("select 1");
|
|
19
|
+
});
|
|
20
|
+
onShutdown("database", () => pool.end());
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { drizzle } from "drizzle-orm/mysql2";
|
|
2
|
+
import { migrate } from "drizzle-orm/mysql2/migrator";
|
|
3
|
+
import mysql from "mysql2/promise";
|
|
4
|
+
|
|
5
|
+
/** Applies ./drizzle migrations to the database at `url`. */
|
|
6
|
+
export async function migrateDatabase(url: string): Promise<void> {
|
|
7
|
+
const connection = await mysql.createConnection({ uri: url });
|
|
8
|
+
try {
|
|
9
|
+
await migrate(drizzle({ client: connection }), { migrationsFolder: "drizzle" });
|
|
10
|
+
} finally {
|
|
11
|
+
await connection.end();
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { defineConfig } from "drizzle-kit";
|
|
3
|
+
|
|
4
|
+
if (existsSync(".env")) process.loadEnvFile(".env");
|
|
5
|
+
|
|
6
|
+
export default defineConfig({
|
|
7
|
+
dialect: "postgresql",
|
|
8
|
+
schema: ["./src/core/*/db/*.schema.ts", "./src/modules/*/db/*.schema.ts"],
|
|
9
|
+
out: "./drizzle",
|
|
10
|
+
casing: "snake_case",
|
|
11
|
+
dbCredentials: { url: process.env.DATABASE_URL ?? "" },
|
|
12
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { drizzle } from "drizzle-orm/node-postgres";
|
|
2
|
+
import pg from "pg";
|
|
3
|
+
import { env } from "#core/config/env";
|
|
4
|
+
import { registerReadinessCheck } from "#core/http/health";
|
|
5
|
+
import { onShutdown } from "#core/process/lifecycle";
|
|
6
|
+
import * as schema from "#generated/schema";
|
|
7
|
+
|
|
8
|
+
export const pool = new pg.Pool({ connectionString: env.DATABASE_URL, max: env.DATABASE_POOL_MAX });
|
|
9
|
+
|
|
10
|
+
export const db = drizzle({ client: pool, schema, casing: "snake_case" });
|
|
11
|
+
|
|
12
|
+
export type Database = typeof db;
|
|
13
|
+
export type Transaction = Parameters<Parameters<Database["transaction"]>[0]>[0];
|
|
14
|
+
/** `db` or an open transaction; services take an Executor so callers can compose them. */
|
|
15
|
+
export type Executor = Database | Transaction;
|
|
16
|
+
|
|
17
|
+
registerReadinessCheck("database", async () => {
|
|
18
|
+
await pool.query("select 1");
|
|
19
|
+
});
|
|
20
|
+
onShutdown("database", () => pool.end());
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { drizzle } from "drizzle-orm/node-postgres";
|
|
2
|
+
import { migrate } from "drizzle-orm/node-postgres/migrator";
|
|
3
|
+
import pg from "pg";
|
|
4
|
+
|
|
5
|
+
/** Applies ./drizzle migrations to the database at `url`. */
|
|
6
|
+
export async function migrateDatabase(url: string): Promise<void> {
|
|
7
|
+
const pool = new pg.Pool({ connectionString: url, max: 1 });
|
|
8
|
+
try {
|
|
9
|
+
await migrate(drizzle({ client: pool }), { migrationsFolder: "drizzle" });
|
|
10
|
+
} finally {
|
|
11
|
+
await pool.end();
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { defineCommand } from "#core/commands/define";
|
|
3
|
+
import { sendEmail } from "#core/email/send";
|
|
4
|
+
|
|
5
|
+
export default defineCommand({
|
|
6
|
+
name: "email:send-test",
|
|
7
|
+
description: "Send a test email (see it in Mailpit locally)",
|
|
8
|
+
args: z.object({ to: z.email().default("dev@example.com") }),
|
|
9
|
+
async run({ to }, { logger }) {
|
|
10
|
+
await sendEmail({ to, subject: "Test email", text: "It works." });
|
|
11
|
+
logger.info({ to }, "test email sent");
|
|
12
|
+
},
|
|
13
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { createTransport, type Transporter } from "nodemailer";
|
|
2
|
+
import { env } from "#core/config/env";
|
|
3
|
+
import { logger } from "#core/logging/logger";
|
|
4
|
+
|
|
5
|
+
export interface EmailMessage {
|
|
6
|
+
to: string;
|
|
7
|
+
subject: string;
|
|
8
|
+
text: string;
|
|
9
|
+
html?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
let transport: Transporter | undefined;
|
|
13
|
+
|
|
14
|
+
/** Sends through SMTP (Mailpit locally) or logs it when EMAIL_DRIVER=console. */
|
|
15
|
+
export async function sendEmail(message: EmailMessage): Promise<void> {
|
|
16
|
+
if (env.EMAIL_DRIVER === "console") {
|
|
17
|
+
logger.info({ to: message.to, subject: message.subject }, "email (console driver)");
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
transport ??= createTransport(env.SMTP_URL);
|
|
21
|
+
await transport.sendMail({ from: env.EMAIL_FROM, ...message });
|
|
22
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { sendEmail } from "#core/email/send";
|
|
3
|
+
|
|
4
|
+
describe("email", () => {
|
|
5
|
+
it("accepts a message with the test (console) driver", async () => {
|
|
6
|
+
await expect(sendEmail({ to: "a@example.com", subject: "Hi", text: "Hello" })).resolves.toBeUndefined();
|
|
7
|
+
});
|
|
8
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { createMiddleware } from "hono/factory";
|
|
2
|
+
import { env } from "#core/config/env";
|
|
3
|
+
import type { AppEnv } from "#core/http/context";
|
|
4
|
+
import ar from "#locales/ar" with { type: "json" };
|
|
5
|
+
import en from "#locales/en" with { type: "json" };
|
|
6
|
+
|
|
7
|
+
type Dictionary = { [key: string]: string | Dictionary };
|
|
8
|
+
|
|
9
|
+
const dictionaries: Record<string, Dictionary> = { en, ar };
|
|
10
|
+
|
|
11
|
+
export const SUPPORTED_LANGUAGES = Object.keys(dictionaries);
|
|
12
|
+
|
|
13
|
+
function lookup(dict: Dictionary | undefined, key: string): string | undefined {
|
|
14
|
+
let node: string | Dictionary | undefined = dict;
|
|
15
|
+
for (const part of key.split(".")) {
|
|
16
|
+
if (!node || typeof node === "string") return undefined;
|
|
17
|
+
node = node[part];
|
|
18
|
+
}
|
|
19
|
+
return typeof node === "string" ? node : undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Translates `key` ("errors.NOT_FOUND"), falling back to the default language, then `fallback`. */
|
|
23
|
+
export function t(language: string, key: string, fallback = key, vars: Record<string, string> = {}): string {
|
|
24
|
+
const text = lookup(dictionaries[language], key) ?? lookup(dictionaries[env.DEFAULT_LANGUAGE], key) ?? fallback;
|
|
25
|
+
return text.replace(/\{(\w+)\}/g, (_, name: string) => vars[name] ?? `{${name}}`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** ?lang, then the `lang` cookie, then Accept-Language; sets `c.var.language`. */
|
|
29
|
+
export const detectLanguage = createMiddleware<AppEnv>(async (c, next) => {
|
|
30
|
+
const candidates = [
|
|
31
|
+
c.req.query("lang"),
|
|
32
|
+
/(?:^|;\s*)lang=([^;]+)/.exec(c.req.header("cookie") ?? "")?.[1],
|
|
33
|
+
...(c.req.header("accept-language") ?? "")
|
|
34
|
+
.split(",")
|
|
35
|
+
.map((part) => part.split(";")[0]?.trim().toLowerCase().split("-")[0]),
|
|
36
|
+
];
|
|
37
|
+
const language = candidates.find((l) => l && SUPPORTED_LANGUAGES.includes(l)) ?? env.DEFAULT_LANGUAGE;
|
|
38
|
+
c.set("language", language);
|
|
39
|
+
c.header("content-language", language);
|
|
40
|
+
await next();
|
|
41
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { t } from "#core/i18n/index";
|
|
3
|
+
import { api } from "#tests/helpers/api";
|
|
4
|
+
|
|
5
|
+
describe("i18n", () => {
|
|
6
|
+
it("translates with a fallback to the default language", () => {
|
|
7
|
+
expect(t("ar", "errors.NOT_FOUND")).toBe("غير موجود");
|
|
8
|
+
expect(t("fr", "errors.NOT_FOUND")).toBe("Not found");
|
|
9
|
+
expect(t("en", "missing.key", "Fallback")).toBe("Fallback");
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("localizes error messages from Accept-Language", async () => {
|
|
13
|
+
const res = await api.get("/api/nope", { headers: { "accept-language": "ar-SA,ar;q=0.9" } });
|
|
14
|
+
expect(res.status).toBe(404);
|
|
15
|
+
const invalid = await api.get(`/api/greetings?name=${"x".repeat(60)}`, {
|
|
16
|
+
headers: { "accept-language": "ar" },
|
|
17
|
+
});
|
|
18
|
+
expect(invalid.body).toMatchObject({ code: "VALIDATION_ERROR", error: "الطلب غير صالح" });
|
|
19
|
+
expect(invalid.headers.get("content-language")).toBe("ar");
|
|
20
|
+
});
|
|
21
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"errors": {
|
|
3
|
+
"VALIDATION_ERROR": "Invalid request",
|
|
4
|
+
"UNAUTHORIZED": "Authentication required",
|
|
5
|
+
"FORBIDDEN": "You do not have permission to do this",
|
|
6
|
+
"NOT_FOUND": "Not found",
|
|
7
|
+
"CONFLICT": "Already exists",
|
|
8
|
+
"INTERNAL_ERROR": "Something went wrong"
|
|
9
|
+
}
|
|
10
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Cron } from "croner";
|
|
2
|
+
import { withLock } from "#core/jobs/lock";
|
|
3
|
+
import { logger } from "#core/logging/logger";
|
|
4
|
+
import { handleSignals, onShutdown } from "#core/process/lifecycle";
|
|
5
|
+
import { schedules } from "#generated/schedules";
|
|
6
|
+
|
|
7
|
+
const jobs = schedules.map((schedule) => {
|
|
8
|
+
const log = logger.child({ schedule: schedule.name });
|
|
9
|
+
return new Cron(schedule.cron, { timezone: "UTC", protect: true, name: schedule.name }, async (self) => {
|
|
10
|
+
const tick = self.currentRun()?.toISOString() ?? new Date().toISOString();
|
|
11
|
+
const ran = await withLock(`schedule:${schedule.name}:${tick}`, schedule.lockTtlMs ?? 60_000, () =>
|
|
12
|
+
schedule.run({ logger: log }),
|
|
13
|
+
).catch((error: unknown) => {
|
|
14
|
+
log.error({ err: error }, "schedule failed");
|
|
15
|
+
return true;
|
|
16
|
+
});
|
|
17
|
+
if (!ran) log.debug("skipped: another process holds this tick");
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
onShutdown("cron", () => {
|
|
22
|
+
for (const job of jobs) job.stop();
|
|
23
|
+
});
|
|
24
|
+
handleSignals();
|
|
25
|
+
logger.info({ schedules: schedules.map((s) => `${s.name} (${s.cron})`) }, "cron started");
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Worker } from "bullmq";
|
|
2
|
+
import { env } from "#core/config/env";
|
|
3
|
+
import { processJob } from "#core/jobs/process";
|
|
4
|
+
import { QUEUE_NAME } from "#core/jobs/queue";
|
|
5
|
+
import { redis } from "#core/jobs/redis";
|
|
6
|
+
import { logger } from "#core/logging/logger";
|
|
7
|
+
import { handleSignals, onShutdown } from "#core/process/lifecycle";
|
|
8
|
+
import { consumers } from "#generated/consumers";
|
|
9
|
+
|
|
10
|
+
const worker = new Worker(
|
|
11
|
+
QUEUE_NAME,
|
|
12
|
+
(job) =>
|
|
13
|
+
processJob(consumers, job.name, job.data, {
|
|
14
|
+
logger: logger.child({ jobId: job.id, topic: job.name }),
|
|
15
|
+
jobId: job.id ?? "",
|
|
16
|
+
attempt: job.attemptsMade + 1,
|
|
17
|
+
}),
|
|
18
|
+
{ connection: redis(), prefix: env.JOBS_PREFIX, concurrency: env.WORKER_CONCURRENCY },
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
worker.on("failed", (job, err) => logger.error({ err, jobId: job?.id, topic: job?.name }, "job failed"));
|
|
22
|
+
onShutdown("worker", () => worker.close());
|
|
23
|
+
handleSignals();
|
|
24
|
+
logger.info({ topics: consumers.map((c) => c.topic) }, "worker started");
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { defineCommand } from "#core/commands/define";
|
|
3
|
+
import { enqueue } from "#core/jobs/queue";
|
|
4
|
+
|
|
5
|
+
export default defineCommand({
|
|
6
|
+
name: "jobs:enqueue",
|
|
7
|
+
description: "Enqueue a job by topic with a JSON payload (for development and debugging)",
|
|
8
|
+
args: z.object({ topic: z.string().min(1), data: z.string().default("{}") }),
|
|
9
|
+
async run({ topic, data }, { logger }) {
|
|
10
|
+
const id = await enqueue(topic, JSON.parse(data) as unknown);
|
|
11
|
+
logger.info({ topic, id }, "job enqueued");
|
|
12
|
+
},
|
|
13
|
+
});
|