create-ortha-app 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.
Files changed (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +7 -0
  3. package/dist/cli.d.ts +3 -0
  4. package/dist/cli.d.ts.map +1 -0
  5. package/dist/cli.js +277 -0
  6. package/dist/index.d.ts +11 -0
  7. package/dist/index.d.ts.map +1 -0
  8. package/dist/index.js +32 -0
  9. package/dist/lib/conditionals.d.ts +37 -0
  10. package/dist/lib/conditionals.d.ts.map +1 -0
  11. package/dist/lib/conditionals.js +115 -0
  12. package/dist/lib/features.d.ts +185 -0
  13. package/dist/lib/features.d.ts.map +1 -0
  14. package/dist/lib/features.js +328 -0
  15. package/dist/lib/template.d.ts +46 -0
  16. package/dist/lib/template.d.ts.map +1 -0
  17. package/dist/lib/template.js +115 -0
  18. package/dist/lib/ui.d.ts +76 -0
  19. package/dist/lib/ui.d.ts.map +1 -0
  20. package/dist/lib/ui.js +310 -0
  21. package/dist/lib/validate.d.ts +13 -0
  22. package/dist/lib/validate.d.ts.map +1 -0
  23. package/dist/lib/validate.js +51 -0
  24. package/package.json +37 -0
  25. package/templates/default/README.md.tmpl +199 -0
  26. package/templates/default/_gitignore +8 -0
  27. package/templates/default/apps/admin/index.html +38 -0
  28. package/templates/default/apps/admin/src/main.tsx +5 -0
  29. package/templates/default/apps/admin/src/plugins.spec.ts +58 -0
  30. package/templates/default/apps/admin/src/plugins.ts +57 -0
  31. package/templates/default/apps/admin/src/styles.css +254 -0
  32. package/templates/default/apps/admin/tsconfig.json +29 -0
  33. package/templates/default/apps/admin/vite.config.mts +67 -0
  34. package/templates/default/apps/admin-e2e/playwright.config.ts +51 -0
  35. package/templates/default/apps/admin-e2e/src/auth.spec.ts +55 -0
  36. package/templates/default/apps/admin-e2e/src/support/seed.ts +62 -0
  37. package/templates/default/apps/admin-e2e/tsconfig.json +23 -0
  38. package/templates/default/apps/server/jest.config.js +38 -0
  39. package/templates/default/apps/server/jest.setup.js +20 -0
  40. package/templates/default/apps/server/ortha.config.ts +505 -0
  41. package/templates/default/apps/server/src/main.ts +26 -0
  42. package/templates/default/apps/server/src/plugins.spec.ts +71 -0
  43. package/templates/default/apps/server/src/plugins.ts +229 -0
  44. package/templates/default/apps/server/tsconfig.json +31 -0
  45. package/templates/default/apps/server-e2e/jest.config.js +47 -0
  46. package/templates/default/apps/server-e2e/src/api.spec.ts +107 -0
  47. package/templates/default/apps/server-e2e/src/global-setup.ts +41 -0
  48. package/templates/default/apps/server-e2e/src/jest.setup.ts +28 -0
  49. package/templates/default/apps/server-e2e/src/support/db.ts +143 -0
  50. package/templates/default/apps/server-e2e/src/support/test-app.ts +64 -0
  51. package/templates/default/apps/server-e2e/tsconfig.json +26 -0
  52. package/templates/default/docker-compose.yml +21 -0
  53. package/templates/default/env.tmpl +140 -0
  54. package/templates/default/package.json.tmpl +51 -0
  55. package/templates/default/tsconfig.json +18 -0
@@ -0,0 +1,229 @@
1
+ import type { ServerPlugin } from '@orthacms/bootstrap-server';
2
+ import { ActivityPlugin } from '@orthacms/activity-server';
3
+ import { ContentPlugin } from '@orthacms/content-server';
4
+ import { DatabasePlugin } from '@orthacms/database';
5
+ import { I18nServerPlugin } from '@orthacms/i18n-server';
6
+ import { IdentityPlugin } from '@orthacms/identity-server';
7
+ // ortha:if sso-oidc
8
+ import type { SsoRegistration } from '@orthacms/identity-domain';
9
+ import { createOidcProvider } from '@orthacms/identity-provider-oidc';
10
+ // ortha:end
11
+ import { MediaServerPlugin } from '@orthacms/media-server';
12
+ // ortha:if media-local
13
+ import { createLocalStorageProvider } from '@orthacms/media-provider-local';
14
+ // ortha:end
15
+ // ortha:if media-s3
16
+ import { createS3StorageProvider } from '@orthacms/media-provider-s3';
17
+ // ortha:end
18
+ // ortha:if media-azure
19
+ import { createAzureStorageProvider } from '@orthacms/media-provider-azure';
20
+ // ortha:end
21
+ // ortha:if media-gcs
22
+ import { createGcsStorageProvider } from '@orthacms/media-provider-gcs';
23
+ // ortha:end
24
+ // ortha:if media-vercel-blob
25
+ import { createVercelBlobStorageProvider } from '@orthacms/media-provider-vercel-blob';
26
+ // ortha:end
27
+ import { UsersPlugin } from '@orthacms/users-server';
28
+ // ortha:if graphql
29
+ import { ContentGraphqlPlugin } from '@orthacms/content-graphql';
30
+ // ortha:end
31
+ // ortha:if mcp
32
+ import { McpPlugin } from '@orthacms/mcp-server';
33
+ // ortha:end
34
+ import {
35
+ CopilotPlugin,
36
+ type ProviderRegistration
37
+ } from '@orthacms/copilot-server';
38
+ import { createFakeProvider } from '@orthacms/copilot-provider-fake';
39
+ // ortha:if copilot-anthropic
40
+ import { createAnthropicProvider } from '@orthacms/copilot-provider-anthropic';
41
+ // ortha:end
42
+ // ortha:if copilot-openai
43
+ import { createOpenAiProvider } from '@orthacms/copilot-provider-openai';
44
+ // ortha:end
45
+ import { WorkspacesPlugin } from '@orthacms/workspaces-server';
46
+ import type { OrthaConfig } from '../ortha.config';
47
+
48
+ /**
49
+ * The model backends this deployment can actually reach, in preference order.
50
+ *
51
+ * **Only what is configured is registered.** `ortha.config.ts` omits a provider
52
+ * whose connection settings are absent, and an unconfigured backend is skipped
53
+ * here too: the first entry serves a run that names no provider, so a keyless
54
+ * one at the top of the list would be the house default and would fail on the
55
+ * first message.
56
+ *
57
+ * `fake` is last and unconditional. It is a shipped adapter, not test
58
+ * scaffolding — it needs no key and no network, so it is what makes the chat
59
+ * work offline, and being last it is the default only when it is the only one.
60
+ */
61
+ // ortha:if sso-oidc
62
+ /**
63
+ * The identity providers this app can actually reach.
64
+ *
65
+ * **Only what is configured is registered.** `ortha.config.ts` omits a provider
66
+ * whose issuer or client id is missing, and an unconfigured one is skipped here
67
+ * too — it would appear on the sign-in page as a button that can only fail.
68
+ *
69
+ * Running two directories at once is another entry. The name is what
70
+ * `/api/auth/sso/<name>/start` and every `sso_identities` row refer to the
71
+ * provider by, so renaming a registration orphans its links. Register the
72
+ * callback URL `<publicBaseUrl>/api/auth/sso/<name>/callback` with the
73
+ * provider; `ssoCallbackUrl` from `@orthacms/identity-server` builds the exact
74
+ * string, which matters because most providers match it byte for byte.
75
+ */
76
+ export function ssoProviders(config: OrthaConfig): SsoRegistration[] {
77
+ const oidc = config.plugins.identity.ssoProviders?.oidc;
78
+ if (!oidc) {
79
+ return [];
80
+ }
81
+ const { name, ...settings } = oidc;
82
+ return [{ name, provider: createOidcProvider(settings) }];
83
+ }
84
+ // ortha:end
85
+
86
+ export function copilotProviders(config: OrthaConfig): ProviderRegistration[] {
87
+ const providers: ProviderRegistration[] = [];
88
+ // ortha:if copilot-anthropic
89
+ if (config.plugins.copilot.providers.claude) {
90
+ providers.push({
91
+ name: 'claude',
92
+ provider: createAnthropicProvider(
93
+ config.plugins.copilot.providers.claude
94
+ )
95
+ });
96
+ }
97
+ // ortha:end
98
+ // ortha:if copilot-openai
99
+ if (config.plugins.copilot.providers.openai) {
100
+ providers.push({
101
+ name: 'openai',
102
+ provider: createOpenAiProvider(
103
+ config.plugins.copilot.providers.openai
104
+ )
105
+ });
106
+ }
107
+ // ortha:end
108
+ providers.push({ name: 'fake', provider: createFakeProvider() });
109
+
110
+ return providers;
111
+ }
112
+
113
+ /**
114
+ * This app's composition — the whole of what its API is.
115
+ *
116
+ * **The order is migration order.** Migrations are applied by walking this
117
+ * array, with no transaction spanning plugins, so a plugin whose tables
118
+ * reference another's must come after it. `WorkspacesPlugin` follows
119
+ * `IdentityPlugin` because its `memberships` table FK-references identity's
120
+ * `users`: put it first and a *fresh* `ortha migrate` fails with
121
+ * `relation "users" does not exist`, while an already-migrated database
122
+ * migrates perfectly happily — so the mistake ships and bites the next clean
123
+ * install. Add new plugins at the end unless you have a reason not to.
124
+ *
125
+ * Order does **not** decide dependency injection: every plugin module is
126
+ * global and every `onPluginInit` runs before the Nest app is created, so no
127
+ * provider can be constructed before the database connection is open.
128
+ *
129
+ * To add a plugin, install it and add a line. To remove one, delete its line —
130
+ * plugins that extend each other do so through optional ports, so removing one
131
+ * degrades the feature rather than failing boot.
132
+ */
133
+ export function buildPlugins(config: OrthaConfig): ServerPlugin[] {
134
+ // No content types yet — see the note below. Held in a variable because
135
+ // the GraphQL adapter takes the plugin itself, not just its types.
136
+ const content = ContentPlugin({ types: [] });
137
+
138
+ return [
139
+ // First: the only plugin that opens a resource in `onPluginInit`.
140
+ DatabasePlugin({ connectionString: config.database.url }),
141
+ // ortha:if sso-oidc
142
+ // Identity, plus the identity providers this app offers. The second
143
+ // argument is where constructed adapters go: `ortha.config.ts` holds
144
+ // the typed view of the environment, and an adapter instance is not an
145
+ // environment value.
146
+ IdentityPlugin(config.plugins.identity, {
147
+ sso: { providers: ssoProviders(config) }
148
+ }),
149
+ // ortha:end
150
+ // ortha:ifnot sso-oidc
151
+ IdentityPlugin(config.plugins.identity),
152
+ // ortha:end
153
+ WorkspacesPlugin(),
154
+ ActivityPlugin(),
155
+ UsersPlugin(),
156
+ // No content types yet. Define some in `src/server/content/`, pass them
157
+ // here as `types`, then add a `drizzle.config.ts` pointing at them and
158
+ // a `migrations` descriptor so `ortha generate` / `ortha migrate` can
159
+ // manage their tables:
160
+ //
161
+ // ContentPlugin({
162
+ // types: contentTypes,
163
+ // migrations: {
164
+ // dir: () => join(process.cwd(), 'migrations'),
165
+ // table: '__drizzle_migrations_content'
166
+ // }
167
+ // })
168
+ //
169
+ // Until then the plugin serves its generic routes with an empty
170
+ // registry, and owns no tables of its own.
171
+ content,
172
+ // ortha:if graphql
173
+ // The same public content API over GraphQL, on /api/v1/graphql. It owns
174
+ // no schema and adds no credential — it reuses content's bearer guards
175
+ // and read services, so a token minted before it existed works against
176
+ // it unchanged. Taking `content` by value lets it fail boot on two
177
+ // content types that would collide as GraphQL names, rather than on the
178
+ // first request from a workspace granted both.
179
+ ContentGraphqlPlugin({
180
+ content,
181
+ playground: config.docs.enabled === true
182
+ }),
183
+ // ortha:end
184
+ // Fills the Content Library's locale extensions, so it reads after it.
185
+ I18nServerPlugin(config.plugins.i18n),
186
+ // This line is the single place that selects storage — one constructed
187
+ // provider, writing every upload to local disk. A deployment runs
188
+ // exactly one; swapping backend is swapping this expression (and the
189
+ // type of `media.storage` with it).
190
+ MediaServerPlugin({
191
+ // ortha:if media-local
192
+ provider: createLocalStorageProvider(config.plugins.media.storage),
193
+ // ortha:end
194
+ // ortha:if media-s3
195
+ provider: createS3StorageProvider(config.plugins.media.storage),
196
+ // ortha:end
197
+ // ortha:if media-azure
198
+ provider: createAzureStorageProvider(config.plugins.media.storage),
199
+ // ortha:end
200
+ // ortha:if media-gcs
201
+ provider: createGcsStorageProvider(config.plugins.media.storage),
202
+ // ortha:end
203
+ // ortha:if media-vercel-blob
204
+ provider: createVercelBlobStorageProvider(
205
+ config.plugins.media.storage
206
+ ),
207
+ // ortha:end
208
+ config: config.plugins.media
209
+ }),
210
+ // Registered after workspaces (runs are workspace-scoped) and identity
211
+ // (runs execute as the calling user, gated on `copilot:use`). The
212
+ // composition root is the single place that selects a backend: the
213
+ // plugin never learns which adapters exist, it takes a list of named,
214
+ // already-constructed providers, and **the order is the setting** —
215
+ // there is no `defaultProvider`, the first entry serves a run that
216
+ // names none.
217
+ CopilotPlugin({
218
+ providers: copilotProviders(config),
219
+ config: config.plugins.copilot
220
+ }),
221
+ // ortha:if mcp
222
+ // The Model Context Protocol front door, registered LAST because it
223
+ // serves whatever the plugins above contributed. Off unless
224
+ // MCP_ENABLED=true: it hands an external agent the same content CRUD a
225
+ // full-scope token has.
226
+ McpPlugin({ config: config.plugins.mcp })
227
+ // ortha:end
228
+ ];
229
+ }
@@ -0,0 +1,31 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "es2022",
4
+ "lib": [
5
+ "es2023"
6
+ ],
7
+ "module": "commonjs",
8
+ "moduleResolution": "node",
9
+ "outDir": "../../dist/server",
10
+ "rootDir": ".",
11
+ "types": [
12
+ "node"
13
+ ],
14
+ "strict": true,
15
+ "skipLibCheck": true,
16
+ "esModuleInterop": true,
17
+ "forceConsistentCasingInFileNames": true,
18
+ "resolveJsonModule": true,
19
+ "sourceMap": true,
20
+ "declaration": false,
21
+ "experimentalDecorators": true,
22
+ "emitDecoratorMetadata": true,
23
+ "tsBuildInfoFile": "../../dist/server/tsconfig.tsbuildinfo"
24
+ },
25
+ "include": [
26
+ "**/*.ts"
27
+ ],
28
+ "exclude": [
29
+ "**/*.spec.ts"
30
+ ]
31
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Server end-to-end tests — the real app, a real database.
3
+ *
4
+ * Separate from `jest.config.js` on purpose: these need a database, take
5
+ * seconds rather than milliseconds, and must run **serially**. `npm test`
6
+ * stays fast and infrastructure-free; `npm run e2e:server` is the slow one you
7
+ * run before pushing.
8
+ */
9
+ module.exports = {
10
+ displayName: 'server-e2e',
11
+ testEnvironment: 'node',
12
+ rootDir: '../..',
13
+ testMatch: ['<rootDir>/apps/server-e2e/src/**/*.spec.ts'],
14
+ globalSetup: '<rootDir>/apps/server-e2e/src/global-setup.ts',
15
+ setupFiles: ['<rootDir>/apps/server-e2e/src/jest.setup.ts'],
16
+ // One worker. The suite truncates shared tables, so parallel workers
17
+ // delete each other's fixtures — and the failure surfaces as whichever
18
+ // assertion happened to read a row mid-truncate, never as the race it is.
19
+ maxWorkers: 1,
20
+ // Booting the app migrates nothing but does open a pool and run the
21
+ // bootstrap seeders; the default 5s is not enough on a cold database.
22
+ testTimeout: 30_000,
23
+ transform: {
24
+ '^.+\\.[tj]s$': [
25
+ '@swc/jest',
26
+ {
27
+ jsc: {
28
+ target: 'es2022',
29
+ parser: { syntax: 'typescript', decorators: true },
30
+ transform: {
31
+ legacyDecorator: true,
32
+ decoratorMetadata: true
33
+ }
34
+ },
35
+ module: { type: 'commonjs' }
36
+ }
37
+ ]
38
+ },
39
+ // Jest runs CommonJS, and `createServer` pulls in the Scalar API-reference
40
+ // renderer, which is published as ESM only — so `node_modules` cannot be
41
+ // ignored wholesale or the import fails with `Unexpected token 'export'`
42
+ // pointing at a file nobody in this app wrote. Letting swc transform that
43
+ // one scope is cheaper than stubbing the module and pretending the docs
44
+ // route does not exist.
45
+ transformIgnorePatterns: ['/node_modules/(?!@scalar/)'],
46
+ moduleFileExtensions: ['ts', 'js', 'json']
47
+ };
@@ -0,0 +1,107 @@
1
+ import request from 'supertest';
2
+ import { resolveTestDatabaseUrl, resetDatabase } from './support/db';
3
+ import {
4
+ ALLOWED_ORIGIN,
5
+ closeTestApp,
6
+ createTestApp,
7
+ type TestApp
8
+ } from './support/test-app';
9
+
10
+ /**
11
+ * The API, driven through the real bootstrap.
12
+ *
13
+ * These are the checks that only mean something once every plugin is wired
14
+ * together — guards actually mounted, the auth flow actually issuing a cookie.
15
+ * Anything provable from a single function belongs in a unit test, where it
16
+ * runs in milliseconds and needs no database.
17
+ */
18
+ describe('the API', () => {
19
+ let harness: TestApp;
20
+
21
+ beforeAll(async () => {
22
+ harness = await createTestApp();
23
+ });
24
+
25
+ afterAll(async () => {
26
+ await closeTestApp(harness);
27
+ });
28
+
29
+ describe('authentication', () => {
30
+ it('refuses an unauthenticated read', async () => {
31
+ await request(harness.server).get('/api/workspaces').expect(401);
32
+ });
33
+
34
+ it('signs in the root admin provisioned at boot', async () => {
35
+ const response = await request(harness.server)
36
+ .post('/api/auth/login')
37
+ .set('Origin', ALLOWED_ORIGIN)
38
+ .send({
39
+ email: process.env['ORTHA_ROOT_ADMIN_EMAIL'],
40
+ password: process.env['ORTHA_ROOT_ADMIN_PASSWORD']
41
+ })
42
+ .expect(201);
43
+
44
+ expect(response.headers['set-cookie']).toBeDefined();
45
+ });
46
+
47
+ it('accepts that session on a protected route', async () => {
48
+ const agent = request.agent(harness.server);
49
+
50
+ await agent
51
+ .post('/api/auth/login')
52
+ .set('Origin', ALLOWED_ORIGIN)
53
+ .send({
54
+ email: process.env['ORTHA_ROOT_ADMIN_EMAIL'],
55
+ password: process.env['ORTHA_ROOT_ADMIN_PASSWORD']
56
+ })
57
+ .expect(201);
58
+
59
+ await agent.get('/api/workspaces').expect(200);
60
+ });
61
+
62
+ it('rejects a wrong password without saying which half was wrong', async () => {
63
+ // Distinguishing "no such user" from "wrong password" turns the
64
+ // login form into an account enumerator.
65
+ const response = await request(harness.server)
66
+ .post('/api/auth/login')
67
+ .set('Origin', ALLOWED_ORIGIN)
68
+ .send({
69
+ email: process.env['ORTHA_ROOT_ADMIN_EMAIL'],
70
+ password: 'not-the-password'
71
+ })
72
+ .expect(401);
73
+
74
+ expect(JSON.stringify(response.body)).not.toMatch(/password|email/i);
75
+ });
76
+ });
77
+
78
+ describe('routing', () => {
79
+ it('answers an unknown path with a JSON 404', async () => {
80
+ const response = await request(harness.server)
81
+ .get('/api/not-a-real-endpoint')
82
+ .expect(404);
83
+
84
+ expect(response.body).toMatchObject({ statusCode: 404 });
85
+ });
86
+ });
87
+ });
88
+
89
+ /**
90
+ * The reset helper, proven on the database this suite actually uses.
91
+ *
92
+ * Every suite that seeds rows depends on this leaving the schema intact — a
93
+ * `TRUNCATE` that took the migration history with it would look like a
94
+ * migration bug in whichever suite ran next.
95
+ */
96
+ describe('resetDatabase()', () => {
97
+ it('empties the tables without touching the migration history', async () => {
98
+ const url = resolveTestDatabaseUrl();
99
+
100
+ await expect(resetDatabase(url)).resolves.toBeUndefined();
101
+
102
+ // The app still boots, which it could not do against a dropped schema.
103
+ const harness = await createTestApp();
104
+ await request(harness.server).get('/api/workspaces').expect(401);
105
+ await closeTestApp(harness);
106
+ });
107
+ });
@@ -0,0 +1,41 @@
1
+ import {
2
+ assertDisposable,
3
+ ensureDatabase,
4
+ loadDotEnv,
5
+ resolveTestDatabaseUrl
6
+ } from './support/db';
7
+
8
+ /**
9
+ * Creates and migrates the e2e database once for the whole run.
10
+ *
11
+ * Migrations go through `applyPluginMigrations` — the same function
12
+ * `ortha migrate` calls — so the schema under test is the real shipped schema,
13
+ * applied in the same plugin order. A hand-rolled setup would drift from what
14
+ * the app does the first time a plugin is added.
15
+ *
16
+ * The app's config and plugin list are pulled in with **dynamic** imports, and
17
+ * that is load-bearing rather than stylistic: `ortha.config.ts` reads
18
+ * `process.env` at import time and throws without `DATABASE_URL`. A static
19
+ * import is hoisted above every statement here, so it would run before `.env`
20
+ * is loaded and before the URL is pointed at the test database — and the whole
21
+ * suite would fail on a variable that is sitting in a file two lines away.
22
+ */
23
+ export default async function globalSetup(): Promise<void> {
24
+ loadDotEnv();
25
+
26
+ const url = resolveTestDatabaseUrl();
27
+ assertDisposable(url);
28
+ await ensureDatabase(url);
29
+
30
+ // Only now is it safe to let the config load.
31
+ process.env['DATABASE_URL'] = url;
32
+
33
+ const [{ applyPluginMigrations }, { default: config }, { buildPlugins }] =
34
+ await Promise.all([
35
+ import('@orthacms/cli'),
36
+ import('../../server/ortha.config'),
37
+ import('../../server/src/plugins')
38
+ ]);
39
+
40
+ await applyPluginMigrations(buildPlugins(config), url);
41
+ }
@@ -0,0 +1,28 @@
1
+ import { Logger } from '@nestjs/common';
2
+ import { loadDotEnv, resolveTestDatabaseUrl } from './support/db';
3
+
4
+ /**
5
+ * Points this worker at the e2e database **before any module loads**.
6
+ *
7
+ * `ortha.config.ts` reads `process.env` at import time, so this has to be a
8
+ * `setupFiles` entry — those run before the module registry is touched, unlike
9
+ * `setupFilesAfterEnv`. Jest loads no `.env` of its own in a worker either,
10
+ * hence `loadDotEnv` first.
11
+ *
12
+ * Nothing is handed over from `global-setup`: the URL is derived by the same
13
+ * pure function in both places, so there is no channel to get out of step.
14
+ */
15
+ loadDotEnv();
16
+
17
+ process.env['DATABASE_URL'] = resolveTestDatabaseUrl();
18
+
19
+ // A root admin is provisioned on boot when these are set, which is what gives
20
+ // the suite a real account to sign in with.
21
+ process.env['ORTHA_ROOT_ADMIN_EMAIL'] ||= 'e2e@example.com';
22
+ process.env['ORTHA_ROOT_ADMIN_PASSWORD'] ||= 'e2e-password-not-a-secret';
23
+
24
+ // `createServer` builds its own Nest app and takes no logger option, so this
25
+ // is the only way to keep a full boot banner — every mapped route, every
26
+ // seeder — out of the output for each suite that boots one. A failing
27
+ // assertion is then the first thing on screen instead of the last.
28
+ Logger.overrideLogger(false);
@@ -0,0 +1,143 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { Pool } from 'pg';
3
+
4
+ /**
5
+ * Loads `.env` into `process.env`.
6
+ *
7
+ * Jest loads nothing — not in the main process where `globalSetup` runs, and
8
+ * not in the workers. `ortha.config.ts` reads `process.env` at import time and
9
+ * refuses to load without `DATABASE_URL`, so every entry point into this suite
10
+ * has to call this **before** anything reaches the config.
11
+ *
12
+ * Values already exported in the shell win, which is what lets CI set the
13
+ * database without a file.
14
+ */
15
+ export function loadDotEnv(): void {
16
+ if (existsSync('.env') && typeof process.loadEnvFile === 'function') {
17
+ process.loadEnvFile('.env');
18
+ }
19
+ }
20
+
21
+ /**
22
+ * The database the server e2e suite runs against.
23
+ *
24
+ * **Never your development database.** This suite truncates every table
25
+ * between tests, and `DATABASE_URL` is routinely set in a `.env` pointing at
26
+ * the database you are actually working in. So the URL is derived into a
27
+ * separate `<name>_e2e`, and `E2E_DATABASE_URL` overrides it — an opt-in name
28
+ * that cannot be triggered by accident.
29
+ */
30
+ const SUFFIX = '_e2e';
31
+
32
+ export function resolveTestDatabaseUrl(): string {
33
+ const explicit = process.env['E2E_DATABASE_URL']?.trim();
34
+ if (explicit) return explicit;
35
+
36
+ const base = process.env['DATABASE_URL']?.trim();
37
+ if (!base) {
38
+ throw new Error(
39
+ 'Neither E2E_DATABASE_URL nor DATABASE_URL is set — the e2e suite ' +
40
+ 'needs to know which database to create and truncate.'
41
+ );
42
+ }
43
+
44
+ const url = new URL(base);
45
+ const database = url.pathname.replace(/^\//, '');
46
+
47
+ // **Idempotent.** `global-setup` runs in Jest's main process and points
48
+ // `DATABASE_URL` at the test database; workers fork from it and inherit
49
+ // that value, so a naive append derives `app_e2e_e2e` in the worker and
50
+ // every test then fails against a database nobody created.
51
+ if (database.endsWith(SUFFIX)) return base;
52
+
53
+ url.pathname = `/${database}${SUFFIX}`;
54
+
55
+ return url.toString();
56
+ }
57
+
58
+ /**
59
+ * Refuses to run against the database `DATABASE_URL` names.
60
+ *
61
+ * The derivation above makes a collision unlikely, but `E2E_DATABASE_URL` can
62
+ * be set to anything — and the first thing this suite does to whatever it is
63
+ * handed is delete every row. Check before the first `TRUNCATE`, not after.
64
+ */
65
+ export function assertDisposable(testUrl: string): void {
66
+ const development = process.env['DATABASE_URL']?.trim();
67
+
68
+ if (development && new URL(testUrl).href === new URL(development).href) {
69
+ throw new Error(
70
+ 'E2E_DATABASE_URL points at the same database as DATABASE_URL. ' +
71
+ 'This suite truncates every table — point it somewhere disposable.'
72
+ );
73
+ }
74
+ }
75
+
76
+ /** Opens a pool against the `postgres` maintenance database on the same host. */
77
+ function maintenancePool(testUrl: string): Pool {
78
+ const url = new URL(testUrl);
79
+ const database = url.pathname.replace(/^\//, '');
80
+ url.pathname = '/postgres';
81
+
82
+ return Object.assign(new Pool({ connectionString: url.toString() }), {
83
+ targetDatabase: database
84
+ });
85
+ }
86
+
87
+ /**
88
+ * Creates the test database if it is not there yet.
89
+ *
90
+ * `CREATE DATABASE` cannot run inside a transaction and has no `IF NOT
91
+ * EXISTS`, so this asks first. Losing the race with another runner is fine —
92
+ * `42P04` means someone else created it, which is the outcome we wanted.
93
+ */
94
+ export async function ensureDatabase(testUrl: string): Promise<void> {
95
+ const pool = maintenancePool(testUrl);
96
+ const database = (pool as Pool & { targetDatabase: string })
97
+ .targetDatabase;
98
+
99
+ try {
100
+ const { rowCount } = await pool.query(
101
+ 'SELECT 1 FROM pg_database WHERE datname = $1',
102
+ [database]
103
+ );
104
+ if (rowCount === 0) {
105
+ // The name comes from our own derivation, not from user input, and
106
+ // CREATE DATABASE takes no parameters.
107
+ await pool.query(`CREATE DATABASE "${database}"`);
108
+ }
109
+ } catch (error) {
110
+ if ((error as { code?: string }).code !== '42P04') throw error;
111
+ } finally {
112
+ await pool.end();
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Empties every table, leaving the schema and the migration history alone.
118
+ *
119
+ * One `TRUNCATE … CASCADE` rather than a delete per table: it ignores foreign
120
+ * keys, so the suite never has to know which order its own content types
121
+ * depend on each other in. `RESTART IDENTITY` keeps sequences from drifting
122
+ * across a run, so an id asserted in one test means the same thing in the next.
123
+ */
124
+ export async function resetDatabase(testUrl: string): Promise<void> {
125
+ const pool = new Pool({ connectionString: testUrl });
126
+
127
+ try {
128
+ const { rows } = await pool.query<{ tables: string[] }>(`
129
+ SELECT array_agg(format('%I.%I', schemaname, tablename)) AS tables
130
+ FROM pg_tables
131
+ WHERE schemaname = 'public'
132
+ AND tablename NOT LIKE '\\_\\_drizzle%'
133
+ `);
134
+ const tables = rows[0]?.tables;
135
+ if (!tables?.length) return;
136
+
137
+ await pool.query(
138
+ `TRUNCATE ${tables.join(', ')} RESTART IDENTITY CASCADE`
139
+ );
140
+ } finally {
141
+ await pool.end();
142
+ }
143
+ }