@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
|
@@ -4,7 +4,12 @@ import { MemoryEventBus } from "@podosoft/podokit-runtime";
|
|
|
4
4
|
import { Elysia } from "elysia";
|
|
5
5
|
import type { RequestIdentity } from "../core/services";
|
|
6
6
|
import { ReadinessService } from "../health/readiness.service";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
eventsRoutes,
|
|
9
|
+
maxStreamsPerUser,
|
|
10
|
+
requestEventIdentity,
|
|
11
|
+
type EventIdentity,
|
|
12
|
+
} from "./events.module";
|
|
8
13
|
import { EventsService } from "./events.service";
|
|
9
14
|
|
|
10
15
|
/** Maps the `x-user`, `x-admin`, `x-roles`, and `x-org` test headers to the request identity. */
|
|
@@ -201,3 +206,45 @@ describe("default event identity", () => {
|
|
|
201
206
|
expect(await identity.subscriber(new Request("http://localhost/events/stream"))).toBeUndefined();
|
|
202
207
|
});
|
|
203
208
|
});
|
|
209
|
+
|
|
210
|
+
describe("stream limit configuration", () => {
|
|
211
|
+
it("reads SSE_MAX_STREAMS_PER_USER and defaults to eight", () => {
|
|
212
|
+
expect(maxStreamsPerUser({})).toBe(8);
|
|
213
|
+
expect(maxStreamsPerUser({ SSE_MAX_STREAMS_PER_USER: "3" })).toBe(3);
|
|
214
|
+
for (const value of ["0", "-1", "1.5", "many"]) {
|
|
215
|
+
expect(() => maxStreamsPerUser({ SSE_MAX_STREAMS_PER_USER: value })).toThrow(
|
|
216
|
+
"SSE_MAX_STREAMS_PER_USER must be a positive integer",
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("answers 429 EVENTS_STREAM_LIMIT over HTTP", async () => {
|
|
222
|
+
const events = new EventsService(new MemoryEventBus(), new ReadinessService(), {
|
|
223
|
+
maxStreamsPerUser: 1,
|
|
224
|
+
});
|
|
225
|
+
await events.connect();
|
|
226
|
+
const controllers: AbortController[] = [];
|
|
227
|
+
opened.push({ events, controllers });
|
|
228
|
+
const app = new Elysia()
|
|
229
|
+
.onError(({ error, set }) => {
|
|
230
|
+
if (!(error instanceof AppException)) return undefined;
|
|
231
|
+
set.status = error.statusCode;
|
|
232
|
+
return { code: error.code };
|
|
233
|
+
})
|
|
234
|
+
.use(eventsRoutes(events, headerIdentity));
|
|
235
|
+
const send = (): Promise<Response> => {
|
|
236
|
+
const controller = new AbortController();
|
|
237
|
+
controllers.push(controller);
|
|
238
|
+
return app.handle(
|
|
239
|
+
new Request("http://localhost/events/stream", {
|
|
240
|
+
headers: { "x-user": "u1" },
|
|
241
|
+
signal: controller.signal,
|
|
242
|
+
}),
|
|
243
|
+
);
|
|
244
|
+
};
|
|
245
|
+
expect((await send()).status).toBe(200);
|
|
246
|
+
const refused = await send();
|
|
247
|
+
expect(refused.status).toBe(429);
|
|
248
|
+
expect(await refused.json()).toEqual({ code: "EVENTS_STREAM_LIMIT" });
|
|
249
|
+
});
|
|
250
|
+
});
|
|
@@ -22,6 +22,15 @@ import { EventsService } from "./events.service";
|
|
|
22
22
|
|
|
23
23
|
export const SSE_EVENTS = Symbol("sse-events") as ServiceKey<EventsService>;
|
|
24
24
|
|
|
25
|
+
/** `SSE_MAX_STREAMS_PER_USER`: open streams one user may hold on each API replica. */
|
|
26
|
+
export function maxStreamsPerUser(env: Record<string, string | undefined> = process.env): number {
|
|
27
|
+
const value = Number(env.SSE_MAX_STREAMS_PER_USER ?? 8);
|
|
28
|
+
if (!Number.isSafeInteger(value) || value < 1) {
|
|
29
|
+
throw new Error("SSE_MAX_STREAMS_PER_USER must be a positive integer");
|
|
30
|
+
}
|
|
31
|
+
return value;
|
|
32
|
+
}
|
|
33
|
+
|
|
25
34
|
/** Resolves who opens a stream. Register one in app.extensions.ts to replace the default. */
|
|
26
35
|
export interface EventIdentity {
|
|
27
36
|
subscriber(request: Request): Promise<EventSubscriber | undefined>;
|
|
@@ -84,7 +93,9 @@ export function eventsRoutes(
|
|
|
84
93
|
"Signed-in users only. `topics` is a comma-separated list; without it the stream " +
|
|
85
94
|
"carries every topic that has no authorizer. Each data event is a default " +
|
|
86
95
|
"`message` whose data is `{ topic, data }`. A `resync` event means events were " +
|
|
87
|
-
"dropped and the client should reload its state."
|
|
96
|
+
"dropped and the client should reload its state. A user may hold " +
|
|
97
|
+
"`SSE_MAX_STREAMS_PER_USER` streams (default 8) on each API replica; another " +
|
|
98
|
+
"answers 429 `EVENTS_STREAM_LIMIT`.",
|
|
88
99
|
},
|
|
89
100
|
})
|
|
90
101
|
.post("/events", async ({ request, body, set }) => {
|
|
@@ -132,7 +143,9 @@ const eventsPlugin: AppPlugin = ({ services }) =>
|
|
|
132
143
|
export const eventsModule: PodokitModule = {
|
|
133
144
|
name: "sse",
|
|
134
145
|
configure: (_env, services): void => {
|
|
135
|
-
const events = new EventsService(services.resolve(EVENTS), services.resolve(READINESS)
|
|
146
|
+
const events = new EventsService(services.resolve(EVENTS), services.resolve(READINESS), {
|
|
147
|
+
maxStreamsPerUser: maxStreamsPerUser(),
|
|
148
|
+
});
|
|
136
149
|
services.register(SSE_EVENTS, events, () => events.close());
|
|
137
150
|
services.onStart(() => events.connect());
|
|
138
151
|
},
|
|
@@ -38,6 +38,22 @@ function user(userId: string, roles: string[] = [], organizationId?: string): Ev
|
|
|
38
38
|
return { userId, roles, ...(organizationId ? { organizationId } : {}) };
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/** A memory bus that can report a transport interruption, as the Redis bus does after reconnecting. */
|
|
42
|
+
class InterruptibleBus extends MemoryEventBus {
|
|
43
|
+
readonly listeners = new Set<() => void>();
|
|
44
|
+
|
|
45
|
+
onInterruption(listener: () => void): () => void {
|
|
46
|
+
this.listeners.add(listener);
|
|
47
|
+
return () => {
|
|
48
|
+
this.listeners.delete(listener);
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interrupt(): void {
|
|
53
|
+
for (const listener of this.listeners) listener();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
41
57
|
const services: EventsService[] = [];
|
|
42
58
|
|
|
43
59
|
async function service(
|
|
@@ -268,6 +284,23 @@ describe("per-connection safety", () => {
|
|
|
268
284
|
expect(decoded(await stream.next())).toEqual({ topic: "t", data: 6 });
|
|
269
285
|
});
|
|
270
286
|
|
|
287
|
+
it("tells every open stream to resync after a transport interruption", async () => {
|
|
288
|
+
const bus = new InterruptibleBus();
|
|
289
|
+
const events = await service({}, bus);
|
|
290
|
+
const topical = await open(events, user("a"), ["t"]);
|
|
291
|
+
const everything = await open(events, user("b"));
|
|
292
|
+
await events.publishAsync({ topic: "t", data: 1 });
|
|
293
|
+
|
|
294
|
+
bus.interrupt();
|
|
295
|
+
expect(await topical.next()).toEqual({ kind: "resync" });
|
|
296
|
+
expect(await everything.next()).toEqual({ kind: "resync" });
|
|
297
|
+
await events.publishAsync({ topic: "t", data: 2 });
|
|
298
|
+
expect(decoded(await topical.next())).toEqual({ topic: "t", data: 2 });
|
|
299
|
+
|
|
300
|
+
await events.close();
|
|
301
|
+
expect(bus.listeners.size).toBe(0);
|
|
302
|
+
});
|
|
303
|
+
|
|
271
304
|
it("reuses one heartbeat timer per connection and releases it on disconnect", async () => {
|
|
272
305
|
const scheduler = fakeScheduler();
|
|
273
306
|
const events = await service({ scheduler });
|
|
@@ -353,6 +386,41 @@ describe("per-connection safety", () => {
|
|
|
353
386
|
});
|
|
354
387
|
});
|
|
355
388
|
|
|
389
|
+
describe("per-user stream limit", () => {
|
|
390
|
+
it("refuses a user's stream beyond the limit on this replica and frees slots on close", async () => {
|
|
391
|
+
const events = await service({ maxStreamsPerUser: 2 });
|
|
392
|
+
const first = await open(events, user("a"));
|
|
393
|
+
await open(events, user("a"));
|
|
394
|
+
|
|
395
|
+
const refused = await open(events, user("a")).catch((error: unknown) => error);
|
|
396
|
+
expect(refused).toBeInstanceOf(AppException);
|
|
397
|
+
expect((refused as AppException).code).toBe("EVENTS_STREAM_LIMIT");
|
|
398
|
+
expect((refused as AppException).statusCode).toBe(429);
|
|
399
|
+
expect(events.streamCount).toBe(2);
|
|
400
|
+
|
|
401
|
+
// Other users keep their own allowance.
|
|
402
|
+
await open(events, user("b"));
|
|
403
|
+
first.close();
|
|
404
|
+
await open(events, user("a"));
|
|
405
|
+
expect(events.streamCount).toBe(3);
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it("counts only live streams after a client disconnects", async () => {
|
|
409
|
+
const events = await service({ maxStreamsPerUser: 1 });
|
|
410
|
+
const controller = new AbortController();
|
|
411
|
+
await events.open(user("a"), undefined, controller.signal);
|
|
412
|
+
controller.abort();
|
|
413
|
+
await open(events, user("a"));
|
|
414
|
+
expect(events.streamCount).toBe(1);
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it("allows eight streams per user by default", async () => {
|
|
418
|
+
const events = await service();
|
|
419
|
+
for (let index = 0; index < 8; index += 1) await open(events, user("a"));
|
|
420
|
+
await expect(open(events, user("a"))).rejects.toThrow("Too many open event streams");
|
|
421
|
+
});
|
|
422
|
+
});
|
|
423
|
+
|
|
356
424
|
describe("SSE wire format", () => {
|
|
357
425
|
it("encodes data events as default messages and control frames separately", () => {
|
|
358
426
|
expect(encodeFrame({ kind: "event", id: "7", data: '{"topic":"t","data":1}' })).toBe(
|
|
@@ -27,6 +27,8 @@ export interface EventsServiceOptions {
|
|
|
27
27
|
retryMs?: number;
|
|
28
28
|
/** Streams end after this long so clients reconnect with a fresh identity. */
|
|
29
29
|
maxConnectionMs?: number;
|
|
30
|
+
/** Open streams one user may hold on this replica. */
|
|
31
|
+
maxStreamsPerUser?: number;
|
|
30
32
|
scheduler?: Scheduler;
|
|
31
33
|
now?: () => number;
|
|
32
34
|
}
|
|
@@ -35,11 +37,14 @@ export class EventsService {
|
|
|
35
37
|
readonly topics = new TopicAuthorizers();
|
|
36
38
|
readonly retryMs: number;
|
|
37
39
|
private readonly streamOptions: EventStreamOptions;
|
|
40
|
+
private readonly maxStreamsPerUser: number;
|
|
38
41
|
private readonly streams = new Set<EventStream>();
|
|
42
|
+
private readonly byUser = new Map<string, Set<EventStream>>();
|
|
39
43
|
private readonly byTopic = new Map<string, Set<EventStream>>();
|
|
40
44
|
private readonly everyTopic = new Set<EventStream>();
|
|
41
45
|
private unregisterReadiness?: () => void;
|
|
42
46
|
private unsubscribe?: Unsubscribe;
|
|
47
|
+
private stopResync?: () => void;
|
|
43
48
|
|
|
44
49
|
constructor(
|
|
45
50
|
private readonly transport: EventBus,
|
|
@@ -47,6 +52,7 @@ export class EventsService {
|
|
|
47
52
|
options: EventsServiceOptions = {},
|
|
48
53
|
) {
|
|
49
54
|
this.retryMs = options.retryMs ?? 3_000;
|
|
55
|
+
this.maxStreamsPerUser = options.maxStreamsPerUser ?? 8;
|
|
50
56
|
this.streamOptions = {
|
|
51
57
|
queueLimit: options.queueLimit ?? 256,
|
|
52
58
|
heartbeatMs: options.heartbeatMs ?? 5_000,
|
|
@@ -69,6 +75,11 @@ export class EventsService {
|
|
|
69
75
|
}
|
|
70
76
|
this.deliver(envelope);
|
|
71
77
|
});
|
|
78
|
+
// Events published while the transport was down never arrive, so open
|
|
79
|
+
// streams are told to reload once it is back.
|
|
80
|
+
this.stopResync = this.transport.onInterruption?.(() => {
|
|
81
|
+
for (const stream of this.streams) stream.resync();
|
|
82
|
+
});
|
|
72
83
|
this.unregisterReadiness = this.readiness.register("events", () => this.transport.ready());
|
|
73
84
|
}
|
|
74
85
|
|
|
@@ -93,7 +104,9 @@ export class EventsService {
|
|
|
93
104
|
|
|
94
105
|
/**
|
|
95
106
|
* Opens a stream for `subscriber`. Without `topics` the stream receives every
|
|
96
|
-
* topic that has no authorizer; guarded topics must be requested by name.
|
|
107
|
+
* topic that has no authorizer; guarded topics must be requested by name. A
|
|
108
|
+
* user beyond the per-user limit is refused rather than closing an older
|
|
109
|
+
* stream, which would make that client reconnect and refuse the next one.
|
|
97
110
|
*/
|
|
98
111
|
async open(
|
|
99
112
|
subscriber: EventSubscriber,
|
|
@@ -109,6 +122,10 @@ export class EventsService {
|
|
|
109
122
|
);
|
|
110
123
|
}
|
|
111
124
|
}
|
|
125
|
+
// No await between this check and attach(), so concurrent opens cannot pass it together.
|
|
126
|
+
if ((this.byUser.get(subscriber.userId)?.size ?? 0) >= this.maxStreamsPerUser) {
|
|
127
|
+
throw new AppException("EVENTS_STREAM_LIMIT", "Too many open event streams", 429);
|
|
128
|
+
}
|
|
112
129
|
const stream = new EventStream(
|
|
113
130
|
subscriber,
|
|
114
131
|
topics ? new Set(topics) : undefined,
|
|
@@ -122,6 +139,7 @@ export class EventsService {
|
|
|
122
139
|
|
|
123
140
|
async close(): Promise<void> {
|
|
124
141
|
this.unregisterReadiness?.();
|
|
142
|
+
this.stopResync?.();
|
|
125
143
|
await this.unsubscribe?.();
|
|
126
144
|
for (const stream of [...this.streams]) stream.close();
|
|
127
145
|
}
|
|
@@ -142,6 +160,9 @@ export class EventsService {
|
|
|
142
160
|
|
|
143
161
|
private attach(stream: EventStream): void {
|
|
144
162
|
this.streams.add(stream);
|
|
163
|
+
const held = this.byUser.get(stream.subscriber.userId) ?? new Set<EventStream>();
|
|
164
|
+
held.add(stream);
|
|
165
|
+
this.byUser.set(stream.subscriber.userId, held);
|
|
145
166
|
if (!stream.topics) {
|
|
146
167
|
this.everyTopic.add(stream);
|
|
147
168
|
return;
|
|
@@ -155,6 +176,9 @@ export class EventsService {
|
|
|
155
176
|
|
|
156
177
|
private detach(stream: EventStream): void {
|
|
157
178
|
this.streams.delete(stream);
|
|
179
|
+
const held = this.byUser.get(stream.subscriber.userId);
|
|
180
|
+
held?.delete(stream);
|
|
181
|
+
if (held?.size === 0) this.byUser.delete(stream.subscriber.userId);
|
|
158
182
|
if (!stream.topics) {
|
|
159
183
|
this.everyTopic.delete(stream);
|
|
160
184
|
return;
|
|
@@ -119,6 +119,52 @@ test("sse: streams require a session and a valid topic list", async ({ playwrigh
|
|
|
119
119
|
}
|
|
120
120
|
});
|
|
121
121
|
|
|
122
|
+
function openStream(cookie: string, controller: AbortController): Promise<Response> {
|
|
123
|
+
return fetch(`${base}/api/events/stream?topics=${uniqueTopic()}`, {
|
|
124
|
+
headers: { ...origin, cookie },
|
|
125
|
+
signal: controller.signal,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
test("sse: a user's open streams are capped per replica", async ({ playwright }) => {
|
|
130
|
+
const member = await newContext(playwright);
|
|
131
|
+
const other = await newContext(playwright);
|
|
132
|
+
const controllers: AbortController[] = [];
|
|
133
|
+
try {
|
|
134
|
+
test.skip(!(await signUp(member)), "requires the auth module");
|
|
135
|
+
expect(await signUp(other)).toBeDefined();
|
|
136
|
+
const cookie = await cookieHeader(member);
|
|
137
|
+
// The limit is SSE_MAX_STREAMS_PER_USER on the server, so open until refused.
|
|
138
|
+
let refused: Response | undefined;
|
|
139
|
+
for (let attempt = 0; attempt < 64 && !refused; attempt += 1) {
|
|
140
|
+
const controller = new AbortController();
|
|
141
|
+
const response = await openStream(cookie, controller);
|
|
142
|
+
if (response.status === 200) controllers.push(controller);
|
|
143
|
+
else refused = response;
|
|
144
|
+
}
|
|
145
|
+
expect(controllers.length).toBeGreaterThan(0);
|
|
146
|
+
expect(refused?.status).toBe(429);
|
|
147
|
+
expect((await refused?.json()).error.code).toBe("EVENTS_STREAM_LIMIT");
|
|
148
|
+
|
|
149
|
+
// Another user is unaffected, and closing a stream frees its slot.
|
|
150
|
+
const otherController = new AbortController();
|
|
151
|
+
controllers.push(otherController);
|
|
152
|
+
expect((await openStream(await cookieHeader(other), otherController)).status).toBe(200);
|
|
153
|
+
controllers.shift()?.abort();
|
|
154
|
+
await expect.poll(async () => {
|
|
155
|
+
const controller = new AbortController();
|
|
156
|
+
const response = await openStream(cookie, controller);
|
|
157
|
+
if (response.status === 200) controllers.push(controller);
|
|
158
|
+
else controller.abort();
|
|
159
|
+
return response.status;
|
|
160
|
+
}, { timeout: 8_000 }).toBe(200);
|
|
161
|
+
} finally {
|
|
162
|
+
for (const controller of controllers) controller.abort();
|
|
163
|
+
await member.dispose();
|
|
164
|
+
await other.dispose();
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
122
168
|
test("sse: only administrators may publish", async ({ playwright }) => {
|
|
123
169
|
const member = await newContext(playwright);
|
|
124
170
|
try {
|
|
@@ -17,7 +17,9 @@
|
|
|
17
17
|
"targetApp": "api",
|
|
18
18
|
"env": [
|
|
19
19
|
"# Maximum encoded Server-Sent Event payload",
|
|
20
|
-
"SSE_MAX_EVENT_BYTES=65536"
|
|
20
|
+
"SSE_MAX_EVENT_BYTES=65536",
|
|
21
|
+
"# Open event streams per user on each API replica",
|
|
22
|
+
"SSE_MAX_STREAMS_PER_USER=8"
|
|
21
23
|
],
|
|
22
24
|
"inject": [
|
|
23
25
|
{ "file": "apps/api/src/app.ts", "marker": "// podokit:end:imports", "text": "import { eventsModule } from \"./events/events.module\";" },
|
|
@@ -42,6 +42,14 @@ Merge added module settings from `.env.example` into an existing `.env`.
|
|
|
42
42
|
|
|
43
43
|
- **Ports: web = 5001, api = 5002.** The web app reaches the API only through its
|
|
44
44
|
SvelteKit server proxy (`/api/*`), never with a direct browser `fetch`.
|
|
45
|
+
- The proxy (`apps/web/src/lib/server/backend-proxy.ts`) is managed and passes only
|
|
46
|
+
allowlisted headers. List extra request and response header names in the
|
|
47
|
+
app-owned `apps/web/src/lib/server/proxy-headers.ts`
|
|
48
|
+
(`APP_FORWARDED_REQUEST_HEADERS`, `APP_RELAYED_RESPONSE_HEADERS`); never edit the
|
|
49
|
+
managed lists. Cap a route's request body in the app-owned
|
|
50
|
+
`apps/web/src/lib/server/body-limits.ts` (`APP_BODY_LIMITS`, 413
|
|
51
|
+
`PAYLOAD_TOO_LARGE`). `BODY_SIZE_LIMIT` is the ceiling for every web route, so
|
|
52
|
+
uploads larger than it must use a direct API path (`apiPaths`) instead.
|
|
45
53
|
- Build: `{{rootRun}} build` · Type-check/lint: `{{rootRun}} lint` · Unit tests: `{{rootRun}} test`
|
|
46
54
|
- Per workspace: `{{apiRun}} build` / `{{webRun}} build`.
|
|
47
55
|
- Application migrations (PostgreSQL or SQLite): `{{apiRun}} migration:run`.
|
|
@@ -92,7 +100,7 @@ Useful commands:
|
|
|
92
100
|
- `podo remove <module>` — un-apply a module (inverse of add; refuses if another module needs it, keeps files you edited).
|
|
93
101
|
- `podo status` / `podo diff` — see your local edits vs. what PodoKit generated.
|
|
94
102
|
- `podo update` — pull in template/module improvements (3-way merges your edits; never touches owned files).
|
|
95
|
-
- `podo deploy` — plan, apply, verify, or roll back an immutable release on Kubernetes or one Docker host. Production API WebSocket paths belong in the deployment profile's exact `exposure.webSocketPaths` allowlist; Compose trusts forwarding headers only from `exposure.trustedProxyCidrs`. Tag `vX.Y.Z` to build and push the images (`.github/workflows/release.yml`; check its runner — GitHub-hosted minutes are free for public repositories only). Use `.agents/skills/podokit-deploy`.
|
|
103
|
+
- `podo deploy` — plan, apply, verify, or roll back an immutable release on Kubernetes or one Docker host. Production API WebSocket paths belong in the deployment profile's exact `exposure.webSocketPaths` allowlist, and API routes that take bodies above the web server's `BODY_SIZE_LIMIT` (file uploads) in `exposure.apiPaths` (and `.podokit/dev.json` `apiPaths` for `podo dev`; `podo add` and `podo deploy init` fill in the paths modules declare), which reach the API without the web proxy, so the API must check their size, session, and `Origin` itself; Compose trusts forwarding headers only from `exposure.trustedProxyCidrs`. Tag `vX.Y.Z` to build and push the images (`.github/workflows/release.yml`; check its runner — GitHub-hosted minutes are free for public repositories only). Use `.agents/skills/podokit-deploy`.
|
|
96
104
|
- `podo deploy sync` — copy local build output into a running Compose deployment and restart it, for iterating without a release. Not a release: no image is published and the next `apply` discards it. Use `.agents/skills/podokit-deploy-fast`.
|
|
97
105
|
|
|
98
106
|
## Commit conventions
|
|
@@ -63,8 +63,17 @@ the same topology, with one route and no project-specific host port.
|
|
|
63
63
|
|
|
64
64
|
When the API exposes WebSocket endpoints, add their exact paths to
|
|
65
65
|
`.podokit/dev.json` under `webSocketPaths`. The shared gateway sends only those paths
|
|
66
|
-
directly to the API and leaves every other path on the web app.
|
|
67
|
-
|
|
66
|
+
directly to the API and leaves every other path on the web app. Routes that accept
|
|
67
|
+
bodies larger than the web server's `BODY_SIZE_LIMIT`, such as file uploads, go under
|
|
68
|
+
`apiPaths` as prefixes like `/api/files/uploads/`; the gateway removes `/api` and
|
|
69
|
+
sends them to the API without the web proxy. `podo add` adds the paths a module
|
|
70
|
+
declares, such as `file-upload`'s, to this file and to every deployment profile. Add `publicUrl` to the same file when
|
|
71
|
+
an HTTPS tunnel preserves its public `Host` header.
|
|
72
|
+
|
|
73
|
+
The containers read `.env.docker`, which Git ignores. `dev` commands create it from
|
|
74
|
+
the committed `.env.docker.example` when it is missing, as in a fresh clone, and fill
|
|
75
|
+
each empty `*_SECRET` value with a random one; `dev env` does only that. Keep shared
|
|
76
|
+
defaults in the example and credentials only in `.env.docker`.
|
|
68
77
|
|
|
69
78
|
In a second terminal, apply the included Todo migration and use the lifecycle helpers:
|
|
70
79
|
|
|
@@ -80,7 +89,9 @@ In a second terminal, apply the included Todo migration and use the lifecycle he
|
|
|
80
89
|
`dev watch` reads `.podokit/manifest.json` and automatically activates `cache`,
|
|
81
90
|
`storage`, and `queue` when installed modules require Redis, Silo, or a worker.
|
|
82
91
|
`dev up` uses the same shared gateway and module profiles without keeping Compose
|
|
83
|
-
Watch attached.
|
|
92
|
+
Watch attached. It rebuilds the images from the current source first, because Compose
|
|
93
|
+
Watch syncs edits into running containers only: a container recreated from an older
|
|
94
|
+
image would lose them. Pass `--no-build` to start from the existing images.
|
|
84
95
|
You can still activate an additional Compose profile explicitly:
|
|
85
96
|
|
|
86
97
|
```bash
|
|
@@ -88,7 +99,8 @@ You can still activate an additional Compose profile explicitly:
|
|
|
88
99
|
```
|
|
89
100
|
|
|
90
101
|
Explicit profile flags are preserved for other lifecycle commands. `dev down`
|
|
91
|
-
|
|
102
|
+
first stops this project's running `dev watch`, so the next `dev watch` starts
|
|
103
|
+
cleanly, then activates all profiles while removing this project's stack and route. If it is
|
|
92
104
|
the final registered
|
|
93
105
|
project, it also removes the shared gateway and network. Source changes are synced
|
|
94
106
|
through Compose Watch, including Vite HMR on the same portless browser origin.
|
|
@@ -149,5 +161,6 @@ PodoKit [deployment guide](https://github.com/podosoft-dev/podokit/blob/main/doc
|
|
|
149
161
|
|
|
150
162
|
For a Docker Compose deployment with API WebSockets, list their exact paths under
|
|
151
163
|
`exposure.webSocketPaths`. The driver keeps one published port, routes those paths
|
|
152
|
-
to API, and sends every other request to web.
|
|
164
|
+
to API, and sends every other request to web. Large-upload routes listed under
|
|
165
|
+
`exposure.apiPaths` reach the API the same way, with `/api` removed. If an external reverse proxy supplies
|
|
153
166
|
forwarding headers, list only its direct CIDR blocks in `exposure.trustedProxyCidrs`.
|
package/dist/update.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { type TemplateVars } from "@podosoft/podokit-template-engine";
|
|
2
2
|
import { type Tier } from "./lockfile";
|
|
3
|
+
import type { ProviderCapability } from "@podosoft/podokit-runtime";
|
|
3
4
|
import type { Toolchain } from "./toolchain";
|
|
5
|
+
import { type ApiPathResult } from "./module-api-paths";
|
|
4
6
|
/**
|
|
5
7
|
* `podo update` planner. For now it produces a dry-run plan only: it assembles
|
|
6
8
|
* the new-version tree from the installed CLI's templates and compares it to the
|
|
@@ -17,12 +19,26 @@ export interface FileChange {
|
|
|
17
19
|
/** Why the action was chosen (for the report). */
|
|
18
20
|
note: string;
|
|
19
21
|
}
|
|
22
|
+
/** A module the target version needs that the project does not record yet. */
|
|
23
|
+
export interface AddedModule {
|
|
24
|
+
name: string;
|
|
25
|
+
/** Installed or added module that needs it. */
|
|
26
|
+
requiredBy: string;
|
|
27
|
+
/** Provider capability it implements, when added for a capability requirement. */
|
|
28
|
+
capability?: ProviderCapability;
|
|
29
|
+
/** Selected provider it implements. */
|
|
30
|
+
provider?: string;
|
|
31
|
+
}
|
|
20
32
|
export interface UpdatePlan {
|
|
21
33
|
fromVersion: string;
|
|
22
34
|
toVersion: string;
|
|
23
35
|
template: string;
|
|
24
36
|
modules: string[];
|
|
37
|
+
/** Modules the update adds, ordered ahead of the modules that need them. */
|
|
38
|
+
addedModules: AddedModule[];
|
|
25
39
|
changes: FileChange[];
|
|
40
|
+
/** Direct API paths that modules newly declare, per configuration file. */
|
|
41
|
+
apiPaths: ApiPathResult;
|
|
26
42
|
}
|
|
27
43
|
/**
|
|
28
44
|
* Build the update plan. `templatesDir` is the installed CLI's template set (the
|
|
@@ -51,6 +67,8 @@ export interface ApplyOptions {
|
|
|
51
67
|
abortOnConflict?: boolean;
|
|
52
68
|
}
|
|
53
69
|
export interface ApplyResult {
|
|
70
|
+
/** Modules added because the target version requires them. */
|
|
71
|
+
addedModules: string[];
|
|
54
72
|
written: string[];
|
|
55
73
|
/** Files relocated by module update migrations. */
|
|
56
74
|
moved: {
|
|
@@ -62,6 +80,8 @@ export interface ApplyResult {
|
|
|
62
80
|
merged: string[];
|
|
63
81
|
/** Files written with conflict markers for manual resolution. */
|
|
64
82
|
conflicts: string[];
|
|
83
|
+
/** Direct API paths added to `.podokit/dev.json` and deployment profiles. */
|
|
84
|
+
apiPaths: ApiPathResult;
|
|
65
85
|
}
|
|
66
86
|
/**
|
|
67
87
|
* Apply an update to the working copy. Clean updates and additions are written,
|
|
@@ -71,5 +91,7 @@ export interface ApplyResult {
|
|
|
71
91
|
* stamps the new version.
|
|
72
92
|
*/
|
|
73
93
|
export declare function applyUpdate(projectRoot: string, templatesDir: string, options?: ApplyOptions): ApplyResult;
|
|
94
|
+
/** Why an update adds a module, for the plan report. */
|
|
95
|
+
export declare function addedModuleReason(module: AddedModule): string;
|
|
74
96
|
/** Count actions for a one-line summary. */
|
|
75
97
|
export declare function summarize(plan: UpdatePlan): Record<Action, number>;
|
package/dist/update.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.planUpdate = planUpdate;
|
|
4
4
|
exports.applyUpdate = applyUpdate;
|
|
5
|
+
exports.addedModuleReason = addedModuleReason;
|
|
5
6
|
exports.summarize = summarize;
|
|
6
7
|
const node_child_process_1 = require("node:child_process");
|
|
7
8
|
const node_fs_1 = require("node:fs");
|
|
@@ -13,6 +14,7 @@ const lockfile_1 = require("./lockfile");
|
|
|
13
14
|
const inspect_1 = require("./inspect");
|
|
14
15
|
const add_1 = require("./add");
|
|
15
16
|
const module_migrations_1 = require("./module-migrations");
|
|
17
|
+
const module_api_paths_1 = require("./module-api-paths");
|
|
16
18
|
function treeText(tree, path) {
|
|
17
19
|
const file = tree.get(path);
|
|
18
20
|
if (!file)
|
|
@@ -25,17 +27,21 @@ function diskContent(projectRoot, path) {
|
|
|
25
27
|
}
|
|
26
28
|
/**
|
|
27
29
|
* Expand the recorded module list against the target manifests. A module may
|
|
28
|
-
* gain a new requirement after it was first added to a project
|
|
29
|
-
*
|
|
30
|
-
*
|
|
30
|
+
* gain a new requirement after it was first added to a project, either another
|
|
31
|
+
* module or a provider capability. Updates must apply that requirement before
|
|
32
|
+
* the dependent module: modules configure in this order, and the target tree
|
|
33
|
+
* would otherwise wire files that were never installed. A capability resolves
|
|
34
|
+
* to the module implementing the project's selected provider, as `podo add`
|
|
35
|
+
* would choose it.
|
|
31
36
|
*/
|
|
32
|
-
function targetModules(projectRoot, templatesDir, modules) {
|
|
37
|
+
function targetModules(projectRoot, templatesDir, modules, providers) {
|
|
33
38
|
const modulesDir = (0, node_path_1.join)(templatesDir, "modules");
|
|
34
39
|
const recorded = new Map(modules.map((module) => [module.name, module]));
|
|
35
40
|
const ordered = [];
|
|
41
|
+
const added = [];
|
|
36
42
|
const visiting = new Set();
|
|
37
43
|
const visited = new Set();
|
|
38
|
-
const visit = (name, requiredBy) => {
|
|
44
|
+
const visit = (name, requiredBy, capability) => {
|
|
39
45
|
if (visited.has(name))
|
|
40
46
|
return;
|
|
41
47
|
if (visiting.has(name)) {
|
|
@@ -52,12 +58,24 @@ function targetModules(projectRoot, templatesDir, modules) {
|
|
|
52
58
|
const moduleManifest = (0, add_1.readModuleManifest)(resolved.dir);
|
|
53
59
|
for (const required of moduleManifest.requires ?? [])
|
|
54
60
|
visit(required, name);
|
|
61
|
+
for (const required of moduleManifest.requiresCapabilities ?? []) {
|
|
62
|
+
visit((0, add_1.selectedProviderModule)(modulesDir, projectRoot, providers, required), name, required);
|
|
63
|
+
}
|
|
55
64
|
visiting.delete(name);
|
|
56
65
|
visited.add(name);
|
|
66
|
+
if (!existing && requiredBy) {
|
|
67
|
+
added.push({
|
|
68
|
+
name: resolved.name,
|
|
69
|
+
requiredBy,
|
|
70
|
+
...(capability ? { capability, provider: providers[capability] } : {}),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
57
73
|
ordered.push({
|
|
58
74
|
...(existing ?? {
|
|
59
75
|
name: resolved.name,
|
|
60
76
|
addedWith: (0, lockfile_1.podokitVersion)(),
|
|
77
|
+
// A freshly added module ships its current layout, as `podo add` records it.
|
|
78
|
+
appliedMigrations: (moduleManifest.migrations ?? []).map((migration) => migration.id),
|
|
61
79
|
}),
|
|
62
80
|
order: ordered.length,
|
|
63
81
|
...(resolved.packageName
|
|
@@ -71,7 +89,19 @@ function targetModules(projectRoot, templatesDir, modules) {
|
|
|
71
89
|
for (const module of [...modules].sort((a, b) => a.order - b.order)) {
|
|
72
90
|
visit(module.name);
|
|
73
91
|
}
|
|
74
|
-
return ordered;
|
|
92
|
+
return { modules: ordered, added };
|
|
93
|
+
}
|
|
94
|
+
function targetApiPaths(projectRoot, templatesDir, modules) {
|
|
95
|
+
const modulesDir = (0, node_path_1.join)(templatesDir, "modules");
|
|
96
|
+
const declared = new Map();
|
|
97
|
+
const pending = [];
|
|
98
|
+
for (const module of modules) {
|
|
99
|
+
const resolved = (0, add_1.resolveModule)(module.packageName ?? module.name, modulesDir, projectRoot);
|
|
100
|
+
const paths = resolved ? ((0, add_1.readModuleManifest)(resolved.dir).apiPaths ?? []) : [];
|
|
101
|
+
declared.set(module.name, paths);
|
|
102
|
+
pending.push(...(0, module_api_paths_1.pendingApiPaths)(module, paths));
|
|
103
|
+
}
|
|
104
|
+
return { declared, pending };
|
|
75
105
|
}
|
|
76
106
|
function targetManagedOverrides(projectRoot, templatesDir, modules, current = []) {
|
|
77
107
|
const overrides = new Set(current);
|
|
@@ -102,7 +132,7 @@ function planUpdate(projectRoot, templatesDir, options = {}) {
|
|
|
102
132
|
const lock = (0, lockfile_1.readFilesLock)(projectRoot);
|
|
103
133
|
if (!manifest || !lock)
|
|
104
134
|
throw new inspect_1.NotAProjectError();
|
|
105
|
-
const modules = targetModules(projectRoot, templatesDir, manifest.modules);
|
|
135
|
+
const { modules, added } = targetModules(projectRoot, templatesDir, manifest.modules, manifest.providers);
|
|
106
136
|
const newTree = (0, assemble_1.assembleProject)({
|
|
107
137
|
templatesDir,
|
|
108
138
|
template: manifest.template,
|
|
@@ -205,12 +235,24 @@ function planUpdate(projectRoot, templatesDir, options = {}) {
|
|
|
205
235
|
note: edited ? "you edited this; update would need a merge" : "clean update",
|
|
206
236
|
});
|
|
207
237
|
}
|
|
238
|
+
// A configuration file the update creates, such as a missing
|
|
239
|
+
// .podokit/dev.json, receives the paths after it is written.
|
|
240
|
+
const created = new Set(changes.filter((change) => change.action === "add").map((change) => change.path));
|
|
241
|
+
const apiPaths = options.onlyPaths
|
|
242
|
+
? { changed: [], skipped: [] }
|
|
243
|
+
: (0, module_api_paths_1.addApiPaths)(projectRoot, targetApiPaths(projectRoot, templatesDir, modules).pending, {
|
|
244
|
+
dryRun: true,
|
|
245
|
+
read: (file) => diskContent(projectRoot, file)?.toString("utf8")
|
|
246
|
+
?? (created.has(file) ? treeText(newTree, file) : null),
|
|
247
|
+
});
|
|
208
248
|
return {
|
|
209
249
|
fromVersion: manifest.podokitVersion,
|
|
210
250
|
toVersion: (0, lockfile_1.podokitVersion)(),
|
|
211
251
|
template: manifest.template,
|
|
212
252
|
modules: modules.map((module) => module.name),
|
|
253
|
+
addedModules: added,
|
|
213
254
|
changes,
|
|
255
|
+
apiPaths,
|
|
214
256
|
};
|
|
215
257
|
}
|
|
216
258
|
function assertRecordedExternalModules(moduleRoot, templatesDir, modules) {
|
|
@@ -333,7 +375,7 @@ function applyUpdate(projectRoot, templatesDir, options = {}) {
|
|
|
333
375
|
forceManagedPaths: options.forceManagedPaths,
|
|
334
376
|
onlyPaths: options.onlyPaths,
|
|
335
377
|
});
|
|
336
|
-
const modules = targetModules(projectRoot, templatesDir, manifest.modules);
|
|
378
|
+
const { modules } = targetModules(projectRoot, templatesDir, manifest.modules, manifest.providers);
|
|
337
379
|
const newTree = (0, assemble_1.assembleProject)({
|
|
338
380
|
templatesDir,
|
|
339
381
|
template: manifest.template,
|
|
@@ -396,11 +438,13 @@ function applyUpdate(projectRoot, templatesDir, options = {}) {
|
|
|
396
438
|
}
|
|
397
439
|
}
|
|
398
440
|
const result = {
|
|
441
|
+
addedModules: plan.addedModules.map((module) => module.name),
|
|
399
442
|
written: [],
|
|
400
443
|
moved: (0, module_migrations_1.applyModuleMigrations)(projectRoot, migrationPlan),
|
|
401
444
|
removed: [],
|
|
402
445
|
merged: [],
|
|
403
446
|
conflicts: [],
|
|
447
|
+
apiPaths: { changed: [], skipped: [] },
|
|
404
448
|
};
|
|
405
449
|
for (const change of plan.changes) {
|
|
406
450
|
const newText = treeText(newTree, change.path);
|
|
@@ -428,6 +472,9 @@ function applyUpdate(projectRoot, templatesDir, options = {}) {
|
|
|
428
472
|
result.merged.push(change.path);
|
|
429
473
|
}
|
|
430
474
|
}
|
|
475
|
+
const apiPaths = options.onlyPaths ? undefined : targetApiPaths(projectRoot, templatesDir, modules);
|
|
476
|
+
if (apiPaths)
|
|
477
|
+
result.apiPaths = (0, module_api_paths_1.addApiPaths)(projectRoot, apiPaths.pending);
|
|
431
478
|
// Keep the assembled target as the managed baseline. A merged working file
|
|
432
479
|
// intentionally remains drifted so a future update performs another 3-way
|
|
433
480
|
// merge instead of treating the user's lines as clean generated output.
|
|
@@ -447,6 +494,12 @@ function applyUpdate(projectRoot, templatesDir, options = {}) {
|
|
|
447
494
|
...(appliedMigrations.get(module.name) ?? []),
|
|
448
495
|
]),
|
|
449
496
|
];
|
|
497
|
+
const appliedApiPaths = [
|
|
498
|
+
...new Set([
|
|
499
|
+
...(module.appliedApiPaths ?? []),
|
|
500
|
+
...(apiPaths?.declared.get(module.name) ?? []),
|
|
501
|
+
]),
|
|
502
|
+
];
|
|
450
503
|
return {
|
|
451
504
|
...module,
|
|
452
505
|
...(resolved?.packageName
|
|
@@ -456,6 +509,7 @@ function applyUpdate(projectRoot, templatesDir, options = {}) {
|
|
|
456
509
|
}
|
|
457
510
|
: {}),
|
|
458
511
|
...(applied.length ? { appliedMigrations: applied } : {}),
|
|
512
|
+
...(appliedApiPaths.length ? { appliedApiPaths } : {}),
|
|
459
513
|
};
|
|
460
514
|
});
|
|
461
515
|
(0, lockfile_1.writeManifest)(projectRoot, {
|
|
@@ -469,6 +523,12 @@ function applyUpdate(projectRoot, templatesDir, options = {}) {
|
|
|
469
523
|
});
|
|
470
524
|
return result;
|
|
471
525
|
}
|
|
526
|
+
/** Why an update adds a module, for the plan report. */
|
|
527
|
+
function addedModuleReason(module) {
|
|
528
|
+
return module.capability
|
|
529
|
+
? `${module.capability} provider "${module.provider}" required by ${module.requiredBy}`
|
|
530
|
+
: `required by ${module.requiredBy}`;
|
|
531
|
+
}
|
|
472
532
|
/** Count actions for a one-line summary. */
|
|
473
533
|
function summarize(plan) {
|
|
474
534
|
const counts = {
|