@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.
- package/README.md +1 -1
- package/dist/deploy-compose-render.js +2 -1
- package/dist/deploy-render.js +14 -8
- package/dist/lockfile.d.ts +8 -1
- package/dist/lockfile.js +22 -3
- package/dist/templates/fullstack/AGENTS.md +22 -3
- package/dist/templates/fullstack/README.md +10 -7
- package/dist/templates/fullstack/apps/api/package.json +1 -1
- package/dist/templates/fullstack/apps/api/scripts/run-migrations.mjs +29 -0
- package/dist/templates/fullstack/apps/api/src/app.extensions.ts +3 -1
- package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +84 -0
- package/dist/templates/fullstack/apps/api/src/database/database.ts +50 -5
- package/dist/templates/fullstack/apps/api/src/database/pool-guard.spec.ts +2 -2
- package/dist/templates/fullstack/apps/api/src/database/run-migrations.spec.ts +118 -0
- package/dist/templates/fullstack/apps/web/Dockerfile +1 -1
- package/dist/templates/fullstack/apps/web/src/error.html +73 -0
- package/dist/templates/fullstack/apps/web/src/lib/server/api.test.ts +116 -0
- package/dist/templates/fullstack/apps/web/src/lib/server/api.ts +34 -4
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +175 -0
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +170 -11
- package/dist/templates/fullstack/apps/web/src/routes/healthz/+server.ts +13 -0
- package/dist/templates/fullstack/dot-env.docker.example +4 -0
- package/dist/templates/fullstack/dot-env.example +7 -0
- package/dist/templates/fullstack/infra/k3s/web-deployment.yaml +6 -3
- package/dist/templates/fullstack/tests/api/health.api.spec.ts +8 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.migration.spec.ts +32 -3
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/migrations/1792060000000-IndexSessionCreatedAt.ts +19 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.test.ts +24 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.test.ts +6 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.ts +5 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +167 -154
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +94 -57
- package/dist/templates/modules/admin-dashboard/files/tests/api/exclusive.api.spec.ts +75 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +49 -39
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/exclusive.ts +80 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +47 -43
- package/dist/templates/modules/admin-dashboard/files/tests/ui/appearance.ui.spec.ts +15 -11
- package/dist/templates/modules/admin-dashboard/files/tests/ui/auth.ui.spec.ts +35 -31
- package/dist/templates/modules/admin-dashboard/files/tests/ui/general-effects.ui.spec.ts +90 -76
- package/dist/templates/modules/admin-dashboard/files/tests/ui/i18n.ui.spec.ts +19 -15
- package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +17 -3
- package/dist/templates/modules/admin-dashboard/files/tests/ui/request-budget.ui.spec.ts +15 -6
- package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +71 -64
- package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +89 -79
- package/dist/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +54 -50
- package/dist/templates/modules/audit-log/module.manifest.json +1 -1
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +11 -5
- package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.spec.ts +83 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.ts +51 -7
- package/dist/templates/modules/auth/module.manifest.json +0 -1
- package/dist/templates/modules/file-upload/files/tests/api/chunked-uploads.api.spec.ts +12 -6
- package/dist/templates/modules/file-upload/module.manifest.json +2 -2
- package/dist/templates/todo/AGENTS.md +10 -3
- package/dist/templates/todo/README.md +10 -8
- package/package.json +1 -1
- 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
|
|
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
|
-
|
|
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) {
|
package/dist/deploy-render.js
CHANGED
|
@@ -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
|
-
//
|
|
390
|
-
|
|
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: ${
|
|
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: ${
|
|
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: ${
|
|
414
|
+
timeoutSeconds: ${PROBE_TIMEOUT_SECONDS}
|
|
413
415
|
`
|
|
414
|
-
:
|
|
415
|
-
|
|
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:
|
|
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
|
package/dist/lockfile.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
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}}
|
|
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
|
-
-
|
|
56
|
-
|
|
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}}
|
|
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}}
|
|
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}}
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
current
|
|
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
|
|
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
|
|
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"]
|