@tatara/cli 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin.mjs +123 -51
- package/package.json +1 -1
- package/templates/features/auth-keycloak/files/src/core/auth/tests/keycloak-tokens.test.ts +1 -1
- 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+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/tests/helpers/auth.ts +1 -1
- 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 +3 -1
- 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
package/dist/bin.mjs
CHANGED
|
@@ -286,7 +286,7 @@ export type PermissionKey =
|
|
|
286
286
|
const seeds = await find(root, dirs.map((d) => `${d}/db/seed.ts`));
|
|
287
287
|
await writeIfChanged(join(out, "seeds.ts"), listModule(seeds, {
|
|
288
288
|
prefix: "seed",
|
|
289
|
-
typeImport: "import type { SeedDefinition } from \"
|
|
289
|
+
typeImport: "import type { SeedDefinition } from \"@tatara/db\";",
|
|
290
290
|
typeName: "SeedDefinition",
|
|
291
291
|
exportName: "seeds"
|
|
292
292
|
}), written);
|
|
@@ -1057,8 +1057,8 @@ const i18n = {
|
|
|
1057
1057
|
},
|
|
1058
1058
|
{
|
|
1059
1059
|
file: "src/core/http/app.ts",
|
|
1060
|
-
slot: "error-
|
|
1061
|
-
content: "
|
|
1060
|
+
slot: "error-options",
|
|
1061
|
+
content: ["message: (err, c) =>", " err.translatable ? t(c.get(\"language\"), `errors.${err.code}`, err.message) : err.message,"].join("\n")
|
|
1062
1062
|
},
|
|
1063
1063
|
{
|
|
1064
1064
|
file: "AGENTS.md",
|
|
@@ -1175,8 +1175,12 @@ const betterAuth = {
|
|
|
1175
1175
|
},
|
|
1176
1176
|
{
|
|
1177
1177
|
file: "src/core/http/app.ts",
|
|
1178
|
-
slot: "openapi",
|
|
1179
|
-
content:
|
|
1178
|
+
slot: "openapi-options",
|
|
1179
|
+
content: [
|
|
1180
|
+
"async extend(document) {",
|
|
1181
|
+
" if (name === \"api\") await mergeAuthOpenApi(document, `${API_PREFIX}/auth`);",
|
|
1182
|
+
"},"
|
|
1183
|
+
].join("\n")
|
|
1180
1184
|
},
|
|
1181
1185
|
{
|
|
1182
1186
|
file: "src/core/http/app.ts",
|
|
@@ -1655,6 +1659,7 @@ const base = {
|
|
|
1655
1659
|
"pino"
|
|
1656
1660
|
],
|
|
1657
1661
|
devDependencies: [
|
|
1662
|
+
"@tatara/testing",
|
|
1658
1663
|
"@tatara/cli",
|
|
1659
1664
|
"typescript",
|
|
1660
1665
|
"@types/node",
|
|
@@ -1852,6 +1857,7 @@ const db$1 = {
|
|
|
1852
1857
|
id: "db",
|
|
1853
1858
|
kind: "database",
|
|
1854
1859
|
label: "Database (shared)",
|
|
1860
|
+
dependencies: ["@tatara/db"],
|
|
1855
1861
|
scripts: {
|
|
1856
1862
|
"db:generate": "tatara db generate",
|
|
1857
1863
|
"db:migrate": "tatara db migrate",
|
|
@@ -1874,7 +1880,7 @@ const db$1 = {
|
|
|
1874
1880
|
file: "tests/setup.ts",
|
|
1875
1881
|
slot: "setup",
|
|
1876
1882
|
content: [
|
|
1877
|
-
"import { workerDatabaseUrl } from \"
|
|
1883
|
+
"import { workerDatabaseUrl } from \"@tatara/testing\";",
|
|
1878
1884
|
"",
|
|
1879
1885
|
"// One database per worker, created by tests/global-setup.ts.",
|
|
1880
1886
|
"process.env.DATABASE_URL = workerDatabaseUrl(process.env.DATABASE_URL ?? \"\", process.env.VITEST_POOL_ID ?? \"1\");"
|
|
@@ -3218,7 +3224,7 @@ function createBaseline(project, deps) {
|
|
|
3218
3224
|
//#region src/upgrade/codemods/core-package.ts
|
|
3219
3225
|
const CORE = "@tatara/core";
|
|
3220
3226
|
/** Files @tatara/core replaces, with the import specifier apps used for each. */
|
|
3221
|
-
const MOVED$
|
|
3227
|
+
const MOVED$2 = {
|
|
3222
3228
|
"src/core/commands/define.ts": "#core/commands/define",
|
|
3223
3229
|
"src/core/commands/runner.ts": "#core/commands/runner",
|
|
3224
3230
|
"src/core/process/lifecycle.ts": "#core/process/lifecycle",
|
|
@@ -3253,8 +3259,8 @@ async function rewriteEnv(ctx) {
|
|
|
3253
3259
|
}
|
|
3254
3260
|
/** Only call sites of functions that now come from @tatara/core take the logger argument. */
|
|
3255
3261
|
async function passLogger(ctx, gone) {
|
|
3256
|
-
const runnerGone = gone.includes(MOVED$
|
|
3257
|
-
const lifecycleGone = gone.includes(MOVED$
|
|
3262
|
+
const runnerGone = gone.includes(MOVED$2["src/core/commands/runner.ts"]);
|
|
3263
|
+
const lifecycleGone = gone.includes(MOVED$2["src/core/process/lifecycle.ts"]);
|
|
3258
3264
|
const cli = await exists(ctx, CLI_ENTRY);
|
|
3259
3265
|
if (cli) {
|
|
3260
3266
|
let next = cli;
|
|
@@ -3307,7 +3313,7 @@ const corePackage = {
|
|
|
3307
3313
|
async run(ctx) {
|
|
3308
3314
|
await ctx.addDependency(CORE, versionOf(CORE));
|
|
3309
3315
|
const gone = [];
|
|
3310
|
-
for (const [path, specifier] of Object.entries(MOVED$
|
|
3316
|
+
for (const [path, specifier] of Object.entries(MOVED$2)) if (await ctx.removeIfUnchanged(path) === "kept") ctx.report.notes.push(`${path} is customised; its imports still point at it. Move the change into the app or eject.`);
|
|
3311
3317
|
else gone.push(specifier);
|
|
3312
3318
|
for (const specifier of gone) await ctx.rewriteImports(specifier, CORE);
|
|
3313
3319
|
await rewriteEnv(ctx);
|
|
@@ -3317,6 +3323,68 @@ const corePackage = {
|
|
|
3317
3323
|
}
|
|
3318
3324
|
};
|
|
3319
3325
|
//#endregion
|
|
3326
|
+
//#region src/upgrade/codemods/db-testing.ts
|
|
3327
|
+
/** Copies replaced by a package export of the same name. */
|
|
3328
|
+
const MOVED$1 = [
|
|
3329
|
+
{
|
|
3330
|
+
path: "src/core/db/id.ts",
|
|
3331
|
+
specifier: "#core/db/id",
|
|
3332
|
+
to: "@tatara/db",
|
|
3333
|
+
needsDb: true
|
|
3334
|
+
},
|
|
3335
|
+
{
|
|
3336
|
+
path: "src/core/db/seed.ts",
|
|
3337
|
+
specifier: "#core/db/seed",
|
|
3338
|
+
to: "@tatara/db",
|
|
3339
|
+
needsDb: true
|
|
3340
|
+
},
|
|
3341
|
+
{
|
|
3342
|
+
path: "tests/helpers/database-url.ts",
|
|
3343
|
+
specifier: "#tests/helpers/database-url",
|
|
3344
|
+
to: "@tatara/testing",
|
|
3345
|
+
needsDb: false
|
|
3346
|
+
}
|
|
3347
|
+
];
|
|
3348
|
+
/** App files that stay, but become what this CLI generates (driver re-exports, thin helpers). */
|
|
3349
|
+
const REGENERATED = [
|
|
3350
|
+
"src/core/db/admin.ts",
|
|
3351
|
+
"src/core/db/migrate.ts",
|
|
3352
|
+
"tests/helpers/api.ts",
|
|
3353
|
+
"tests/global-setup.ts"
|
|
3354
|
+
];
|
|
3355
|
+
async function read$1(ctx, path) {
|
|
3356
|
+
return ctx.read(path).catch(() => void 0);
|
|
3357
|
+
}
|
|
3358
|
+
const dbTesting = {
|
|
3359
|
+
version: "0.4.0",
|
|
3360
|
+
id: "db-testing",
|
|
3361
|
+
description: "Use @tatara/db (ids, seeds, driver helpers, migrations) and @tatara/testing",
|
|
3362
|
+
async run(ctx) {
|
|
3363
|
+
const hasDb = ctx.project.config.database !== "none";
|
|
3364
|
+
if (hasDb) await ctx.addDependency("@tatara/db", versionOf("@tatara/db"));
|
|
3365
|
+
await ctx.addDependency("@tatara/testing", versionOf("@tatara/testing"), { dev: true });
|
|
3366
|
+
const regenerate = [];
|
|
3367
|
+
for (const path of REGENERATED) {
|
|
3368
|
+
const text = await read$1(ctx, path);
|
|
3369
|
+
if (text === void 0) continue;
|
|
3370
|
+
if (await ctx.baseline.isUnchanged(path, text)) regenerate.push(path);
|
|
3371
|
+
else ctx.report.notes.push(`${path} is customised and was kept; compare it with a newly generated app.`);
|
|
3372
|
+
}
|
|
3373
|
+
const moved = MOVED$1.filter((m) => hasDb || !m.needsDb);
|
|
3374
|
+
for (const path of regenerate) {
|
|
3375
|
+
const next = await ctx.renderCurrent(path);
|
|
3376
|
+
if (next !== void 0) await ctx.write(path, next);
|
|
3377
|
+
}
|
|
3378
|
+
for (const entry of moved) {
|
|
3379
|
+
if (await ctx.removeIfUnchanged(entry.path) === "kept") {
|
|
3380
|
+
ctx.report.notes.push(`${entry.path} is customised; its imports still point at it.`);
|
|
3381
|
+
continue;
|
|
3382
|
+
}
|
|
3383
|
+
await ctx.rewriteImports(entry.specifier, entry.to);
|
|
3384
|
+
}
|
|
3385
|
+
}
|
|
3386
|
+
};
|
|
3387
|
+
//#endregion
|
|
3320
3388
|
//#region src/upgrade/codemods/http-package.ts
|
|
3321
3389
|
const HTTP = "@tatara/http";
|
|
3322
3390
|
/** Wiring files the app keeps; replaced by what this CLI generates when the app never changed them. */
|
|
@@ -3382,48 +3450,52 @@ async function outsideImporters(ctx) {
|
|
|
3382
3450
|
//#endregion
|
|
3383
3451
|
//#region src/upgrade/codemods.ts
|
|
3384
3452
|
/** Every codemod tatara ships, oldest first. Each step that moves code into a package adds one. */
|
|
3385
|
-
const CODEMODS = [
|
|
3386
|
-
|
|
3387
|
-
|
|
3388
|
-
|
|
3389
|
-
|
|
3390
|
-
|
|
3391
|
-
|
|
3392
|
-
|
|
3393
|
-
|
|
3394
|
-
|
|
3395
|
-
|
|
3396
|
-
|
|
3397
|
-
|
|
3398
|
-
|
|
3399
|
-
|
|
3400
|
-
|
|
3401
|
-
|
|
3402
|
-
|
|
3403
|
-
|
|
3404
|
-
|
|
3405
|
-
const
|
|
3406
|
-
|
|
3407
|
-
|
|
3408
|
-
|
|
3409
|
-
|
|
3410
|
-
|
|
3411
|
-
|
|
3412
|
-
|
|
3413
|
-
|
|
3414
|
-
|
|
3415
|
-
|
|
3416
|
-
|
|
3417
|
-
|
|
3453
|
+
const CODEMODS = [
|
|
3454
|
+
corePackage,
|
|
3455
|
+
{
|
|
3456
|
+
version: "0.3.0",
|
|
3457
|
+
id: "http-package",
|
|
3458
|
+
description: "Use @tatara/http for app setup, errors, the envelope, OpenAPI docs and the server",
|
|
3459
|
+
async run(ctx) {
|
|
3460
|
+
await ctx.addDependency(HTTP, versionOf(HTTP));
|
|
3461
|
+
const blockers = [];
|
|
3462
|
+
const replace = [];
|
|
3463
|
+
for (const path of [
|
|
3464
|
+
APP,
|
|
3465
|
+
ROUTES,
|
|
3466
|
+
...SERVERS
|
|
3467
|
+
]) {
|
|
3468
|
+
const text = await read(ctx, path);
|
|
3469
|
+
if (text === void 0) continue;
|
|
3470
|
+
if (await ctx.baseline.isUnchanged(path, text)) replace.push(path);
|
|
3471
|
+
else blockers.push(`${path} is customised`);
|
|
3472
|
+
}
|
|
3473
|
+
const present = [];
|
|
3474
|
+
for (const entry of MOVED) {
|
|
3475
|
+
const text = await read(ctx, entry.path);
|
|
3476
|
+
if (text === void 0) continue;
|
|
3477
|
+
present.push(entry);
|
|
3478
|
+
if (!await ctx.baseline.isUnchanged(entry.path, text)) blockers.push(`${entry.path} is customised`);
|
|
3479
|
+
}
|
|
3480
|
+
for (const path of await outsideImporters(ctx)) blockers.push(`${path} imports one of the moved files`);
|
|
3481
|
+
if (blockers.length > 0) {
|
|
3482
|
+
ctx.report.notes.push(`Nothing in src/core/http was changed: ${blockers.join("; ")}. Move these onto @tatara/http (createApp, mountRoutes, serveOpenApi, handleErrors, startServer and its error classes) by hand, then delete the old files.`);
|
|
3483
|
+
return;
|
|
3484
|
+
}
|
|
3485
|
+
for (const path of replace) {
|
|
3486
|
+
const next = await ctx.renderCurrent(path);
|
|
3487
|
+
if (next !== void 0) await ctx.write(path, next);
|
|
3488
|
+
}
|
|
3489
|
+
for (const entry of present) await ctx.removeIfUnchanged(entry.path);
|
|
3490
|
+
for (const specifier of [
|
|
3491
|
+
"#core/http/errors",
|
|
3492
|
+
"#core/http/response",
|
|
3493
|
+
"#core/http/middleware/validation"
|
|
3494
|
+
]) await ctx.rewriteImports(specifier, HTTP);
|
|
3418
3495
|
}
|
|
3419
|
-
|
|
3420
|
-
|
|
3421
|
-
|
|
3422
|
-
"#core/http/response",
|
|
3423
|
-
"#core/http/middleware/validation"
|
|
3424
|
-
]) await ctx.rewriteImports(specifier, HTTP);
|
|
3425
|
-
}
|
|
3426
|
-
}];
|
|
3496
|
+
},
|
|
3497
|
+
dbTesting
|
|
3498
|
+
];
|
|
3427
3499
|
//#endregion
|
|
3428
3500
|
//#region src/upgrade/context.ts
|
|
3429
3501
|
const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it } from "vitest";
|
|
|
3
3
|
import { auth } from "#core/auth/auth";
|
|
4
4
|
import { issuer } 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";
|
|
@@ -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";
|
|
@@ -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,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";
|
|
@@ -1,13 +1 @@
|
|
|
1
|
-
|
|
2
|
-
import { resolve } from "node:path";
|
|
3
|
-
import { promisify } from "node:util";
|
|
4
|
-
|
|
5
|
-
const run = promisify(execFile);
|
|
6
|
-
|
|
7
|
-
/** Applies prisma/migrations to the database at `url` (`prisma migrate deploy`). */
|
|
8
|
-
export async function migrateDatabase(url: string): Promise<void> {
|
|
9
|
-
await run(resolve("node_modules/.bin/prisma"), ["migrate", "deploy"], {
|
|
10
|
-
// tatara-check-ignore env-access: the Prisma CLI needs the full environment
|
|
11
|
-
env: { ...process.env, DATABASE_URL: url },
|
|
12
|
-
});
|
|
13
|
-
}
|
|
1
|
+
export { migrateDatabase } from "@tatara/db/prisma";
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { count, desc, eq } from "drizzle-orm";
|
|
2
2
|
import { db, type Executor } from "#core/db/client";
|
|
3
|
-
import { newId } from "
|
|
3
|
+
import { newId } from "@tatara/db";
|
|
4
4
|
import { NotFoundError } from "@tatara/http";
|
|
5
5
|
import type { PaginationQuery } from "@tatara/http";
|
|
6
6
|
import { __namePluralCamel__ } from "#modules/__moduleKebab__/db/__nameKebab__.schema";
|
package/templates/versions.json
CHANGED
|
@@ -1,13 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,14 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,9 +0,0 @@
|
|
|
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
|
-
}
|