@podosoft/podokit 1.3.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 +10 -1
- package/dist/add.js +20 -7
- 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 +15 -2
- package/dist/lib.d.ts +2 -0
- package/dist/lib.js +4 -2
- package/dist/lockfile.d.ts +3 -0
- package/dist/lockfile.js +9 -0
- 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/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 +9 -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/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/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 +7 -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/modules/admin-dashboard/files/apps/api/src/bootstrap-admin-runtime.ts +1 -1
- 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/server/asset-paths.ts +11 -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/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 +10 -10
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/object-response.ts +12 -0
- package/dist/templates/modules/admin-dashboard/module.manifest.json +3 -2
- 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/migrate.ts +3 -2
- 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.service.spec.ts +33 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +7 -0
- package/dist/templates/todo/AGENTS.md +9 -1
- package/dist/templates/todo/README.md +18 -5
- package/dist/update.d.ts +5 -0
- package/dist/update.js +35 -0
- 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
|
@@ -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>
|