@podosoft/podokit 1.5.0 → 1.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/dist/app-proxy-timeouts.d.ts +27 -0
- package/dist/app-proxy-timeouts.js +180 -0
- package/dist/assemble.d.ts +5 -2
- package/dist/assemble.js +14 -4
- package/dist/deploy-checks.d.ts +44 -0
- package/dist/deploy-checks.js +267 -0
- package/dist/deploy-compose-profile.d.ts +6 -2
- package/dist/deploy-compose-profile.js +15 -6
- package/dist/deploy-compose-render.d.ts +12 -4
- package/dist/deploy-compose-render.js +47 -18
- package/dist/deploy-compose.d.ts +2 -5
- package/dist/deploy-compose.js +7 -2
- package/dist/deploy-profile.d.ts +6 -2
- package/dist/deploy-profile.js +11 -7
- package/dist/deploy-render.d.ts +4 -1
- package/dist/deploy-render.js +27 -21
- package/dist/deploy-schema.d.ts +60 -2
- package/dist/deploy-schema.js +141 -14
- package/dist/deploy.d.ts +2 -5
- package/dist/deploy.js +5 -2
- package/dist/dev.d.ts +21 -0
- package/dist/dev.js +78 -3
- package/dist/eject.js +2 -0
- package/dist/index.js +85 -5
- package/dist/lib.d.ts +2 -0
- package/dist/lib.js +5 -2
- package/dist/lockfile.d.ts +24 -0
- package/dist/lockfile.js +74 -4
- package/dist/provider.js +10 -5
- package/dist/remove.js +1 -1
- package/dist/template-files.d.ts +42 -0
- package/dist/template-files.js +102 -0
- package/dist/templates/fullstack/AGENTS.md +30 -4
- package/dist/templates/fullstack/README.md +8 -7
- package/dist/templates/fullstack/apps/api/package.json +4 -4
- package/dist/templates/fullstack/apps/api/scripts/build.extensions.mjs +6 -0
- package/dist/templates/fullstack/apps/api/scripts/build.mjs +65 -16
- package/dist/templates/fullstack/apps/api/scripts/run-migrations.mjs +7 -3
- package/dist/templates/fullstack/apps/api/scripts/verify-api-contract.ts +7 -2
- package/dist/templates/fullstack/apps/api/src/app.spec.ts +22 -0
- package/dist/templates/fullstack/apps/api/src/app.ts +41 -25
- package/dist/templates/fullstack/apps/api/src/build.spec.ts +121 -0
- package/dist/templates/fullstack/apps/api/src/config/env.extensions.ts +20 -0
- package/dist/templates/fullstack/apps/api/src/config/env.validation.spec.ts +76 -0
- package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +18 -3
- package/dist/templates/fullstack/apps/api/src/config/provider-selection.spec.ts +53 -0
- package/dist/templates/fullstack/apps/api/src/config/provider-selection.ts +79 -0
- package/dist/templates/fullstack/apps/api/src/config/providers.ts +7 -3
- package/dist/templates/fullstack/apps/api/src/core/api-contract.extensions.ts +6 -0
- package/dist/templates/fullstack/apps/api/src/core/api-contract.spec.ts +72 -4
- package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +78 -6
- package/dist/templates/fullstack/apps/api/src/core/services.ts +13 -0
- package/dist/templates/fullstack/apps/api/src/database/data-source.spec.ts +27 -0
- package/dist/templates/fullstack/apps/api/src/database/data-source.ts +7 -10
- package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +12 -0
- package/dist/templates/fullstack/apps/api/src/database/database.ts +2 -3
- package/dist/templates/fullstack/apps/api/src/database/entities.ts +12 -0
- package/dist/templates/fullstack/apps/api/src/database/migrate.ts +12 -2
- package/dist/templates/fullstack/apps/api/src/database/migration-files.spec.ts +116 -0
- package/dist/templates/fullstack/apps/api/src/database/migration-files.ts +134 -0
- package/dist/templates/fullstack/apps/api/src/database/migration-readiness.spec.ts +36 -0
- package/dist/templates/fullstack/apps/api/src/database/migration-readiness.ts +25 -0
- package/dist/templates/fullstack/apps/api/src/database/run-migrations.spec.ts +12 -1
- package/dist/templates/fullstack/apps/api/src/database/sqlite-migrator.ts +2 -36
- package/dist/templates/fullstack/apps/api/src/main.ts +2 -2
- package/dist/templates/fullstack/apps/api/tsconfig.json +2 -0
- package/dist/templates/fullstack/apps/web/package.json +1 -1
- package/dist/templates/fullstack/apps/web/src/app.css +3 -0
- package/dist/templates/fullstack/apps/web/src/app.extensions.css +11 -0
- package/dist/templates/fullstack/apps/web/src/lib/components/site-runtime.svelte +3 -1
- package/dist/templates/fullstack/apps/web/src/lib/server/api.test.ts +19 -0
- package/dist/templates/fullstack/apps/web/src/lib/server/api.ts +4 -1
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +79 -0
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +55 -1
- package/dist/templates/fullstack/apps/web/src/lib/server/proxy-timeouts.ts +2 -1
- package/dist/templates/fullstack/apps/web/tsconfig.json +1 -1
- package/dist/templates/fullstack/apps/web/vite.config.test.ts +33 -0
- package/dist/templates/fullstack/apps/web/vite.config.ts +55 -13
- package/dist/templates/fullstack/apps/web/vite.extensions.ts +23 -0
- package/dist/templates/fullstack/compose.dev.yaml +7 -4
- package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/SKILL.md +2 -2
- package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +13 -3
- package/dist/templates/fullstack/dot-claude/skills/podokit-update/SKILL.md +5 -2
- package/dist/templates/fullstack/dot-env.docker.example +5 -2
- package/dist/templates/fullstack/dot-env.example +4 -0
- package/dist/templates/fullstack/dot-podokit/dev.json +1 -1
- package/dist/templates/fullstack/infra/k3s/configmap.yaml +1 -0
- package/dist/templates/fullstack/tests/README.md +50 -1
- package/dist/templates/fullstack/tests/helpers/app-paths.ts +18 -0
- package/dist/templates/fullstack/tests/helpers/paths.ts +33 -0
- package/dist/templates/fullstack/tests/helpers/seeded-admin.ts +15 -0
- package/dist/templates/fullstack/tests/package.json +3 -3
- package/dist/templates/fullstack/tests/playwright.config.ts +10 -4
- package/dist/templates/fullstack/tests/playwright.extensions.ts +149 -1
- package/dist/templates/fullstack/tests/run-e2e.ts +28 -0
- package/dist/templates/fullstack/tests/ui/smoke.ui.spec.ts +5 -4
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.test.ts +37 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +15 -6
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-flag-settings.test.ts +31 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-flag-settings.ts +31 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-flags.ts +12 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-nav.ts +11 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/nav.test.ts +53 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/nav.ts +35 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/registry.svelte.ts +15 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +11 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/admin-sidebar.svelte +2 -23
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/data-table.svelte +7 -2
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/data-table.test.ts +30 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/impersonation-banner.svelte +3 -2
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/settings/app-flags-settings.svelte +65 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/sidebar-user-menu.svelte +32 -23
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/sidebar-user-menu.test.ts +27 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-footer.svelte +4 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-runtime.svelte +3 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/theme-toggle.svelte +1 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/en.json +6 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/ko.json +6 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/app-hooks.ts +11 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/non-indexable-paths.ts +8 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/search-indexing.test.ts +31 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/search-indexing.ts +5 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/settings/+page.svelte +1 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/account/+page.server.ts +2 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/account/+page.svelte +1 -5
- package/dist/templates/modules/admin-dashboard/files/dot-claude/skills/podokit-data-table/SKILL.md +2 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +159 -170
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +72 -81
- package/dist/templates/modules/admin-dashboard/files/tests/api/email.api.spec.ts +1 -6
- package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +43 -53
- package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +3 -1
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/accounts.ts +24 -6
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/app-accounts.ts +24 -0
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/devices.ts +41 -11
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/disposable-users.ts +5 -3
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/hydration.ts +13 -4
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/mailpit.ts +3 -9
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/sms.ts +2 -4
- package/dist/templates/modules/admin-dashboard/files/tests/playwright.config.ts +14 -6
- package/dist/templates/modules/admin-dashboard/files/tests/ui/account-flows.ui.spec.ts +4 -5
- package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +55 -55
- package/dist/templates/modules/admin-dashboard/files/tests/ui/app-flags.ui.spec.ts +63 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/appearance.ui.spec.ts +12 -16
- package/dist/templates/modules/admin-dashboard/files/tests/ui/audit.ui.spec.ts +3 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/auth.ui.spec.ts +32 -35
- package/dist/templates/modules/admin-dashboard/files/tests/ui/dashboard.ui.spec.ts +4 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/email-admin.ui.spec.ts +1 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +1 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/general-effects.ui.spec.ts +82 -91
- package/dist/templates/modules/admin-dashboard/files/tests/ui/guards.ui.spec.ts +6 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/i18n.ui.spec.ts +16 -20
- package/dist/templates/modules/admin-dashboard/files/tests/ui/impersonation.ui.spec.ts +6 -3
- package/dist/templates/modules/admin-dashboard/files/tests/ui/login-methods.ui.spec.ts +2 -3
- package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +2 -4
- package/dist/templates/modules/admin-dashboard/files/tests/ui/organizations.ui.spec.ts +5 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/passkey.ui.spec.ts +7 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/same-origin-requests.ui.spec.ts +89 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/sessions.ui.spec.ts +5 -4
- package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +67 -73
- package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +84 -93
- package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +8 -7
- package/dist/templates/modules/admin-dashboard/files/tests/ui/theme-mode.ui.spec.ts +35 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +51 -54
- package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +10 -9
- package/dist/templates/modules/admin-dashboard/module.manifest.json +7 -2
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.module.ts +5 -1
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.spec.ts +17 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +18 -0
- package/dist/templates/modules/audit-log/module.manifest.json +15 -3
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-app-name.spec.ts +21 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-config-store.ts +13 -1
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-environment.ts +9 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +19 -3
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.module.ts +2 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.runtime.spec.ts +10 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +7 -5
- package/dist/templates/modules/auth/files/apps/api/src/auth/schema-recheck.spec.ts +86 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/schema-recheck.ts +44 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +2 -1
- package/dist/templates/modules/auth/files/apps/api/src/migrate.ts +3 -1
- package/dist/templates/modules/auth/files/apps/api/src/settings/app-flags.ts +9 -0
- package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.spec.ts +43 -0
- package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.ts +30 -5
- package/dist/templates/modules/auth/module.manifest.json +8 -2
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.spec.ts +107 -1
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.ts +113 -9
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/worker.module.spec.ts +40 -0
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/worker.module.ts +52 -5
- package/dist/templates/modules/bullmq/files/apps/api/src/main-worker.ts +9 -7
- package/dist/templates/modules/bullmq/files/apps/api/src/worker.extensions.ts +13 -0
- package/dist/templates/modules/bullmq/module.manifest.json +5 -1
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.worker.spec.ts +2 -0
- package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.spec.ts +174 -1
- package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.ts +150 -3
- package/dist/templates/modules/jobs-local/module.manifest.json +3 -0
- package/dist/templates/modules/logging/files/apps/api/src/logging/logging.module.spec.ts +16 -0
- package/dist/templates/modules/logging/files/apps/api/src/logging/logging.module.ts +15 -10
- package/dist/templates/modules/mailer/module.manifest.json +1 -1
- package/dist/templates/modules/object-storage-local/files/tests/api/storage-local.api.spec.ts +3 -6
- package/dist/templates/modules/object-storage-s3/files/infra/docker/minio.compose.yml +5 -4
- package/dist/templates/modules/object-storage-s3/files/tests/api/storage.api.spec.ts +3 -6
- package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +45 -20
- package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +3 -6
- package/dist/templates/todo/AGENTS.md +5 -1
- package/dist/templates/todo/README.md +6 -4
- package/dist/text-diff.d.ts +12 -0
- package/dist/text-diff.js +111 -0
- package/dist/update.d.ts +61 -3
- package/dist/update.js +152 -27
- package/package.json +2 -2
- package/dist/templates/modules/admin-dashboard/files/tests/api/exclusive.api.spec.ts +0 -75
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/exclusive.ts +0 -80
|
@@ -16,9 +16,13 @@
|
|
|
16
16
|
"apps/api/src/jobs/**",
|
|
17
17
|
"apps/api/src/main-worker.ts"
|
|
18
18
|
],
|
|
19
|
+
"ownedGlobs": [
|
|
20
|
+
"apps/api/src/worker.extensions.ts"
|
|
21
|
+
],
|
|
19
22
|
"targetApp": "api",
|
|
20
23
|
"dependencies": {
|
|
21
|
-
"bullmq": "^5.57.0"
|
|
24
|
+
"bullmq": "^5.57.0",
|
|
25
|
+
"cron-parser": "^4.9.0"
|
|
22
26
|
},
|
|
23
27
|
"scripts": {
|
|
24
28
|
"start:worker": "bun dist/main-worker.js",
|
package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.spec.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { afterEach, describe, expect, it } from "bun:test";
|
|
2
2
|
import { SQL } from "bun";
|
|
3
|
-
import { LocalJobQueue } from "./local-job.queue";
|
|
3
|
+
import { LocalJobQueue, nextScheduledTime, type ScheduleTimer } from "./local-job.queue";
|
|
4
4
|
|
|
5
5
|
let sql: SQL | undefined;
|
|
6
6
|
|
|
@@ -66,3 +66,176 @@ describe("local job queue", () => {
|
|
|
66
66
|
queue.close();
|
|
67
67
|
});
|
|
68
68
|
});
|
|
69
|
+
|
|
70
|
+
/** A clock and timer the test moves by hand. */
|
|
71
|
+
function manualTime(start: number): { now: () => number; timer: ScheduleTimer; advance: (to: number) => void; waiting: () => number } {
|
|
72
|
+
let now = start;
|
|
73
|
+
const timers: Array<{ at: number; task: () => void; done: boolean }> = [];
|
|
74
|
+
return {
|
|
75
|
+
now: () => now,
|
|
76
|
+
timer: {
|
|
77
|
+
after: (delayMs, task) => {
|
|
78
|
+
const entry = { at: now + delayMs, task, done: false };
|
|
79
|
+
timers.push(entry);
|
|
80
|
+
return () => { entry.done = true; };
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
advance: (to) => {
|
|
84
|
+
for (;;) {
|
|
85
|
+
const due = timers.filter((entry) => !entry.done && entry.at <= to).sort((a, b) => a.at - b.at)[0];
|
|
86
|
+
if (!due) break;
|
|
87
|
+
due.done = true;
|
|
88
|
+
now = due.at;
|
|
89
|
+
due.task();
|
|
90
|
+
}
|
|
91
|
+
now = to;
|
|
92
|
+
},
|
|
93
|
+
waiting: () => timers.filter((entry) => !entry.done).length,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function scheduledJobs(database: SQL): Promise<string[]> {
|
|
98
|
+
// Scheduled jobs are added without waiting; let those inserts finish.
|
|
99
|
+
await Bun.sleep(5);
|
|
100
|
+
const rows = await database<Array<{ id: string }>>`SELECT "id" FROM "podokit_job" ORDER BY "createdAt", "id"`;
|
|
101
|
+
return rows.map((row) => row.id);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
describe("local job schedules", () => {
|
|
105
|
+
it("adds one job for each time of an interval and keeps going", async () => {
|
|
106
|
+
sql = new SQL(":memory:");
|
|
107
|
+
const time = manualTime(10_500);
|
|
108
|
+
const queue = new LocalJobQueue(sql, { pollIntervalMs: 60_000, now: time.now, timer: time.timer });
|
|
109
|
+
await queue.schedule("nightly", "report", { scope: "all" }, { everyMs: 1_000 });
|
|
110
|
+
await queue.start();
|
|
111
|
+
time.advance(11_000);
|
|
112
|
+
expect(await scheduledJobs(sql)).toEqual(["report:schedule:nightly:11000"]);
|
|
113
|
+
time.advance(13_200);
|
|
114
|
+
expect(await scheduledJobs(sql)).toEqual([
|
|
115
|
+
"report:schedule:nightly:11000",
|
|
116
|
+
"report:schedule:nightly:12000",
|
|
117
|
+
"report:schedule:nightly:13000",
|
|
118
|
+
]);
|
|
119
|
+
await expect(queue.get("report:schedule:nightly:12000")).resolves.toMatchObject({ name: "report", status: "waiting" });
|
|
120
|
+
queue.close();
|
|
121
|
+
expect(time.waiting()).toBe(0);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("replaces a schedule with the same id and stops one on unschedule", async () => {
|
|
125
|
+
sql = new SQL(":memory:");
|
|
126
|
+
const time = manualTime(0);
|
|
127
|
+
const queue = new LocalJobQueue(sql, { pollIntervalMs: 60_000, now: time.now, timer: time.timer });
|
|
128
|
+
await queue.start();
|
|
129
|
+
await queue.schedule("sync", "sync", {}, { everyMs: 1_000 });
|
|
130
|
+
await queue.schedule("sync", "sync", {}, { everyMs: 5_000 });
|
|
131
|
+
time.advance(5_000);
|
|
132
|
+
expect(await scheduledJobs(sql)).toEqual(["sync:schedule:sync:5000"]);
|
|
133
|
+
await queue.unschedule("sync");
|
|
134
|
+
time.advance(20_000);
|
|
135
|
+
expect(await scheduledJobs(sql)).toEqual(["sync:schedule:sync:5000"]);
|
|
136
|
+
queue.close();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("reads cron expressions in the schedule's time zone, UTC by default", () => {
|
|
140
|
+
const after = Date.UTC(2026, 0, 1, 0, 0);
|
|
141
|
+
expect(nextScheduledTime({ cron: "0 3 * * *" }, after)).toBe(Date.UTC(2026, 0, 1, 3, 0));
|
|
142
|
+
expect(nextScheduledTime({ cron: "0 3 * * *", timezone: "Asia/Seoul" }, after)).toBe(Date.UTC(2026, 0, 1, 18, 0));
|
|
143
|
+
expect(nextScheduledTime({ everyMs: 60_000 }, after + 1)).toBe(after + 60_000);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("rejects an invalid schedule before it is kept", async () => {
|
|
147
|
+
sql = new SQL(":memory:");
|
|
148
|
+
const queue = new LocalJobQueue(sql);
|
|
149
|
+
await expect(queue.schedule("bad", "report", {}, { cron: "61 * * * *" })).rejects.toThrow();
|
|
150
|
+
await expect(queue.schedule("bad", "report", {}, { cron: "0 3 * * *", timezone: "Mars/Olympus" })).rejects.toThrow();
|
|
151
|
+
await expect(queue.schedule("bad", "report", {}, { everyMs: 10 })).rejects.toThrow("at least 1000");
|
|
152
|
+
await expect(queue.schedule("bad", "report", {}, { everyMs: 1_000 }, { keepCompleted: -1 })).rejects.toThrow(
|
|
153
|
+
"keepCompleted must be a whole number of at least 0",
|
|
154
|
+
);
|
|
155
|
+
queue.close();
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
/** Moves the clock to each time and runs the job the schedule added then. */
|
|
160
|
+
async function runScheduled(queue: LocalJobQueue, time: ReturnType<typeof manualTime>, times: number[]): Promise<void> {
|
|
161
|
+
for (const at of times) {
|
|
162
|
+
time.advance(at);
|
|
163
|
+
// The schedule adds its job without waiting; let the insert finish.
|
|
164
|
+
await Bun.sleep(1);
|
|
165
|
+
expect(await queue.runOnce()).toBe(true);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function finishedJobs(database: SQL): Promise<string[]> {
|
|
170
|
+
const rows = await database<Array<{ id: string; status: string }>>`
|
|
171
|
+
SELECT "id", "status" FROM "podokit_job" ORDER BY "updatedAt", "id"
|
|
172
|
+
`;
|
|
173
|
+
return rows.map((row) => `${row.id} ${row.status}`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
describe("local job schedule cleanup", () => {
|
|
177
|
+
it("keeps the newest finished jobs of a scheduled run's name up to the schedule's limits", async () => {
|
|
178
|
+
sql = new SQL(":memory:");
|
|
179
|
+
const time = manualTime(0);
|
|
180
|
+
const queue = new LocalJobQueue(sql, { pollIntervalMs: 60_000, now: time.now, timer: time.timer });
|
|
181
|
+
await queue.process("report", (_payload, job) => {
|
|
182
|
+
if (job.id.endsWith(":3000") || job.id.endsWith(":5000")) throw new Error("expected failure");
|
|
183
|
+
});
|
|
184
|
+
await queue.process("other", () => undefined);
|
|
185
|
+
const other = await queue.enqueue("other", {});
|
|
186
|
+
const adHoc = await queue.enqueue("report", {});
|
|
187
|
+
while (await queue.runOnce());
|
|
188
|
+
await queue.schedule("tick", "report", {}, { everyMs: 1_000 }, { keepCompleted: 2, keepFailed: 1 });
|
|
189
|
+
await queue.start();
|
|
190
|
+
await Bun.sleep(5);
|
|
191
|
+
|
|
192
|
+
await runScheduled(queue, time, [1_000, 2_000, 3_000, 4_000, 5_000, 6_000]);
|
|
193
|
+
|
|
194
|
+
// As BullMQ trims a queue: the job enqueue() added goes too, another name's stays.
|
|
195
|
+
expect(await finishedJobs(sql)).toEqual([
|
|
196
|
+
`${other.id} completed`,
|
|
197
|
+
"report:schedule:tick:4000 completed",
|
|
198
|
+
"report:schedule:tick:5000 failed",
|
|
199
|
+
"report:schedule:tick:6000 completed",
|
|
200
|
+
]);
|
|
201
|
+
expect(await queue.get(adHoc.id)).toBeNull();
|
|
202
|
+
queue.close();
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("removes only the run itself when a limit is 0", async () => {
|
|
206
|
+
sql = new SQL(":memory:");
|
|
207
|
+
const time = manualTime(0);
|
|
208
|
+
const queue = new LocalJobQueue(sql, { pollIntervalMs: 60_000, now: time.now, timer: time.timer });
|
|
209
|
+
await queue.process("report", (_payload, job) => {
|
|
210
|
+
if (job.id.endsWith(":2000")) throw new Error("expected failure");
|
|
211
|
+
});
|
|
212
|
+
const adHoc = await queue.enqueue("report", {});
|
|
213
|
+
while (await queue.runOnce());
|
|
214
|
+
await queue.schedule("tick", "report", {}, { everyMs: 1_000 }, { keepCompleted: 0, keepFailed: 0 });
|
|
215
|
+
await queue.start();
|
|
216
|
+
await Bun.sleep(5);
|
|
217
|
+
|
|
218
|
+
await runScheduled(queue, time, [1_000, 2_000, 3_000]);
|
|
219
|
+
|
|
220
|
+
expect(await finishedJobs(sql)).toEqual([`${adHoc.id} completed`]);
|
|
221
|
+
queue.close();
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("keeps 100 completed runs of a schedule by default", async () => {
|
|
225
|
+
sql = new SQL(":memory:");
|
|
226
|
+
const time = manualTime(0);
|
|
227
|
+
const queue = new LocalJobQueue(sql, { pollIntervalMs: 60_000, now: time.now, timer: time.timer });
|
|
228
|
+
await queue.process("report", () => undefined);
|
|
229
|
+
await queue.schedule("tick", "report", {}, { everyMs: 1_000 });
|
|
230
|
+
await queue.start();
|
|
231
|
+
await Bun.sleep(5);
|
|
232
|
+
|
|
233
|
+
await runScheduled(queue, time, Array.from({ length: 105 }, (_, index) => (index + 1) * 1_000));
|
|
234
|
+
|
|
235
|
+
const kept = await finishedJobs(sql);
|
|
236
|
+
expect(kept).toHaveLength(100);
|
|
237
|
+
expect(kept[0]).toBe("report:schedule:tick:6000 completed");
|
|
238
|
+
expect(kept.at(-1)).toBe("report:schedule:tick:105000 completed");
|
|
239
|
+
queue.close();
|
|
240
|
+
});
|
|
241
|
+
});
|
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
import type { SQL } from "bun";
|
|
2
|
+
import cronParser from "cron-parser";
|
|
2
3
|
import {
|
|
4
|
+
assertRepeatOptions,
|
|
3
5
|
type EnqueueOptions,
|
|
4
6
|
type JobHandler,
|
|
5
7
|
type JobQueue,
|
|
6
8
|
type JobReference,
|
|
7
9
|
type JobSnapshot,
|
|
8
10
|
type JobStatus,
|
|
11
|
+
type RepeatOptions,
|
|
12
|
+
type ScheduleOptions,
|
|
13
|
+
type ScheduleRetention,
|
|
14
|
+
scheduleRetention,
|
|
9
15
|
type Unsubscribe,
|
|
10
16
|
} from "@podosoft/podokit-runtime";
|
|
11
17
|
|
|
@@ -22,15 +28,58 @@ interface JobRow {
|
|
|
22
28
|
error: string | null;
|
|
23
29
|
}
|
|
24
30
|
|
|
31
|
+
export interface ScheduleTimer {
|
|
32
|
+
/** Runs `task` once after `delayMs`; the returned function cancels it. */
|
|
33
|
+
after(delayMs: number, task: () => void): () => void;
|
|
34
|
+
}
|
|
35
|
+
|
|
25
36
|
export interface LocalJobQueueOptions {
|
|
26
37
|
pollIntervalMs?: number;
|
|
27
38
|
now?: () => number;
|
|
39
|
+
/** Waits for schedules; an unref'd setTimeout by default. */
|
|
40
|
+
timer?: ScheduleTimer;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface Schedule {
|
|
44
|
+
id: string;
|
|
45
|
+
name: string;
|
|
46
|
+
payload: unknown;
|
|
47
|
+
repeat: RepeatOptions;
|
|
48
|
+
retention: ScheduleRetention;
|
|
49
|
+
cancel?: () => void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
type FinishedStatus = "completed" | "failed";
|
|
53
|
+
|
|
54
|
+
// A scheduled job's deduplication key: this prefix, the schedule id, ":", and the time.
|
|
55
|
+
const SCHEDULE_KEY = "schedule:";
|
|
56
|
+
|
|
57
|
+
// setTimeout fires at once for a delay above 2^31 - 1 ms, so longer waits are split.
|
|
58
|
+
const MAX_TIMER_DELAY_MS = 2 ** 31 - 1;
|
|
59
|
+
|
|
60
|
+
const unrefTimer: ScheduleTimer = {
|
|
61
|
+
after: (delayMs, task) => {
|
|
62
|
+
const timer = setTimeout(task, delayMs);
|
|
63
|
+
timer.unref?.();
|
|
64
|
+
return () => clearTimeout(timer);
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** The first time after `after` (epoch milliseconds) that `repeat` adds a job. */
|
|
69
|
+
export function nextScheduledTime(repeat: RepeatOptions, after: number): number {
|
|
70
|
+
if (repeat.everyMs !== undefined) return (Math.floor(after / repeat.everyMs) + 1) * repeat.everyMs;
|
|
71
|
+
return cronParser
|
|
72
|
+
.parseExpression(repeat.cron ?? "", { currentDate: new Date(after), tz: repeat.timezone ?? "UTC" })
|
|
73
|
+
.next()
|
|
74
|
+
.getTime();
|
|
28
75
|
}
|
|
29
76
|
|
|
30
77
|
export class LocalJobQueue implements JobQueue {
|
|
31
78
|
private readonly handlers = new Map<string, JobHandler>();
|
|
79
|
+
private readonly schedules = new Map<string, Schedule>();
|
|
32
80
|
private readonly pollIntervalMs: number;
|
|
33
81
|
private readonly now: () => number;
|
|
82
|
+
private readonly scheduleTimer: ScheduleTimer;
|
|
34
83
|
private timer?: ReturnType<typeof setTimeout>;
|
|
35
84
|
private started = false;
|
|
36
85
|
private initialized?: Promise<void>;
|
|
@@ -44,6 +93,7 @@ export class LocalJobQueue implements JobQueue {
|
|
|
44
93
|
throw new Error("pollIntervalMs must be an integer of at least 10");
|
|
45
94
|
}
|
|
46
95
|
this.now = options.now ?? Date.now;
|
|
96
|
+
this.scheduleTimer = options.timer ?? unrefTimer;
|
|
47
97
|
}
|
|
48
98
|
|
|
49
99
|
initialize(): Promise<void> {
|
|
@@ -63,7 +113,8 @@ export class LocalJobQueue implements JobQueue {
|
|
|
63
113
|
if (this.started) return;
|
|
64
114
|
await this.initialize();
|
|
65
115
|
this.started = true;
|
|
66
|
-
this.
|
|
116
|
+
this.poll(0);
|
|
117
|
+
for (const schedule of this.schedules.values()) this.arm(schedule);
|
|
67
118
|
}
|
|
68
119
|
|
|
69
120
|
async enqueue(
|
|
@@ -118,6 +169,29 @@ export class LocalJobQueue implements JobQueue {
|
|
|
118
169
|
});
|
|
119
170
|
}
|
|
120
171
|
|
|
172
|
+
async schedule(
|
|
173
|
+
id: string,
|
|
174
|
+
name: string,
|
|
175
|
+
payload: unknown,
|
|
176
|
+
repeat: RepeatOptions,
|
|
177
|
+
options?: ScheduleOptions,
|
|
178
|
+
): Promise<void> {
|
|
179
|
+
assertRepeatOptions(repeat);
|
|
180
|
+
const retention = scheduleRetention(options);
|
|
181
|
+
// Rejects an invalid cron expression or time zone now rather than at the first run.
|
|
182
|
+
nextScheduledTime(repeat, this.now());
|
|
183
|
+
if (JSON.stringify(payload) === undefined) throw new Error("Job payload must be JSON serializable");
|
|
184
|
+
this.schedules.get(id)?.cancel?.();
|
|
185
|
+
const schedule: Schedule = { id, name, payload, repeat, retention };
|
|
186
|
+
this.schedules.set(id, schedule);
|
|
187
|
+
if (this.started) this.arm(schedule);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async unschedule(id: string): Promise<void> {
|
|
191
|
+
this.schedules.get(id)?.cancel?.();
|
|
192
|
+
this.schedules.delete(id);
|
|
193
|
+
}
|
|
194
|
+
|
|
121
195
|
async runOnce(): Promise<boolean> {
|
|
122
196
|
await this.initialize();
|
|
123
197
|
const now = this.now();
|
|
@@ -147,6 +221,7 @@ export class LocalJobQueue implements JobQueue {
|
|
|
147
221
|
SET "status" = 'completed', "updatedAt" = ${completedAt}, "error" = NULL
|
|
148
222
|
WHERE "id" = ${row.id}
|
|
149
223
|
`;
|
|
224
|
+
await this.removeFinished(row, "completed");
|
|
150
225
|
} catch (error) {
|
|
151
226
|
await this.fail(row, error);
|
|
152
227
|
}
|
|
@@ -157,9 +232,41 @@ export class LocalJobQueue implements JobQueue {
|
|
|
157
232
|
this.started = false;
|
|
158
233
|
if (this.timer) clearTimeout(this.timer);
|
|
159
234
|
this.timer = undefined;
|
|
235
|
+
for (const schedule of this.schedules.values()) schedule.cancel?.();
|
|
160
236
|
this.handlers.clear();
|
|
161
237
|
}
|
|
162
238
|
|
|
239
|
+
/**
|
|
240
|
+
* Adds the schedule's next job when its time comes. The job id names the
|
|
241
|
+
* schedule and the time, so a job is added once for each time.
|
|
242
|
+
*/
|
|
243
|
+
private arm(schedule: Schedule, after = this.now()): void {
|
|
244
|
+
const at = nextScheduledTime(schedule.repeat, after);
|
|
245
|
+
schedule.cancel = this.waitUntil(at, () => {
|
|
246
|
+
if (!this.started || this.schedules.get(schedule.id) !== schedule) return;
|
|
247
|
+
void this.enqueue(schedule.name, schedule.payload, { deduplicationKey: `${SCHEDULE_KEY}${schedule.id}:${at}` })
|
|
248
|
+
.catch((error: unknown) => {
|
|
249
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
250
|
+
process.stderr.write(`Add scheduled job failed: ${schedule.id} ${message}\n`);
|
|
251
|
+
});
|
|
252
|
+
this.arm(schedule, Math.max(this.now(), at));
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
private waitUntil(at: number, task: () => void): () => void {
|
|
257
|
+
let cancel = (): void => undefined;
|
|
258
|
+
const step = (): void => {
|
|
259
|
+
const delay = at - this.now();
|
|
260
|
+
if (delay <= 0) {
|
|
261
|
+
task();
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
cancel = this.scheduleTimer.after(Math.min(delay, MAX_TIMER_DELAY_MS), step);
|
|
265
|
+
};
|
|
266
|
+
cancel = this.scheduleTimer.after(Math.max(0, Math.min(at - this.now(), MAX_TIMER_DELAY_MS)), step);
|
|
267
|
+
return () => cancel();
|
|
268
|
+
}
|
|
269
|
+
|
|
163
270
|
private async createTables(): Promise<void> {
|
|
164
271
|
await this.sql`
|
|
165
272
|
CREATE TABLE IF NOT EXISTS "podokit_job" (
|
|
@@ -179,9 +286,48 @@ export class LocalJobQueue implements JobQueue {
|
|
|
179
286
|
CREATE INDEX IF NOT EXISTS "podokit_job_pending"
|
|
180
287
|
ON "podokit_job" ("status", "availableAt", "createdAt")
|
|
181
288
|
`;
|
|
289
|
+
await this.sql`
|
|
290
|
+
CREATE INDEX IF NOT EXISTS "podokit_job_finished"
|
|
291
|
+
ON "podokit_job" ("name", "status", "updatedAt")
|
|
292
|
+
`;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** The schedule whose job id `row` has, while that schedule is kept. */
|
|
296
|
+
private scheduleOf(row: JobRow): Schedule | undefined {
|
|
297
|
+
for (const schedule of this.schedules.values()) {
|
|
298
|
+
const prefix = `${schedule.name}:${SCHEDULE_KEY}${schedule.id}:`;
|
|
299
|
+
if (schedule.name === row.name && row.id.startsWith(prefix) && /^\d+$/.test(row.id.slice(prefix.length))) {
|
|
300
|
+
return schedule;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return undefined;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* After a scheduled run finishes, keeps the newest finished jobs of its name
|
|
308
|
+
* up to the schedule's limit, as BullMQ trims a queue, or removes only the
|
|
309
|
+
* run when the limit is 0.
|
|
310
|
+
*/
|
|
311
|
+
private async removeFinished(row: JobRow, status: FinishedStatus): Promise<void> {
|
|
312
|
+
const schedule = this.scheduleOf(row);
|
|
313
|
+
if (!schedule) return;
|
|
314
|
+
const keep = status === "completed" ? schedule.retention.keepCompleted : schedule.retention.keepFailed;
|
|
315
|
+
if (keep === 0) {
|
|
316
|
+
await this.sql`DELETE FROM "podokit_job" WHERE "id" = ${row.id}`;
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
await this.sql`
|
|
320
|
+
DELETE FROM "podokit_job"
|
|
321
|
+
WHERE "name" = ${row.name} AND "status" = ${status} AND "id" NOT IN (
|
|
322
|
+
SELECT "id" FROM "podokit_job"
|
|
323
|
+
WHERE "name" = ${row.name} AND "status" = ${status}
|
|
324
|
+
ORDER BY "updatedAt" DESC, "id" DESC
|
|
325
|
+
LIMIT ${keep}
|
|
326
|
+
)
|
|
327
|
+
`;
|
|
182
328
|
}
|
|
183
329
|
|
|
184
|
-
private
|
|
330
|
+
private poll(delay: number): void {
|
|
185
331
|
if (!this.started) return;
|
|
186
332
|
this.timer = setTimeout(() => {
|
|
187
333
|
void this.runOnce()
|
|
@@ -189,7 +335,7 @@ export class LocalJobQueue implements JobQueue {
|
|
|
189
335
|
const message = error instanceof Error ? error.message : String(error);
|
|
190
336
|
process.stderr.write(`Process local job failed: ${message}\n`);
|
|
191
337
|
})
|
|
192
|
-
.finally(() => this.
|
|
338
|
+
.finally(() => this.poll(this.pollIntervalMs));
|
|
193
339
|
}, delay);
|
|
194
340
|
this.timer.unref?.();
|
|
195
341
|
}
|
|
@@ -206,6 +352,7 @@ export class LocalJobQueue implements JobQueue {
|
|
|
206
352
|
"error" = ${message.slice(0, 2_000)}
|
|
207
353
|
WHERE "id" = ${row.id}
|
|
208
354
|
`;
|
|
355
|
+
if (!retry) await this.removeFinished(row, "failed");
|
|
209
356
|
}
|
|
210
357
|
|
|
211
358
|
private snapshot(row: JobRow): JobSnapshot {
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { validateEnv } from "../config/env.validation";
|
|
3
|
+
import { loggingOptions } from "./logging.module";
|
|
4
|
+
|
|
5
|
+
describe("logging options", () => {
|
|
6
|
+
test("writes JSON without the development transport in production", () => {
|
|
7
|
+
expect(loggingOptions(validateEnv({ NODE_ENV: "production" })).transport).toBeUndefined();
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test("pretty-prints single lines in development", () => {
|
|
11
|
+
expect(loggingOptions(validateEnv({})).transport).toEqual({
|
|
12
|
+
target: "pino-pretty",
|
|
13
|
+
options: { singleLine: true },
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
});
|
|
@@ -1,16 +1,21 @@
|
|
|
1
|
-
import pino from "pino";
|
|
1
|
+
import pino, { type LoggerOptions } from "pino";
|
|
2
|
+
import type { AppEnv } from "../config/env.validation";
|
|
2
3
|
import { LOGGER, type PodokitModule } from "../core/services";
|
|
3
4
|
|
|
5
|
+
/** Pretty single-line logs in development, JSON in production. */
|
|
6
|
+
export function loggingOptions(env: Pick<AppEnv, "NODE_ENV">): LoggerOptions {
|
|
7
|
+
return {
|
|
8
|
+
level: process.env.LOG_LEVEL ?? "info",
|
|
9
|
+
// pino-pretty is a development dependency, so production must not ask for it.
|
|
10
|
+
transport: env.NODE_ENV === "production"
|
|
11
|
+
? undefined
|
|
12
|
+
: { target: "pino-pretty", options: { singleLine: true } },
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
4
16
|
export const loggingModule: PodokitModule = {
|
|
5
17
|
name: "logging",
|
|
6
|
-
configure: (
|
|
7
|
-
|
|
8
|
-
const logger = pino({
|
|
9
|
-
level: process.env.LOG_LEVEL ?? "info",
|
|
10
|
-
transport: isProduction
|
|
11
|
-
? undefined
|
|
12
|
-
: { target: "pino-pretty", options: { singleLine: true } },
|
|
13
|
-
});
|
|
14
|
-
services.override(LOGGER, logger);
|
|
18
|
+
configure: (env, services): void => {
|
|
19
|
+
services.override(LOGGER, pino(loggingOptions(env)));
|
|
15
20
|
},
|
|
16
21
|
};
|
package/dist/templates/modules/object-storage-local/files/tests/api/storage-local.api.spec.ts
CHANGED
|
@@ -1,13 +1,8 @@
|
|
|
1
1
|
import { expect, test, type APIRequestContext, type PlaywrightWorkerArgs } from "@playwright/test";
|
|
2
|
+
import { seededAdmin } from "../helpers/seeded-admin";
|
|
2
3
|
|
|
3
4
|
const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
|
|
4
5
|
const origin = { origin: base };
|
|
5
|
-
// The administrator seeded by the admin-dashboard Playwright setup. Inlined so this
|
|
6
|
-
// spec also compiles in apps that do not install that module.
|
|
7
|
-
const admin = {
|
|
8
|
-
email: "admin@example.com",
|
|
9
|
-
password: process.env.E2E_ADMIN_PASSWORD || "Podokit3e-Str0ng!pw",
|
|
10
|
-
};
|
|
11
6
|
|
|
12
7
|
type Playwright = PlaywrightWorkerArgs["playwright"];
|
|
13
8
|
|
|
@@ -36,6 +31,8 @@ async function signUp(ctx: APIRequestContext): Promise<boolean> {
|
|
|
36
31
|
|
|
37
32
|
/** Resolves false when no administrator session is available. */
|
|
38
33
|
async function signInAdmin(ctx: APIRequestContext): Promise<boolean> {
|
|
34
|
+
const admin = await seededAdmin();
|
|
35
|
+
if (!admin) return false;
|
|
39
36
|
const response = await ctx.post("/api/auth/sign-in/email", { data: admin });
|
|
40
37
|
return response.ok() && ((await sessionRoles(ctx)) ?? []).includes("admin");
|
|
41
38
|
}
|
|
@@ -24,12 +24,13 @@ services:
|
|
|
24
24
|
profiles: ["dev"]
|
|
25
25
|
depends_on:
|
|
26
26
|
- minio
|
|
27
|
+
# -e stops at the first failing command, so a bucket that cannot be
|
|
28
|
+
# created fails this service instead of leaving uploads to fail later.
|
|
27
29
|
entrypoint: >
|
|
28
|
-
/bin/sh -
|
|
30
|
+
/bin/sh -ec "
|
|
29
31
|
until mc alias set local http://minio:9000 ${S3_ACCESS_KEY_ID:-podokit} ${S3_SECRET_ACCESS_KEY:-podokitsecret}; do echo 'waiting for minio'; sleep 1; done;
|
|
30
|
-
mc mb -
|
|
31
|
-
echo 'bucket ready'
|
|
32
|
-
exit 0"
|
|
32
|
+
mc mb --ignore-existing local/${S3_BUCKET:-podokit};
|
|
33
|
+
echo 'bucket ready'"
|
|
33
34
|
|
|
34
35
|
volumes:
|
|
35
36
|
minio-data:
|
|
@@ -1,13 +1,8 @@
|
|
|
1
1
|
import { expect, test, type APIRequestContext, type PlaywrightWorkerArgs } from "@playwright/test";
|
|
2
|
+
import { seededAdmin } from "../helpers/seeded-admin";
|
|
2
3
|
|
|
3
4
|
const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
|
|
4
5
|
const origin = { origin: base };
|
|
5
|
-
// The administrator seeded by the admin-dashboard Playwright setup. Inlined so this
|
|
6
|
-
// spec also compiles in apps that do not install that module.
|
|
7
|
-
const admin = {
|
|
8
|
-
email: "admin@example.com",
|
|
9
|
-
password: process.env.E2E_ADMIN_PASSWORD || "Podokit3e-Str0ng!pw",
|
|
10
|
-
};
|
|
11
6
|
|
|
12
7
|
type Playwright = PlaywrightWorkerArgs["playwright"];
|
|
13
8
|
|
|
@@ -36,6 +31,8 @@ async function signUp(ctx: APIRequestContext): Promise<boolean> {
|
|
|
36
31
|
|
|
37
32
|
/** Resolves false when no administrator session is available. */
|
|
38
33
|
async function signInAdmin(ctx: APIRequestContext): Promise<boolean> {
|
|
34
|
+
const admin = await seededAdmin();
|
|
35
|
+
if (!admin) return false;
|
|
39
36
|
const response = await ctx.post("/api/auth/sign-in/email", { data: admin });
|
|
40
37
|
return response.ok() && ((await sessionRoles(ctx)) ?? []).includes("admin");
|
|
41
38
|
}
|
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
expect,
|
|
3
|
+
test,
|
|
4
|
+
type APIRequestContext,
|
|
5
|
+
type APIResponse,
|
|
6
|
+
type PlaywrightWorkerArgs,
|
|
7
|
+
} from "@playwright/test";
|
|
2
8
|
|
|
3
9
|
const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
|
|
4
10
|
const origin = { origin: base };
|
|
@@ -19,20 +25,42 @@ const limit = Number(process.env.RATE_LIMIT_MAX ?? 300);
|
|
|
19
25
|
const authLimit = Number(process.env.RATE_LIMIT_AUTH_MAX ?? 20);
|
|
20
26
|
const runtimeLimit = Number(process.env.RATE_LIMIT_RUNTIME_MAX ?? 1000);
|
|
21
27
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
28
|
+
type Playwright = PlaywrightWorkerArgs["playwright"];
|
|
29
|
+
const password = "Podokit3e-Str0ng!pw";
|
|
30
|
+
|
|
31
|
+
function uniqueEmail(): string {
|
|
32
|
+
return `rate-limit-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@example.com`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Sign-ups and sign-ins are authentication writes. From an anonymous client the
|
|
36
|
+
// limiter counts them against the per-address budget that the setup project's
|
|
37
|
+
// and every other spec's sign-ins share. Only this worker's first one is
|
|
38
|
+
// anonymous: the later ones carry its session cookie, so the limiter counts them
|
|
39
|
+
// against that session instead.
|
|
40
|
+
let carrier: string | undefined;
|
|
41
|
+
|
|
42
|
+
/** Run one authentication write and return a context holding the session it set. */
|
|
43
|
+
async function authenticated(
|
|
44
|
+
playwright: Playwright,
|
|
45
|
+
path: "/api/auth/sign-up/email" | "/api/auth/sign-in/email",
|
|
46
|
+
data: Record<string, string>,
|
|
47
|
+
): Promise<APIRequestContext> {
|
|
48
|
+
const writer = await playwright.request.newContext({
|
|
49
|
+
baseURL: base,
|
|
50
|
+
extraHTTPHeaders: { ...origin, ...(carrier ? { cookie: carrier } : {}) },
|
|
33
51
|
});
|
|
34
|
-
|
|
35
|
-
|
|
52
|
+
const response = await writer.post(path, { data });
|
|
53
|
+
expect(response.ok(), `${path}: ${response.status()}`).toBeTruthy();
|
|
54
|
+
const storageState = await writer.storageState();
|
|
55
|
+
await writer.dispose();
|
|
56
|
+
const token = storageState.cookies.find((cookie) => cookie.name.endsWith("session_token"));
|
|
57
|
+
carrier ??= token ? `${token.name}=${token.value}` : undefined;
|
|
58
|
+
return playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin, storageState });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A new account, signed in. */
|
|
62
|
+
async function session(playwright: Playwright, email = uniqueEmail()): Promise<APIRequestContext> {
|
|
63
|
+
return authenticated(playwright, "/api/auth/sign-up/email", { email, password, name: "Rate Limit" });
|
|
36
64
|
}
|
|
37
65
|
|
|
38
66
|
test("rate limit: health probes stay available while ordinary routes return 429 @smoke", async ({
|
|
@@ -73,10 +101,8 @@ test("rate limit: health probes stay available while ordinary routes return 429
|
|
|
73
101
|
test("rate limit: a user's sessions share one counter and forged sessions do not get one", async ({
|
|
74
102
|
playwright,
|
|
75
103
|
}) => {
|
|
76
|
-
const email =
|
|
77
|
-
const
|
|
78
|
-
const first = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
79
|
-
expect((await first.post("/api/auth/sign-up/email", { data: { email, password, name: "Rate Limit" } })).ok()).toBeTruthy();
|
|
104
|
+
const email = uniqueEmail();
|
|
105
|
+
const first = await session(playwright, email);
|
|
80
106
|
const probe = await first.get("/api/cache/rate-limit-probe");
|
|
81
107
|
test.skip(probe.status() >= 500, "throttler storage (redis) not reachable");
|
|
82
108
|
const remaining = (response: APIResponse): number => Number(response.headers()["ratelimit-remaining"]);
|
|
@@ -86,8 +112,7 @@ test("rate limit: a user's sessions share one counter and forged sessions do not
|
|
|
86
112
|
// session. The probe requires a session, so each request below resolves it; a
|
|
87
113
|
// session counts under its user from the request after the API first resolves
|
|
88
114
|
// it (or from that request when authentication runs before the limiter).
|
|
89
|
-
const second = await playwright
|
|
90
|
-
expect((await second.post("/api/auth/sign-in/email", { data: { email, password } })).ok()).toBeTruthy();
|
|
115
|
+
const second = await authenticated(playwright, "/api/auth/sign-in/email", { email, password });
|
|
91
116
|
const cookie = (await first.storageState()).cookies.find((c) => c.name.endsWith("session_token"));
|
|
92
117
|
expect(cookie, "sign-up set a session cookie").toBeTruthy();
|
|
93
118
|
const bearer = await playwright.request.newContext({
|