@tatara/cli 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/dist/bin.mjs +2910 -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 +28 -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 +68 -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/types.ts +85 -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 +262 -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 +229 -0
- package/templates/features/base/files/AGENTS.md +69 -0
- package/templates/features/base/files/CLAUDE.md +1 -0
- package/templates/features/base/files/Dockerfile +15 -0
- package/templates/features/base/files/README.md +31 -0
- package/templates/features/base/files/_github/workflows/ci.yml +16 -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 +25 -0
- package/templates/features/base/files/src/core/commands/runner.ts +54 -0
- package/templates/features/base/files/src/core/http/app.ts +52 -0
- package/templates/features/base/files/src/core/http/context.ts +18 -0
- package/templates/features/base/files/src/core/http/errors.ts +51 -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 +67 -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/principal.ts +23 -0
- package/templates/features/better-auth/files/src/core/auth/sms.ts +14 -0
- package/templates/features/better-auth/files/tests/helpers/auth.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/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 +126 -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,40 @@
|
|
|
1
|
+
import { auth } from "#core/auth/auth";
|
|
2
|
+
import { newId } from "#core/db/id";
|
|
3
|
+
// tatara:slot imports
|
|
4
|
+
|
|
5
|
+
export interface AsUserOptions {
|
|
6
|
+
email?: string;
|
|
7
|
+
name?: string;
|
|
8
|
+
/** user roles (admin plugin); ignored without organizations + roles */
|
|
9
|
+
roles?: string[];
|
|
10
|
+
// tatara:slot options
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Creates a verified user and returns their id. */
|
|
14
|
+
export async function createTestUser(options: AsUserOptions = {}): Promise<string> {
|
|
15
|
+
const ctx = await auth.$context;
|
|
16
|
+
const user = await ctx.internalAdapter.createUser({
|
|
17
|
+
email: options.email ?? `user-${newId()}@example.com`,
|
|
18
|
+
name: options.name ?? "Test User",
|
|
19
|
+
emailVerified: true,
|
|
20
|
+
// tatara:slot user-fields
|
|
21
|
+
}, { method: "admin" });
|
|
22
|
+
return user.id;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A new user with a real session; the token is sent as a bearer token. */
|
|
26
|
+
export async function authenticate(
|
|
27
|
+
options: AsUserOptions = {},
|
|
28
|
+
): Promise<{ userId: string; headers: Record<string, string> }> {
|
|
29
|
+
const userId = await createTestUser(options);
|
|
30
|
+
const ctx = await auth.$context;
|
|
31
|
+
const session = await ctx.internalAdapter.createSession(userId);
|
|
32
|
+
return { userId, headers: { authorization: `Bearer ${session.token}` } };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Headers that authenticate as a new user. */
|
|
36
|
+
export async function asUser(options: AsUserOptions = {}): Promise<Record<string, string>> {
|
|
37
|
+
const user = await authenticate(options);
|
|
38
|
+
// tatara:slot as-user
|
|
39
|
+
return user.headers;
|
|
40
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
|
2
|
+
import * as authSchema from "#core/auth/db/auth.schema";
|
|
3
|
+
import { db } from "#core/db/client";
|
|
4
|
+
// tatara:slot imports
|
|
5
|
+
|
|
6
|
+
export const database = drizzleAdapter(db, {
|
|
7
|
+
provider: "mysql",
|
|
8
|
+
schema: {
|
|
9
|
+
user: authSchema.users,
|
|
10
|
+
session: authSchema.sessions,
|
|
11
|
+
account: authSchema.accounts,
|
|
12
|
+
verification: authSchema.verifications,
|
|
13
|
+
// tatara:slot adapter-schema
|
|
14
|
+
},
|
|
15
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { boolean, mysqlTable, text, timestamp, varchar } from "drizzle-orm/mysql-core";
|
|
2
|
+
|
|
3
|
+
const id = () => varchar({ length: 36 });
|
|
4
|
+
const createdAt = () => timestamp({ fsp: 3, mode: "date" }).notNull().defaultNow();
|
|
5
|
+
const updatedAt = () => timestamp({ fsp: 3, mode: "date" }).notNull().defaultNow();
|
|
6
|
+
|
|
7
|
+
export const users = mysqlTable("users", {
|
|
8
|
+
id: id().primaryKey(),
|
|
9
|
+
name: varchar({ length: 255 }).notNull(),
|
|
10
|
+
email: varchar({ length: 255 }).notNull().unique(),
|
|
11
|
+
emailVerified: boolean().notNull().default(false),
|
|
12
|
+
image: text(),
|
|
13
|
+
// tatara:slot user-fields
|
|
14
|
+
createdAt: createdAt(),
|
|
15
|
+
updatedAt: updatedAt(),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export const sessions = mysqlTable("sessions", {
|
|
19
|
+
id: id().primaryKey(),
|
|
20
|
+
expiresAt: timestamp({ fsp: 3, mode: "date" }).notNull(),
|
|
21
|
+
token: varchar({ length: 255 }).notNull().unique(),
|
|
22
|
+
ipAddress: varchar({ length: 64 }),
|
|
23
|
+
userAgent: text(),
|
|
24
|
+
userId: id()
|
|
25
|
+
.notNull()
|
|
26
|
+
.references(() => users.id, { onDelete: "cascade" }),
|
|
27
|
+
// tatara:slot session-fields
|
|
28
|
+
createdAt: createdAt(),
|
|
29
|
+
updatedAt: updatedAt(),
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
export const accounts = mysqlTable("accounts", {
|
|
33
|
+
id: id().primaryKey(),
|
|
34
|
+
accountId: varchar({ length: 255 }).notNull(),
|
|
35
|
+
providerId: varchar({ length: 64 }).notNull(),
|
|
36
|
+
userId: id()
|
|
37
|
+
.notNull()
|
|
38
|
+
.references(() => users.id, { onDelete: "cascade" }),
|
|
39
|
+
accessToken: text(),
|
|
40
|
+
refreshToken: text(),
|
|
41
|
+
idToken: text(),
|
|
42
|
+
accessTokenExpiresAt: timestamp({ fsp: 3, mode: "date" }),
|
|
43
|
+
refreshTokenExpiresAt: timestamp({ fsp: 3, mode: "date" }),
|
|
44
|
+
scope: text(),
|
|
45
|
+
password: text(),
|
|
46
|
+
createdAt: createdAt(),
|
|
47
|
+
updatedAt: updatedAt(),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
export const verifications = mysqlTable("verifications", {
|
|
51
|
+
id: id().primaryKey(),
|
|
52
|
+
identifier: varchar({ length: 255 }).notNull(),
|
|
53
|
+
value: text().notNull(),
|
|
54
|
+
expiresAt: timestamp({ fsp: 3, mode: "date" }).notNull(),
|
|
55
|
+
createdAt: createdAt(),
|
|
56
|
+
updatedAt: updatedAt(),
|
|
57
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
|
2
|
+
import * as authSchema from "#core/auth/db/auth.schema";
|
|
3
|
+
import { db } from "#core/db/client";
|
|
4
|
+
// tatara:slot imports
|
|
5
|
+
|
|
6
|
+
export const database = drizzleAdapter(db, {
|
|
7
|
+
provider: "pg",
|
|
8
|
+
schema: {
|
|
9
|
+
user: authSchema.users,
|
|
10
|
+
session: authSchema.sessions,
|
|
11
|
+
account: authSchema.accounts,
|
|
12
|
+
verification: authSchema.verifications,
|
|
13
|
+
// tatara:slot adapter-schema
|
|
14
|
+
},
|
|
15
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { boolean, pgTable, text, timestamp } from "drizzle-orm/pg-core";
|
|
2
|
+
|
|
3
|
+
const createdAt = () => timestamp({ withTimezone: true, mode: "date" }).notNull().defaultNow();
|
|
4
|
+
const updatedAt = () => timestamp({ withTimezone: true, mode: "date" }).notNull().defaultNow();
|
|
5
|
+
|
|
6
|
+
export const users = pgTable("users", {
|
|
7
|
+
id: text().primaryKey(),
|
|
8
|
+
name: text().notNull(),
|
|
9
|
+
email: text().notNull().unique(),
|
|
10
|
+
emailVerified: boolean().notNull().default(false),
|
|
11
|
+
image: text(),
|
|
12
|
+
// tatara:slot user-fields
|
|
13
|
+
createdAt: createdAt(),
|
|
14
|
+
updatedAt: updatedAt(),
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
export const sessions = pgTable("sessions", {
|
|
18
|
+
id: text().primaryKey(),
|
|
19
|
+
expiresAt: timestamp({ withTimezone: true, mode: "date" }).notNull(),
|
|
20
|
+
token: text().notNull().unique(),
|
|
21
|
+
ipAddress: text(),
|
|
22
|
+
userAgent: text(),
|
|
23
|
+
userId: text()
|
|
24
|
+
.notNull()
|
|
25
|
+
.references(() => users.id, { onDelete: "cascade" }),
|
|
26
|
+
// tatara:slot session-fields
|
|
27
|
+
createdAt: createdAt(),
|
|
28
|
+
updatedAt: updatedAt(),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
export const accounts = pgTable("accounts", {
|
|
32
|
+
id: text().primaryKey(),
|
|
33
|
+
accountId: text().notNull(),
|
|
34
|
+
providerId: text().notNull(),
|
|
35
|
+
userId: text()
|
|
36
|
+
.notNull()
|
|
37
|
+
.references(() => users.id, { onDelete: "cascade" }),
|
|
38
|
+
accessToken: text(),
|
|
39
|
+
refreshToken: text(),
|
|
40
|
+
idToken: text(),
|
|
41
|
+
accessTokenExpiresAt: timestamp({ withTimezone: true, mode: "date" }),
|
|
42
|
+
refreshTokenExpiresAt: timestamp({ withTimezone: true, mode: "date" }),
|
|
43
|
+
scope: text(),
|
|
44
|
+
password: text(),
|
|
45
|
+
createdAt: createdAt(),
|
|
46
|
+
updatedAt: updatedAt(),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
export const verifications = pgTable("verifications", {
|
|
50
|
+
id: text().primaryKey(),
|
|
51
|
+
identifier: text().notNull(),
|
|
52
|
+
value: text().notNull(),
|
|
53
|
+
expiresAt: timestamp({ withTimezone: true, mode: "date" }).notNull(),
|
|
54
|
+
createdAt: createdAt(),
|
|
55
|
+
updatedAt: updatedAt(),
|
|
56
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// better-auth tables. Field names are what better-auth expects; columns are snake_case.
|
|
2
|
+
|
|
3
|
+
model User {
|
|
4
|
+
id String @id @db.VarChar(36)
|
|
5
|
+
name String @db.VarChar(255)
|
|
6
|
+
email String @unique @db.VarChar(255)
|
|
7
|
+
emailVerified Boolean @default(false) @map("email_verified")
|
|
8
|
+
image String? @db.Text
|
|
9
|
+
// tatara:slot user-fields
|
|
10
|
+
createdAt DateTime @default(now()) @map("created_at")
|
|
11
|
+
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
|
12
|
+
sessions Session[]
|
|
13
|
+
accounts Account[]
|
|
14
|
+
// tatara:slot user-relations
|
|
15
|
+
|
|
16
|
+
@@map("users")
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
model Session {
|
|
20
|
+
id String @id @db.VarChar(36)
|
|
21
|
+
expiresAt DateTime @map("expires_at")
|
|
22
|
+
token String @unique @db.VarChar(255)
|
|
23
|
+
ipAddress String? @map("ip_address") @db.VarChar(64)
|
|
24
|
+
userAgent String? @map("user_agent") @db.Text
|
|
25
|
+
userId String @map("user_id") @db.VarChar(36)
|
|
26
|
+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
27
|
+
// tatara:slot session-fields
|
|
28
|
+
createdAt DateTime @default(now()) @map("created_at")
|
|
29
|
+
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
|
30
|
+
|
|
31
|
+
@@map("sessions")
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
model Account {
|
|
35
|
+
id String @id @db.VarChar(36)
|
|
36
|
+
accountId String @map("account_id") @db.VarChar(255)
|
|
37
|
+
providerId String @map("provider_id") @db.VarChar(64)
|
|
38
|
+
userId String @map("user_id") @db.VarChar(36)
|
|
39
|
+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
40
|
+
accessToken String? @map("access_token") @db.Text
|
|
41
|
+
refreshToken String? @map("refresh_token") @db.Text
|
|
42
|
+
idToken String? @map("id_token") @db.Text
|
|
43
|
+
accessTokenExpiresAt DateTime? @map("access_token_expires_at")
|
|
44
|
+
refreshTokenExpiresAt DateTime? @map("refresh_token_expires_at")
|
|
45
|
+
scope String? @db.Text
|
|
46
|
+
password String? @db.Text
|
|
47
|
+
createdAt DateTime @default(now()) @map("created_at")
|
|
48
|
+
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
|
49
|
+
|
|
50
|
+
@@map("accounts")
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
model Verification {
|
|
54
|
+
id String @id @db.VarChar(36)
|
|
55
|
+
identifier String @db.VarChar(255)
|
|
56
|
+
value String @db.Text
|
|
57
|
+
expiresAt DateTime @map("expires_at")
|
|
58
|
+
createdAt DateTime @default(now()) @map("created_at")
|
|
59
|
+
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
|
60
|
+
|
|
61
|
+
@@map("verifications")
|
|
62
|
+
}
|
|
@@ -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 } 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: z.coerce.boolean().default(false) }),
|
|
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,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());
|