@podosoft/podokit 1.2.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/add.d.ts +13 -2
- package/dist/add.js +135 -20
- package/dist/api-paths.d.ts +34 -0
- package/dist/api-paths.js +103 -0
- package/dist/deploy-compose-profile.d.ts +4 -0
- package/dist/deploy-compose-profile.js +13 -2
- package/dist/deploy-compose-render.d.ts +1 -1
- package/dist/deploy-compose-render.js +23 -7
- package/dist/deploy-compose.js +2 -1
- package/dist/deploy-profile.d.ts +3 -0
- package/dist/deploy-profile.js +12 -4
- package/dist/deploy-render.js +54 -8
- package/dist/deploy.js +21 -1
- package/dist/dev.d.ts +14 -0
- package/dist/dev.js +152 -10
- package/dist/index.js +23 -2
- package/dist/lib.d.ts +4 -2
- package/dist/lib.js +5 -2
- package/dist/lockfile.d.ts +6 -1
- package/dist/lockfile.js +26 -6
- package/dist/module-api-paths.d.ts +52 -0
- package/dist/module-api-paths.js +189 -0
- package/dist/remove.d.ts +3 -0
- package/dist/remove.js +12 -3
- package/dist/templates/fullstack/AGENTS.md +17 -2
- package/dist/templates/fullstack/README.md +19 -5
- package/dist/templates/fullstack/apps/api/package.json +1 -1
- package/dist/templates/fullstack/apps/api/scripts/build.mjs +1 -0
- package/dist/templates/fullstack/apps/api/src/app.extensions.ts +7 -0
- package/dist/templates/fullstack/apps/api/src/app.spec.ts +20 -1
- package/dist/templates/fullstack/apps/api/src/app.ts +11 -0
- package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +3 -0
- package/dist/templates/fullstack/apps/api/src/config/redis.connection.spec.ts +48 -2
- package/dist/templates/fullstack/apps/api/src/config/redis.connection.ts +45 -0
- package/dist/templates/fullstack/apps/api/src/config/server-options.spec.ts +46 -0
- package/dist/templates/fullstack/apps/api/src/config/server-options.ts +48 -0
- package/dist/templates/fullstack/apps/api/src/core/api-contract.spec.ts +1 -0
- package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +10 -1
- package/dist/templates/fullstack/apps/api/src/core/services.spec.ts +196 -0
- package/dist/templates/fullstack/apps/api/src/core/services.ts +181 -2
- package/dist/templates/fullstack/apps/api/src/database/data-source.spec.ts +15 -0
- package/dist/templates/fullstack/apps/api/src/database/data-source.ts +3 -0
- package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +8 -1
- package/dist/templates/fullstack/apps/api/src/database/database.ts +29 -10
- package/dist/templates/fullstack/apps/api/src/database/migrate.ts +1 -1
- package/dist/templates/fullstack/apps/api/src/database/pool-guard.spec.ts +188 -0
- package/dist/templates/fullstack/apps/api/src/database/pool-guard.ts +151 -0
- package/dist/templates/fullstack/apps/api/src/health/health.plugin.spec.ts +28 -4
- package/dist/templates/fullstack/apps/api/src/health/health.plugin.ts +10 -2
- package/dist/templates/fullstack/apps/api/src/main.ts +2 -1
- package/dist/templates/fullstack/apps/web/components.json +3 -1
- package/dist/templates/fullstack/apps/web/package.json +1 -1
- package/dist/templates/fullstack/apps/web/src/lib/components/ui/textarea/index.ts +7 -0
- package/dist/templates/fullstack/apps/web/src/lib/components/ui/textarea/textarea.svelte +22 -0
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +276 -1
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +194 -9
- package/dist/templates/fullstack/apps/web/src/lib/server/body-limits.ts +15 -0
- package/dist/templates/fullstack/apps/web/src/lib/server/proxy-headers.ts +12 -0
- package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +10 -3
- package/dist/templates/fullstack/dot-claude/skills/podokit-elysia-endpoint/SKILL.md +5 -0
- package/dist/templates/fullstack/dot-claude/skills/podokit-sveltekit-route/SKILL.md +14 -1
- package/dist/templates/fullstack/dot-devcontainer/devcontainer.json +1 -0
- package/dist/templates/fullstack/{dot-env.docker → dot-env.docker.example} +7 -4
- package/dist/templates/fullstack/dot-env.example +10 -0
- package/dist/templates/fullstack/dot-gitignore +1 -0
- package/dist/templates/fullstack/dot-podokit/dev.json +2 -1
- package/dist/templates/fullstack/infra/k3s/README.md +3 -1
- package/dist/templates/fullstack/tests/playwright.extensions.ts +2 -2
- package/dist/templates/modules/admin-dashboard/files/apps/api/scripts/bootstrap-admin.mjs +21 -26
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.migration.spec.ts +74 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.routes.spec.ts +76 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.routes.ts +41 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.service.spec.ts +124 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.service.ts +153 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin.module.ts +5 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/bootstrap-admin-runtime.ts +60 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/migrations/1792060000000-IndexSessionCreatedAt.ts +35 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +27 -9
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/users.test.ts +59 -32
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/users.ts +81 -11
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/asset-paths.ts +11 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/guards.test.ts +23 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/guards.ts +2 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/public-paths.ts +9 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.test.ts +125 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.ts +107 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/users/+page.svelte +13 -19
- package/dist/templates/modules/admin-dashboard/files/tests/api/admin-users.api.spec.ts +86 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +1 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +2 -1
- package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +2 -1
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/disposable-users.ts +15 -12
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/object-response.ts +12 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/data-table.ui.spec.ts +2 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/email-admin.ui.spec.ts +0 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +1 -3
- package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +3 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/request-budget.ui.spec.ts +14 -15
- package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +13 -8
- package/dist/templates/modules/admin-dashboard/files/tests/ui/users-crud.ui.spec.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +14 -3
- package/dist/templates/modules/admin-dashboard/module.manifest.json +4 -2
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.migration.spec.ts +53 -11
- package/dist/templates/modules/audit-log/files/apps/api/src/migrations/1792000000000-IndexAuditLogFilters.ts +26 -4
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.spec.ts +26 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +17 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +8 -4
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +2 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/database-outage.spec.ts +114 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/database-outage.ts +50 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/db.spec.ts +4 -1
- package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +11 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/maintenance-command.ts +5 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/maintenance.ts +14 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/postgres-pool.spec.ts +136 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/postgres-pool.ts +40 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.rows.spec.ts +53 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.rows.ts +65 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +16 -37
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/session-expiry.spec.ts +33 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/session-expiry.ts +29 -0
- package/dist/templates/modules/auth/files/apps/api/src/migrate.ts +3 -2
- package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/references/bootstrap-admin.md +2 -0
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.spec.ts +121 -1
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.ts +108 -10
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/jobs.module.ts +3 -1
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/queue.ts +31 -3
- package/dist/templates/modules/bullmq/module.manifest.json +5 -1
- package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/events-redis.module.ts +2 -4
- package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/redis-event.bus.spec.ts +224 -0
- package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/redis-event.bus.ts +124 -35
- package/dist/templates/modules/events-redis/module.manifest.json +4 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/files/chunked-uploads.routes.ts +136 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/files/chunked-uploads.spec.ts +636 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/files/chunked-uploads.ts +673 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.spec.ts +10 -4
- package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.ts +37 -8
- package/dist/templates/modules/file-upload/files/apps/api/src/files/upload-config.ts +99 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/migrations/1792100000000-InitFileUploadSessions.ts +49 -0
- package/dist/templates/modules/file-upload/files/apps/web/src/lib/uploads.test.ts +456 -0
- package/dist/templates/modules/file-upload/files/apps/web/src/lib/uploads.ts +356 -0
- package/dist/templates/modules/file-upload/files/tests/api/chunked-uploads.api.spec.ts +147 -0
- package/dist/templates/modules/file-upload/module.manifest.json +34 -6
- package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/jobs-local.module.ts +1 -1
- package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.spec.ts +23 -0
- package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.ts +30 -20
- package/dist/templates/modules/jobs-local/module.manifest.json +3 -0
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/download-url.ts +20 -0
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.spec.ts +79 -12
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.ts +16 -1
- package/dist/templates/modules/object-storage-local/module.manifest.json +11 -2
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-multipart.spec.ts +115 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-multipart.ts +171 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.spec.ts +222 -9
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.ts +55 -19
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-signer.spec.ts +71 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-signer.ts +98 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage-demo.spec.ts +6 -3
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.config.ts +7 -2
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.service.ts +187 -44
- package/dist/templates/modules/object-storage-s3/module.manifest.json +10 -2
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +11 -22
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.spec.ts +113 -0
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.ts +122 -0
- package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +24 -0
- package/dist/templates/modules/rate-limit/module.manifest.json +7 -0
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.spec.ts +14 -1
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.ts +9 -12
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis.module.ts +16 -4
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis.service.spec.ts +233 -5
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis.service.ts +134 -32
- package/dist/templates/modules/redis/module.manifest.json +4 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/event-stream.ts +11 -4
- package/dist/templates/modules/sse/files/apps/api/src/events/events.module.spec.ts +48 -1
- package/dist/templates/modules/sse/files/apps/api/src/events/events.module.ts +15 -2
- package/dist/templates/modules/sse/files/apps/api/src/events/events.service.spec.ts +68 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +25 -1
- package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +46 -0
- package/dist/templates/modules/sse/module.manifest.json +3 -1
- package/dist/templates/todo/AGENTS.md +9 -1
- package/dist/templates/todo/README.md +18 -5
- package/dist/update.d.ts +22 -0
- package/dist/update.js +68 -8
- package/package.json +2 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/schema-validation.ts +0 -13
- package/dist/templates/modules/auth/files/apps/api/src/auth/skip-schema-validation.ts +0 -5
|
@@ -153,6 +153,12 @@ describe("PostgreSQL pool settings", () => {
|
|
|
153
153
|
expect(() => validateEnv({ DB_POOL_MAX: "0" })).toThrow("DB_POOL_MAX");
|
|
154
154
|
expect(() => validateEnv({ DB_AUTH_POOL_MAX: "-1" })).toThrow("DB_AUTH_POOL_MAX");
|
|
155
155
|
expect(() => validateEnv({ DB_CONNECT_TIMEOUT_MS: "0" })).toThrow("DB_CONNECT_TIMEOUT_MS");
|
|
156
|
+
expect(() => validateEnv({ DB_QUERY_TIMEOUT_MS: "0" })).toThrow("DB_QUERY_TIMEOUT_MS");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("bounds a query on a server that stopped answering after ten seconds by default", () => {
|
|
160
|
+
expect(env.DB_QUERY_TIMEOUT_MS).toBe(10_000);
|
|
161
|
+
expect(validateEnv({ DB_QUERY_TIMEOUT_MS: "2500" }).DB_QUERY_TIMEOUT_MS).toBe(2_500);
|
|
156
162
|
});
|
|
157
163
|
});
|
|
158
164
|
|
|
@@ -168,11 +174,12 @@ describe("isDatabaseUnavailable", () => {
|
|
|
168
174
|
}
|
|
169
175
|
});
|
|
170
176
|
|
|
171
|
-
it("recognizes pg pool and
|
|
177
|
+
it("recognizes pg pool, connection, and query timeouts, also when wrapped", () => {
|
|
172
178
|
const acquire = new Error("timeout exceeded when trying to connect");
|
|
173
179
|
const connect = new Error("Connection terminated due to connection timeout");
|
|
174
180
|
expect(isDatabaseUnavailable(acquire)).toBe(true);
|
|
175
181
|
expect(isDatabaseUnavailable(connect)).toBe(true);
|
|
182
|
+
expect(isDatabaseUnavailable(new Error("Query read timeout"))).toBe(true);
|
|
176
183
|
expect(isDatabaseUnavailable(new Error("Failed to get session", { cause: acquire }))).toBe(true);
|
|
177
184
|
});
|
|
178
185
|
|
|
@@ -4,6 +4,7 @@ import { mkdirSync } from "node:fs";
|
|
|
4
4
|
import { dirname, resolve } from "node:path";
|
|
5
5
|
import type { AppEnv } from "../config/env.validation";
|
|
6
6
|
import { PROVIDERS } from "../config/providers";
|
|
7
|
+
import { answersOnNewConnection, PoolGuard } from "./pool-guard";
|
|
7
8
|
|
|
8
9
|
function isSqliteUrl(url: string): boolean {
|
|
9
10
|
return url === ":memory:" || /^(?:sqlite|file):/.test(url);
|
|
@@ -62,8 +63,9 @@ export function postgresPoolOptions(env: AppEnv): PostgresPoolOptions {
|
|
|
62
63
|
};
|
|
63
64
|
}
|
|
64
65
|
|
|
65
|
-
// Bun SQL and pg report a database that cannot be reached,
|
|
66
|
-
// could not be opened or taken from a full pool in time,
|
|
66
|
+
// Bun SQL and pg report a database that cannot be reached, a connection that
|
|
67
|
+
// could not be opened or taken from a full pool in time, or a query the server
|
|
68
|
+
// did not answer in time, with these shapes.
|
|
67
69
|
const UNAVAILABLE_SQL_CODES = new Set([
|
|
68
70
|
"ERR_POSTGRES_CONNECTION_TIMEOUT",
|
|
69
71
|
"ERR_POSTGRES_CONNECTION_REFUSED",
|
|
@@ -73,6 +75,7 @@ const UNAVAILABLE_SQL_CODES = new Set([
|
|
|
73
75
|
const UNAVAILABLE_PG_MESSAGES = new Set([
|
|
74
76
|
"timeout exceeded when trying to connect",
|
|
75
77
|
"Connection terminated due to connection timeout",
|
|
78
|
+
"Query read timeout",
|
|
76
79
|
]);
|
|
77
80
|
|
|
78
81
|
/** Whether an error, or one it wraps, means the database is unavailable rather than the request being wrong. */
|
|
@@ -86,20 +89,36 @@ export function isDatabaseUnavailable(error: unknown): boolean {
|
|
|
86
89
|
return false;
|
|
87
90
|
}
|
|
88
91
|
|
|
89
|
-
function createSql(provider: DatabaseProviderName, env: AppEnv): SQL {
|
|
90
|
-
const url = databaseUrl(env);
|
|
91
|
-
return provider === "sqlite"
|
|
92
|
-
? new SQL(url, { adapter: "sqlite", create: true, readwrite: true, strict: true })
|
|
93
|
-
: new SQL(url, postgresPoolOptions(env));
|
|
94
|
-
}
|
|
95
|
-
|
|
96
92
|
export class Database {
|
|
97
93
|
readonly provider: DatabaseProviderName = selectedDatabaseProvider();
|
|
98
94
|
readonly sql: SQL;
|
|
95
|
+
private readonly guard?: PoolGuard;
|
|
99
96
|
private ready?: Promise<void>;
|
|
100
97
|
|
|
101
98
|
constructor(env: AppEnv, sql?: SQL) {
|
|
102
|
-
|
|
99
|
+
if (sql) {
|
|
100
|
+
this.sql = sql;
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const url = databaseUrl(env);
|
|
104
|
+
if (this.provider === "sqlite") {
|
|
105
|
+
this.sql = new SQL(url, { adapter: "sqlite", create: true, readwrite: true, strict: true });
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
this.guard = new PoolGuard(() => new SQL(url, postgresPoolOptions(env)), {
|
|
109
|
+
stallTimeoutMs: env.DB_QUERY_TIMEOUT_MS,
|
|
110
|
+
serverAnswers: () => answersOnNewConnection(url, env.READINESS_CHECK_TIMEOUT_MS),
|
|
111
|
+
});
|
|
112
|
+
// The supervisor's next connect() then checks the new pool.
|
|
113
|
+
this.guard.onLost(() => {
|
|
114
|
+
this.ready = undefined;
|
|
115
|
+
});
|
|
116
|
+
this.sql = this.guard.sql;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Takes the callback to run when the pool is replaced because PostgreSQL stopped answering. */
|
|
120
|
+
onLost(listener: (reason: unknown) => void): void {
|
|
121
|
+
this.guard?.onLost(listener);
|
|
103
122
|
}
|
|
104
123
|
|
|
105
124
|
connect(): Promise<void> {
|
|
@@ -25,7 +25,7 @@ async function migrate(): Promise<void> {
|
|
|
25
25
|
await dataSource.initialize();
|
|
26
26
|
try {
|
|
27
27
|
if (process.argv.includes("--revert")) await dataSource.undoLastMigration({ transaction: "all" });
|
|
28
|
-
else await dataSource.runMigrations(
|
|
28
|
+
else await dataSource.runMigrations();
|
|
29
29
|
} finally {
|
|
30
30
|
await dataSource.destroy();
|
|
31
31
|
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { SQL } from "bun";
|
|
2
|
+
import { describe, expect, it } from "bun:test";
|
|
3
|
+
import { answersOnNewConnection, PoolGuard } from "./pool-guard";
|
|
4
|
+
|
|
5
|
+
const STALL_MS = 20;
|
|
6
|
+
|
|
7
|
+
/** Settles like a Bun query: a subclass of Promise whose `then` returns a plain promise. */
|
|
8
|
+
class FakeQuery extends Promise<unknown> {
|
|
9
|
+
static override get [Symbol.species](): PromiseConstructor {
|
|
10
|
+
return Promise;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface FakePool {
|
|
15
|
+
sql: SQL;
|
|
16
|
+
closed: () => boolean;
|
|
17
|
+
/** Settles every query that waits for the server. */
|
|
18
|
+
resume: () => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface FakeServer {
|
|
22
|
+
/** While true, queries wait until the pool closes or the server resumes. */
|
|
23
|
+
stalled: boolean;
|
|
24
|
+
pools: FakePool[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function fakeServer(): FakeServer & { open: () => SQL } {
|
|
28
|
+
const server: FakeServer = { stalled: false, pools: [] };
|
|
29
|
+
const open = (): SQL => {
|
|
30
|
+
const waiting = new Set<(error?: Error) => void>();
|
|
31
|
+
let closed = false;
|
|
32
|
+
const query = (): FakeQuery =>
|
|
33
|
+
new FakeQuery((resolve, reject) => {
|
|
34
|
+
if (closed) return reject(new Error("Connection closed"));
|
|
35
|
+
if (!server.stalled) return resolve([{ ok: 1 }]);
|
|
36
|
+
waiting.add((error) => (error ? reject(error) : resolve([{ ok: 1 }])));
|
|
37
|
+
});
|
|
38
|
+
const settleAll = (error?: Error): void => {
|
|
39
|
+
for (const settle of waiting) settle(error);
|
|
40
|
+
waiting.clear();
|
|
41
|
+
};
|
|
42
|
+
const sql = Object.assign((..._args: unknown[]): FakeQuery => query(), {
|
|
43
|
+
begin: async (run: (tx: () => FakeQuery) => Promise<unknown>): Promise<unknown> => run(query),
|
|
44
|
+
close: async (): Promise<void> => {
|
|
45
|
+
closed = true;
|
|
46
|
+
settleAll(new Error("Connection closed"));
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
// The fake covers the members the guard and these tests use.
|
|
50
|
+
const pool = sql as unknown as SQL;
|
|
51
|
+
server.pools.push({ sql: pool, closed: () => closed, resume: () => settleAll() });
|
|
52
|
+
return pool;
|
|
53
|
+
};
|
|
54
|
+
return Object.assign(server, { open });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function guarded(answers: boolean): {
|
|
58
|
+
server: ReturnType<typeof fakeServer>;
|
|
59
|
+
guard: PoolGuard;
|
|
60
|
+
checks: () => number;
|
|
61
|
+
losses: Error[];
|
|
62
|
+
} {
|
|
63
|
+
const server = fakeServer();
|
|
64
|
+
let checks = 0;
|
|
65
|
+
const guard = new PoolGuard(server.open, {
|
|
66
|
+
stallTimeoutMs: STALL_MS,
|
|
67
|
+
serverAnswers: async () => {
|
|
68
|
+
checks += 1;
|
|
69
|
+
return answers;
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
const losses: Error[] = [];
|
|
73
|
+
guard.onLost((reason) => losses.push(reason));
|
|
74
|
+
return { server, guard, checks: () => checks, losses };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function outcome(pending: Promise<unknown>): Promise<string> {
|
|
78
|
+
return pending.then(() => "resolved", (error: unknown) => `rejected: ${error instanceof Error ? error.message : String(error)}`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const pause = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
|
|
82
|
+
|
|
83
|
+
describe("PoolGuard", () => {
|
|
84
|
+
it("passes queries, fragments, helpers, and transactions through to the pool", async () => {
|
|
85
|
+
const guard = new PoolGuard(() => new SQL(":memory:", { adapter: "sqlite" }), {
|
|
86
|
+
stallTimeoutMs: STALL_MS,
|
|
87
|
+
serverAnswers: async () => true,
|
|
88
|
+
});
|
|
89
|
+
const { sql } = guard;
|
|
90
|
+
try {
|
|
91
|
+
await sql`CREATE TABLE item (id INTEGER PRIMARY KEY, name TEXT)`;
|
|
92
|
+
await sql`INSERT INTO item ${sql([{ id: 1, name: "a" }, { id: 2, name: "b" }], "id", "name")}`;
|
|
93
|
+
const filter = sql`WHERE "name" = ${"b"}`;
|
|
94
|
+
expect(await sql`SELECT "id" FROM item ${filter}`).toEqual([{ id: 2 }]);
|
|
95
|
+
expect(await sql`SELECT count(*) AS n FROM item`.values()).toEqual([[2]]);
|
|
96
|
+
const names = await sql.begin(async (tx) => {
|
|
97
|
+
await tx`UPDATE item SET "name" = 'c' WHERE "id" = 1`;
|
|
98
|
+
return tx`SELECT "name" FROM item ORDER BY "id"`;
|
|
99
|
+
});
|
|
100
|
+
expect(names).toEqual([{ name: "c" }, { name: "b" }]);
|
|
101
|
+
expect(await Promise.all([sql`SELECT 1 AS v`, sql.unsafe("SELECT 2 AS v")])).toEqual([[{ v: 1 }], [{ v: 2 }]]);
|
|
102
|
+
} finally {
|
|
103
|
+
await sql.close();
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("replaces the pool when a query stalls and the server does not answer", async () => {
|
|
108
|
+
const { server, guard, checks, losses } = guarded(false);
|
|
109
|
+
await guard.sql`SELECT 1`;
|
|
110
|
+
server.stalled = true;
|
|
111
|
+
const stalled = outcome(guard.sql`SELECT 1`);
|
|
112
|
+
const transaction = outcome(guard.sql.begin(async (tx) => tx`SELECT 1`));
|
|
113
|
+
|
|
114
|
+
expect(await stalled).toBe("rejected: Connection closed");
|
|
115
|
+
expect(await transaction).toBe("rejected: Connection closed");
|
|
116
|
+
expect(checks()).toBe(1);
|
|
117
|
+
expect(losses).toHaveLength(1);
|
|
118
|
+
expect(losses[0]?.message).toContain(`unanswered for ${STALL_MS} ms`);
|
|
119
|
+
expect(server.pools.map((pool) => pool.closed())).toEqual([true, false]);
|
|
120
|
+
|
|
121
|
+
server.stalled = false;
|
|
122
|
+
expect(await guard.sql`SELECT 1`).toEqual([{ ok: 1 }]);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("keeps a slow query running while the server answers", async () => {
|
|
126
|
+
const { server, guard, checks, losses } = guarded(true);
|
|
127
|
+
await guard.sql`SELECT 1`;
|
|
128
|
+
server.stalled = true;
|
|
129
|
+
const slow = outcome(guard.sql`SELECT 1`);
|
|
130
|
+
await pause(STALL_MS * 3);
|
|
131
|
+
|
|
132
|
+
expect(checks()).toBe(1);
|
|
133
|
+
server.pools[0]?.resume();
|
|
134
|
+
expect(await slow).toBe("resolved");
|
|
135
|
+
expect(losses).toHaveLength(0);
|
|
136
|
+
expect(server.pools).toHaveLength(1);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("checks the server once for queries that stall together", async () => {
|
|
140
|
+
const { server, guard, checks, losses } = guarded(false);
|
|
141
|
+
await guard.sql`SELECT 1`;
|
|
142
|
+
server.stalled = true;
|
|
143
|
+
const results = await Promise.all([1, 2, 3].map(() => outcome(guard.sql`SELECT 1`)));
|
|
144
|
+
|
|
145
|
+
expect(results).toEqual(Array(3).fill("rejected: Connection closed"));
|
|
146
|
+
expect(checks()).toBe(1);
|
|
147
|
+
expect(losses).toHaveLength(1);
|
|
148
|
+
expect(server.pools).toHaveLength(2);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("leaves a pool that never answered to the connection timeout", async () => {
|
|
152
|
+
const { server, guard, checks } = guarded(false);
|
|
153
|
+
server.stalled = true;
|
|
154
|
+
const pending = guard.sql`SELECT 1`;
|
|
155
|
+
void pending.then(undefined, () => undefined);
|
|
156
|
+
await pause(STALL_MS * 3);
|
|
157
|
+
|
|
158
|
+
expect(checks()).toBe(0);
|
|
159
|
+
expect(server.pools).toHaveLength(1);
|
|
160
|
+
server.pools[0]?.resume();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("starts the clock only when a query runs, not when it is built", async () => {
|
|
164
|
+
const { server, guard, checks } = guarded(false);
|
|
165
|
+
await guard.sql`SELECT 1`;
|
|
166
|
+
server.stalled = true;
|
|
167
|
+
guard.sql`WHERE true`;
|
|
168
|
+
await pause(STALL_MS * 3);
|
|
169
|
+
|
|
170
|
+
expect(checks()).toBe(0);
|
|
171
|
+
expect(server.pools).toHaveLength(1);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe("answersOnNewConnection", () => {
|
|
176
|
+
it("reports a server that accepts the connection but never answers", async () => {
|
|
177
|
+
const server = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } });
|
|
178
|
+
try {
|
|
179
|
+
const startedAt = Date.now();
|
|
180
|
+
expect(await answersOnNewConnection(`postgres://podokit:podokit@127.0.0.1:${server.port}/podokit`, 200)).toBe(
|
|
181
|
+
false,
|
|
182
|
+
);
|
|
183
|
+
expect(Date.now() - startedAt).toBeLessThan(1_000);
|
|
184
|
+
} finally {
|
|
185
|
+
server.stop(true);
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
});
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { SQL } from "bun";
|
|
2
|
+
|
|
3
|
+
// Bun 1.4 treats `close({ timeout: 0 })` like no timeout and waits for every
|
|
4
|
+
// pending query, so a stalled pool is closed after the shortest delay instead.
|
|
5
|
+
const CLOSE_NOW_SECONDS = 0.001;
|
|
6
|
+
|
|
7
|
+
// Pool members whose result is pending database work: `unsafe` and `file` build
|
|
8
|
+
// queries, and `begin` and `transaction` settle when the transaction ends.
|
|
9
|
+
const WATCHED_MEMBERS = new Set<PropertyKey>(["unsafe", "file", "begin", "transaction"]);
|
|
10
|
+
|
|
11
|
+
/** Whether PostgreSQL at `url` answers `SELECT 1` on a new connection within `timeoutMs`. */
|
|
12
|
+
export async function answersOnNewConnection(url: string, timeoutMs: number): Promise<boolean> {
|
|
13
|
+
const sql = new SQL(url, { max: 1, connectionTimeout: timeoutMs / 1000 });
|
|
14
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
15
|
+
const deadline = new Promise<boolean>((resolve) => {
|
|
16
|
+
timer = setTimeout(() => resolve(false), timeoutMs);
|
|
17
|
+
});
|
|
18
|
+
try {
|
|
19
|
+
return await Promise.race([sql`SELECT 1`.then(() => true, () => false), deadline]);
|
|
20
|
+
} finally {
|
|
21
|
+
clearTimeout(timer);
|
|
22
|
+
void sql.close({ timeout: CLOSE_NOW_SECONDS }).catch(() => undefined);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface PoolGuardOptions {
|
|
27
|
+
/** How long a query or transaction may stay pending before the server is checked. */
|
|
28
|
+
stallTimeoutMs: number;
|
|
29
|
+
/** Resolves whether the server answers a new connection now; never rejects. */
|
|
30
|
+
serverAnswers: () => Promise<boolean>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface Generation {
|
|
34
|
+
sql: SQL;
|
|
35
|
+
/** Whether a query on this pool has succeeded, so it may hold connections the server stopped answering. */
|
|
36
|
+
answered: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Keeps a Bun SQL pool usable when PostgreSQL keeps its connections open but
|
|
41
|
+
* stops answering, as a paused container, a frozen VM, or a network partition
|
|
42
|
+
* does. Bun SQL has no query timeout, `query.cancel()` does not stop a query
|
|
43
|
+
* that is running, and one pool connection cannot be closed on its own, so a
|
|
44
|
+
* query on such a connection waits until the server answers again or TCP gives
|
|
45
|
+
* up, and the pool keeps the connection.
|
|
46
|
+
*
|
|
47
|
+
* `sql` stands in for the pool and passes every call through. A query or
|
|
48
|
+
* transaction still pending after `stallTimeoutMs` makes the guard check the
|
|
49
|
+
* server on a new connection. When the server does not answer either, the guard
|
|
50
|
+
* opens a new pool and closes the old one, which fails every query still waiting
|
|
51
|
+
* on it with `ERR_POSTGRES_CONNECTION_CLOSED`. A slow query on a server that
|
|
52
|
+
* answers keeps running.
|
|
53
|
+
*/
|
|
54
|
+
export class PoolGuard {
|
|
55
|
+
readonly sql: SQL;
|
|
56
|
+
private generation: Generation;
|
|
57
|
+
private checking?: Promise<void>;
|
|
58
|
+
private readonly lostListeners: Array<(reason: Error) => void> = [];
|
|
59
|
+
|
|
60
|
+
constructor(
|
|
61
|
+
private readonly open: () => SQL,
|
|
62
|
+
private readonly options: PoolGuardOptions,
|
|
63
|
+
) {
|
|
64
|
+
this.generation = { sql: open(), answered: false };
|
|
65
|
+
this.sql = new Proxy(this.generation.sql, {
|
|
66
|
+
apply: (_target, _thisArg, args: unknown[]) => {
|
|
67
|
+
const generation = this.generation;
|
|
68
|
+
return this.watch(generation, Reflect.apply(generation.sql, undefined, args));
|
|
69
|
+
},
|
|
70
|
+
get: (_target, key) => this.member(key),
|
|
71
|
+
has: (_target, key) => Reflect.has(this.generation.sql, key),
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Takes the callback to run whenever the guard replaces a pool the server stopped answering. */
|
|
76
|
+
onLost(listener: (reason: Error) => void): void {
|
|
77
|
+
this.lostListeners.push(listener);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private member(key: PropertyKey): unknown {
|
|
81
|
+
const generation = this.generation;
|
|
82
|
+
const value: unknown = Reflect.get(generation.sql, key);
|
|
83
|
+
if (typeof value !== "function") return value;
|
|
84
|
+
return (...args: unknown[]): unknown => {
|
|
85
|
+
const result: unknown = Reflect.apply(value, generation.sql, args);
|
|
86
|
+
return WATCHED_MEMBERS.has(key) ? this.watch(generation, result) : result;
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private watch(generation: Generation, result: unknown): unknown {
|
|
91
|
+
if (!(result instanceof Promise)) return result;
|
|
92
|
+
const pending: Promise<unknown> = result;
|
|
93
|
+
const then = pending.then;
|
|
94
|
+
// A transaction is a plain promise that is already running, and `await`
|
|
95
|
+
// does not call `then` on a plain promise.
|
|
96
|
+
if (Object.getPrototypeOf(pending) === Promise.prototype) {
|
|
97
|
+
this.track(generation, pending, then);
|
|
98
|
+
return pending;
|
|
99
|
+
}
|
|
100
|
+
// Bun builds a query without running it, and the same object can be nested
|
|
101
|
+
// in another query as a fragment, so the clock starts with the first `then`,
|
|
102
|
+
// which is what runs it. Every call still reaches Bun's own `then`.
|
|
103
|
+
let tracked = false;
|
|
104
|
+
Object.defineProperty(pending, "then", {
|
|
105
|
+
configurable: true,
|
|
106
|
+
writable: true,
|
|
107
|
+
value: (...args: Parameters<Promise<unknown>["then"]>): Promise<unknown> => {
|
|
108
|
+
const chained = then.apply(pending, args);
|
|
109
|
+
if (!tracked) {
|
|
110
|
+
tracked = true;
|
|
111
|
+
this.track(generation, pending, then);
|
|
112
|
+
}
|
|
113
|
+
return chained;
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
return pending;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private track(generation: Generation, pending: Promise<unknown>, then: Promise<unknown>["then"]): void {
|
|
120
|
+
const timer = setTimeout(() => this.stalled(generation), this.options.stallTimeoutMs);
|
|
121
|
+
timer.unref?.();
|
|
122
|
+
then.call(
|
|
123
|
+
pending,
|
|
124
|
+
() => {
|
|
125
|
+
clearTimeout(timer);
|
|
126
|
+
generation.answered = true;
|
|
127
|
+
},
|
|
128
|
+
() => clearTimeout(timer),
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private stalled(generation: Generation): void {
|
|
133
|
+
// A pool that never answered holds only connections that are still opening,
|
|
134
|
+
// and the connection timeout ends those.
|
|
135
|
+
if (generation !== this.generation || !generation.answered) return;
|
|
136
|
+
this.checking ??= this.check(generation).finally(() => {
|
|
137
|
+
this.checking = undefined;
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private async check(generation: Generation): Promise<void> {
|
|
142
|
+
if (await this.options.serverAnswers()) return;
|
|
143
|
+
if (generation !== this.generation) return;
|
|
144
|
+
this.generation = { sql: this.open(), answered: false };
|
|
145
|
+
void generation.sql.close({ timeout: CLOSE_NOW_SECONDS }).catch(() => undefined);
|
|
146
|
+
const reason = new Error(
|
|
147
|
+
`PostgreSQL left a query unanswered for ${this.options.stallTimeoutMs} ms and did not answer a new connection; replaced the pool`,
|
|
148
|
+
);
|
|
149
|
+
for (const listener of this.lostListeners) listener(reason);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
@@ -49,7 +49,9 @@ async function readiness(
|
|
|
49
49
|
const app = new Elysia()
|
|
50
50
|
.onError(({ error, set }) => {
|
|
51
51
|
set.status = error instanceof AppException ? error.statusCode : 500;
|
|
52
|
-
return
|
|
52
|
+
return error instanceof AppException
|
|
53
|
+
? { code: error.code, message: error.message }
|
|
54
|
+
: { code: "INTERNAL_ERROR" };
|
|
53
55
|
})
|
|
54
56
|
.use(healthPlugin(appContext(env, services)));
|
|
55
57
|
const response = await app.handle(new Request("http://localhost/health/ready"));
|
|
@@ -69,10 +71,29 @@ describe("GET /health/ready", () => {
|
|
|
69
71
|
});
|
|
70
72
|
});
|
|
71
73
|
|
|
72
|
-
it("fails when the database is down", async () => {
|
|
74
|
+
it("fails when the database is down and names it", async () => {
|
|
73
75
|
const result = await readiness({ databaseUp: false });
|
|
74
76
|
|
|
75
|
-
expect(result).toEqual({
|
|
77
|
+
expect(result).toEqual({
|
|
78
|
+
status: 503,
|
|
79
|
+
body: { code: "READINESS_FAILED", message: "One or more dependencies are unavailable: db" },
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("names every module check that is down", async () => {
|
|
84
|
+
const result = await readiness({
|
|
85
|
+
databaseUp: true,
|
|
86
|
+
checks: {
|
|
87
|
+
cache: () => Promise.resolve(),
|
|
88
|
+
redis: () => Promise.reject(new Error("Connection closed")),
|
|
89
|
+
events: () => Promise.reject(new Error("Event transport is not subscribed")),
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
expect(result).toEqual({
|
|
94
|
+
status: 503,
|
|
95
|
+
body: { code: "READINESS_FAILED", message: "One or more dependencies are unavailable: redis, events" },
|
|
96
|
+
});
|
|
76
97
|
});
|
|
77
98
|
|
|
78
99
|
it("fails when a module check exceeds its timeout", async () => {
|
|
@@ -81,6 +102,9 @@ describe("GET /health/ready", () => {
|
|
|
81
102
|
checks: { storage: () => new Promise<void>(() => {}) },
|
|
82
103
|
});
|
|
83
104
|
|
|
84
|
-
expect(result).toEqual({
|
|
105
|
+
expect(result).toEqual({
|
|
106
|
+
status: 503,
|
|
107
|
+
body: { code: "READINESS_FAILED", message: "One or more dependencies are unavailable: storage" },
|
|
108
|
+
});
|
|
85
109
|
});
|
|
86
110
|
});
|
|
@@ -14,8 +14,16 @@ export const healthPlugin: AppPlugin = ({ readiness }) =>
|
|
|
14
14
|
"/health/ready",
|
|
15
15
|
async () => {
|
|
16
16
|
const { [DATABASE_READINESS_CHECK]: db, ...checks } = await readiness.run();
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
const down = [
|
|
18
|
+
...(db === "up" ? [] : ["db"]),
|
|
19
|
+
...Object.entries(checks).flatMap(([name, status]) => status === "down" ? [name] : []),
|
|
20
|
+
];
|
|
21
|
+
if (down.length > 0) {
|
|
22
|
+
throw new AppException(
|
|
23
|
+
"READINESS_FAILED",
|
|
24
|
+
`One or more dependencies are unavailable: ${down.join(", ")}`,
|
|
25
|
+
503,
|
|
26
|
+
);
|
|
19
27
|
}
|
|
20
28
|
return { status: "ready" as const, db: "up" as const, checks };
|
|
21
29
|
},
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createApp } from "./app";
|
|
2
2
|
import { validateEnv } from "./config/env.validation";
|
|
3
|
+
import { apiServerOptions } from "./config/server-options";
|
|
3
4
|
import { createCoreServices } from "./core/services";
|
|
4
5
|
|
|
5
6
|
const env = validateEnv(process.env);
|
|
@@ -9,7 +10,7 @@ const app = createApp({ env, services });
|
|
|
9
10
|
async function bootstrap(): Promise<void> {
|
|
10
11
|
try {
|
|
11
12
|
await services.start();
|
|
12
|
-
app.listen({ hostname: "0.0.0.0", port: env.PORT });
|
|
13
|
+
app.listen({ ...apiServerOptions(env), hostname: "0.0.0.0", port: env.PORT });
|
|
13
14
|
process.stdout.write(`API listening on http://0.0.0.0:${env.PORT}\n`);
|
|
14
15
|
} catch (error) {
|
|
15
16
|
await services.close();
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { cn, type WithElementRef, type WithoutChildren } from "#lib/utils.js";
|
|
3
|
+
import type { HTMLTextareaAttributes } from "svelte/elements";
|
|
4
|
+
|
|
5
|
+
let {
|
|
6
|
+
ref = $bindable(null),
|
|
7
|
+
value = $bindable(),
|
|
8
|
+
class: className,
|
|
9
|
+
"data-slot": dataSlot = "textarea",
|
|
10
|
+
...restProps
|
|
11
|
+
}: WithoutChildren<WithElementRef<HTMLTextareaAttributes>> = $props();
|
|
12
|
+
</script>
|
|
13
|
+
|
|
14
|
+
<textarea
|
|
15
|
+
bind:this={ref}
|
|
16
|
+
data-slot={dataSlot}
|
|
17
|
+
class={cn(
|
|
18
|
+
"border-input dark:bg-input/30 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 disabled:bg-input/50 dark:disabled:bg-input/80 rounded-lg border bg-transparent px-2.5 py-2 text-base transition-colors focus-visible:ring-3 aria-invalid:ring-3 md:text-sm flex field-sizing-content min-h-16 w-full outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
|
19
|
+
className
|
|
20
|
+
)}
|
|
21
|
+
bind:value
|
|
22
|
+
{...restProps}></textarea>
|