@podosoft/podokit 1.5.0 → 1.6.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/README.md +2 -1
- package/dist/app-proxy-timeouts.d.ts +27 -0
- package/dist/app-proxy-timeouts.js +180 -0
- package/dist/assemble.d.ts +5 -2
- package/dist/assemble.js +14 -4
- package/dist/deploy-checks.d.ts +44 -0
- package/dist/deploy-checks.js +267 -0
- package/dist/deploy-compose-profile.d.ts +6 -2
- package/dist/deploy-compose-profile.js +15 -6
- package/dist/deploy-compose-render.d.ts +12 -4
- package/dist/deploy-compose-render.js +47 -18
- package/dist/deploy-compose.d.ts +2 -5
- package/dist/deploy-compose.js +7 -2
- package/dist/deploy-profile.d.ts +6 -2
- package/dist/deploy-profile.js +11 -7
- package/dist/deploy-render.d.ts +4 -1
- package/dist/deploy-render.js +27 -21
- package/dist/deploy-schema.d.ts +56 -2
- package/dist/deploy-schema.js +130 -14
- package/dist/deploy.d.ts +2 -5
- package/dist/deploy.js +5 -2
- package/dist/dev.d.ts +21 -0
- package/dist/dev.js +78 -3
- package/dist/eject.js +2 -0
- package/dist/index.js +85 -5
- package/dist/lib.d.ts +2 -0
- package/dist/lib.js +5 -2
- package/dist/lockfile.d.ts +24 -0
- package/dist/lockfile.js +74 -4
- package/dist/provider.js +10 -5
- package/dist/remove.js +1 -1
- package/dist/template-files.d.ts +42 -0
- package/dist/template-files.js +102 -0
- package/dist/templates/fullstack/AGENTS.md +28 -4
- package/dist/templates/fullstack/README.md +8 -7
- package/dist/templates/fullstack/apps/api/package.json +4 -4
- package/dist/templates/fullstack/apps/api/scripts/build.extensions.mjs +6 -0
- package/dist/templates/fullstack/apps/api/scripts/build.mjs +65 -16
- package/dist/templates/fullstack/apps/api/scripts/run-migrations.mjs +7 -3
- package/dist/templates/fullstack/apps/api/scripts/verify-api-contract.ts +5 -1
- package/dist/templates/fullstack/apps/api/src/app.spec.ts +22 -0
- package/dist/templates/fullstack/apps/api/src/app.ts +41 -25
- package/dist/templates/fullstack/apps/api/src/build.spec.ts +118 -0
- package/dist/templates/fullstack/apps/api/src/config/env.extensions.ts +20 -0
- package/dist/templates/fullstack/apps/api/src/config/env.validation.spec.ts +76 -0
- package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +18 -3
- package/dist/templates/fullstack/apps/api/src/config/provider-selection.spec.ts +53 -0
- package/dist/templates/fullstack/apps/api/src/config/provider-selection.ts +79 -0
- package/dist/templates/fullstack/apps/api/src/config/providers.ts +7 -3
- package/dist/templates/fullstack/apps/api/src/core/api-contract.extensions.ts +6 -0
- package/dist/templates/fullstack/apps/api/src/core/api-contract.spec.ts +29 -0
- package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +39 -2
- package/dist/templates/fullstack/apps/api/src/core/services.ts +13 -0
- package/dist/templates/fullstack/apps/api/src/database/data-source.spec.ts +27 -0
- package/dist/templates/fullstack/apps/api/src/database/data-source.ts +7 -10
- package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +12 -0
- package/dist/templates/fullstack/apps/api/src/database/database.ts +2 -3
- package/dist/templates/fullstack/apps/api/src/database/entities.ts +12 -0
- package/dist/templates/fullstack/apps/api/src/database/migrate.ts +12 -2
- package/dist/templates/fullstack/apps/api/src/database/migration-files.spec.ts +116 -0
- package/dist/templates/fullstack/apps/api/src/database/migration-files.ts +134 -0
- package/dist/templates/fullstack/apps/api/src/database/migration-readiness.spec.ts +36 -0
- package/dist/templates/fullstack/apps/api/src/database/migration-readiness.ts +25 -0
- package/dist/templates/fullstack/apps/api/src/database/run-migrations.spec.ts +12 -1
- package/dist/templates/fullstack/apps/api/src/database/sqlite-migrator.ts +2 -36
- package/dist/templates/fullstack/apps/api/src/main.ts +2 -2
- package/dist/templates/fullstack/apps/api/tsconfig.json +2 -0
- package/dist/templates/fullstack/apps/web/package.json +1 -1
- package/dist/templates/fullstack/apps/web/src/app.css +3 -0
- package/dist/templates/fullstack/apps/web/src/app.extensions.css +11 -0
- package/dist/templates/fullstack/apps/web/src/lib/components/site-runtime.svelte +3 -1
- package/dist/templates/fullstack/apps/web/src/lib/server/api.test.ts +19 -0
- package/dist/templates/fullstack/apps/web/src/lib/server/api.ts +4 -1
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +79 -0
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +55 -1
- package/dist/templates/fullstack/apps/web/src/lib/server/proxy-timeouts.ts +2 -1
- package/dist/templates/fullstack/apps/web/tsconfig.json +1 -1
- package/dist/templates/fullstack/apps/web/vite.config.test.ts +33 -0
- package/dist/templates/fullstack/apps/web/vite.config.ts +55 -13
- package/dist/templates/fullstack/apps/web/vite.extensions.ts +23 -0
- package/dist/templates/fullstack/compose.dev.yaml +7 -4
- package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/SKILL.md +2 -2
- package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +12 -3
- package/dist/templates/fullstack/dot-claude/skills/podokit-update/SKILL.md +5 -2
- package/dist/templates/fullstack/dot-env.docker.example +5 -2
- package/dist/templates/fullstack/dot-env.example +4 -0
- package/dist/templates/fullstack/dot-podokit/dev.json +1 -1
- package/dist/templates/fullstack/infra/k3s/configmap.yaml +1 -0
- package/dist/templates/fullstack/tests/README.md +40 -1
- package/dist/templates/fullstack/tests/helpers/app-paths.ts +18 -0
- package/dist/templates/fullstack/tests/helpers/paths.ts +33 -0
- package/dist/templates/fullstack/tests/package.json +2 -2
- package/dist/templates/fullstack/tests/playwright.config.ts +7 -3
- package/dist/templates/fullstack/tests/playwright.extensions.ts +88 -1
- package/dist/templates/fullstack/tests/ui/smoke.ui.spec.ts +5 -4
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.test.ts +37 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +15 -6
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-flag-settings.test.ts +31 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-flag-settings.ts +31 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-flags.ts +12 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-nav.ts +11 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/nav.test.ts +39 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/nav.ts +35 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/registry.svelte.ts +15 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +11 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/admin-sidebar.svelte +2 -23
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/data-table.svelte +7 -2
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/data-table.test.ts +30 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/impersonation-banner.svelte +3 -2
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/settings/app-flags-settings.svelte +65 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/sidebar-user-menu.svelte +32 -23
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/sidebar-user-menu.test.ts +27 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-footer.svelte +4 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-runtime.svelte +3 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/en.json +6 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/ko.json +6 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/app-hooks.ts +11 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/non-indexable-paths.ts +8 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/search-indexing.test.ts +31 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/search-indexing.ts +5 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/settings/+page.svelte +1 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/account/+page.server.ts +2 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/account/+page.svelte +1 -5
- package/dist/templates/modules/admin-dashboard/files/dot-claude/skills/podokit-data-table/SKILL.md +2 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +159 -170
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +72 -81
- package/dist/templates/modules/admin-dashboard/files/tests/api/email.api.spec.ts +1 -6
- package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +43 -53
- package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +3 -1
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/accounts.ts +24 -6
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/app-accounts.ts +24 -0
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/devices.ts +41 -11
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/disposable-users.ts +5 -3
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/mailpit.ts +3 -9
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/sms.ts +2 -4
- package/dist/templates/modules/admin-dashboard/files/tests/playwright.config.ts +11 -5
- package/dist/templates/modules/admin-dashboard/files/tests/ui/account-flows.ui.spec.ts +4 -5
- package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +55 -55
- package/dist/templates/modules/admin-dashboard/files/tests/ui/app-flags.ui.spec.ts +63 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/appearance.ui.spec.ts +12 -16
- package/dist/templates/modules/admin-dashboard/files/tests/ui/audit.ui.spec.ts +3 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/auth.ui.spec.ts +32 -35
- package/dist/templates/modules/admin-dashboard/files/tests/ui/dashboard.ui.spec.ts +4 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/email-admin.ui.spec.ts +1 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +1 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/general-effects.ui.spec.ts +82 -91
- package/dist/templates/modules/admin-dashboard/files/tests/ui/guards.ui.spec.ts +6 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/i18n.ui.spec.ts +16 -20
- package/dist/templates/modules/admin-dashboard/files/tests/ui/impersonation.ui.spec.ts +6 -3
- package/dist/templates/modules/admin-dashboard/files/tests/ui/login-methods.ui.spec.ts +2 -3
- package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +2 -4
- package/dist/templates/modules/admin-dashboard/files/tests/ui/organizations.ui.spec.ts +5 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/passkey.ui.spec.ts +7 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/same-origin-requests.ui.spec.ts +89 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/sessions.ui.spec.ts +5 -4
- package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +67 -73
- package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +84 -93
- package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +8 -7
- package/dist/templates/modules/admin-dashboard/files/tests/ui/theme-mode.ui.spec.ts +29 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +51 -54
- package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +10 -9
- package/dist/templates/modules/admin-dashboard/module.manifest.json +7 -2
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.module.ts +5 -1
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.spec.ts +17 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +18 -0
- package/dist/templates/modules/audit-log/module.manifest.json +15 -3
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-app-name.spec.ts +21 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-config-store.ts +13 -1
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-environment.ts +9 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +19 -3
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.module.ts +2 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.runtime.spec.ts +10 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +7 -5
- package/dist/templates/modules/auth/files/apps/api/src/auth/schema-recheck.spec.ts +86 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/schema-recheck.ts +44 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +2 -1
- package/dist/templates/modules/auth/files/apps/api/src/migrate.ts +3 -1
- package/dist/templates/modules/auth/files/apps/api/src/settings/app-flags.ts +9 -0
- package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.spec.ts +43 -0
- package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.ts +30 -5
- package/dist/templates/modules/auth/module.manifest.json +8 -2
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.spec.ts +57 -1
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.ts +93 -9
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/worker.module.spec.ts +40 -0
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/worker.module.ts +52 -5
- package/dist/templates/modules/bullmq/files/apps/api/src/main-worker.ts +9 -7
- package/dist/templates/modules/bullmq/files/apps/api/src/worker.extensions.ts +13 -0
- package/dist/templates/modules/bullmq/module.manifest.json +5 -1
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.worker.spec.ts +2 -0
- package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.spec.ts +87 -1
- package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.ts +93 -3
- package/dist/templates/modules/jobs-local/module.manifest.json +3 -0
- package/dist/templates/modules/logging/files/apps/api/src/logging/logging.module.spec.ts +16 -0
- package/dist/templates/modules/logging/files/apps/api/src/logging/logging.module.ts +15 -10
- package/dist/templates/modules/mailer/module.manifest.json +1 -1
- package/dist/templates/modules/object-storage-local/files/tests/api/storage-local.api.spec.ts +1 -1
- package/dist/templates/modules/object-storage-s3/files/infra/docker/minio.compose.yml +5 -4
- package/dist/templates/modules/object-storage-s3/files/tests/api/storage.api.spec.ts +1 -1
- package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +45 -20
- package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +1 -1
- package/dist/templates/todo/AGENTS.md +5 -1
- package/dist/templates/todo/README.md +6 -4
- package/dist/text-diff.d.ts +12 -0
- package/dist/text-diff.js +111 -0
- package/dist/update.d.ts +61 -3
- package/dist/update.js +152 -27
- package/package.json +2 -2
- package/dist/templates/modules/admin-dashboard/files/tests/api/exclusive.api.spec.ts +0 -75
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/exclusive.ts +0 -80
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CACHE,
|
|
3
|
+
EVENTS,
|
|
4
|
+
JOBS,
|
|
5
|
+
OBJECT_STORAGE,
|
|
6
|
+
PROVIDER_CAPABILITIES,
|
|
7
|
+
PROVIDER_NAMES,
|
|
8
|
+
type ProviderCapability,
|
|
9
|
+
type ProviderSelections,
|
|
10
|
+
type ServiceKey,
|
|
11
|
+
} from "@podosoft/podokit-runtime";
|
|
12
|
+
|
|
13
|
+
/** The variable that replaces each capability's generated provider at startup. */
|
|
14
|
+
export const PROVIDER_VARIABLES: Readonly<Record<ProviderCapability, string>> = {
|
|
15
|
+
database: "DATABASE_PROVIDER",
|
|
16
|
+
cache: "CACHE_PROVIDER",
|
|
17
|
+
"object-storage": "OBJECT_STORAGE_PROVIDER",
|
|
18
|
+
events: "EVENTS_PROVIDER",
|
|
19
|
+
jobs: "JOBS_PROVIDER",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type ProviderEnvironment = Readonly<Record<string, string | undefined>>;
|
|
23
|
+
|
|
24
|
+
/** The capabilities whose provider a variable selects, for example in a desktop build. */
|
|
25
|
+
export function selectedFromEnvironment(env: ProviderEnvironment = process.env): ProviderCapability[] {
|
|
26
|
+
return PROVIDER_CAPABILITIES.filter((capability) => Boolean(env[PROVIDER_VARIABLES[capability]]?.trim()));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The generated selection, with a capability replaced when its variable is set,
|
|
31
|
+
* so one build can run, say, PostgreSQL on a server and SQLite in a desktop
|
|
32
|
+
* shell. The selected provider's module must be installed; an unknown name
|
|
33
|
+
* fails at startup.
|
|
34
|
+
*/
|
|
35
|
+
export function providersFromEnvironment(
|
|
36
|
+
generated: ProviderSelections,
|
|
37
|
+
env: ProviderEnvironment = process.env,
|
|
38
|
+
): ProviderSelections {
|
|
39
|
+
const selections: Record<string, string> = { ...generated };
|
|
40
|
+
for (const capability of selectedFromEnvironment(env)) {
|
|
41
|
+
const variable = PROVIDER_VARIABLES[capability];
|
|
42
|
+
const value = env[variable]?.trim().toLowerCase() ?? "";
|
|
43
|
+
const allowed: readonly string[] = PROVIDER_NAMES[capability];
|
|
44
|
+
if (!allowed.includes(value)) {
|
|
45
|
+
throw new Error(`Invalid environment: ${variable} must be one of ${allowed.join(", ")}`);
|
|
46
|
+
}
|
|
47
|
+
selections[capability] = value;
|
|
48
|
+
}
|
|
49
|
+
return selections as unknown as ProviderSelections;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// The service each provider module registers when its provider is selected.
|
|
53
|
+
// The database has no module: the core service serves PostgreSQL and SQLite.
|
|
54
|
+
const PROVIDER_SERVICES: Partial<Record<ProviderCapability, ServiceKey<unknown>>> = {
|
|
55
|
+
cache: CACHE,
|
|
56
|
+
"object-storage": OBJECT_STORAGE,
|
|
57
|
+
events: EVENTS,
|
|
58
|
+
jobs: JOBS,
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Fails startup when a variable selects a provider whose module is not
|
|
63
|
+
* installed, which would otherwise fail the first request that needs it.
|
|
64
|
+
*/
|
|
65
|
+
export function assertSelectedProvidersInstalled(
|
|
66
|
+
services: { tryResolve<T>(key: ServiceKey<T>): T | undefined },
|
|
67
|
+
providers: ProviderSelections,
|
|
68
|
+
env: ProviderEnvironment = process.env,
|
|
69
|
+
): void {
|
|
70
|
+
const missing = selectedFromEnvironment(env).flatMap((capability) => {
|
|
71
|
+
const key = PROVIDER_SERVICES[capability];
|
|
72
|
+
return key && services.tryResolve(key) === undefined
|
|
73
|
+
? [`${PROVIDER_VARIABLES[capability]}=${providers[capability]}`]
|
|
74
|
+
: [];
|
|
75
|
+
});
|
|
76
|
+
if (missing.length > 0) {
|
|
77
|
+
throw new Error(`Invalid environment: no installed module provides ${missing.join(", ")}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import type { ProviderSelections } from "@podosoft/podokit-runtime";
|
|
2
|
+
import { providersFromEnvironment } from "./provider-selection";
|
|
2
3
|
|
|
3
|
-
//
|
|
4
|
+
// The providers selected when the project was generated or by `podo provider
|
|
5
|
+
// set`. Typed with every provider name, not the selected ones: the module of a
|
|
4
6
|
// provider that stays installed after a switch compares against its own name.
|
|
5
|
-
|
|
7
|
+
// A <CAPABILITY>_PROVIDER variable replaces one at startup; see
|
|
8
|
+
// provider-selection.ts.
|
|
9
|
+
export const PROVIDERS: Readonly<ProviderSelections> = providersFromEnvironment({
|
|
6
10
|
database: "{{databaseProvider}}",
|
|
7
11
|
cache: "{{cacheProvider}}",
|
|
8
12
|
"object-storage": "{{objectStorageProvider}}",
|
|
9
13
|
events: "{{eventsProvider}}",
|
|
10
14
|
jobs: "{{jobsProvider}}",
|
|
11
|
-
};
|
|
15
|
+
});
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// This owned file is never overwritten by `podo update`. List the operations
|
|
2
|
+
// the application's own API must serve, as "METHOD /path". `bun run contract`
|
|
3
|
+
// fails when one is not registered or missing from the OpenAPI document, for
|
|
4
|
+
// example after a route was renamed or hidden by mistake. Parameters may use
|
|
5
|
+
// either syntax: "GET /reports/{id}" or "GET /reports/:id".
|
|
6
|
+
export const APP_DOCUMENTED_ROUTES: readonly string[] = [];
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { describe, expect, it } from "bun:test";
|
|
2
2
|
import {
|
|
3
3
|
assertApiContract,
|
|
4
|
+
assertApplicationRoutes,
|
|
4
5
|
assertConsistentRouteParameters,
|
|
5
6
|
documentedApiRoutes,
|
|
6
7
|
expectedApiRoutes,
|
|
7
8
|
missingApiRoutes,
|
|
9
|
+
normalizeApiRoute,
|
|
10
|
+
registeredApiRoutes,
|
|
8
11
|
} from "./api-contract";
|
|
9
12
|
|
|
10
13
|
describe("API contract inventory", () => {
|
|
@@ -57,4 +60,30 @@ describe("API contract inventory", () => {
|
|
|
57
60
|
{ method: "DELETE", path: "/hosts/:hostId/services/:serviceId" },
|
|
58
61
|
])).not.toThrow();
|
|
59
62
|
});
|
|
63
|
+
|
|
64
|
+
it("normalizes runtime and OpenAPI parameter syntax", () => {
|
|
65
|
+
expect(normalizeApiRoute("get /reports/:id/rows/:rowId")).toBe("GET /reports/{id}/rows/{rowId}");
|
|
66
|
+
expect(normalizeApiRoute("GET /reports/{id}")).toBe("GET /reports/{id}");
|
|
67
|
+
expect(registeredApiRoutes([
|
|
68
|
+
{ method: "GET", path: "/reports/:id" },
|
|
69
|
+
{ method: "POST", path: "/reports" },
|
|
70
|
+
{ path: "/no-method" },
|
|
71
|
+
])).toEqual(new Set(["GET /reports/{id}", "POST /reports"]));
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("requires every application route to be registered and documented", () => {
|
|
75
|
+
const document = { paths: { "/reports/{id}": { get: {} }, "/hidden": {} } };
|
|
76
|
+
const routes = [{ method: "GET", path: "/reports/:id" }, { method: "DELETE", path: "/hidden" }];
|
|
77
|
+
|
|
78
|
+
expect(() => assertApplicationRoutes(document, routes, ["GET /reports/:id", "GET /reports/{id}"])).not.toThrow();
|
|
79
|
+
expect(() => assertApplicationRoutes(document, routes, [])).not.toThrow();
|
|
80
|
+
expect(() => assertApplicationRoutes(document, routes, ["DELETE /hidden", "POST /reports"])).toThrow(
|
|
81
|
+
[
|
|
82
|
+
"Application API contract failed:",
|
|
83
|
+
"DELETE /hidden is missing from the OpenAPI document",
|
|
84
|
+
"POST /reports is not registered",
|
|
85
|
+
"POST /reports is missing from the OpenAPI document",
|
|
86
|
+
].join("\n"),
|
|
87
|
+
);
|
|
88
|
+
});
|
|
60
89
|
});
|
|
@@ -97,6 +97,7 @@ const MODULE_ROUTES: Record<string, string[]> = {
|
|
|
97
97
|
};
|
|
98
98
|
|
|
99
99
|
interface RuntimeRoute {
|
|
100
|
+
method?: string;
|
|
100
101
|
path: string;
|
|
101
102
|
}
|
|
102
103
|
|
|
@@ -109,11 +110,47 @@ function record(value: unknown): Record<string, unknown> {
|
|
|
109
110
|
function runtimeRoutes(value: unknown): RuntimeRoute[] {
|
|
110
111
|
if (!Array.isArray(value)) return [];
|
|
111
112
|
return value.flatMap((route) => {
|
|
112
|
-
const path = record(route)
|
|
113
|
-
|
|
113
|
+
const { path, method } = record(route);
|
|
114
|
+
if (typeof path !== "string") return [];
|
|
115
|
+
return [typeof method === "string" ? { method, path } : { path }];
|
|
114
116
|
});
|
|
115
117
|
}
|
|
116
118
|
|
|
119
|
+
/** "METHOD /path" with OpenAPI parameters, so ":id" and "{id}" compare equal. */
|
|
120
|
+
export function normalizeApiRoute(route: string): string {
|
|
121
|
+
const [method = "", path = ""] = route.trim().split(/\s+/, 2);
|
|
122
|
+
const openApiPath = path
|
|
123
|
+
.split("/")
|
|
124
|
+
.map((segment) => segment.startsWith(":") ? `{${segment.slice(1).replace(/[?*]$/, "")}}` : segment)
|
|
125
|
+
.join("/");
|
|
126
|
+
return `${method.toUpperCase()} ${openApiPath}`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The operations an Elysia app registers, as normalized "METHOD /path" entries. */
|
|
130
|
+
export function registeredApiRoutes(routes: unknown): Set<string> {
|
|
131
|
+
const registered = new Set<string>();
|
|
132
|
+
for (const route of runtimeRoutes(routes)) {
|
|
133
|
+
if (route.method) registered.add(normalizeApiRoute(`${route.method} ${route.path}`));
|
|
134
|
+
}
|
|
135
|
+
return registered;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Fails unless every application route is both registered and documented.
|
|
140
|
+
* `expected` comes from APP_DOCUMENTED_ROUTES in api-contract.extensions.ts.
|
|
141
|
+
*/
|
|
142
|
+
export function assertApplicationRoutes(document: unknown, routes: unknown, expected: readonly string[]): void {
|
|
143
|
+
const documented = documentedApiRoutes(document);
|
|
144
|
+
const registered = registeredApiRoutes(routes);
|
|
145
|
+
const problems = [...new Set(expected.map(normalizeApiRoute))].sort().flatMap((route) => [
|
|
146
|
+
...(registered.has(route) ? [] : [`${route} is not registered`]),
|
|
147
|
+
...(documented.has(route) ? [] : [`${route} is missing from the OpenAPI document`]),
|
|
148
|
+
]);
|
|
149
|
+
if (problems.length > 0) {
|
|
150
|
+
throw new Error(`Application API contract failed:\n${problems.join("\n")}`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
117
154
|
/**
|
|
118
155
|
* Memoirist stores one parameter name at each structural radix-tree position.
|
|
119
156
|
* Routes such as `/hosts/:id` and `/hosts/:hostId/files` therefore cannot share
|
|
@@ -6,6 +6,8 @@ import {
|
|
|
6
6
|
} from "@podosoft/podokit-runtime";
|
|
7
7
|
import type { AppEnv } from "../config/env.validation";
|
|
8
8
|
import { Database } from "../database/database";
|
|
9
|
+
import { migrationSet } from "../database/migration-files";
|
|
10
|
+
import { MIGRATIONS_READINESS_CHECK, pendingMigrationsCheck } from "../database/migration-readiness";
|
|
9
11
|
import { DATABASE_READINESS_CHECK, ReadinessService } from "../health/readiness.service";
|
|
10
12
|
|
|
11
13
|
export type { ServiceKey } from "@podosoft/podokit-runtime";
|
|
@@ -410,6 +412,17 @@ export function createCoreServices(env: AppEnv): ServiceRegistry {
|
|
|
410
412
|
});
|
|
411
413
|
services.register(READINESS, readiness);
|
|
412
414
|
readiness.register(DATABASE_READINESS_CHECK, () => services.resolve(DATABASE).ping());
|
|
415
|
+
if (env.NODE_ENV === "production") {
|
|
416
|
+
// Throws when the build output lacks its migration list, so the API does not
|
|
417
|
+
// start without knowing which migrations its schema needs.
|
|
418
|
+
const migrations = migrationSet({ production: true });
|
|
419
|
+
if (migrations.compiled) {
|
|
420
|
+
readiness.register(
|
|
421
|
+
MIGRATIONS_READINESS_CHECK,
|
|
422
|
+
pendingMigrationsCheck(() => services.resolve(DATABASE).sql, migrations),
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
413
426
|
services.register(LOGGER, logger);
|
|
414
427
|
services.register(ACCESS_POLICY, accessPolicy);
|
|
415
428
|
services.register(REQUEST_GUARDS, requestGuards);
|
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
import { describe, expect, it } from "bun:test";
|
|
2
2
|
import type { DatabaseProviderName } from "@podosoft/podokit-runtime";
|
|
3
|
+
import { Column, Entity, PrimaryColumn } from "typeorm";
|
|
3
4
|
import { PROVIDERS } from "../config/providers";
|
|
5
|
+
import { APP_ENTITIES } from "./entities";
|
|
6
|
+
|
|
7
|
+
@Entity("probe_item")
|
|
8
|
+
class ProbeItem {
|
|
9
|
+
@PrimaryColumn()
|
|
10
|
+
id!: string;
|
|
11
|
+
|
|
12
|
+
@Column()
|
|
13
|
+
count!: number;
|
|
14
|
+
}
|
|
4
15
|
|
|
5
16
|
function databaseProvider(): DatabaseProviderName {
|
|
6
17
|
return PROVIDERS.database;
|
|
@@ -13,3 +24,19 @@ describe.skipIf(databaseProvider() !== "postgres")("migration data source", () =
|
|
|
13
24
|
expect(dataSourceOptions.migrationsTransactionMode).toBe("each");
|
|
14
25
|
});
|
|
15
26
|
});
|
|
27
|
+
|
|
28
|
+
describe("application entities", () => {
|
|
29
|
+
// The TypeORM data source serves PostgreSQL; SQLite migrates without it.
|
|
30
|
+
it.skipIf(databaseProvider() !== "postgres")("loads the entities of the owned entities.ts", async () => {
|
|
31
|
+
const { dataSourceOptions } = await import("./data-source.js");
|
|
32
|
+
expect(dataSourceOptions.entities).toBe(APP_ENTITIES);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("compiles TypeORM entity decorators with their design types", () => {
|
|
36
|
+
// TypeORM infers a column's type from this metadata.
|
|
37
|
+
const designType = (Reflect as unknown as {
|
|
38
|
+
getMetadata(key: string, target: object, property: string): unknown;
|
|
39
|
+
}).getMetadata("design:type", ProbeItem.prototype, "count");
|
|
40
|
+
expect(designType).toBe(Number);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -1,20 +1,18 @@
|
|
|
1
1
|
import "dotenv/config";
|
|
2
|
-
import { existsSync } from "node:fs";
|
|
3
|
-
import { join } from "node:path";
|
|
4
2
|
import { DataSource, type DataSourceOptions } from "typeorm";
|
|
5
3
|
import type { DatabaseProviderName } from "@podosoft/podokit-runtime";
|
|
6
4
|
import { PROVIDERS } from "../config/providers";
|
|
7
5
|
import { validateEnv } from "../config/env.validation";
|
|
8
6
|
import { databaseUrl, sqliteDatabasePath } from "./database";
|
|
7
|
+
import { APP_ENTITIES } from "./entities";
|
|
8
|
+
import { migrationPaths, migrationSet } from "./migration-files";
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
const
|
|
12
|
-
? [join(compiledMigrations, "[0-9]*.js")]
|
|
13
|
-
: [join(process.cwd(), "src", "migrations", "[0-9]*.ts")];
|
|
10
|
+
/** The migrations the data source loads; compare them with `assertMigrationsLoaded`. */
|
|
11
|
+
export const appMigrations = migrationSet();
|
|
14
12
|
|
|
15
13
|
const commonOptions = {
|
|
16
|
-
entities:
|
|
17
|
-
migrations,
|
|
14
|
+
entities: APP_ENTITIES,
|
|
15
|
+
migrations: migrationPaths(appMigrations),
|
|
18
16
|
// One transaction per migration, so a migration can set `transaction = false`
|
|
19
17
|
// for statements such as CREATE INDEX CONCURRENTLY.
|
|
20
18
|
migrationsTransactionMode: "each" as const,
|
|
@@ -44,6 +42,5 @@ export const dataSourceOptions: DataSourceOptions = databaseProvider() === "sqli
|
|
|
44
42
|
...commonOptions,
|
|
45
43
|
};
|
|
46
44
|
|
|
47
|
-
//
|
|
45
|
+
// The TypeORM CLI (see package.json scripts) takes the file's only DataSource export.
|
|
48
46
|
export const appDataSource = new DataSource(dataSourceOptions);
|
|
49
|
-
export default appDataSource;
|
|
@@ -86,6 +86,18 @@ describe("database provider", () => {
|
|
|
86
86
|
expect(() => databaseUrl({ ...env, DATABASE_URL })).toThrow("DATABASE_URL must use");
|
|
87
87
|
});
|
|
88
88
|
|
|
89
|
+
it("puts the default SQLite database at SQLITE_PATH", () => {
|
|
90
|
+
const root = mkdtempSync(join(tmpdir(), "podokit-sqlite-"));
|
|
91
|
+
try {
|
|
92
|
+
const path = join(root, "state", "app.sqlite");
|
|
93
|
+
expect(databaseUrl({ ...env, SQLITE_PATH: path }, "sqlite")).toBe(`sqlite://${path}`);
|
|
94
|
+
expect(existsSync(dirname(path))).toBe(true);
|
|
95
|
+
expect(databaseUrl(env, "sqlite")).toBe(`sqlite://${join(process.cwd(), "data/podokit.sqlite")}`);
|
|
96
|
+
} finally {
|
|
97
|
+
rmSync(root, { recursive: true, force: true });
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
89
101
|
it("creates the parent directory for an explicit SQLite database", () => {
|
|
90
102
|
const root = mkdtempSync(join(tmpdir(), "podokit-sqlite-"));
|
|
91
103
|
try {
|
|
@@ -14,8 +14,7 @@ function selectedDatabaseProvider(): DatabaseProviderName {
|
|
|
14
14
|
return PROVIDERS.database;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
export function databaseUrl(env: AppEnv): string {
|
|
18
|
-
const provider = selectedDatabaseProvider();
|
|
17
|
+
export function databaseUrl(env: AppEnv, provider: DatabaseProviderName = selectedDatabaseProvider()): string {
|
|
19
18
|
if (env.DATABASE_URL) {
|
|
20
19
|
if (provider === "sqlite" && !isSqliteUrl(env.DATABASE_URL)) {
|
|
21
20
|
throw new Error("DATABASE_URL must use sqlite:, file:, or :memory: for the SQLite provider");
|
|
@@ -26,7 +25,7 @@ export function databaseUrl(env: AppEnv): string {
|
|
|
26
25
|
return env.DATABASE_URL;
|
|
27
26
|
}
|
|
28
27
|
if (provider === "sqlite") {
|
|
29
|
-
const path = resolve(
|
|
28
|
+
const path = resolve(env.SQLITE_PATH);
|
|
30
29
|
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
31
30
|
return `sqlite://${path}`;
|
|
32
31
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { DataSourceOptions } from "typeorm";
|
|
2
|
+
|
|
3
|
+
// This owned file is never overwritten by `podo update`. List the
|
|
4
|
+
// application's TypeORM entities here: the migration data source loads them,
|
|
5
|
+
// so `migration:generate` compares them with the database and code that uses
|
|
6
|
+
// `appDataSource` gets their repositories. For example:
|
|
7
|
+
//
|
|
8
|
+
// import { Invoice } from "../invoices/invoice.entity";
|
|
9
|
+
// export const APP_ENTITIES: AppEntities = [Invoice];
|
|
10
|
+
export type AppEntities = NonNullable<DataSourceOptions["entities"]>;
|
|
11
|
+
|
|
12
|
+
export const APP_ENTITIES: AppEntities = [];
|
|
@@ -3,12 +3,21 @@ import type { DatabaseProviderName } from "@podosoft/podokit-runtime";
|
|
|
3
3
|
import { validateEnv } from "../config/env.validation";
|
|
4
4
|
import { PROVIDERS } from "../config/providers";
|
|
5
5
|
import { databaseUrl, sqliteDatabasePath } from "./database";
|
|
6
|
+
import { assertMigrationsLoaded, loadMigrations, migrationName, migrationSet } from "./migration-files";
|
|
6
7
|
import { revertLatestSqliteMigration, runSqliteMigrations } from "./sqlite-migrator";
|
|
7
8
|
|
|
8
9
|
function databaseProvider(): DatabaseProviderName {
|
|
9
10
|
return PROVIDERS.database;
|
|
10
11
|
}
|
|
11
12
|
|
|
13
|
+
/** Prints the migrations this build runs, one name per line, without a database. */
|
|
14
|
+
async function list(): Promise<void> {
|
|
15
|
+
const set = migrationSet();
|
|
16
|
+
const loaded = await loadMigrations(set);
|
|
17
|
+
for (const { migration } of loaded) process.stdout.write(`${migrationName(migration)}\n`);
|
|
18
|
+
process.stderr.write(`${loaded.length} migrations in ${set.directory}\n`);
|
|
19
|
+
}
|
|
20
|
+
|
|
12
21
|
async function migrate(): Promise<void> {
|
|
13
22
|
if (databaseProvider() === "sqlite") {
|
|
14
23
|
const env = validateEnv(process.env);
|
|
@@ -21,9 +30,10 @@ async function migrate(): Promise<void> {
|
|
|
21
30
|
}
|
|
22
31
|
return;
|
|
23
32
|
}
|
|
24
|
-
const { appDataSource: dataSource } = await import("./data-source.js");
|
|
33
|
+
const { appDataSource: dataSource, appMigrations } = await import("./data-source.js");
|
|
25
34
|
await dataSource.initialize();
|
|
26
35
|
try {
|
|
36
|
+
assertMigrationsLoaded(dataSource.migrations, appMigrations);
|
|
27
37
|
if (process.argv.includes("--revert")) await dataSource.undoLastMigration({ transaction: "all" });
|
|
28
38
|
else await dataSource.runMigrations();
|
|
29
39
|
} finally {
|
|
@@ -31,7 +41,7 @@ async function migrate(): Promise<void> {
|
|
|
31
41
|
}
|
|
32
42
|
}
|
|
33
43
|
|
|
34
|
-
void migrate().catch((error: unknown) => {
|
|
44
|
+
void (process.argv.includes("--list") ? list() : migrate()).catch((error: unknown) => {
|
|
35
45
|
const message = error instanceof Error ? error.message : String(error);
|
|
36
46
|
process.stderr.write(`Migration failed: ${message}\n`);
|
|
37
47
|
process.exitCode = 1;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import type { MigrationInterface } from "typeorm";
|
|
6
|
+
import {
|
|
7
|
+
assertMigrationsLoaded,
|
|
8
|
+
loadMigrations,
|
|
9
|
+
MIGRATION_LIST,
|
|
10
|
+
migrationPaths,
|
|
11
|
+
migrationSet,
|
|
12
|
+
} from "./migration-files";
|
|
13
|
+
|
|
14
|
+
const roots: string[] = [];
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
function apiRoot(files: Record<string, string>): string {
|
|
21
|
+
const root = mkdtempSync(join(tmpdir(), "migration-files-"));
|
|
22
|
+
roots.push(root);
|
|
23
|
+
for (const [path, content] of Object.entries(files)) {
|
|
24
|
+
mkdirSync(dirname(join(root, path)), { recursive: true });
|
|
25
|
+
writeFileSync(join(root, path), content);
|
|
26
|
+
}
|
|
27
|
+
return root;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const migrationSource = (name: string): string =>
|
|
31
|
+
`export class ${name} { name = "${name}"; async up() {} async down() {} }\n`;
|
|
32
|
+
|
|
33
|
+
const loaded = (...names: string[]): MigrationInterface[] =>
|
|
34
|
+
names.map((name) => ({ name, up: async () => undefined, down: async () => undefined }));
|
|
35
|
+
|
|
36
|
+
describe("migration files", () => {
|
|
37
|
+
it("lists source migrations in timestamp order outside production", () => {
|
|
38
|
+
const root = apiRoot({
|
|
39
|
+
"src/migrations/1700000000002-Second.ts": migrationSource("Second1700000000002"),
|
|
40
|
+
"src/migrations/1700000000001-First.ts": migrationSource("First1700000000001"),
|
|
41
|
+
"src/migrations/helpers.ts": "export {};\n",
|
|
42
|
+
"dist/migrations/1690000000000-Stale.js": migrationSource("Stale1690000000000"),
|
|
43
|
+
});
|
|
44
|
+
const set = migrationSet({ root, production: false });
|
|
45
|
+
expect(set).toEqual({
|
|
46
|
+
directory: join(root, "src/migrations"),
|
|
47
|
+
compiled: false,
|
|
48
|
+
files: [{ file: "1700000000001-First.ts" }, { file: "1700000000002-Second.ts" }],
|
|
49
|
+
});
|
|
50
|
+
expect(migrationPaths(set)).toEqual([
|
|
51
|
+
join(root, "src/migrations/1700000000001-First.ts"),
|
|
52
|
+
join(root, "src/migrations/1700000000002-Second.ts"),
|
|
53
|
+
]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("reads the build's list in production", () => {
|
|
57
|
+
const list = [{ file: "1700000000001-First.js", name: "First1700000000001" }];
|
|
58
|
+
const root = apiRoot({
|
|
59
|
+
[`dist/migrations/${MIGRATION_LIST}`]: JSON.stringify(list),
|
|
60
|
+
"dist/migrations/1700000000001-First.js": migrationSource("First1700000000001"),
|
|
61
|
+
});
|
|
62
|
+
expect(migrationSet({ root, production: true })).toEqual({
|
|
63
|
+
directory: join(root, "dist/migrations"),
|
|
64
|
+
compiled: true,
|
|
65
|
+
files: list,
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("refuses a build output without its list instead of running no migrations", () => {
|
|
70
|
+
const root = apiRoot({ "dist/main.js": "", "dist/migrations/src/migrations/1700000000001-First.js": "" });
|
|
71
|
+
expect(() => migrationSet({ root, production: true })).toThrow(`${MIGRATION_LIST} is missing`);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("refuses a list whose files are missing", () => {
|
|
75
|
+
const root = apiRoot({
|
|
76
|
+
[`dist/migrations/${MIGRATION_LIST}`]: JSON.stringify([{ file: "1700000000001-First.js", name: "First1700000000001" }]),
|
|
77
|
+
});
|
|
78
|
+
expect(() => migrationSet({ root, production: true })).toThrow("Compiled migrations are missing");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("runs source migrations when production code runs without a build", () => {
|
|
82
|
+
const root = apiRoot({ "src/migrations/1700000000001-First.ts": migrationSource("First1700000000001") });
|
|
83
|
+
expect(migrationSet({ root, production: true })).toMatchObject({ compiled: false, files: [{ file: "1700000000001-First.ts" }] });
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("fails when a runner loaded fewer migrations than the set holds", () => {
|
|
87
|
+
const root = apiRoot({
|
|
88
|
+
"src/migrations/1700000000001-First.ts": migrationSource("First1700000000001"),
|
|
89
|
+
"src/migrations/1700000000002-Second.ts": migrationSource("Second1700000000002"),
|
|
90
|
+
});
|
|
91
|
+
const set = migrationSet({ root, production: false });
|
|
92
|
+
expect(() => assertMigrationsLoaded([], set)).toThrow("Loaded 0 of the 2 migrations");
|
|
93
|
+
expect(() => assertMigrationsLoaded(loaded("First1700000000001", "Second1700000000002"), set)).not.toThrow();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("fails when the build's listed names did not load", () => {
|
|
97
|
+
const set = {
|
|
98
|
+
directory: "dist/migrations",
|
|
99
|
+
compiled: true,
|
|
100
|
+
files: [{ file: "1700000000001-First.js", name: "First1700000000001" }],
|
|
101
|
+
};
|
|
102
|
+
expect(() => assertMigrationsLoaded(loaded("Other1700000000001"), set)).toThrow("did not load: First1700000000001");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("loads one migration class from each file", async () => {
|
|
106
|
+
const root = apiRoot({
|
|
107
|
+
"src/migrations/1700000000001-First.ts": migrationSource("First1700000000001"),
|
|
108
|
+
"src/migrations/1700000000002-Two.ts": `${migrationSource("A1700000000002")}${migrationSource("B1700000000002")}`,
|
|
109
|
+
});
|
|
110
|
+
const set = migrationSet({ root, production: false });
|
|
111
|
+
await expect(loadMigrations({ ...set, files: set.files.slice(0, 1) })).resolves.toMatchObject([
|
|
112
|
+
{ file: "1700000000001-First.ts", timestamp: 1700000000001, migration: { name: "First1700000000001" } },
|
|
113
|
+
]);
|
|
114
|
+
await expect(loadMigrations(set)).rejects.toThrow("must export exactly one migration class");
|
|
115
|
+
});
|
|
116
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
import type { MigrationInterface } from "typeorm";
|
|
5
|
+
|
|
6
|
+
/** The list `scripts/build.mjs` writes next to the compiled migrations. */
|
|
7
|
+
export const MIGRATION_LIST = "migrations.json";
|
|
8
|
+
|
|
9
|
+
const SOURCE_FILE = /^\d+-.+\.ts$/;
|
|
10
|
+
|
|
11
|
+
export interface MigrationFile {
|
|
12
|
+
file: string;
|
|
13
|
+
/** The migration's name, recorded by the build; unknown for source files until loaded. */
|
|
14
|
+
name?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface MigrationSet {
|
|
18
|
+
directory: string;
|
|
19
|
+
/** True for the build output, which lists its migrations in `migrations.json`. */
|
|
20
|
+
compiled: boolean;
|
|
21
|
+
/** In timestamp order. */
|
|
22
|
+
files: MigrationFile[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface MigrationSetOptions {
|
|
26
|
+
/** The API directory. Defaults to the working directory. */
|
|
27
|
+
root?: string;
|
|
28
|
+
/** Reads the build output. Defaults to NODE_ENV=production. */
|
|
29
|
+
production?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function readList(path: string): MigrationFile[] {
|
|
33
|
+
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
|
|
34
|
+
if (
|
|
35
|
+
!Array.isArray(parsed) ||
|
|
36
|
+
!parsed.every((entry: unknown) =>
|
|
37
|
+
typeof entry === "object" && entry !== null &&
|
|
38
|
+
typeof (entry as Record<string, unknown>).file === "string" &&
|
|
39
|
+
typeof (entry as Record<string, unknown>).name === "string"
|
|
40
|
+
)
|
|
41
|
+
) {
|
|
42
|
+
throw new Error(`${path} must list { file, name } entries; rebuild the API`);
|
|
43
|
+
}
|
|
44
|
+
return parsed as MigrationFile[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The migrations this process runs. In production that is the build output's
|
|
49
|
+
* list; a build output without its list fails instead of running none.
|
|
50
|
+
* Otherwise it is every `<timestamp>-<name>.ts` file in `src/migrations`.
|
|
51
|
+
*/
|
|
52
|
+
export function migrationSet(options: MigrationSetOptions = {}): MigrationSet {
|
|
53
|
+
const root = options.root ?? process.cwd();
|
|
54
|
+
const production = options.production ?? process.env.NODE_ENV === "production";
|
|
55
|
+
const compiled = join(root, "dist", "migrations");
|
|
56
|
+
const source = join(root, "src", "migrations");
|
|
57
|
+
if (production && existsSync(join(root, "dist"))) {
|
|
58
|
+
const list = join(compiled, MIGRATION_LIST);
|
|
59
|
+
if (!existsSync(list)) {
|
|
60
|
+
throw new Error(`${list} is missing; build the API with scripts/build.mjs before running it`);
|
|
61
|
+
}
|
|
62
|
+
const files = readList(list);
|
|
63
|
+
const missing = files.filter((entry) => !existsSync(join(compiled, entry.file)));
|
|
64
|
+
if (missing.length > 0) {
|
|
65
|
+
throw new Error(`Compiled migrations are missing from ${compiled}: ${missing.map((entry) => entry.file).join(", ")}`);
|
|
66
|
+
}
|
|
67
|
+
return { directory: compiled, compiled: true, files };
|
|
68
|
+
}
|
|
69
|
+
const files = existsSync(source)
|
|
70
|
+
? readdirSync(source).filter((file) => SOURCE_FILE.test(file)).sort().map((file) => ({ file }))
|
|
71
|
+
: [];
|
|
72
|
+
return { directory: source, compiled: false, files };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function migrationPaths(set: MigrationSet): string[] {
|
|
76
|
+
return set.files.map((entry) => join(set.directory, entry.file));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
type MigrationConstructor = new () => MigrationInterface;
|
|
80
|
+
|
|
81
|
+
function isMigration(value: unknown): value is MigrationConstructor {
|
|
82
|
+
if (typeof value !== "function") return false;
|
|
83
|
+
const prototype = (value as { prototype?: unknown }).prototype;
|
|
84
|
+
if (!prototype || typeof prototype !== "object") return false;
|
|
85
|
+
const record = prototype as Record<string, unknown>;
|
|
86
|
+
return typeof record.up === "function" && typeof record.down === "function";
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** TypeORM's name for a migration: its `name` property, else its class name. */
|
|
90
|
+
export function migrationName(migration: MigrationInterface): string {
|
|
91
|
+
return migration.name || migration.constructor.name;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface LoadedMigration {
|
|
95
|
+
file: string;
|
|
96
|
+
timestamp: number;
|
|
97
|
+
migration: MigrationInterface;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Imports every file of the set; each must export exactly one migration class. */
|
|
101
|
+
export async function loadMigrations(set: MigrationSet): Promise<LoadedMigration[]> {
|
|
102
|
+
const loaded: LoadedMigration[] = [];
|
|
103
|
+
for (const entry of set.files) {
|
|
104
|
+
const module = await import(pathToFileURL(join(set.directory, entry.file)).href) as Record<string, unknown>;
|
|
105
|
+
const constructors = Object.values(module).filter(isMigration);
|
|
106
|
+
const Constructor = constructors[0];
|
|
107
|
+
if (constructors.length !== 1 || !Constructor) {
|
|
108
|
+
throw new Error(`Migration ${entry.file} must export exactly one migration class`);
|
|
109
|
+
}
|
|
110
|
+
const timestamp = Number(entry.file.slice(0, entry.file.indexOf("-")));
|
|
111
|
+
if (!Number.isSafeInteger(timestamp)) throw new Error(`Migration ${entry.file} has an invalid timestamp`);
|
|
112
|
+
loaded.push({ file: entry.file, timestamp, migration: new Constructor() });
|
|
113
|
+
}
|
|
114
|
+
return loaded;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Fails unless the migrations a runner loaded are the set: TypeORM loads
|
|
119
|
+
* nothing, without an error, from a path that matches no file.
|
|
120
|
+
*/
|
|
121
|
+
export function assertMigrationsLoaded(loaded: readonly MigrationInterface[], set: MigrationSet): void {
|
|
122
|
+
if (loaded.length !== set.files.length) {
|
|
123
|
+
throw new Error(
|
|
124
|
+
`Loaded ${loaded.length} of the ${set.files.length} migrations in ${set.directory}; ` +
|
|
125
|
+
"each migration file must export exactly one migration class",
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
if (!set.compiled) return;
|
|
129
|
+
const names = new Set(loaded.map(migrationName));
|
|
130
|
+
const absent = set.files.filter((entry) => entry.name && !names.has(entry.name));
|
|
131
|
+
if (absent.length > 0) {
|
|
132
|
+
throw new Error(`Migrations listed in ${MIGRATION_LIST} did not load: ${absent.map((entry) => entry.name).join(", ")}`);
|
|
133
|
+
}
|
|
134
|
+
}
|