@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
|
@@ -16,6 +16,7 @@ describe("API contract inventory", () => {
|
|
|
16
16
|
expect(expected).toContain("POST /api/auth/sign-in/email");
|
|
17
17
|
expect(expected).toContain("PUT /cache/{key}");
|
|
18
18
|
expect(expected).toContain("POST /files");
|
|
19
|
+
expect(expected).toContain("PUT /files/uploads/{id}/parts/{partNumber}");
|
|
19
20
|
});
|
|
20
21
|
|
|
21
22
|
it("uses one structural parameter name for every public blog post route", () => {
|
|
@@ -71,7 +71,15 @@ const MODULE_ROUTES: Record<string, string[]> = {
|
|
|
71
71
|
"DELETE /admin/blog/{id}",
|
|
72
72
|
],
|
|
73
73
|
bullmq: ["POST /jobs", "GET /jobs/{id}"],
|
|
74
|
-
"file-upload": [
|
|
74
|
+
"file-upload": [
|
|
75
|
+
"POST /files",
|
|
76
|
+
"GET /files/{key}/url",
|
|
77
|
+
"POST /files/uploads",
|
|
78
|
+
"GET /files/uploads/{id}",
|
|
79
|
+
"PUT /files/uploads/{id}/parts/{partNumber}",
|
|
80
|
+
"POST /files/uploads/{id}/complete",
|
|
81
|
+
"DELETE /files/uploads/{id}",
|
|
82
|
+
],
|
|
75
83
|
"job-progress": ["POST /progress"],
|
|
76
84
|
"object-storage-local": [
|
|
77
85
|
"GET /files/content/*",
|
|
@@ -4,11 +4,15 @@ import { validateEnv } from "../config/env.validation";
|
|
|
4
4
|
import { PROVIDERS } from "../config/providers";
|
|
5
5
|
import {
|
|
6
6
|
AccessPolicy,
|
|
7
|
+
ConnectionSupervisor,
|
|
7
8
|
createCoreServices,
|
|
8
9
|
OpenApiRegistry,
|
|
9
10
|
REQUEST_IDENTITY,
|
|
10
11
|
RequestGuardRegistry,
|
|
11
12
|
ServiceRegistry,
|
|
13
|
+
type ConnectionEvents,
|
|
14
|
+
type RetryTimer,
|
|
15
|
+
type SupervisedConnection,
|
|
12
16
|
} from "./services";
|
|
13
17
|
|
|
14
18
|
function databaseProvider(): DatabaseProviderName {
|
|
@@ -50,6 +54,180 @@ describe("ServiceRegistry", () => {
|
|
|
50
54
|
});
|
|
51
55
|
});
|
|
52
56
|
|
|
57
|
+
describe("ServiceRegistry supervision", () => {
|
|
58
|
+
test("starts and runs start hooks while a supervised dependency is down", async () => {
|
|
59
|
+
const registry = new ServiceRegistry();
|
|
60
|
+
let attempts = 0;
|
|
61
|
+
let hookRan = false;
|
|
62
|
+
registry.supervise("redis", {
|
|
63
|
+
connect: () => {
|
|
64
|
+
attempts += 1;
|
|
65
|
+
return Promise.reject(new Error("Connection closed"));
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
registry.onStart(() => { hookRan = true; });
|
|
69
|
+
registry.freeze();
|
|
70
|
+
|
|
71
|
+
await registry.start();
|
|
72
|
+
expect(hookRan).toBe(true);
|
|
73
|
+
expect(attempts).toBe(1);
|
|
74
|
+
await registry.close();
|
|
75
|
+
await Bun.sleep(400);
|
|
76
|
+
expect(attempts).toBe(1);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("connects a reachable dependency before the start hooks run", async () => {
|
|
80
|
+
const registry = new ServiceRegistry();
|
|
81
|
+
const order: string[] = [];
|
|
82
|
+
registry.supervise("database", {
|
|
83
|
+
connect: async () => {
|
|
84
|
+
await Bun.sleep(5);
|
|
85
|
+
order.push("connected");
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
registry.onStart(() => { order.push("hook"); });
|
|
89
|
+
registry.freeze();
|
|
90
|
+
|
|
91
|
+
await registry.start();
|
|
92
|
+
expect(order).toEqual(["connected", "hook"]);
|
|
93
|
+
await registry.close();
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
interface FakeTimer extends RetryTimer {
|
|
98
|
+
delays: number[];
|
|
99
|
+
pending(): number;
|
|
100
|
+
fire(): Promise<void>;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function fakeTimer(): FakeTimer {
|
|
104
|
+
const tasks: Array<{ task: () => void; cancelled: boolean }> = [];
|
|
105
|
+
const delays: number[] = [];
|
|
106
|
+
return {
|
|
107
|
+
delays,
|
|
108
|
+
after: (delayMs, task) => {
|
|
109
|
+
const entry = { task, cancelled: false };
|
|
110
|
+
delays.push(delayMs);
|
|
111
|
+
tasks.push(entry);
|
|
112
|
+
return () => { entry.cancelled = true; };
|
|
113
|
+
},
|
|
114
|
+
pending: () => tasks.filter((entry) => !entry.cancelled).length,
|
|
115
|
+
fire: async () => {
|
|
116
|
+
const entry = tasks.shift();
|
|
117
|
+
if (entry && !entry.cancelled) entry.task();
|
|
118
|
+
await Bun.sleep(0);
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function recordingEvents(): ConnectionEvents & { log: string[] } {
|
|
124
|
+
const log: string[] = [];
|
|
125
|
+
return {
|
|
126
|
+
log,
|
|
127
|
+
unavailable: (name, reason) => { log.push(`unavailable ${name}: ${reason}`); },
|
|
128
|
+
restored: (name) => { log.push(`restored ${name}`); },
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
class FlakyConnection implements SupervisedConnection {
|
|
133
|
+
attempts = 0;
|
|
134
|
+
failuresLeft = 0;
|
|
135
|
+
private listener?: (reason: unknown) => void;
|
|
136
|
+
|
|
137
|
+
connect(): Promise<void> {
|
|
138
|
+
this.attempts += 1;
|
|
139
|
+
if (this.failuresLeft > 0) {
|
|
140
|
+
this.failuresLeft -= 1;
|
|
141
|
+
return Promise.reject(new Error("Connection closed"));
|
|
142
|
+
}
|
|
143
|
+
return Promise.resolve();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
onLost(listener: (reason: unknown) => void): void {
|
|
147
|
+
this.listener = listener;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
drop(): void {
|
|
151
|
+
this.listener?.(new Error("Connection reset"));
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
describe("ConnectionSupervisor", () => {
|
|
156
|
+
const policy = { initialDelayMs: 100, maxDelayMs: 400 };
|
|
157
|
+
|
|
158
|
+
test("retries with doubling delays up to the cap and reports each state change once", async () => {
|
|
159
|
+
const connection = new FlakyConnection();
|
|
160
|
+
connection.failuresLeft = 5;
|
|
161
|
+
const timer = fakeTimer();
|
|
162
|
+
const events = recordingEvents();
|
|
163
|
+
const supervisor = new ConnectionSupervisor("redis", connection, events, { policy, timer });
|
|
164
|
+
|
|
165
|
+
await supervisor.start();
|
|
166
|
+
expect(supervisor.connected).toBe(false);
|
|
167
|
+
while (timer.pending() > 0) await timer.fire();
|
|
168
|
+
|
|
169
|
+
expect(connection.attempts).toBe(6);
|
|
170
|
+
expect(timer.delays).toEqual([100, 200, 400, 400, 400]);
|
|
171
|
+
expect(events.log).toEqual(["unavailable redis: Connection closed", "restored redis"]);
|
|
172
|
+
expect(supervisor.connected).toBe(true);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("reopens a lost connection and starts the backoff over", async () => {
|
|
176
|
+
const connection = new FlakyConnection();
|
|
177
|
+
const timer = fakeTimer();
|
|
178
|
+
const events = recordingEvents();
|
|
179
|
+
const supervisor = new ConnectionSupervisor("redis", connection, events, { policy, timer });
|
|
180
|
+
await supervisor.start();
|
|
181
|
+
expect(supervisor.connected).toBe(true);
|
|
182
|
+
|
|
183
|
+
connection.failuresLeft = 1;
|
|
184
|
+
connection.drop();
|
|
185
|
+
connection.drop();
|
|
186
|
+
expect(supervisor.connected).toBe(false);
|
|
187
|
+
expect(timer.pending()).toBe(1);
|
|
188
|
+
while (timer.pending() > 0) await timer.fire();
|
|
189
|
+
|
|
190
|
+
expect(timer.delays).toEqual([100, 200]);
|
|
191
|
+
expect(connection.attempts).toBe(3);
|
|
192
|
+
expect(events.log).toEqual(["unavailable redis: Connection reset", "restored redis"]);
|
|
193
|
+
expect(supervisor.connected).toBe(true);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("tries again when the connection drops while an attempt runs", async () => {
|
|
197
|
+
const connection = new FlakyConnection();
|
|
198
|
+
const timer = fakeTimer();
|
|
199
|
+
const supervisor = new ConnectionSupervisor("events", {
|
|
200
|
+
connect: async () => {
|
|
201
|
+
await connection.connect();
|
|
202
|
+
if (connection.attempts === 1) connection.drop();
|
|
203
|
+
},
|
|
204
|
+
onLost: (listener) => connection.onLost(listener),
|
|
205
|
+
}, recordingEvents(), { policy, timer });
|
|
206
|
+
|
|
207
|
+
await supervisor.start();
|
|
208
|
+
expect(supervisor.connected).toBe(false);
|
|
209
|
+
await timer.fire();
|
|
210
|
+
expect(connection.attempts).toBe(2);
|
|
211
|
+
expect(supervisor.connected).toBe(true);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("stops retrying once stopped", async () => {
|
|
215
|
+
const connection = new FlakyConnection();
|
|
216
|
+
connection.failuresLeft = 10;
|
|
217
|
+
const timer = fakeTimer();
|
|
218
|
+
const supervisor = new ConnectionSupervisor("redis", connection, recordingEvents(), { policy, timer });
|
|
219
|
+
await supervisor.start();
|
|
220
|
+
expect(timer.pending()).toBe(1);
|
|
221
|
+
|
|
222
|
+
supervisor.stop();
|
|
223
|
+
expect(timer.pending()).toBe(0);
|
|
224
|
+
connection.drop();
|
|
225
|
+
await timer.fire();
|
|
226
|
+
expect(connection.attempts).toBe(1);
|
|
227
|
+
expect(timer.pending()).toBe(0);
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
53
231
|
describe("AccessPolicy", () => {
|
|
54
232
|
test("defaults to session access", () => {
|
|
55
233
|
const policy = new AccessPolicy();
|
|
@@ -66,6 +244,24 @@ describe("AccessPolicy", () => {
|
|
|
66
244
|
expect(policy.resolve(new Request("http://localhost/api/auth/sign-in/email"))).toBe("public");
|
|
67
245
|
});
|
|
68
246
|
|
|
247
|
+
test("applies GET rules to HEAD requests and leaves other methods alone", () => {
|
|
248
|
+
const policy = new AccessPolicy();
|
|
249
|
+
policy.register("GET", "/profile-images/:fileName", "public");
|
|
250
|
+
policy.register("POST", "/uploads", "public");
|
|
251
|
+
policy.register("GET", "/reports/:id", "api-key");
|
|
252
|
+
|
|
253
|
+
const head = (path: string): Request => new Request(`http://localhost${path}`, { method: "HEAD" });
|
|
254
|
+
expect(policy.resolve(head("/profile-images/a.png"))).toBe("public");
|
|
255
|
+
expect(policy.resolve(head("/reports/1"))).toBe("api-key");
|
|
256
|
+
expect(policy.resolve(head("/uploads"))).toBe("session");
|
|
257
|
+
expect(policy.resolve(head("/todos"))).toBe("session");
|
|
258
|
+
expect(policy.resolve(new Request("http://localhost/profile-images/a.png", { method: "DELETE" }))).toBe("session");
|
|
259
|
+
|
|
260
|
+
policy.register("HEAD", "/profile-images/:fileName", "session");
|
|
261
|
+
expect(policy.resolve(head("/profile-images/a.png"))).toBe("session");
|
|
262
|
+
expect(policy.resolve(new Request("http://localhost/profile-images/a.png"))).toBe("public");
|
|
263
|
+
});
|
|
264
|
+
|
|
69
265
|
test("uses the most recently registered matching rule", () => {
|
|
70
266
|
const policy = new AccessPolicy();
|
|
71
267
|
policy.register("*", "/content/*", "public");
|
|
@@ -10,9 +10,151 @@ import { DATABASE_READINESS_CHECK, ReadinessService } from "../health/readiness.
|
|
|
10
10
|
|
|
11
11
|
export type { ServiceKey } from "@podosoft/podokit-runtime";
|
|
12
12
|
|
|
13
|
+
/** A dependency that `ServiceRegistry.supervise` keeps connected. */
|
|
14
|
+
export interface SupervisedConnection {
|
|
15
|
+
/**
|
|
16
|
+
* Opens whatever is closed and restores what depends on it, such as
|
|
17
|
+
* subscriptions. Rejects while the dependency is unreachable.
|
|
18
|
+
*/
|
|
19
|
+
connect(): Promise<void>;
|
|
20
|
+
/** Takes the callback to run whenever an open connection drops. */
|
|
21
|
+
onLost?(listener: (reason: unknown) => void): void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ReconnectPolicy {
|
|
25
|
+
/** Delay before the first retry. Each further failure doubles it. */
|
|
26
|
+
initialDelayMs: number;
|
|
27
|
+
/** Longest delay between two attempts. */
|
|
28
|
+
maxDelayMs: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const DEFAULT_RECONNECT_POLICY: ReconnectPolicy = { initialDelayMs: 250, maxDelayMs: 5_000 };
|
|
32
|
+
|
|
33
|
+
export interface ConnectionEvents {
|
|
34
|
+
unavailable(name: string, reason: string): void;
|
|
35
|
+
restored(name: string, downtimeMs: number): void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface RetryTimer {
|
|
39
|
+
/** Runs `task` once after `delayMs`; the returned function cancels it. */
|
|
40
|
+
after(delayMs: number, task: () => void): () => void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const unrefTimer: RetryTimer = {
|
|
44
|
+
after: (delayMs, task) => {
|
|
45
|
+
const timer = setTimeout(task, delayMs);
|
|
46
|
+
timer.unref?.();
|
|
47
|
+
return () => clearTimeout(timer);
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export interface SupervisorOptions {
|
|
52
|
+
policy?: ReconnectPolicy;
|
|
53
|
+
timer?: RetryTimer;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function reasonOf(error: unknown): string {
|
|
57
|
+
return error instanceof Error ? error.message : String(error);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Connects in the background and keeps the connection open: a failed attempt is
|
|
62
|
+
* retried with capped exponential backoff, and a lost connection is reopened.
|
|
63
|
+
* Events fire when the state changes, not on every attempt.
|
|
64
|
+
*/
|
|
65
|
+
export class ConnectionSupervisor {
|
|
66
|
+
private up = false;
|
|
67
|
+
private stopped = false;
|
|
68
|
+
private failures = 0;
|
|
69
|
+
private drops = 0;
|
|
70
|
+
private lastDrop: unknown;
|
|
71
|
+
private downSince?: number;
|
|
72
|
+
private cancelRetry?: () => void;
|
|
73
|
+
private firstAttempt?: Promise<void>;
|
|
74
|
+
private readonly policy: ReconnectPolicy;
|
|
75
|
+
private readonly timer: RetryTimer;
|
|
76
|
+
|
|
77
|
+
constructor(
|
|
78
|
+
readonly name: string,
|
|
79
|
+
private readonly connection: SupervisedConnection,
|
|
80
|
+
private readonly events: ConnectionEvents,
|
|
81
|
+
options: SupervisorOptions = {},
|
|
82
|
+
) {
|
|
83
|
+
this.policy = options.policy ?? DEFAULT_RECONNECT_POLICY;
|
|
84
|
+
this.timer = options.timer ?? unrefTimer;
|
|
85
|
+
connection.onLost?.((reason) => this.lost(reason));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
get connected(): boolean {
|
|
89
|
+
return this.up;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Resolves after the first attempt, whether or not it connected. */
|
|
93
|
+
start(): Promise<void> {
|
|
94
|
+
this.firstAttempt ??= this.attempt();
|
|
95
|
+
return this.firstAttempt;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
stop(): void {
|
|
99
|
+
this.stopped = true;
|
|
100
|
+
this.up = false;
|
|
101
|
+
this.cancelRetry?.();
|
|
102
|
+
this.cancelRetry = undefined;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private lost(reason: unknown): void {
|
|
106
|
+
if (this.stopped) return;
|
|
107
|
+
this.drops += 1;
|
|
108
|
+
this.lastDrop = reason;
|
|
109
|
+
// While an attempt is pending it sees the changed count and tries again.
|
|
110
|
+
if (!this.up) return;
|
|
111
|
+
this.up = false;
|
|
112
|
+
this.retry(reason);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
private async attempt(): Promise<void> {
|
|
116
|
+
this.cancelRetry = undefined;
|
|
117
|
+
if (this.stopped) return;
|
|
118
|
+
const drops = this.drops;
|
|
119
|
+
try {
|
|
120
|
+
await this.connection.connect();
|
|
121
|
+
} catch (error) {
|
|
122
|
+
if (!this.stopped) this.retry(error);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (this.stopped) return;
|
|
126
|
+
// Bun reports a failed reconnect through the same close callback, so a drop
|
|
127
|
+
// during the attempt can mean a part of the connection is closed again.
|
|
128
|
+
if (drops !== this.drops) {
|
|
129
|
+
this.retry(this.lastDrop);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
this.up = true;
|
|
133
|
+
this.failures = 0;
|
|
134
|
+
if (this.downSince !== undefined) {
|
|
135
|
+
this.events.restored(this.name, Date.now() - this.downSince);
|
|
136
|
+
this.downSince = undefined;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private retry(reason: unknown): void {
|
|
141
|
+
if (this.downSince === undefined) {
|
|
142
|
+
this.downSince = Date.now();
|
|
143
|
+
this.events.unavailable(this.name, reasonOf(reason));
|
|
144
|
+
}
|
|
145
|
+
const delay = Math.min(
|
|
146
|
+
this.policy.maxDelayMs,
|
|
147
|
+
this.policy.initialDelayMs * 2 ** Math.min(this.failures, 30),
|
|
148
|
+
);
|
|
149
|
+
this.failures += 1;
|
|
150
|
+
this.cancelRetry = this.timer.after(delay, () => void this.attempt());
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
13
154
|
export class ServiceRegistry {
|
|
14
155
|
private readonly values = new Map<symbol, unknown>();
|
|
15
156
|
private readonly starters: Array<() => void | Promise<void>> = [];
|
|
157
|
+
private readonly supervisors: ConnectionSupervisor[] = [];
|
|
16
158
|
private readonly closers: Array<() => void | Promise<void>> = [];
|
|
17
159
|
private startPromise?: Promise<void>;
|
|
18
160
|
private closePromise?: Promise<void>;
|
|
@@ -41,17 +183,44 @@ export class ServiceRegistry {
|
|
|
41
183
|
return this.values.get(key) as T | undefined;
|
|
42
184
|
}
|
|
43
185
|
|
|
186
|
+
/** Runs `start` before the server listens; a rejection fails startup. */
|
|
44
187
|
onStart(start: () => void | Promise<void>): void {
|
|
45
188
|
if (this.frozen) throw new Error("Service registry is frozen");
|
|
46
189
|
this.starters.push(start);
|
|
47
190
|
}
|
|
48
191
|
|
|
192
|
+
/**
|
|
193
|
+
* Connects `connection` when the registry starts and keeps it connected.
|
|
194
|
+
* Startup waits only for the first attempt, so the server listens while the
|
|
195
|
+
* dependency is down; register a readiness check that reports it.
|
|
196
|
+
*/
|
|
197
|
+
supervise(name: string, connection: SupervisedConnection): void {
|
|
198
|
+
if (this.frozen) throw new Error("Service registry is frozen");
|
|
199
|
+
this.supervisors.push(new ConnectionSupervisor(name, connection, {
|
|
200
|
+
// Resolved on use: a module such as logging may replace the logger.
|
|
201
|
+
unavailable: (dependency, reason) => {
|
|
202
|
+
this.tryResolve(LOGGER)?.warn(
|
|
203
|
+
{ dependency, reason },
|
|
204
|
+
"Dependency connection failed; retrying in the background",
|
|
205
|
+
);
|
|
206
|
+
},
|
|
207
|
+
restored: (dependency, downtimeMs) => {
|
|
208
|
+
this.tryResolve(LOGGER)?.info({ dependency, downtimeMs }, "Dependency connection restored");
|
|
209
|
+
},
|
|
210
|
+
}));
|
|
211
|
+
}
|
|
212
|
+
|
|
49
213
|
freeze(): void {
|
|
50
214
|
this.frozen = true;
|
|
51
215
|
}
|
|
52
216
|
|
|
217
|
+
/**
|
|
218
|
+
* Makes a first connection attempt for every supervised dependency at once,
|
|
219
|
+
* so those that are up are connected before the start hooks run in order.
|
|
220
|
+
*/
|
|
53
221
|
start(): Promise<void> {
|
|
54
222
|
this.startPromise ??= (async () => {
|
|
223
|
+
await Promise.all(this.supervisors.map((supervisor) => supervisor.start()));
|
|
55
224
|
for (const start of this.starters) await start();
|
|
56
225
|
})();
|
|
57
226
|
return this.startPromise;
|
|
@@ -59,6 +228,7 @@ export class ServiceRegistry {
|
|
|
59
228
|
|
|
60
229
|
close(): Promise<void> {
|
|
61
230
|
this.closePromise ??= (async () => {
|
|
231
|
+
for (const supervisor of this.supervisors) supervisor.stop();
|
|
62
232
|
for (const close of [...this.closers].reverse()) await close();
|
|
63
233
|
})();
|
|
64
234
|
return this.closePromise;
|
|
@@ -90,13 +260,19 @@ export class AccessPolicy {
|
|
|
90
260
|
this.rules.push({ method: method.toUpperCase(), matcher: routeMatcher(pattern), level });
|
|
91
261
|
}
|
|
92
262
|
|
|
263
|
+
/**
|
|
264
|
+
* The level of the most recently registered matching rule. A HEAD request
|
|
265
|
+
* also matches GET rules, because Elysia answers it with the GET handler.
|
|
266
|
+
*/
|
|
93
267
|
resolve(request: Request): AccessLevel {
|
|
94
268
|
const path = new URL(request.url).pathname;
|
|
95
269
|
const method = request.method.toUpperCase();
|
|
96
270
|
const rule = [...this.rules]
|
|
97
271
|
.reverse()
|
|
98
272
|
.find((candidate) =>
|
|
99
|
-
(candidate.method === "*" || candidate.method === method
|
|
273
|
+
(candidate.method === "*" || candidate.method === method ||
|
|
274
|
+
(method === "HEAD" && candidate.method === "GET")) &&
|
|
275
|
+
candidate.matcher.test(path)
|
|
100
276
|
);
|
|
101
277
|
return rule?.level ?? "session";
|
|
102
278
|
}
|
|
@@ -199,7 +375,10 @@ export function createCoreServices(env: AppEnv): ServiceRegistry {
|
|
|
199
375
|
accessPolicy.register("*", "/api-docs-json", "public");
|
|
200
376
|
accessPolicy.register("*", "/api-docs-elysia-json", "public");
|
|
201
377
|
services.register(DATABASE, database, () => database.close());
|
|
202
|
-
services.
|
|
378
|
+
services.supervise(DATABASE_READINESS_CHECK, {
|
|
379
|
+
connect: () => services.resolve(DATABASE).connect(),
|
|
380
|
+
onLost: (listener) => database.onLost(listener),
|
|
381
|
+
});
|
|
203
382
|
services.register(READINESS, readiness);
|
|
204
383
|
readiness.register(DATABASE_READINESS_CHECK, () => services.resolve(DATABASE).ping());
|
|
205
384
|
services.register(LOGGER, logger);
|
|
@@ -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> {
|