@podosoft/podokit 1.5.0 → 1.6.1

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 (215) hide show
  1. package/README.md +2 -1
  2. package/dist/app-proxy-timeouts.d.ts +27 -0
  3. package/dist/app-proxy-timeouts.js +180 -0
  4. package/dist/assemble.d.ts +5 -2
  5. package/dist/assemble.js +14 -4
  6. package/dist/deploy-checks.d.ts +44 -0
  7. package/dist/deploy-checks.js +267 -0
  8. package/dist/deploy-compose-profile.d.ts +6 -2
  9. package/dist/deploy-compose-profile.js +15 -6
  10. package/dist/deploy-compose-render.d.ts +12 -4
  11. package/dist/deploy-compose-render.js +47 -18
  12. package/dist/deploy-compose.d.ts +2 -5
  13. package/dist/deploy-compose.js +7 -2
  14. package/dist/deploy-profile.d.ts +6 -2
  15. package/dist/deploy-profile.js +11 -7
  16. package/dist/deploy-render.d.ts +4 -1
  17. package/dist/deploy-render.js +27 -21
  18. package/dist/deploy-schema.d.ts +60 -2
  19. package/dist/deploy-schema.js +141 -14
  20. package/dist/deploy.d.ts +2 -5
  21. package/dist/deploy.js +5 -2
  22. package/dist/dev.d.ts +21 -0
  23. package/dist/dev.js +78 -3
  24. package/dist/eject.js +2 -0
  25. package/dist/index.js +85 -5
  26. package/dist/lib.d.ts +2 -0
  27. package/dist/lib.js +5 -2
  28. package/dist/lockfile.d.ts +24 -0
  29. package/dist/lockfile.js +74 -4
  30. package/dist/provider.js +10 -5
  31. package/dist/remove.js +1 -1
  32. package/dist/template-files.d.ts +42 -0
  33. package/dist/template-files.js +102 -0
  34. package/dist/templates/fullstack/AGENTS.md +30 -4
  35. package/dist/templates/fullstack/README.md +8 -7
  36. package/dist/templates/fullstack/apps/api/package.json +4 -4
  37. package/dist/templates/fullstack/apps/api/scripts/build.extensions.mjs +6 -0
  38. package/dist/templates/fullstack/apps/api/scripts/build.mjs +65 -16
  39. package/dist/templates/fullstack/apps/api/scripts/run-migrations.mjs +7 -3
  40. package/dist/templates/fullstack/apps/api/scripts/verify-api-contract.ts +7 -2
  41. package/dist/templates/fullstack/apps/api/src/app.spec.ts +22 -0
  42. package/dist/templates/fullstack/apps/api/src/app.ts +41 -25
  43. package/dist/templates/fullstack/apps/api/src/build.spec.ts +121 -0
  44. package/dist/templates/fullstack/apps/api/src/config/env.extensions.ts +20 -0
  45. package/dist/templates/fullstack/apps/api/src/config/env.validation.spec.ts +76 -0
  46. package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +18 -3
  47. package/dist/templates/fullstack/apps/api/src/config/provider-selection.spec.ts +53 -0
  48. package/dist/templates/fullstack/apps/api/src/config/provider-selection.ts +79 -0
  49. package/dist/templates/fullstack/apps/api/src/config/providers.ts +7 -3
  50. package/dist/templates/fullstack/apps/api/src/core/api-contract.extensions.ts +6 -0
  51. package/dist/templates/fullstack/apps/api/src/core/api-contract.spec.ts +72 -4
  52. package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +78 -6
  53. package/dist/templates/fullstack/apps/api/src/core/services.ts +13 -0
  54. package/dist/templates/fullstack/apps/api/src/database/data-source.spec.ts +27 -0
  55. package/dist/templates/fullstack/apps/api/src/database/data-source.ts +7 -10
  56. package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +12 -0
  57. package/dist/templates/fullstack/apps/api/src/database/database.ts +2 -3
  58. package/dist/templates/fullstack/apps/api/src/database/entities.ts +12 -0
  59. package/dist/templates/fullstack/apps/api/src/database/migrate.ts +12 -2
  60. package/dist/templates/fullstack/apps/api/src/database/migration-files.spec.ts +116 -0
  61. package/dist/templates/fullstack/apps/api/src/database/migration-files.ts +134 -0
  62. package/dist/templates/fullstack/apps/api/src/database/migration-readiness.spec.ts +36 -0
  63. package/dist/templates/fullstack/apps/api/src/database/migration-readiness.ts +25 -0
  64. package/dist/templates/fullstack/apps/api/src/database/run-migrations.spec.ts +12 -1
  65. package/dist/templates/fullstack/apps/api/src/database/sqlite-migrator.ts +2 -36
  66. package/dist/templates/fullstack/apps/api/src/main.ts +2 -2
  67. package/dist/templates/fullstack/apps/api/tsconfig.json +2 -0
  68. package/dist/templates/fullstack/apps/web/package.json +1 -1
  69. package/dist/templates/fullstack/apps/web/src/app.css +3 -0
  70. package/dist/templates/fullstack/apps/web/src/app.extensions.css +11 -0
  71. package/dist/templates/fullstack/apps/web/src/lib/components/site-runtime.svelte +3 -1
  72. package/dist/templates/fullstack/apps/web/src/lib/server/api.test.ts +19 -0
  73. package/dist/templates/fullstack/apps/web/src/lib/server/api.ts +4 -1
  74. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +79 -0
  75. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +55 -1
  76. package/dist/templates/fullstack/apps/web/src/lib/server/proxy-timeouts.ts +2 -1
  77. package/dist/templates/fullstack/apps/web/tsconfig.json +1 -1
  78. package/dist/templates/fullstack/apps/web/vite.config.test.ts +33 -0
  79. package/dist/templates/fullstack/apps/web/vite.config.ts +55 -13
  80. package/dist/templates/fullstack/apps/web/vite.extensions.ts +23 -0
  81. package/dist/templates/fullstack/compose.dev.yaml +7 -4
  82. package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/SKILL.md +2 -2
  83. package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +13 -3
  84. package/dist/templates/fullstack/dot-claude/skills/podokit-update/SKILL.md +5 -2
  85. package/dist/templates/fullstack/dot-env.docker.example +5 -2
  86. package/dist/templates/fullstack/dot-env.example +4 -0
  87. package/dist/templates/fullstack/dot-podokit/dev.json +1 -1
  88. package/dist/templates/fullstack/infra/k3s/configmap.yaml +1 -0
  89. package/dist/templates/fullstack/tests/README.md +50 -1
  90. package/dist/templates/fullstack/tests/helpers/app-paths.ts +18 -0
  91. package/dist/templates/fullstack/tests/helpers/paths.ts +33 -0
  92. package/dist/templates/fullstack/tests/helpers/seeded-admin.ts +15 -0
  93. package/dist/templates/fullstack/tests/package.json +3 -3
  94. package/dist/templates/fullstack/tests/playwright.config.ts +10 -4
  95. package/dist/templates/fullstack/tests/playwright.extensions.ts +149 -1
  96. package/dist/templates/fullstack/tests/run-e2e.ts +28 -0
  97. package/dist/templates/fullstack/tests/ui/smoke.ui.spec.ts +5 -4
  98. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.test.ts +37 -1
  99. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +15 -6
  100. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-flag-settings.test.ts +31 -0
  101. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-flag-settings.ts +31 -0
  102. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-flags.ts +12 -0
  103. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-nav.ts +11 -0
  104. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/nav.test.ts +53 -0
  105. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/nav.ts +35 -0
  106. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/registry.svelte.ts +15 -0
  107. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +11 -1
  108. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/admin-sidebar.svelte +2 -23
  109. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/data-table.svelte +7 -2
  110. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/data-table.test.ts +30 -0
  111. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/impersonation-banner.svelte +3 -2
  112. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/settings/app-flags-settings.svelte +65 -0
  113. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/sidebar-user-menu.svelte +32 -23
  114. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/sidebar-user-menu.test.ts +27 -0
  115. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-footer.svelte +4 -1
  116. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-runtime.svelte +3 -1
  117. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/theme-toggle.svelte +1 -1
  118. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/en.json +6 -0
  119. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/ko.json +6 -0
  120. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/app-hooks.ts +11 -0
  121. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/non-indexable-paths.ts +8 -0
  122. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/search-indexing.test.ts +31 -0
  123. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/search-indexing.ts +5 -1
  124. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/settings/+page.svelte +1 -1
  125. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/account/+page.server.ts +2 -1
  126. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/account/+page.svelte +1 -5
  127. package/dist/templates/modules/admin-dashboard/files/dot-claude/skills/podokit-data-table/SKILL.md +2 -0
  128. package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +1 -1
  129. package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +159 -170
  130. package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +72 -81
  131. package/dist/templates/modules/admin-dashboard/files/tests/api/email.api.spec.ts +1 -6
  132. package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +1 -1
  133. package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +43 -53
  134. package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +3 -1
  135. package/dist/templates/modules/admin-dashboard/files/tests/helpers/accounts.ts +24 -6
  136. package/dist/templates/modules/admin-dashboard/files/tests/helpers/app-accounts.ts +24 -0
  137. package/dist/templates/modules/admin-dashboard/files/tests/helpers/devices.ts +41 -11
  138. package/dist/templates/modules/admin-dashboard/files/tests/helpers/disposable-users.ts +5 -3
  139. package/dist/templates/modules/admin-dashboard/files/tests/helpers/hydration.ts +13 -4
  140. package/dist/templates/modules/admin-dashboard/files/tests/helpers/mailpit.ts +3 -9
  141. package/dist/templates/modules/admin-dashboard/files/tests/helpers/sms.ts +2 -4
  142. package/dist/templates/modules/admin-dashboard/files/tests/playwright.config.ts +14 -6
  143. package/dist/templates/modules/admin-dashboard/files/tests/ui/account-flows.ui.spec.ts +4 -5
  144. package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +55 -55
  145. package/dist/templates/modules/admin-dashboard/files/tests/ui/app-flags.ui.spec.ts +63 -0
  146. package/dist/templates/modules/admin-dashboard/files/tests/ui/appearance.ui.spec.ts +12 -16
  147. package/dist/templates/modules/admin-dashboard/files/tests/ui/audit.ui.spec.ts +3 -2
  148. package/dist/templates/modules/admin-dashboard/files/tests/ui/auth.ui.spec.ts +32 -35
  149. package/dist/templates/modules/admin-dashboard/files/tests/ui/dashboard.ui.spec.ts +4 -2
  150. package/dist/templates/modules/admin-dashboard/files/tests/ui/email-admin.ui.spec.ts +1 -2
  151. package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +1 -2
  152. package/dist/templates/modules/admin-dashboard/files/tests/ui/general-effects.ui.spec.ts +82 -91
  153. package/dist/templates/modules/admin-dashboard/files/tests/ui/guards.ui.spec.ts +6 -2
  154. package/dist/templates/modules/admin-dashboard/files/tests/ui/i18n.ui.spec.ts +16 -20
  155. package/dist/templates/modules/admin-dashboard/files/tests/ui/impersonation.ui.spec.ts +6 -3
  156. package/dist/templates/modules/admin-dashboard/files/tests/ui/login-methods.ui.spec.ts +2 -3
  157. package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +2 -4
  158. package/dist/templates/modules/admin-dashboard/files/tests/ui/organizations.ui.spec.ts +5 -2
  159. package/dist/templates/modules/admin-dashboard/files/tests/ui/passkey.ui.spec.ts +7 -2
  160. package/dist/templates/modules/admin-dashboard/files/tests/ui/same-origin-requests.ui.spec.ts +89 -0
  161. package/dist/templates/modules/admin-dashboard/files/tests/ui/sessions.ui.spec.ts +5 -4
  162. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +67 -73
  163. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +84 -93
  164. package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +8 -7
  165. package/dist/templates/modules/admin-dashboard/files/tests/ui/theme-mode.ui.spec.ts +35 -0
  166. package/dist/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +51 -54
  167. package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +10 -9
  168. package/dist/templates/modules/admin-dashboard/module.manifest.json +7 -2
  169. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.module.ts +5 -1
  170. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.spec.ts +17 -0
  171. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +18 -0
  172. package/dist/templates/modules/audit-log/module.manifest.json +15 -3
  173. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-app-name.spec.ts +21 -0
  174. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-config-store.ts +13 -1
  175. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-environment.ts +9 -2
  176. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +19 -3
  177. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.module.ts +2 -0
  178. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.runtime.spec.ts +10 -0
  179. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +7 -5
  180. package/dist/templates/modules/auth/files/apps/api/src/auth/schema-recheck.spec.ts +86 -0
  181. package/dist/templates/modules/auth/files/apps/api/src/auth/schema-recheck.ts +44 -0
  182. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +2 -1
  183. package/dist/templates/modules/auth/files/apps/api/src/migrate.ts +3 -1
  184. package/dist/templates/modules/auth/files/apps/api/src/settings/app-flags.ts +9 -0
  185. package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.spec.ts +43 -0
  186. package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.ts +30 -5
  187. package/dist/templates/modules/auth/module.manifest.json +8 -2
  188. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.spec.ts +107 -1
  189. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.ts +113 -9
  190. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/worker.module.spec.ts +40 -0
  191. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/worker.module.ts +52 -5
  192. package/dist/templates/modules/bullmq/files/apps/api/src/main-worker.ts +9 -7
  193. package/dist/templates/modules/bullmq/files/apps/api/src/worker.extensions.ts +13 -0
  194. package/dist/templates/modules/bullmq/module.manifest.json +5 -1
  195. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.worker.spec.ts +2 -0
  196. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.spec.ts +174 -1
  197. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.ts +150 -3
  198. package/dist/templates/modules/jobs-local/module.manifest.json +3 -0
  199. package/dist/templates/modules/logging/files/apps/api/src/logging/logging.module.spec.ts +16 -0
  200. package/dist/templates/modules/logging/files/apps/api/src/logging/logging.module.ts +15 -10
  201. package/dist/templates/modules/mailer/module.manifest.json +1 -1
  202. package/dist/templates/modules/object-storage-local/files/tests/api/storage-local.api.spec.ts +3 -6
  203. package/dist/templates/modules/object-storage-s3/files/infra/docker/minio.compose.yml +5 -4
  204. package/dist/templates/modules/object-storage-s3/files/tests/api/storage.api.spec.ts +3 -6
  205. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +45 -20
  206. package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +3 -6
  207. package/dist/templates/todo/AGENTS.md +5 -1
  208. package/dist/templates/todo/README.md +6 -4
  209. package/dist/text-diff.d.ts +12 -0
  210. package/dist/text-diff.js +111 -0
  211. package/dist/update.d.ts +61 -3
  212. package/dist/update.js +152 -27
  213. package/package.json +2 -2
  214. package/dist/templates/modules/admin-dashboard/files/tests/api/exclusive.api.spec.ts +0 -75
  215. package/dist/templates/modules/admin-dashboard/files/tests/helpers/exclusive.ts +0 -80
@@ -1,8 +1,12 @@
1
1
  import { z } from "zod";
2
+ import { appEnvShape, refineAppEnv } from "./env.extensions";
2
3
 
3
4
  // Schema-validated environment. Fails fast at boot if something is wrong.
4
- const schema = z.object({
5
+ // Application variables come from the owned env.extensions.ts.
6
+ const podokitShape = {
5
7
  NODE_ENV: z.string().default("development"),
8
+ // The address the API listens on; 127.0.0.1 keeps it off the network.
9
+ HOST: z.string().trim().min(1).default("0.0.0.0"),
6
10
  PORT: z.coerce.number().default(5002),
7
11
  // Bytes one request body may hold; 128 MiB is Bun's default.
8
12
  API_MAX_REQUEST_BODY_SIZE: z.coerce.number().int().positive().default(128 * 1024 * 1024),
@@ -24,6 +28,8 @@ const schema = z.object({
24
28
  .default("x-forwarded-for"),
25
29
  CORS_ORIGIN: z.string().optional(),
26
30
  DATABASE_URL: z.string().optional(),
31
+ // The SQLite file when DATABASE_URL is unset, relative to the API directory.
32
+ SQLITE_PATH: z.string().trim().min(1).default("./data/podokit.sqlite"),
27
33
  POSTGRES_HOST: z.string().default("localhost"),
28
34
  POSTGRES_PORT: z.coerce.number().default(5432),
29
35
  POSTGRES_USER: z.string().default("podokit"),
@@ -36,9 +42,18 @@ const schema = z.object({
36
42
  DB_AUTH_POOL_MAX: z.coerce.number().int().positive().default(10),
37
43
  READINESS_CHECK_TIMEOUT_MS: z.coerce.number().int().positive().default(800),
38
44
  READINESS_CACHE_MS: z.coerce.number().int().nonnegative().default(2000),
39
- });
45
+ } satisfies z.ZodRawShape;
40
46
 
41
- export type AppEnv = z.infer<typeof schema>;
47
+ const redefined = Object.keys(appEnvShape).filter((name) => name in podokitShape);
48
+ if (redefined.length > 0) {
49
+ throw new Error(`Invalid environment schema: env.extensions.ts redefines ${redefined.join(", ")}`);
50
+ }
51
+
52
+ const objectSchema = z.object({ ...podokitShape, ...appEnvShape });
53
+
54
+ export type AppEnv = z.infer<typeof objectSchema>;
55
+
56
+ const schema = objectSchema.superRefine((env, context) => refineAppEnv(env, context));
42
57
 
43
58
  // Renamed settings fail loudly: falling back to the default would count and
44
59
  // record every browser request under the web server's address.
@@ -0,0 +1,53 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { CACHE, type ProviderSelections, type ServiceKey } from "@podosoft/podokit-runtime";
3
+ import {
4
+ assertSelectedProvidersInstalled,
5
+ providersFromEnvironment,
6
+ selectedFromEnvironment,
7
+ } from "./provider-selection";
8
+
9
+ const generated: ProviderSelections = {
10
+ database: "postgres",
11
+ cache: "redis",
12
+ "object-storage": "s3",
13
+ events: "redis",
14
+ jobs: "bullmq",
15
+ };
16
+
17
+ function registry(keys: symbol[]): { tryResolve<T>(key: ServiceKey<T>): T | undefined } {
18
+ return { tryResolve: <T>(key: ServiceKey<T>) => (keys.includes(key) ? ({} as T) : undefined) };
19
+ }
20
+
21
+ describe("provider selection", () => {
22
+ it("keeps the generated providers without variables", () => {
23
+ expect(providersFromEnvironment(generated, {})).toEqual(generated);
24
+ expect(providersFromEnvironment(generated, { DATABASE_PROVIDER: " " })).toEqual(generated);
25
+ });
26
+
27
+ it("replaces a capability whose variable is set", () => {
28
+ const env = { DATABASE_PROVIDER: "SQLite", CACHE_PROVIDER: "memory", JOBS_PROVIDER: "local" };
29
+ expect(providersFromEnvironment(generated, env)).toEqual({
30
+ ...generated,
31
+ database: "sqlite",
32
+ cache: "memory",
33
+ jobs: "local",
34
+ });
35
+ expect(selectedFromEnvironment(env)).toEqual(["database", "cache", "jobs"]);
36
+ });
37
+
38
+ it("rejects an unknown provider name", () => {
39
+ expect(() => providersFromEnvironment(generated, { OBJECT_STORAGE_PROVIDER: "gcs" })).toThrow(
40
+ "OBJECT_STORAGE_PROVIDER must be one of s3, local",
41
+ );
42
+ });
43
+
44
+ it("fails when a variable selects a provider without its module", () => {
45
+ const env = { CACHE_PROVIDER: "memory", DATABASE_PROVIDER: "sqlite" };
46
+ const providers = providersFromEnvironment(generated, env);
47
+ expect(() => assertSelectedProvidersInstalled(registry([]), providers, env)).toThrow(
48
+ "no installed module provides CACHE_PROVIDER=memory",
49
+ );
50
+ expect(() => assertSelectedProvidersInstalled(registry([CACHE]), providers, env)).not.toThrow();
51
+ expect(() => assertSelectedProvidersInstalled(registry([]), generated, {})).not.toThrow();
52
+ });
53
+ });
@@ -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
- // Typed with every provider name, not the selected ones: the module of a
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
- export const PROVIDERS: Readonly<ProviderSelections> = {
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,15 +1,19 @@
1
1
  import { describe, expect, it } from "bun:test";
2
+ import { LOCAL_PROVIDER_SELECTIONS, SERVER_PROVIDER_SELECTIONS } from "@podosoft/podokit-runtime";
2
3
  import {
3
4
  assertApiContract,
5
+ assertApplicationRoutes,
4
6
  assertConsistentRouteParameters,
5
7
  documentedApiRoutes,
6
8
  expectedApiRoutes,
7
9
  missingApiRoutes,
10
+ normalizeApiRoute,
11
+ registeredApiRoutes,
8
12
  } from "./api-contract";
9
13
 
10
14
  describe("API contract inventory", () => {
11
15
  it("covers the core, Todo, module, and Better Auth routes", () => {
12
- const expected = expectedApiRoutes("todo", ["auth", "redis", "file-upload"]);
16
+ const expected = expectedApiRoutes("todo", ["auth", "redis", "file-upload"], SERVER_PROVIDER_SELECTIONS);
13
17
 
14
18
  expect(expected).toContain("GET /health/ready");
15
19
  expect(expected).toContain("PATCH /todos/{id}");
@@ -20,7 +24,7 @@ describe("API contract inventory", () => {
20
24
  });
21
25
 
22
26
  it("uses one structural parameter name for every public blog post route", () => {
23
- const expected = expectedApiRoutes("fullstack", ["blog"]);
27
+ const expected = expectedApiRoutes("fullstack", ["blog"], SERVER_PROVIDER_SELECTIONS);
24
28
 
25
29
  expect(expected).toContain("GET /blog/{postRef}");
26
30
  expect(expected).toContain("PATCH /blog/{postRef}");
@@ -29,15 +33,53 @@ describe("API contract inventory", () => {
29
33
  expect(expected).not.toContain("PATCH /blog/{id}");
30
34
  });
31
35
 
36
+ it("expects a provider module's routes only while its provider is selected", () => {
37
+ const modules = [
38
+ "object-storage-s3",
39
+ "object-storage-local",
40
+ "redis",
41
+ "cache-memory",
42
+ "bullmq",
43
+ "jobs-local",
44
+ ];
45
+ const server = expectedApiRoutes("fullstack", modules, SERVER_PROVIDER_SELECTIONS);
46
+ const local = expectedApiRoutes("fullstack", modules, LOCAL_PROVIDER_SELECTIONS);
47
+
48
+ expect(server).toContain("PUT /storage/{key}");
49
+ expect(server).not.toContain("GET /files/content/*");
50
+ expect(server).toContain("PUT /cache/{key}");
51
+ expect(server).toContain("POST /jobs");
52
+ expect(local).toContain("PUT /storage/{key}");
53
+ expect(local).toContain("GET /files/content/*");
54
+ expect(local).not.toContain("PUT /cache/{key}");
55
+ expect(local).not.toContain("GET /cache/{key}");
56
+ expect(local).toContain("GET /jobs/{id}");
57
+
58
+ const s3Document = {
59
+ paths: {
60
+ "/health": { get: {} },
61
+ "/health/ready": { get: {} },
62
+ "/storage/{key}": { get: {}, put: {} },
63
+ "/storage/{key}/presigned": { get: {} },
64
+ },
65
+ };
66
+ const storage = ["object-storage-s3", "object-storage-local"];
67
+ expect(() => assertApiContract(s3Document, "fullstack", storage, SERVER_PROVIDER_SELECTIONS)).not.toThrow();
68
+ expect(() => assertApiContract(s3Document, "fullstack", storage, {
69
+ ...SERVER_PROVIDER_SELECTIONS,
70
+ "object-storage": "local",
71
+ })).toThrow("GET /files/content/*");
72
+ });
73
+
32
74
  it("normalizes OpenAPI operations and reports an exact missing route", () => {
33
75
  const document = { paths: { "/health": { get: {} }, "/health/ready": { get: {} } } };
34
76
 
35
77
  expect(documentedApiRoutes(document)).toEqual(new Set(["GET /health", "GET /health/ready"]));
36
- expect(missingApiRoutes(document, "fullstack", ["redis"])).toEqual([
78
+ expect(missingApiRoutes(document, "fullstack", ["redis"], SERVER_PROVIDER_SELECTIONS)).toEqual([
37
79
  "GET /cache/{key}",
38
80
  "PUT /cache/{key}",
39
81
  ]);
40
- expect(() => assertApiContract(document, "fullstack", ["redis"])).toThrow(
82
+ expect(() => assertApiContract(document, "fullstack", ["redis"], SERVER_PROVIDER_SELECTIONS)).toThrow(
41
83
  "OpenAPI contract is missing routes",
42
84
  );
43
85
  });
@@ -57,4 +99,30 @@ describe("API contract inventory", () => {
57
99
  { method: "DELETE", path: "/hosts/:hostId/services/:serviceId" },
58
100
  ])).not.toThrow();
59
101
  });
102
+
103
+ it("normalizes runtime and OpenAPI parameter syntax", () => {
104
+ expect(normalizeApiRoute("get /reports/:id/rows/:rowId")).toBe("GET /reports/{id}/rows/{rowId}");
105
+ expect(normalizeApiRoute("GET /reports/{id}")).toBe("GET /reports/{id}");
106
+ expect(registeredApiRoutes([
107
+ { method: "GET", path: "/reports/:id" },
108
+ { method: "POST", path: "/reports" },
109
+ { path: "/no-method" },
110
+ ])).toEqual(new Set(["GET /reports/{id}", "POST /reports"]));
111
+ });
112
+
113
+ it("requires every application route to be registered and documented", () => {
114
+ const document = { paths: { "/reports/{id}": { get: {} }, "/hidden": {} } };
115
+ const routes = [{ method: "GET", path: "/reports/:id" }, { method: "DELETE", path: "/hidden" }];
116
+
117
+ expect(() => assertApplicationRoutes(document, routes, ["GET /reports/:id", "GET /reports/{id}"])).not.toThrow();
118
+ expect(() => assertApplicationRoutes(document, routes, [])).not.toThrow();
119
+ expect(() => assertApplicationRoutes(document, routes, ["DELETE /hidden", "POST /reports"])).toThrow(
120
+ [
121
+ "Application API contract failed:",
122
+ "DELETE /hidden is missing from the OpenAPI document",
123
+ "POST /reports is not registered",
124
+ "POST /reports is missing from the OpenAPI document",
125
+ ].join("\n"),
126
+ );
127
+ });
60
128
  });
@@ -1,3 +1,5 @@
1
+ import type { ProviderCapability, ProviderSelections } from "@podosoft/podokit-runtime";
2
+
1
3
  const OPERATIONS = new Set(["get", "post", "put", "patch", "delete", "options", "head"]);
2
4
 
3
5
  const CORE_ROUTES = [
@@ -81,6 +83,7 @@ const MODULE_ROUTES: Record<string, string[]> = {
81
83
  "DELETE /files/uploads/{id}",
82
84
  ],
83
85
  "job-progress": ["POST /progress"],
86
+ "jobs-local": ["POST /jobs", "GET /jobs/{id}"],
84
87
  "object-storage-local": [
85
88
  "GET /files/content/*",
86
89
  "PUT /storage/{key}",
@@ -96,7 +99,24 @@ const MODULE_ROUTES: Record<string, string[]> = {
96
99
  sse: ["GET /events/stream", "POST /events"],
97
100
  };
98
101
 
102
+ /**
103
+ * The provider each provider module implements. Such a module registers its
104
+ * routes only while its provider is selected, so an app may keep, say, both
105
+ * object-storage modules installed and serve the routes of one.
106
+ */
107
+ export const MODULE_PROVIDERS: Readonly<Record<string, readonly [ProviderCapability, string]>> = {
108
+ bullmq: ["jobs", "bullmq"],
109
+ "cache-memory": ["cache", "memory"],
110
+ "events-memory": ["events", "memory"],
111
+ "events-redis": ["events", "redis"],
112
+ "jobs-local": ["jobs", "local"],
113
+ "object-storage-local": ["object-storage", "local"],
114
+ "object-storage-s3": ["object-storage", "s3"],
115
+ redis: ["cache", "redis"],
116
+ };
117
+
99
118
  interface RuntimeRoute {
119
+ method?: string;
100
120
  path: string;
101
121
  }
102
122
 
@@ -109,11 +129,47 @@ function record(value: unknown): Record<string, unknown> {
109
129
  function runtimeRoutes(value: unknown): RuntimeRoute[] {
110
130
  if (!Array.isArray(value)) return [];
111
131
  return value.flatMap((route) => {
112
- const path = record(route).path;
113
- return typeof path === "string" ? [{ path }] : [];
132
+ const { path, method } = record(route);
133
+ if (typeof path !== "string") return [];
134
+ return [typeof method === "string" ? { method, path } : { path }];
114
135
  });
115
136
  }
116
137
 
138
+ /** "METHOD /path" with OpenAPI parameters, so ":id" and "{id}" compare equal. */
139
+ export function normalizeApiRoute(route: string): string {
140
+ const [method = "", path = ""] = route.trim().split(/\s+/, 2);
141
+ const openApiPath = path
142
+ .split("/")
143
+ .map((segment) => segment.startsWith(":") ? `{${segment.slice(1).replace(/[?*]$/, "")}}` : segment)
144
+ .join("/");
145
+ return `${method.toUpperCase()} ${openApiPath}`;
146
+ }
147
+
148
+ /** The operations an Elysia app registers, as normalized "METHOD /path" entries. */
149
+ export function registeredApiRoutes(routes: unknown): Set<string> {
150
+ const registered = new Set<string>();
151
+ for (const route of runtimeRoutes(routes)) {
152
+ if (route.method) registered.add(normalizeApiRoute(`${route.method} ${route.path}`));
153
+ }
154
+ return registered;
155
+ }
156
+
157
+ /**
158
+ * Fails unless every application route is both registered and documented.
159
+ * `expected` comes from APP_DOCUMENTED_ROUTES in api-contract.extensions.ts.
160
+ */
161
+ export function assertApplicationRoutes(document: unknown, routes: unknown, expected: readonly string[]): void {
162
+ const documented = documentedApiRoutes(document);
163
+ const registered = registeredApiRoutes(routes);
164
+ const problems = [...new Set(expected.map(normalizeApiRoute))].sort().flatMap((route) => [
165
+ ...(registered.has(route) ? [] : [`${route} is not registered`]),
166
+ ...(documented.has(route) ? [] : [`${route} is missing from the OpenAPI document`]),
167
+ ]);
168
+ if (problems.length > 0) {
169
+ throw new Error(`Application API contract failed:\n${problems.join("\n")}`);
170
+ }
171
+ }
172
+
117
173
  /**
118
174
  * Memoirist stores one parameter name at each structural radix-tree position.
119
175
  * Routes such as `/hosts/:id` and `/hosts/:hostId/files` therefore cannot share
@@ -158,10 +214,20 @@ export function documentedApiRoutes(document: unknown): Set<string> {
158
214
  return routes;
159
215
  }
160
216
 
161
- export function expectedApiRoutes(template: string, modules: string[]): Set<string> {
217
+ /**
218
+ * The routes the template and the installed modules serve. A provider module
219
+ * counts only when `providers`, the app's PROVIDERS, selects its provider.
220
+ */
221
+ export function expectedApiRoutes(
222
+ template: string,
223
+ modules: string[],
224
+ providers: Readonly<ProviderSelections>,
225
+ ): Set<string> {
162
226
  const routes = new Set(CORE_ROUTES);
163
227
  for (const route of TEMPLATE_ROUTES[template] ?? []) routes.add(route);
164
228
  for (const module of modules) {
229
+ const provider = MODULE_PROVIDERS[module];
230
+ if (provider && providers[provider[0]] !== provider[1]) continue;
165
231
  for (const route of MODULE_ROUTES[module] ?? []) routes.add(route);
166
232
  }
167
233
  return routes;
@@ -171,15 +237,21 @@ export function missingApiRoutes(
171
237
  document: unknown,
172
238
  template: string,
173
239
  modules: string[],
240
+ providers: Readonly<ProviderSelections>,
174
241
  ): string[] {
175
242
  const documented = documentedApiRoutes(document);
176
- return [...expectedApiRoutes(template, modules)]
243
+ return [...expectedApiRoutes(template, modules, providers)]
177
244
  .filter((route) => !documented.has(route))
178
245
  .sort();
179
246
  }
180
247
 
181
- export function assertApiContract(document: unknown, template: string, modules: string[]): void {
182
- const missing = missingApiRoutes(document, template, modules);
248
+ export function assertApiContract(
249
+ document: unknown,
250
+ template: string,
251
+ modules: string[],
252
+ providers: Readonly<ProviderSelections>,
253
+ ): void {
254
+ const missing = missingApiRoutes(document, template, modules, providers);
183
255
  if (missing.length > 0) {
184
256
  throw new Error(`OpenAPI contract is missing routes:\n${missing.join("\n")}`);
185
257
  }
@@ -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
- const compiledMigrations = join(process.cwd(), "dist", "migrations");
11
- const migrations = existsSync(compiledMigrations)
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
- // Used by the TypeORM CLI for migrations (see package.json scripts).
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("./data/podokit.sqlite");
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;