@podosoft/podokit 1.4.0 → 1.4.2

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 (56) hide show
  1. package/README.md +1 -1
  2. package/dist/deploy-compose-render.js +2 -1
  3. package/dist/deploy-render.js +14 -8
  4. package/dist/lockfile.d.ts +8 -1
  5. package/dist/lockfile.js +22 -3
  6. package/dist/templates/fullstack/AGENTS.md +22 -3
  7. package/dist/templates/fullstack/README.md +10 -7
  8. package/dist/templates/fullstack/apps/api/package.json +1 -1
  9. package/dist/templates/fullstack/apps/api/scripts/run-migrations.mjs +29 -0
  10. package/dist/templates/fullstack/apps/api/src/app.extensions.ts +3 -1
  11. package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +84 -0
  12. package/dist/templates/fullstack/apps/api/src/database/database.ts +50 -5
  13. package/dist/templates/fullstack/apps/api/src/database/pool-guard.spec.ts +2 -2
  14. package/dist/templates/fullstack/apps/api/src/database/run-migrations.spec.ts +118 -0
  15. package/dist/templates/fullstack/apps/web/Dockerfile +1 -1
  16. package/dist/templates/fullstack/apps/web/src/error.html +73 -0
  17. package/dist/templates/fullstack/apps/web/src/lib/server/api.test.ts +116 -0
  18. package/dist/templates/fullstack/apps/web/src/lib/server/api.ts +34 -4
  19. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +175 -0
  20. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +170 -11
  21. package/dist/templates/fullstack/apps/web/src/routes/healthz/+server.ts +13 -0
  22. package/dist/templates/fullstack/dot-env.docker.example +4 -0
  23. package/dist/templates/fullstack/dot-env.example +7 -0
  24. package/dist/templates/fullstack/infra/k3s/web-deployment.yaml +6 -3
  25. package/dist/templates/fullstack/tests/api/health.api.spec.ts +8 -0
  26. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.migration.spec.ts +32 -3
  27. package/dist/templates/modules/admin-dashboard/files/apps/api/src/migrations/1792060000000-IndexSessionCreatedAt.ts +19 -1
  28. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.test.ts +24 -1
  29. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.test.ts +6 -0
  30. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.ts +5 -0
  31. package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +167 -154
  32. package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +94 -57
  33. package/dist/templates/modules/admin-dashboard/files/tests/api/exclusive.api.spec.ts +75 -0
  34. package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +49 -39
  35. package/dist/templates/modules/admin-dashboard/files/tests/helpers/exclusive.ts +80 -0
  36. package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +47 -43
  37. package/dist/templates/modules/admin-dashboard/files/tests/ui/appearance.ui.spec.ts +15 -11
  38. package/dist/templates/modules/admin-dashboard/files/tests/ui/auth.ui.spec.ts +35 -31
  39. package/dist/templates/modules/admin-dashboard/files/tests/ui/general-effects.ui.spec.ts +90 -76
  40. package/dist/templates/modules/admin-dashboard/files/tests/ui/i18n.ui.spec.ts +19 -15
  41. package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +17 -3
  42. package/dist/templates/modules/admin-dashboard/files/tests/ui/request-budget.ui.spec.ts +15 -6
  43. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +71 -64
  44. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +89 -79
  45. package/dist/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +54 -50
  46. package/dist/templates/modules/audit-log/module.manifest.json +1 -1
  47. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +11 -5
  48. package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.spec.ts +83 -0
  49. package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.ts +51 -7
  50. package/dist/templates/modules/auth/module.manifest.json +0 -1
  51. package/dist/templates/modules/file-upload/files/tests/api/chunked-uploads.api.spec.ts +12 -6
  52. package/dist/templates/modules/file-upload/module.manifest.json +2 -2
  53. package/dist/templates/todo/AGENTS.md +10 -3
  54. package/dist/templates/todo/README.md +10 -8
  55. package/package.json +1 -1
  56. package/dist/templates/modules/auth/files/apps/api/scripts/run-migrations.mjs +0 -14
package/README.md CHANGED
@@ -66,7 +66,7 @@ bunx --bun @podosoft/podokit provider set events memory --apply
66
66
  bunx --bun @podosoft/podokit provider set jobs local --apply
67
67
  bun install
68
68
  cp .env.example .env
69
- bun run --cwd apps/api migration:run
69
+ bun run --cwd apps/api migrate:all
70
70
  ```
71
71
 
72
72
  Run `bun run --cwd apps/api dev` and `bun run --cwd apps/web dev` in separate
@@ -311,7 +311,8 @@ function renderComposeDocument(profile, release, images, stateDigest, workerComm
311
311
  healthcheckPath: "/health/ready",
312
312
  }));
313
313
  services.push(applicationService(profile, "web", images.web, release, stateDigest, {
314
- healthcheckPath: "/",
314
+ // The web server's own route: an API outage must not mark it unhealthy.
315
+ healthcheckPath: "/healthz",
315
316
  publish: !hasGateway,
316
317
  }));
317
318
  if (hasGateway) {
@@ -386,8 +386,10 @@ metadata:
386
386
  ${entries.length ? `data:\n${entries.map(([key, value]) => ` ${key}: ${quote(value)}`).join("\n")}` : "data: {}"}
387
387
  `;
388
388
  }
389
- // Above the API's 800 ms per-check readiness bound; the Kubernetes default is 1 s.
390
- const API_PROBE_TIMEOUT_SECONDS = 2;
389
+ // Every rendered API and web probe allows 2 s instead of the Kubernetes default
390
+ // of 1 s: above the API's 800 ms per-check readiness bound, and enough for a busy
391
+ // web server.
392
+ const PROBE_TIMEOUT_SECONDS = 2;
391
393
  function deployment(profile, kind, workload, image, rolloutStateDigest, runtimeConfigDigest) {
392
394
  const name = `${profile.target.release}-${kind}`;
393
395
  const workloadSecret = kind === "api" ? profile.secrets.api : profile.secrets.web;
@@ -399,25 +401,29 @@ function deployment(profile, kind, workload, image, rolloutStateDigest, runtimeC
399
401
  ? ` readinessProbe:
400
402
  httpGet: { path: /health/ready, port: 3000 }
401
403
  periodSeconds: 5
402
- timeoutSeconds: ${API_PROBE_TIMEOUT_SECONDS}
404
+ timeoutSeconds: ${PROBE_TIMEOUT_SECONDS}
403
405
  livenessProbe:
404
406
  httpGet: { path: /health, port: 3000 }
405
407
  initialDelaySeconds: 20
406
408
  periodSeconds: 10
407
- timeoutSeconds: ${API_PROBE_TIMEOUT_SECONDS}
409
+ timeoutSeconds: ${PROBE_TIMEOUT_SECONDS}
408
410
  startupProbe:
409
411
  httpGet: { path: /health, port: 3000 }
410
412
  failureThreshold: 30
411
413
  periodSeconds: 2
412
- timeoutSeconds: ${API_PROBE_TIMEOUT_SECONDS}
414
+ timeoutSeconds: ${PROBE_TIMEOUT_SECONDS}
413
415
  `
414
- : ` readinessProbe:
415
- httpGet: { path: /, port: 3000 }
416
+ : // The web server's own route: an API outage must not take the web pods
417
+ // out of service or restart them.
418
+ ` readinessProbe:
419
+ httpGet: { path: /healthz, port: 3000 }
416
420
  periodSeconds: 5
421
+ timeoutSeconds: ${PROBE_TIMEOUT_SECONDS}
417
422
  livenessProbe:
418
- httpGet: { path: /, port: 3000 }
423
+ httpGet: { path: /healthz, port: 3000 }
419
424
  initialDelaySeconds: 20
420
425
  periodSeconds: 10
426
+ timeoutSeconds: ${PROBE_TIMEOUT_SECONDS}
421
427
  `;
422
428
  return `apiVersion: apps/v1
423
429
  kind: Deployment
@@ -82,7 +82,14 @@ export declare function podokitVersion(): string;
82
82
  export declare function matchGlob(path: string, glob: string): boolean;
83
83
  /** Classify a file's ownership tier. See ADR-0010 priority order. */
84
84
  export declare function classifyTier(relPath: string, content: string | Buffer, ownedGlobs: string[], managedOverrides?: string[]): Tier;
85
- /** List project files as POSIX-relative paths, skipping generated/vendored dirs. */
85
+ /**
86
+ * Whether a path is a local environment file such as `.env`, `.env.docker` or
87
+ * `apps/api/.env.local`. Git ignores these and they hold secrets, so the lock
88
+ * never records them; the committed `*.example` files are project files.
89
+ */
90
+ export declare function isLocalEnvFile(relPath: string): boolean;
91
+ /** List project files as POSIX-relative paths, skipping generated/vendored dirs
92
+ * and local environment files. */
86
93
  export declare function walkFiles(projectRoot: string, prefix?: string): string[];
87
94
  /** Recompute the files.lock by hashing and classifying every project file. */
88
95
  export declare function computeFilesLock(projectRoot: string, ownedGlobs: string[], managedOverrides?: string[]): FilesLock;
package/dist/lockfile.js CHANGED
@@ -11,6 +11,7 @@ exports.writeFilesLock = writeFilesLock;
11
11
  exports.podokitVersion = podokitVersion;
12
12
  exports.matchGlob = matchGlob;
13
13
  exports.classifyTier = classifyTier;
14
+ exports.isLocalEnvFile = isLocalEnvFile;
14
15
  exports.walkFiles = walkFiles;
15
16
  exports.computeFilesLock = computeFilesLock;
16
17
  exports.readFilesLock = readFilesLock;
@@ -57,6 +58,8 @@ exports.DEFAULT_OWNED_GLOBS = [
57
58
  // Owned additions to the managed /api proxy's header lists and body limits.
58
59
  "apps/web/src/lib/server/proxy-headers.ts",
59
60
  "apps/web/src/lib/server/body-limits.ts",
61
+ // Owned fallback page SvelteKit serves when the root layout or a hook fails.
62
+ "apps/web/src/error.html",
60
63
  // AI agent guidance — yours to customize; update never touches it.
61
64
  "AGENTS.md",
62
65
  "CLAUDE.md",
@@ -166,7 +169,17 @@ function classifyTier(relPath, content, ownedGlobs, managedOverrides = []) {
166
169
  return "assembled";
167
170
  return "managed";
168
171
  }
169
- /** List project files as POSIX-relative paths, skipping generated/vendored dirs. */
172
+ /**
173
+ * Whether a path is a local environment file such as `.env`, `.env.docker` or
174
+ * `apps/api/.env.local`. Git ignores these and they hold secrets, so the lock
175
+ * never records them; the committed `*.example` files are project files.
176
+ */
177
+ function isLocalEnvFile(relPath) {
178
+ const name = relPath.slice(relPath.lastIndexOf("/") + 1);
179
+ return (name === ".env" || name.startsWith(".env.")) && !name.endsWith(".example");
180
+ }
181
+ /** List project files as POSIX-relative paths, skipping generated/vendored dirs
182
+ * and local environment files. */
170
183
  function walkFiles(projectRoot, prefix = "") {
171
184
  const out = [];
172
185
  for (const entry of (0, node_fs_1.readdirSync)((0, node_path_1.join)(projectRoot, prefix))) {
@@ -176,7 +189,7 @@ function walkFiles(projectRoot, prefix = "") {
176
189
  if ((0, node_fs_1.statSync)((0, node_path_1.join)(projectRoot, rel)).isDirectory()) {
177
190
  out.push(...walkFiles(projectRoot, rel));
178
191
  }
179
- else {
192
+ else if (!isLocalEnvFile(rel)) {
180
193
  out.push(rel);
181
194
  }
182
195
  }
@@ -196,7 +209,13 @@ function computeFilesLock(projectRoot, ownedGlobs, managedOverrides = []) {
196
209
  }
197
210
  function readFilesLock(projectRoot) {
198
211
  const file = filesLockPath(projectRoot);
199
- return (0, node_fs_1.existsSync)(file) ? JSON.parse((0, node_fs_1.readFileSync)(file, "utf8")) : null;
212
+ if (!(0, node_fs_1.existsSync)(file))
213
+ return null;
214
+ const lock = JSON.parse((0, node_fs_1.readFileSync)(file, "utf8"));
215
+ // Earlier versions recorded local env files. Leaving them out here keeps
216
+ // update from treating them as PodoKit files, and the next write drops them.
217
+ const files = Object.entries(lock.files).filter(([path]) => !isLocalEnvFile(path));
218
+ return { ...lock, files: Object.fromEntries(files) };
200
219
  }
201
220
  /**
202
221
  * Compare the current disk against the lock: report managed/assembled files the
@@ -27,7 +27,7 @@ Provider selection does not migrate or delete existing data.
27
27
  ```bash
28
28
  {{packageManager}} install
29
29
  cp .env.example .env
30
- {{apiRun}} migration:run
30
+ {{apiRun}} migrate:all
31
31
  {{rootRun}} dev # api on http://localhost:5002, web on http://localhost:5001
32
32
  ```
33
33
 
@@ -50,10 +50,29 @@ Merge added module settings from `.env.example` into an existing `.env`.
50
50
  `apps/web/src/lib/server/body-limits.ts` (`APP_BODY_LIMITS`, 413
51
51
  `PAYLOAD_TOO_LARGE`). `BODY_SIZE_LIMIT` is the ceiling for every web route, so
52
52
  uploads larger than it must use a direct API path (`apiPaths`) instead.
53
+ - Server-side API calls (`serverApiClient`) give up after `BACKEND_TIMEOUT_MS`
54
+ (2000), and the proxy answers 504 `BACKEND_TIMEOUT` when the API sends no
55
+ response headers within `BACKEND_PROXY_TIMEOUT_MS` (30000). Once the API's
56
+ `GET /health` stops answering too, calls throw `BackendUnavailableError` and the
57
+ proxy answers 503 `BACKEND_UNAVAILABLE` at once until it answers again. Handle
58
+ any non-`ApiError` failure in a load function as a temporary outage. Raising
59
+ the API's `apiServerOptions.idleTimeout` needs a matching
60
+ `BACKEND_PROXY_TIMEOUT_MS`.
61
+ - `GET /healthz` (`apps/web/src/routes/healthz/+server.ts`) is the web server's own
62
+ health route for the Kubernetes probes, the Compose healthcheck, and the image
63
+ `HEALTHCHECK`. Keep it free of API calls, so an API outage does not take the web
64
+ out of service.
65
+ - When the root layout or a server hook fails, such as with 503 while the API is
66
+ down, SvelteKit serves the app-owned `apps/web/src/error.html`. It is plain HTML
67
+ with inline styles: no components, app CSS, scripts, or assets, and only the
68
+ `%sveltekit.status%` and `%sveltekit.error.message%` placeholders.
53
69
  - Build: `{{rootRun}} build` · Type-check/lint: `{{rootRun}} lint` · Unit tests: `{{rootRun}} test`
54
70
  - Per workspace: `{{apiRun}} build` / `{{webRun}} build`.
55
- - Application migrations (PostgreSQL or SQLite): `{{apiRun}} migration:run`.
56
- - When `auth` is installed, run `{{apiRun}} migrate:all` for Better Auth and application tables.
71
+ - Set up the database and apply later migrations, including after adding a module, with
72
+ `{{apiRun}} migrate:all` (PostgreSQL or SQLite). With `auth` installed it applies the
73
+ Better Auth schema before the application migrations. `{{apiRun}} migration:run` runs
74
+ only the application migrations and never creates Better Auth tables;
75
+ `{{apiRun}} migration:revert` reverts the latest application migration.
57
76
  - e2e (Playwright, ships in `tests/`): `{{rootRun}} test:e2e`.
58
77
  - Playwright officially runs on Node, so `bunx playwright` respects its Node
59
78
  shebang. Node LTS is a test-tool dependency only; API, web, worker, migrations,
@@ -82,7 +82,7 @@ Run lifecycle and container commands from a second terminal:
82
82
  {{packageExecutor}} @podosoft/podokit dev up -d # detached stack without source watching
83
83
  {{packageExecutor}} @podosoft/podokit dev ps
84
84
  {{packageExecutor}} @podosoft/podokit dev logs
85
- {{packageExecutor}} @podosoft/podokit dev exec api {{apiRun}} migration:run
85
+ {{packageExecutor}} @podosoft/podokit dev exec api {{apiRun}} migrate:all
86
86
  {{packageExecutor}} @podosoft/podokit dev down
87
87
  ```
88
88
 
@@ -113,7 +113,7 @@ web and API processes on the host. Prepare the project first:
113
113
  ```bash
114
114
  {{packageManager}} install
115
115
  cp .env.example .env
116
- {{apiRun}} migration:run
116
+ {{apiRun}} migrate:all
117
117
  ```
118
118
 
119
119
  For PostgreSQL, start `docker compose -f infra/docker/docker-compose.yml up -d`
@@ -131,7 +131,7 @@ Run each service in a separate terminal from the project root:
131
131
  ```
132
132
 
133
133
  - API: http://localhost:5002 — health at `/health`, docs at `/api-docs`
134
- - Web: http://localhost:5001
134
+ - Web: http://localhost:5001 — its own health at `/healthz`, which never calls the API
135
135
 
136
136
  ## Database & migrations
137
137
 
@@ -140,12 +140,15 @@ runs PostgreSQL migrations; SQLite uses the local migration runner. Add a
140
140
  numbered migration under `apps/api/src/migrations`, then run it:
141
141
 
142
142
  ```bash
143
- {{apiRun}} migration:run
143
+ {{apiRun}} migrate:all # set up the database and apply new migrations
144
+ {{apiRun}} migration:revert # roll back the last application migration
144
145
  ```
145
146
 
146
- When `auth` is installed, use `{{apiRun}} migrate:all` to create or update both
147
- Better Auth and application tables. Set the module's required environment
148
- values before migrating.
147
+ `migrate:all` is the one command for setup, after adding a module, and in
148
+ production. When `auth` is installed, it applies the Better Auth schema before
149
+ the application migrations, so set the module's required environment values
150
+ first. `{{apiRun}} migration:run` runs only the application migrations and
151
+ never creates Better Auth tables.
149
152
 
150
153
  Want a worked example? Generate the `todo` template instead:
151
154
  `{{packageExecutor}} @podosoft/podokit create my-app --template todo`.
@@ -9,7 +9,7 @@
9
9
  "lint": "bunx --bun tsc -p tsconfig.json --noEmit",
10
10
  "test": "bun test src",
11
11
  "contract": "bun scripts/verify-api-contract.ts",
12
- "migrate:all": "bun dist/database/migrate.js",
12
+ "migrate:all": "bun scripts/run-migrations.mjs",
13
13
  "typeorm": "bunx typeorm-ts-node-commonjs -d src/database/data-source.ts",
14
14
  "migration:run": "bun src/database/migrate.ts",
15
15
  "migration:revert": "bun src/database/migrate.ts --revert",
@@ -0,0 +1,29 @@
1
+ import { existsSync } from "node:fs";
2
+ import { fileURLToPath } from "node:url";
3
+
4
+ // Production images ship only the compiled output.
5
+ function entry(name) {
6
+ const compiled = new URL(`../dist/${name}.js`, import.meta.url);
7
+ if (process.env.NODE_ENV === "production" && existsSync(compiled)) return compiled;
8
+ const source = new URL(`../src/${name}.ts`, import.meta.url);
9
+ return existsSync(source) ? source : null;
10
+ }
11
+
12
+ // The auth module adds src/migrate.ts, which applies the Better Auth schema
13
+ // before it runs the application migrations.
14
+ const target = entry("migrate") ?? entry("database/migrate");
15
+ if (!target) {
16
+ process.stderr.write("Run database migrations failed: no migration entry in src or dist\n");
17
+ process.exit(1);
18
+ }
19
+ // Run in the API directory wherever this was started from: Bun reads decorator
20
+ // settings from the tsconfig.json of the working directory, and the data source
21
+ // finds .env and the migrations relative to it.
22
+ const subprocess = Bun.spawn([process.execPath, fileURLToPath(target)], {
23
+ cwd: fileURLToPath(new URL("..", import.meta.url)),
24
+ stdin: "inherit",
25
+ stdout: "inherit",
26
+ stderr: "inherit",
27
+ });
28
+
29
+ process.exitCode = await subprocess.exited;
@@ -13,5 +13,7 @@ export const extensionModules: PodokitModule[] = [];
13
13
  // Bun server options such as a larger request body limit for uploads, for
14
14
  // example { maxRequestBodySize: 1024 ** 3, idleTimeout: 120 }. Values set here
15
15
  // win over API_MAX_REQUEST_BODY_SIZE; a function receives the validated
16
- // environment instead: (env) => ({ maxRequestBodySize: ... }).
16
+ // environment instead: (env) => ({ maxRequestBodySize: ... }). A longer
17
+ // idleTimeout also needs BACKEND_PROXY_TIMEOUT_MS on the web server, which stops
18
+ // waiting for response headers after 30 seconds by default.
17
19
  export const apiServerOptions: ApiServerOptionsSource = {};
@@ -1,8 +1,10 @@
1
1
  import { SQL } from "bun";
2
2
  import { describe, expect, it } from "bun:test";
3
3
  import { existsSync, mkdtempSync, rmSync } from "node:fs";
4
+ import { connect, createServer, type AddressInfo } from "node:net";
4
5
  import { tmpdir } from "node:os";
5
6
  import { dirname, join } from "node:path";
7
+ import { QueryFailedError } from "typeorm";
6
8
  import { validateEnv } from "../config/env.validation";
7
9
  import { PROVIDERS } from "../config/providers";
8
10
  import {
@@ -183,6 +185,61 @@ describe("isDatabaseUnavailable", () => {
183
185
  expect(isDatabaseUnavailable(new Error("Failed to get session", { cause: acquire }))).toBe(true);
184
186
  });
185
187
 
188
+ it("recognizes refused, dropped, and shut-down connections from pg and Bun SQL", () => {
189
+ const socket = (code: string): Error => Object.assign(new Error(`connect ${code}`), { code });
190
+ const server = (code: string): Error => Object.assign(new Error("server error"), { code });
191
+ for (const code of ["ECONNREFUSED", "ECONNRESET", "ETIMEDOUT", "EHOSTUNREACH", "ENETUNREACH", "EPIPE"]) {
192
+ expect(isDatabaseUnavailable(socket(code))).toBe(true);
193
+ }
194
+ expect(isDatabaseUnavailable(new Error("Connection terminated unexpectedly"))).toBe(true);
195
+ for (const code of ["57P01", "57P02", "57P03", "08006", "08P01"]) {
196
+ expect(isDatabaseUnavailable(server(code))).toBe(true);
197
+ expect(
198
+ isDatabaseUnavailable(new SQL.PostgresError("server error", { code: "ERR_POSTGRES_SERVER_ERROR", errno: code })),
199
+ ).toBe(true);
200
+ }
201
+ expect(isDatabaseUnavailable(new Error("Failed to save", { cause: socket("ECONNREFUSED") }))).toBe(true);
202
+ });
203
+
204
+ it("recognizes host lookup failures from Bun and Node", () => {
205
+ // Bun reports an unknown host as ENOTFOUND and a lookup timeout as ETIMEOUT;
206
+ // Node reports a lookup it could not complete as EAI_AGAIN.
207
+ for (const code of ["ENOTFOUND", "ETIMEOUT", "EAI_AGAIN"]) {
208
+ expect(isDatabaseUnavailable(Object.assign(new Error(`getaddrinfo ${code} db`), { code }))).toBe(true);
209
+ }
210
+ });
211
+
212
+ it("recognizes a pg client that was ended or broke while a query waited", () => {
213
+ for (const message of [
214
+ "Connection terminated",
215
+ "Client was closed and is not queryable",
216
+ "Client has encountered a connection error and is not queryable",
217
+ ]) {
218
+ expect(isDatabaseUnavailable(new Error(message))).toBe(true);
219
+ }
220
+ });
221
+
222
+ it("follows the per-address errors of a connection that tried several addresses", () => {
223
+ const refused = (address: string): Error =>
224
+ Object.assign(new Error(`connect ECONNREFUSED ${address}:5432`), { code: "ECONNREFUSED" });
225
+ expect(isDatabaseUnavailable(new AggregateError([refused("::1"), refused("127.0.0.1")]))).toBe(true);
226
+ expect(isDatabaseUnavailable(new AggregateError([new Error("first"), new Error("second")]))).toBe(false);
227
+ });
228
+
229
+ it("follows the driver error that TypeORM wraps", () => {
230
+ const failed = (driverError: Error): QueryFailedError => new QueryFailedError("SELECT 1", [], driverError);
231
+ expect(isDatabaseUnavailable(failed(new Error("Connection terminated")))).toBe(true);
232
+ const refused = Object.assign(new Error("connect ECONNREFUSED 127.0.0.1:5432"), { code: "ECONNREFUSED" });
233
+ expect(isDatabaseUnavailable(failed(new AggregateError([refused])))).toBe(true);
234
+ expect(isDatabaseUnavailable(failed(Object.assign(new Error("duplicate key"), { code: "23505" })))).toBe(false);
235
+ });
236
+
237
+ it("stops at an error that wraps itself", () => {
238
+ const loop = new Error("outer");
239
+ loop.cause = new Error("inner", { cause: loop });
240
+ expect(isDatabaseUnavailable(loop)).toBe(false);
241
+ });
242
+
186
243
  it("leaves query and application errors alone", () => {
187
244
  expect(isDatabaseUnavailable(new SQL.PostgresError("syntax error", { code: "ERR_POSTGRES_SYNTAX_ERROR" })))
188
245
  .toBe(false);
@@ -190,10 +247,37 @@ describe("isDatabaseUnavailable", () => {
190
247
  isDatabaseUnavailable(new SQL.PostgresError("duplicate key", { code: "ERR_POSTGRES_SERVER_ERROR", errno: "23505" })),
191
248
  ).toBe(false);
192
249
  expect(isDatabaseUnavailable(new Error("timeout"))).toBe(false);
250
+ expect(isDatabaseUnavailable(Object.assign(new Error("duplicate key"), { code: "23505" }))).toBe(false);
251
+ expect(isDatabaseUnavailable(Object.assign(new Error("not found"), { code: "ENOENT" }))).toBe(false);
193
252
  expect(isDatabaseUnavailable("ERR_POSTGRES_CONNECTION_TIMEOUT")).toBe(false);
194
253
  expect(isDatabaseUnavailable(undefined)).toBe(false);
195
254
  });
196
255
 
256
+ it("recognizes a real lookup of a host that does not exist", async () => {
257
+ // .invalid never resolves (RFC 2606). A resolver that does not answer at all
258
+ // fails with ETIMEOUT after about 16 seconds, which is recognized too.
259
+ const error: unknown = await fetch("http://database.invalid/").then(() => undefined, (reason: unknown) => reason);
260
+ expect(isDatabaseUnavailable(error)).toBe(true);
261
+ }, 30_000);
262
+
263
+ it("recognizes a real refused connection to localhost", async () => {
264
+ const server = createServer();
265
+ await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
266
+ const { port } = server.address() as AddressInfo;
267
+ await new Promise<void>((resolve) => server.close(() => resolve()));
268
+ // localhost may resolve to ::1 and 127.0.0.1; a client that tries both fails
269
+ // with an AggregateError of the two refusals.
270
+ const error = await new Promise<unknown>((resolve) => {
271
+ const socket = connect({ host: "localhost", port });
272
+ socket.once("connect", () => {
273
+ socket.destroy();
274
+ resolve(undefined);
275
+ });
276
+ socket.once("error", resolve);
277
+ });
278
+ expect(isDatabaseUnavailable(error)).toBe(true);
279
+ });
280
+
197
281
  it("recognizes a real Bun SQL connection timeout", async () => {
198
282
  // Accepts the TCP connection and never answers, like an overloaded server.
199
283
  const server = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } });
@@ -75,16 +75,61 @@ const UNAVAILABLE_SQL_CODES = new Set([
75
75
  const UNAVAILABLE_PG_MESSAGES = new Set([
76
76
  "timeout exceeded when trying to connect",
77
77
  "Connection terminated due to connection timeout",
78
+ "Connection terminated unexpectedly",
79
+ // The client was ended, for example by a pool replacement, while a query waited.
80
+ "Connection terminated",
81
+ "Client was closed and is not queryable",
82
+ "Client has encountered a connection error and is not queryable",
78
83
  "Query read timeout",
79
84
  ]);
85
+ // Socket and host lookup errors from a pg client whose server is gone or
86
+ // unreachable. Bun reports a lookup timeout as ETIMEOUT, Node as EAI_AGAIN.
87
+ const UNAVAILABLE_SOCKET_CODES = new Set([
88
+ "ECONNREFUSED",
89
+ "ECONNRESET",
90
+ "ETIMEDOUT",
91
+ "EHOSTUNREACH",
92
+ "ENETUNREACH",
93
+ "EPIPE",
94
+ "ENOTFOUND",
95
+ "ETIMEOUT",
96
+ "EAI_AGAIN",
97
+ ]);
98
+ // SQLSTATEs for a server shutting down or refusing connections (class 57P) and
99
+ // for broken connections (class 08); pg reports them as `code`, Bun SQL as `errno`.
100
+ const UNAVAILABLE_SQLSTATES = new Set(["57P01", "57P02", "57P03"]);
101
+
102
+ function unavailableSqlState(value: unknown): boolean {
103
+ return typeof value === "string" && (UNAVAILABLE_SQLSTATES.has(value) || /^08[0-9A-Z]{3}$/.test(value));
104
+ }
105
+
106
+ function signalsUnavailable(error: Error): boolean {
107
+ if (error instanceof SQL.PostgresError) {
108
+ if (UNAVAILABLE_SQL_CODES.has(error.code) || unavailableSqlState(error.errno)) return true;
109
+ } else {
110
+ const code = (error as { code?: unknown }).code;
111
+ if (typeof code === "string" && (UNAVAILABLE_SOCKET_CODES.has(code) || unavailableSqlState(code))) return true;
112
+ }
113
+ return UNAVAILABLE_PG_MESSAGES.has(error.message);
114
+ }
115
+
116
+ // Bounds the walk over wrapped errors, which may form a cycle.
117
+ const MAX_WRAPPED_ERRORS = 16;
80
118
 
81
119
  /** Whether an error, or one it wraps, means the database is unavailable rather than the request being wrong. */
82
120
  export function isDatabaseUnavailable(error: unknown): boolean {
83
- let current: unknown = error;
84
- for (let depth = 0; depth < 5 && current instanceof Error; depth += 1) {
85
- if (current instanceof SQL.PostgresError && UNAVAILABLE_SQL_CODES.has(current.code)) return true;
86
- if (UNAVAILABLE_PG_MESSAGES.has(current.message)) return true;
87
- current = current.cause;
121
+ const pending: unknown[] = [error];
122
+ const seen = new Set<Error>();
123
+ while (pending.length > 0 && seen.size < MAX_WRAPPED_ERRORS) {
124
+ const current = pending.shift();
125
+ if (!(current instanceof Error) || seen.has(current)) continue;
126
+ seen.add(current);
127
+ if (signalsUnavailable(current)) return true;
128
+ // TypeORM's QueryFailedError keeps the driver's error as `driverError`, and
129
+ // a connection that tried several addresses, such as IPv6 and IPv4 for
130
+ // localhost, fails with an AggregateError of the per-address errors.
131
+ pending.push(current.cause, (current as { driverError?: unknown }).driverError);
132
+ if (current instanceof AggregateError) pending.push(...current.errors);
88
133
  }
89
134
  return false;
90
135
  }
@@ -91,7 +91,7 @@ describe("PoolGuard", () => {
91
91
  await sql`CREATE TABLE item (id INTEGER PRIMARY KEY, name TEXT)`;
92
92
  await sql`INSERT INTO item ${sql([{ id: 1, name: "a" }, { id: 2, name: "b" }], "id", "name")}`;
93
93
  const filter = sql`WHERE "name" = ${"b"}`;
94
- expect(await sql`SELECT "id" FROM item ${filter}`).toEqual([{ id: 2 }]);
94
+ expect(await sql<Array<{ id: number }>>`SELECT "id" FROM item ${filter}`).toEqual([{ id: 2 }]);
95
95
  expect(await sql`SELECT count(*) AS n FROM item`.values()).toEqual([[2]]);
96
96
  const names = await sql.begin(async (tx) => {
97
97
  await tx`UPDATE item SET "name" = 'c' WHERE "id" = 1`;
@@ -119,7 +119,7 @@ describe("PoolGuard", () => {
119
119
  expect(server.pools.map((pool) => pool.closed())).toEqual([true, false]);
120
120
 
121
121
  server.stalled = false;
122
- expect(await guard.sql`SELECT 1`).toEqual([{ ok: 1 }]);
122
+ expect(await guard.sql<Array<{ ok: number }>>`SELECT 1`).toEqual([{ ok: 1 }]);
123
123
  });
124
124
 
125
125
  it("keeps a slow query running while the server answers", async () => {
@@ -0,0 +1,118 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { cpSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+
6
+ const LAUNCHER = join(__dirname, "../../scripts/run-migrations.mjs");
7
+
8
+ interface Launch {
9
+ code: number;
10
+ stdout: string;
11
+ stderr: string;
12
+ }
13
+
14
+ interface LaunchOptions {
15
+ /** Starts the launcher from the directory above the API, like a repository root. */
16
+ fromParent?: boolean;
17
+ files?: Record<string, string>;
18
+ }
19
+
20
+ /** Runs the migrate:all launcher in an API layout whose entries print their own path. */
21
+ async function launch(entries: string[], nodeEnv: string, body = "", options: LaunchOptions = {}): Promise<Launch> {
22
+ const parent = mkdtempSync(join(tmpdir(), "run-migrations-"));
23
+ const root = join(parent, "api");
24
+ try {
25
+ mkdirSync(join(root, "scripts"), { recursive: true });
26
+ cpSync(LAUNCHER, join(root, "scripts/run-migrations.mjs"));
27
+ for (const entry of entries) {
28
+ mkdirSync(dirname(join(root, entry)), { recursive: true });
29
+ writeFileSync(join(root, entry), `console.log(${JSON.stringify(entry)});\n${body}`);
30
+ }
31
+ for (const [path, content] of Object.entries(options.files ?? {})) {
32
+ mkdirSync(dirname(join(root, path)), { recursive: true });
33
+ writeFileSync(join(root, path), content);
34
+ }
35
+ const child = Bun.spawn([process.execPath, options.fromParent ? "api/scripts/run-migrations.mjs" : "scripts/run-migrations.mjs"], {
36
+ cwd: options.fromParent ? parent : root,
37
+ env: { ...process.env, NODE_ENV: nodeEnv },
38
+ stdout: "pipe",
39
+ stderr: "pipe",
40
+ });
41
+ const [stdout, stderr, code] = await Promise.all([
42
+ new Response(child.stdout).text(),
43
+ new Response(child.stderr).text(),
44
+ child.exited,
45
+ ]);
46
+ return { code, stdout: stdout.trim(), stderr };
47
+ } finally {
48
+ rmSync(parent, { recursive: true, force: true });
49
+ }
50
+ }
51
+
52
+ describe("migrate:all launcher", () => {
53
+ it("runs the application migrations from source in development", async () => {
54
+ const result = await launch(["src/database/migrate.ts", "dist/database/migrate.js"], "development");
55
+ expect(result).toMatchObject({ code: 0, stdout: "src/database/migrate.ts" });
56
+ });
57
+
58
+ it("runs the compiled application migrations in production", async () => {
59
+ const result = await launch(["src/database/migrate.ts", "dist/database/migrate.js"], "production");
60
+ expect(result).toMatchObject({ code: 0, stdout: "dist/database/migrate.js" });
61
+ });
62
+
63
+ it("prefers the auth module's entry, which applies the Better Auth schema first", async () => {
64
+ expect(await launch(["src/migrate.ts", "src/database/migrate.ts"], "development"))
65
+ .toMatchObject({ code: 0, stdout: "src/migrate.ts" });
66
+ // A production image has only the compiled output.
67
+ expect(await launch(["dist/migrate.js", "dist/database/migrate.js"], "production"))
68
+ .toMatchObject({ code: 0, stdout: "dist/migrate.js" });
69
+ });
70
+
71
+ it("exits with the migration's status", async () => {
72
+ const result = await launch(["src/database/migrate.ts"], "development", "process.exit(3);\n");
73
+ expect(result.code).toBe(3);
74
+ });
75
+
76
+ it("runs the migration in the API directory when started from elsewhere", async () => {
77
+ const apiRoot = 'realpathSync(join(import.meta.dir, "../.."))';
78
+ const result = await launch(
79
+ ["src/database/migrate.ts"],
80
+ "development",
81
+ `import { realpathSync } from "node:fs";\nimport { join } from "node:path";\n` +
82
+ `console.log(realpathSync(process.cwd()) === ${apiRoot});\n`,
83
+ { fromParent: true },
84
+ );
85
+ expect(result).toMatchObject({ code: 0, stdout: "src/database/migrate.ts\ntrue" });
86
+ });
87
+
88
+ it("applies the API's decorator settings when started from elsewhere", async () => {
89
+ // TypeORM entities need legacy decorators, which Bun takes from the
90
+ // tsconfig.json of the working directory rather than of the source file.
91
+ const result = await launch(
92
+ ["src/database/migrate.ts"],
93
+ "development",
94
+ 'await import("../entity.ts");\n',
95
+ {
96
+ fromParent: true,
97
+ files: {
98
+ "tsconfig.json": JSON.stringify({ compilerOptions: { experimentalDecorators: true, emitDecoratorMetadata: true } }),
99
+ "src/entity.ts": [
100
+ "const Column = (): PropertyDecorator => (target) => console.log(typeof target);",
101
+ "export class Item {",
102
+ " @Column()",
103
+ " name!: string;",
104
+ "}",
105
+ "",
106
+ ].join("\n"),
107
+ },
108
+ },
109
+ );
110
+ expect(result).toMatchObject({ code: 0, stdout: "src/database/migrate.ts\nobject" });
111
+ });
112
+
113
+ it("fails when no migration entry exists", async () => {
114
+ const result = await launch([], "development");
115
+ expect(result.code).toBe(1);
116
+ expect(result.stderr).toContain("no migration entry in src or dist");
117
+ });
118
+ });
@@ -37,5 +37,5 @@ WORKDIR /app/apps/web
37
37
  USER bun
38
38
  EXPOSE 3000
39
39
  HEALTHCHECK --interval=10s --timeout=5s --start-period=30s --retries=6 \
40
- CMD bun -e "fetch('http://127.0.0.1:' + (process.env.PORT ?? '3000') + '/').then((r) => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"
40
+ CMD bun -e "fetch('http://127.0.0.1:' + (process.env.PORT ?? '3000') + '/healthz').then((r) => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"
41
41
  CMD ["bun", "./build"]