@tatara/cli 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin.mjs +311 -132
- package/package.json +1 -1
- package/templates/features/admin/files/src/modules/example/routes/admin-greeting.routes.ts +1 -1
- package/templates/features/auth/files/src/core/auth/routes/me.routes.ts +1 -1
- package/templates/features/auth-keycloak/files/src/core/auth/commands/keycloak-token.ts +2 -2
- package/templates/features/auth-keycloak/files/src/core/auth/keycloak.ts +7 -56
- package/templates/features/auth-keycloak/files/src/core/auth/tests/keycloak-tokens.test.ts +3 -3
- package/templates/features/auth-keycloak/files+auth-orgs/src/core/auth/tests/keycloak-organizations.test.ts +1 -1
- package/templates/features/authz/files/src/core/authz/db/seed.ts +1 -1
- package/templates/features/authz/files/src/core/authz/routes/authorization.routes.ts +1 -1
- package/templates/features/authz/files+drizzle/src/core/authz/db/store.ts +1 -1
- package/templates/features/authz/files+prisma/src/core/authz/db/store.ts +1 -1
- package/templates/features/base/files/src/core/http/app.ts +2 -8
- package/templates/features/base/files/tests/helpers/api.ts +3 -47
- package/templates/features/better-auth/files/src/core/auth/auth.ts +1 -1
- package/templates/features/better-auth/files/src/core/auth/db/seed.ts +1 -1
- package/templates/features/better-auth/files/src/core/auth/delivery.ts +6 -30
- package/templates/features/better-auth/files/src/core/auth/principal.ts +7 -38
- package/templates/features/better-auth/files/src/core/auth/sms.ts +3 -12
- package/templates/features/better-auth/files/tests/helpers/auth.ts +9 -12
- package/templates/features/db/files/tests/global-setup.ts +4 -16
- package/templates/features/db/files+mysql+drizzle/src/core/db/admin.ts +2 -53
- package/templates/features/db/files+mysql+prisma/src/core/db/admin.ts +7 -78
- package/templates/features/db/files+postgres/src/core/db/admin.ts +2 -50
- package/templates/features/drizzle/files+mysql/src/core/db/migrate.ts +1 -13
- package/templates/features/drizzle/files+postgres/src/core/db/migrate.ts +1 -13
- package/templates/features/prisma/files/src/core/db/migrate.ts +1 -13
- package/templates/generators/resource/files+drizzle/src/modules/__moduleKebab__/services/__nameKebab__.service.ts +1 -1
- package/templates/generators/resource/files+prisma/src/modules/__moduleKebab__/services/__nameKebab__.service.ts +1 -1
- package/templates/versions.json +5 -1
- package/templates/features/auth/files/src/core/auth/middleware.ts +0 -29
- package/templates/features/auth-keycloak/files/src/core/auth/keycloak-plugin.ts +0 -154
- package/templates/features/better-auth/files/src/core/auth/openapi.ts +0 -60
- package/templates/features/db/files/src/core/db/id.ts +0 -13
- package/templates/features/db/files/src/core/db/seed.ts +0 -14
- package/templates/features/db/files/tests/helpers/database-url.ts +0 -9
|
@@ -1,58 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { keycloakVerifier } from "@tatara/keycloak";
|
|
2
2
|
import { env } from "#core/config/env";
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
/** Tests replace the realm's key set with a local one. */
|
|
11
|
-
export function setKeySource(source: JWTVerifyGetKey): void {
|
|
12
|
-
keySource = source;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export interface KeycloakClaims extends JWTPayload {
|
|
16
|
-
sub: string;
|
|
17
|
-
email?: string;
|
|
18
|
-
name?: string;
|
|
19
|
-
preferred_username?: string;
|
|
20
|
-
azp?: string;
|
|
21
|
-
roles?: string[];
|
|
22
|
-
realm_access?: { roles?: string[] };
|
|
23
|
-
resource_access?: Record<string, { roles?: string[] }>;
|
|
24
|
-
organization?: string[] | Record<string, unknown>;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/** Verifies signature, issuer, audience and expiry. Throws when the token is not valid. */
|
|
28
|
-
export async function verifyAccessToken(token: string): Promise<KeycloakClaims> {
|
|
29
|
-
const { payload } = await jwtVerify(token, keySource, {
|
|
30
|
-
issuer,
|
|
31
|
-
audience: env.KEYCLOAK_AUDIENCE,
|
|
32
|
-
});
|
|
33
|
-
if (typeof payload.sub !== "string") throw new Error("token has no subject");
|
|
34
|
-
return payload as KeycloakClaims;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export function rolesOf(claims: KeycloakClaims): string[] {
|
|
38
|
-
const roles = new Set<string>([
|
|
39
|
-
...(claims.roles ?? []),
|
|
40
|
-
...(claims.realm_access?.roles ?? []),
|
|
41
|
-
...(claims.resource_access?.[env.KEYCLOAK_AUDIENCE]?.roles ?? []),
|
|
42
|
-
]);
|
|
43
|
-
return [...roles];
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/** Keycloak organizations put memberships in the `organization` claim; the first one wins. */
|
|
47
|
-
export function organizationOf(claims: KeycloakClaims): string | null {
|
|
48
|
-
const org = claims.organization;
|
|
49
|
-
if (Array.isArray(org)) return org[0] ?? null;
|
|
50
|
-
if (org && typeof org === "object") {
|
|
51
|
-
const [first] = Object.entries(org);
|
|
52
|
-
if (!first) return null;
|
|
53
|
-
const [alias, value] = first;
|
|
54
|
-
const id = (value as { id?: unknown } | undefined)?.id;
|
|
55
|
-
return typeof id === "string" ? id : alias;
|
|
56
|
-
}
|
|
57
|
-
return null;
|
|
58
|
-
}
|
|
4
|
+
/** Verifies access tokens from the realm; `keycloakTokens()` in auth.ts uses it. */
|
|
5
|
+
export const keycloak = keycloakVerifier({
|
|
6
|
+
url: env.KEYCLOAK_URL,
|
|
7
|
+
realm: env.KEYCLOAK_REALM,
|
|
8
|
+
audience: env.KEYCLOAK_AUDIENCE,
|
|
9
|
+
});
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { generateKeyPair, SignJWT } from "jose";
|
|
2
2
|
import { beforeEach, describe, expect, it } from "vitest";
|
|
3
3
|
import { auth } from "#core/auth/auth";
|
|
4
|
-
import {
|
|
4
|
+
import { keycloak } from "#core/auth/keycloak";
|
|
5
5
|
import { env } from "#core/config/env";
|
|
6
|
-
import { newId } from "
|
|
6
|
+
import { newId } from "@tatara/db";
|
|
7
7
|
import { api } from "#tests/helpers/api";
|
|
8
8
|
import { createTestUser, keycloakToken } from "#tests/helpers/auth";
|
|
9
9
|
import { resetDatabase } from "#tests/helpers/db";
|
|
@@ -63,7 +63,7 @@ describe("Keycloak access tokens", () => {
|
|
|
63
63
|
const forged = await new SignJWT({ roles: ["admin"] })
|
|
64
64
|
.setProtectedHeader({ alg: "RS256", kid: "test" })
|
|
65
65
|
.setSubject(newId())
|
|
66
|
-
.setIssuer(issuer)
|
|
66
|
+
.setIssuer(keycloak.issuer)
|
|
67
67
|
.setAudience(env.KEYCLOAK_AUDIENCE)
|
|
68
68
|
.setExpirationTime("5m")
|
|
69
69
|
.sign(privateKey);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, it } from "vitest";
|
|
2
2
|
import { auth } from "#core/auth/auth";
|
|
3
|
-
import { newId } from "
|
|
3
|
+
import { newId } from "@tatara/db";
|
|
4
4
|
import { api } from "#tests/helpers/api";
|
|
5
5
|
import { keycloakToken } from "#tests/helpers/auth";
|
|
6
6
|
import { resetDatabase } from "#tests/helpers/db";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createRoute, z } from "@hono/zod-openapi";
|
|
2
|
-
import { currentPrincipal, requireAuth } from "
|
|
2
|
+
import { currentPrincipal, requireAuth } from "@tatara/auth";
|
|
3
3
|
import { requirePermission } from "#core/authz/middleware";
|
|
4
4
|
import { effectivePermissions, orgOf } from "#core/authz/resolver";
|
|
5
5
|
import { listAudit } from "#core/authz/services/audit";
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
} from "#core/authz/db/authz.schema";
|
|
11
11
|
import type { AuthzStore, OverlayRow, PermissionGrant, RoleRow } from "#core/authz/types";
|
|
12
12
|
import { db } from "#core/db/client";
|
|
13
|
-
import { newId } from "
|
|
13
|
+
import { newId } from "@tatara/db";
|
|
14
14
|
|
|
15
15
|
const toScope = (scope: string): Scope | null => (scope === "" ? null : (scope as Scope));
|
|
16
16
|
|
|
@@ -2,7 +2,7 @@ import type { OverlayEffect, Scope } from "#core/authz/constants";
|
|
|
2
2
|
import { SYSTEM_ORG_ID } from "#core/authz/constants";
|
|
3
3
|
import type { AuthzStore, OverlayRow, PermissionGrant, RoleRow } from "#core/authz/types";
|
|
4
4
|
import { db } from "#core/db/client";
|
|
5
|
-
import { newId } from "
|
|
5
|
+
import { newId } from "@tatara/db";
|
|
6
6
|
|
|
7
7
|
const toScope = (scope: string): Scope | null => (scope === "" ? null : (scope as Scope));
|
|
8
8
|
|
|
@@ -29,16 +29,10 @@ export function createHttpApp(name: AppName, options: { title: string }) {
|
|
|
29
29
|
mountRoutes(app, routes, { app: name, prefix: API_PREFIX });
|
|
30
30
|
serveOpenApi(app, {
|
|
31
31
|
title: options.title,
|
|
32
|
-
|
|
33
|
-
// tatara:slot openapi
|
|
34
|
-
},
|
|
32
|
+
// tatara:slot openapi-options
|
|
35
33
|
});
|
|
36
34
|
handleErrors(app, {
|
|
37
|
-
|
|
38
|
-
let message = err.message;
|
|
39
|
-
// tatara:slot error-message
|
|
40
|
-
return message;
|
|
41
|
-
},
|
|
35
|
+
// tatara:slot error-options
|
|
42
36
|
});
|
|
43
37
|
return app;
|
|
44
38
|
}
|
|
@@ -1,53 +1,9 @@
|
|
|
1
|
+
import { client } from "@tatara/testing";
|
|
1
2
|
import { app as apiApp } from "#apps/api/app";
|
|
2
3
|
// tatara:slot imports
|
|
3
4
|
|
|
4
|
-
type
|
|
5
|
-
|
|
6
|
-
export interface ApiResponse<T = unknown> {
|
|
7
|
-
status: number;
|
|
8
|
-
headers: Headers;
|
|
9
|
-
body: T;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export interface RequestOptions {
|
|
13
|
-
headers?: Record<string, string>;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
async function send<T>(
|
|
17
|
-
target: HttpApp,
|
|
18
|
-
method: string,
|
|
19
|
-
path: string,
|
|
20
|
-
body: unknown,
|
|
21
|
-
options: RequestOptions = {},
|
|
22
|
-
): Promise<ApiResponse<T>> {
|
|
23
|
-
const headers: Record<string, string> = { ...options.headers };
|
|
24
|
-
if (body !== undefined) headers["content-type"] = "application/json";
|
|
25
|
-
const res = await target.request(path, {
|
|
26
|
-
method,
|
|
27
|
-
headers,
|
|
28
|
-
body: body === undefined ? undefined : JSON.stringify(body),
|
|
29
|
-
});
|
|
30
|
-
const text = await res.text();
|
|
31
|
-
let parsed: unknown = text;
|
|
32
|
-
try {
|
|
33
|
-
parsed = text ? JSON.parse(text) : null;
|
|
34
|
-
} catch {}
|
|
35
|
-
return { status: res.status, headers: res.headers, body: parsed as T };
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export function client(target: HttpApp) {
|
|
39
|
-
return {
|
|
40
|
-
get: <T = unknown>(path: string, o?: RequestOptions) => send<T>(target, "GET", path, undefined, o),
|
|
41
|
-
post: <T = unknown>(path: string, body?: unknown, o?: RequestOptions) =>
|
|
42
|
-
send<T>(target, "POST", path, body, o),
|
|
43
|
-
put: <T = unknown>(path: string, body?: unknown, o?: RequestOptions) =>
|
|
44
|
-
send<T>(target, "PUT", path, body, o),
|
|
45
|
-
patch: <T = unknown>(path: string, body?: unknown, o?: RequestOptions) =>
|
|
46
|
-
send<T>(target, "PATCH", path, body, o),
|
|
47
|
-
delete: <T = unknown>(path: string, o?: RequestOptions) =>
|
|
48
|
-
send<T>(target, "DELETE", path, undefined, o),
|
|
49
|
-
};
|
|
50
|
-
}
|
|
5
|
+
export type { ApiResponse, RequestOptions } from "@tatara/testing";
|
|
6
|
+
export { client };
|
|
51
7
|
|
|
52
8
|
/** Calls the api app in-process (no network). */
|
|
53
9
|
export const api = client(apiApp);
|
|
@@ -2,7 +2,7 @@ import { betterAuth } from "better-auth";
|
|
|
2
2
|
import { bearer, openAPI } from "better-auth/plugins";
|
|
3
3
|
import { database } from "#core/auth/adapter";
|
|
4
4
|
import { env } from "#core/config/env";
|
|
5
|
-
import { newId } from "
|
|
5
|
+
import { newId } from "@tatara/db";
|
|
6
6
|
// tatara:slot imports
|
|
7
7
|
|
|
8
8
|
export const auth = betterAuth({
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { auth } from "#core/auth/auth";
|
|
2
2
|
import { env } from "#core/config/env";
|
|
3
|
-
import { defineSeed } from "
|
|
3
|
+
import { defineSeed } from "@tatara/db";
|
|
4
4
|
|
|
5
5
|
/** Development admin account (never created in production). */
|
|
6
6
|
export default defineSeed({
|
|
@@ -1,36 +1,12 @@
|
|
|
1
|
+
import { createOutbox } from "@tatara/auth";
|
|
1
2
|
import { smsSender } from "#core/auth/sms";
|
|
2
3
|
import { env } from "#core/config/env";
|
|
3
4
|
// tatara:slot imports
|
|
4
5
|
|
|
5
|
-
export interface OutboxMessage {
|
|
6
|
-
channel: "email" | "sms";
|
|
7
|
-
to: string;
|
|
8
|
-
subject?: string;
|
|
9
|
-
text: string;
|
|
10
|
-
/** one-time code, when the message carries one */
|
|
11
|
-
code?: string;
|
|
12
|
-
sentAt: string;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
const outbox: OutboxMessage[] = [];
|
|
16
|
-
|
|
17
6
|
/** Only with DEV_OUTBOX=true in development or test: the outbox exposes sign-in codes. */
|
|
18
|
-
export
|
|
19
|
-
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/** Messages are kept here so tests and local sign-in can read codes. */
|
|
23
|
-
function record(message: Omit<OutboxMessage, "sentAt">) {
|
|
24
|
-
if (!outboxEnabled()) return;
|
|
25
|
-
outbox.push({ ...message, sentAt: new Date().toISOString() });
|
|
26
|
-
if (outbox.length > 200) outbox.shift();
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/** Newest first. Empty unless the outbox is enabled. */
|
|
30
|
-
export function readOutbox(to?: string): OutboxMessage[] {
|
|
31
|
-
if (!outboxEnabled()) return [];
|
|
32
|
-
return outbox.filter((m) => !to || m.to === to).reverse();
|
|
33
|
-
}
|
|
7
|
+
export const outbox = createOutbox({
|
|
8
|
+
enabled: env.DEV_OUTBOX && (env.NODE_ENV === "development" || env.NODE_ENV === "test"),
|
|
9
|
+
});
|
|
34
10
|
|
|
35
11
|
export async function deliverEmail(message: {
|
|
36
12
|
to: string;
|
|
@@ -38,11 +14,11 @@ export async function deliverEmail(message: {
|
|
|
38
14
|
text: string;
|
|
39
15
|
code?: string;
|
|
40
16
|
}): Promise<void> {
|
|
41
|
-
record({ channel: "email", ...message });
|
|
17
|
+
outbox.record({ channel: "email", ...message });
|
|
42
18
|
// tatara:slot email-delivery
|
|
43
19
|
}
|
|
44
20
|
|
|
45
21
|
export async function deliverSms(message: { to: string; text: string; code?: string }): Promise<void> {
|
|
46
|
-
record({ channel: "sms", ...message });
|
|
22
|
+
outbox.record({ channel: "sms", ...message });
|
|
47
23
|
await smsSender.send(message.to, message.text);
|
|
48
24
|
}
|
|
@@ -1,41 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { principalMiddleware, sessionPrincipal } from "@tatara/auth";
|
|
2
2
|
import { auth } from "#core/auth/auth";
|
|
3
3
|
import type { AppEnv } from "#core/http/context";
|
|
4
|
+
// tatara:slot imports
|
|
4
5
|
|
|
5
|
-
/**
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
if (!orgId) return null;
|
|
11
|
-
const { adapter } = await auth.$context;
|
|
12
|
-
const member = await adapter.findOne({
|
|
13
|
-
model: "member",
|
|
14
|
-
where: [
|
|
15
|
-
{ field: "organizationId", value: orgId },
|
|
16
|
-
{ field: "userId", value: userId },
|
|
17
|
-
],
|
|
18
|
-
});
|
|
19
|
-
return member ? orgId : null;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/** Resolves the session (cookie or bearer token) into `c.var.principal`. */
|
|
23
|
-
export const attachPrincipal = createMiddleware<AppEnv>(async (c, next) => {
|
|
24
|
-
const session = await auth.api.getSession({ headers: c.req.raw.headers });
|
|
25
|
-
if (session) {
|
|
26
|
-
const user = session.user as typeof session.user & { role?: string | null };
|
|
27
|
-
const s = session.session as typeof session.session & { activeOrganizationId?: string | null };
|
|
28
|
-
c.set("principal", {
|
|
29
|
-
userId: user.id,
|
|
30
|
-
orgId: await activeMembership(user.id, s.activeOrganizationId),
|
|
31
|
-
roles: (user.role ?? "user")
|
|
32
|
-
.split(",")
|
|
33
|
-
.map((role) => role.trim())
|
|
34
|
-
.filter(Boolean),
|
|
35
|
-
kind: "user",
|
|
36
|
-
claims: {},
|
|
37
|
-
});
|
|
38
|
-
}
|
|
39
|
-
// tatara:slot no-session
|
|
40
|
-
await next();
|
|
41
|
-
});
|
|
6
|
+
/** Sets `c.var.principal` from the first resolver that recognises the request's credentials. */
|
|
7
|
+
export const attachPrincipal = principalMiddleware<AppEnv>([
|
|
8
|
+
(c) => sessionPrincipal(auth, c.req.raw.headers),
|
|
9
|
+
// tatara:slot resolvers
|
|
10
|
+
]);
|
|
@@ -1,14 +1,5 @@
|
|
|
1
|
+
import { consoleSmsSender, type SmsSender } from "@tatara/auth";
|
|
1
2
|
import { logger } from "#core/logging/logger";
|
|
2
3
|
|
|
3
|
-
/**
|
|
4
|
-
export
|
|
5
|
-
send(to: string, text: string): Promise<void>;
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
const consoleSender: SmsSender = {
|
|
9
|
-
async send(to) {
|
|
10
|
-
logger.info({ to }, "sms not sent: no SMS provider configured (see src/core/auth/sms.ts)");
|
|
11
|
-
},
|
|
12
|
-
};
|
|
13
|
-
|
|
14
|
-
export const smsSender: SmsSender = consoleSender;
|
|
4
|
+
/** Logs instead of sending. Replace with an SmsSender for your provider. */
|
|
5
|
+
export const smsSender: SmsSender = consoleSmsSender(logger);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
+
import { createTestUser as createUser, sessionHeaders } from "@tatara/auth/testing";
|
|
1
2
|
import { auth } from "#core/auth/auth";
|
|
2
|
-
import { newId } from "#core/db/id";
|
|
3
3
|
// tatara:slot imports
|
|
4
4
|
|
|
5
5
|
export interface AsUserOptions {
|
|
@@ -12,14 +12,13 @@ export interface AsUserOptions {
|
|
|
12
12
|
|
|
13
13
|
/** Creates a verified user and returns their id. */
|
|
14
14
|
export async function createTestUser(options: AsUserOptions = {}): Promise<string> {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
}
|
|
22
|
-
return user.id;
|
|
15
|
+
return createUser(auth, {
|
|
16
|
+
email: options.email,
|
|
17
|
+
name: options.name,
|
|
18
|
+
fields: {
|
|
19
|
+
// tatara:slot user-fields
|
|
20
|
+
},
|
|
21
|
+
});
|
|
23
22
|
}
|
|
24
23
|
|
|
25
24
|
/** A new user with a real session; the token is sent as a bearer token. */
|
|
@@ -28,9 +27,7 @@ export async function authenticate(
|
|
|
28
27
|
): Promise<{ userId: string; headers: Record<string, string> }> {
|
|
29
28
|
// tatara:slot authenticate
|
|
30
29
|
const userId = await createTestUser(options);
|
|
31
|
-
|
|
32
|
-
const session = await ctx.internalAdapter.createSession(userId);
|
|
33
|
-
return { userId, headers: { authorization: `Bearer ${session.token}` } };
|
|
30
|
+
return { userId, headers: await sessionHeaders(auth, userId) };
|
|
34
31
|
}
|
|
35
32
|
|
|
36
33
|
/** Headers that authenticate as a new user. */
|
|
@@ -1,27 +1,15 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import { parseEnv } from "node:util";
|
|
3
|
+
import { prepareTestDatabases } from "@tatara/testing";
|
|
3
4
|
import { ensureDatabase } from "#core/db/admin";
|
|
4
5
|
import { migrateDatabase } from "#core/db/migrate";
|
|
5
|
-
import { TEST_WORKERS, workerDatabaseUrl } from "#tests/helpers/database-url";
|
|
6
6
|
|
|
7
7
|
/** Creates and migrates one database per test worker, once per run. */
|
|
8
8
|
export default async function setup() {
|
|
9
9
|
const fileEnv = existsSync(".env.test")
|
|
10
10
|
? (parseEnv(readFileSync(".env.test", "utf8")) as Record<string, string>)
|
|
11
11
|
: {};
|
|
12
|
-
const
|
|
13
|
-
if (!
|
|
14
|
-
|
|
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
|
-
}
|
|
12
|
+
const baseUrl = process.env.DATABASE_URL ?? fileEnv.DATABASE_URL;
|
|
13
|
+
if (!baseUrl) throw new Error("DATABASE_URL is not set for tests (.env.test)");
|
|
14
|
+
await prepareTestDatabases({ baseUrl, ensureDatabase, migrateDatabase });
|
|
27
15
|
}
|
|
@@ -1,53 +1,2 @@
|
|
|
1
|
-
|
|
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
|
-
}
|
|
1
|
+
// This app uses MySQL through mysql2.
|
|
2
|
+
export { dropAllTables, ensureDatabase, truncateAllTables } from "@tatara/db/mysql2";
|
|
@@ -1,78 +1,7 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
}
|
|
1
|
+
// This app uses MySQL through the mariadb driver (Prisma adapter).
|
|
2
|
+
export {
|
|
3
|
+
connectionConfig,
|
|
4
|
+
dropAllTables,
|
|
5
|
+
ensureDatabase,
|
|
6
|
+
truncateAllTables,
|
|
7
|
+
} from "@tatara/db/mariadb";
|
|
@@ -1,50 +1,2 @@
|
|
|
1
|
-
|
|
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
|
-
}
|
|
1
|
+
// This app uses PostgreSQL through node-postgres.
|
|
2
|
+
export { dropAllTables, ensureDatabase, truncateAllTables } from "@tatara/db/postgres";
|
|
@@ -1,13 +1 @@
|
|
|
1
|
-
|
|
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
|
-
}
|
|
1
|
+
export { migrateDatabase } from "@tatara/db/drizzle-mysql";
|
|
@@ -1,13 +1 @@
|
|
|
1
|
-
|
|
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
|
-
}
|
|
1
|
+
export { migrateDatabase } from "@tatara/db/drizzle-postgres";
|