@podosoft/podokit 1.6.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.
Files changed (29) hide show
  1. package/dist/deploy-checks.js +3 -3
  2. package/dist/deploy-schema.d.ts +7 -3
  3. package/dist/deploy-schema.js +16 -5
  4. package/dist/templates/fullstack/AGENTS.md +3 -1
  5. package/dist/templates/fullstack/apps/api/package.json +1 -1
  6. package/dist/templates/fullstack/apps/api/scripts/verify-api-contract.ts +2 -1
  7. package/dist/templates/fullstack/apps/api/src/build.spec.ts +5 -2
  8. package/dist/templates/fullstack/apps/api/src/core/api-contract.spec.ts +43 -4
  9. package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +39 -4
  10. package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +2 -1
  11. package/dist/templates/fullstack/tests/README.md +20 -10
  12. package/dist/templates/fullstack/tests/helpers/seeded-admin.ts +15 -0
  13. package/dist/templates/fullstack/tests/package.json +3 -3
  14. package/dist/templates/fullstack/tests/playwright.config.ts +8 -6
  15. package/dist/templates/fullstack/tests/playwright.extensions.ts +73 -12
  16. package/dist/templates/fullstack/tests/run-e2e.ts +28 -0
  17. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/nav.test.ts +17 -3
  18. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/theme-toggle.svelte +1 -1
  19. package/dist/templates/modules/admin-dashboard/files/tests/helpers/hydration.ts +13 -4
  20. package/dist/templates/modules/admin-dashboard/files/tests/playwright.config.ts +8 -6
  21. package/dist/templates/modules/admin-dashboard/files/tests/ui/theme-mode.ui.spec.ts +16 -10
  22. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.spec.ts +50 -0
  23. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.ts +23 -3
  24. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.spec.ts +87 -0
  25. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.ts +60 -3
  26. package/dist/templates/modules/object-storage-local/files/tests/api/storage-local.api.spec.ts +3 -6
  27. package/dist/templates/modules/object-storage-s3/files/tests/api/storage.api.spec.ts +3 -6
  28. package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +3 -6
  29. package/package.json +2 -2
@@ -123,9 +123,9 @@ function connectionBudget(profile, modules) {
123
123
  perProcessTerms.push(`DB_AUTH_POOL_MAX ${authPool}`);
124
124
  perProcess += authPool;
125
125
  }
126
- for (const pool of postgres.appPools ?? []) {
127
- const size = poolSize(config, pool.sizeKey, 0);
128
- perProcessTerms.push(`${pool.sizeKey} ${size}${pool.extraConnections ? ` + ${pool.extraConnections}` : ""}`);
126
+ for (const [index, pool] of (postgres.appPools ?? []).entries()) {
127
+ const [label, size] = "sizeKey" in pool ? [pool.sizeKey, poolSize(config, pool.sizeKey, 0)] : [`appPools[${index}]`, pool.size];
128
+ perProcessTerms.push(`${label} ${size}${pool.extraConnections ? ` + ${pool.extraConnections}` : ""}`);
129
129
  perProcess += size + pool.extraConnections;
130
130
  }
131
131
  const worker = profile.workloads.worker;
@@ -70,14 +70,18 @@ export declare function parseReleaseProfile(release: Record<string, unknown>): R
70
70
  export declare function parseRuntimeConfig(runtimeConfig: Record<string, unknown>): Record<string, string>;
71
71
  /**
72
72
  * An application-owned PostgreSQL pool each API or worker process opens beside
73
- * PodoKit's own: `sizeKey` names the runtimeConfig key that caps it, and
73
+ * PodoKit's own. Either `sizeKey` names the runtimeConfig key that caps it, or
74
+ * `size` is the cap the application's code sets, such as a driver's default.
74
75
  * `extraConnections` counts connections it opens beyond that cap, such as one to
75
76
  * check a server that stopped answering.
76
77
  */
77
- export interface AppConnectionPool {
78
+ export type AppConnectionPool = {
78
79
  sizeKey: string;
79
80
  extraConnections: number;
80
- }
81
+ } | {
82
+ size: number;
83
+ extraConnections: number;
84
+ };
81
85
  /** What the connection budget needs to know about the database, beyond its mode. */
82
86
  export interface PostgresBudgetProfile {
83
87
  /** The server's max_connections; PodoKit sets it on a database it runs. */
@@ -270,7 +270,22 @@ function parsePostgresBudget(value, field, runtimeConfig) {
270
270
  const entryField = `${field}.appPools[${index}]`;
271
271
  if (!isRecord(entry))
272
272
  throw new Error(`Deployment profile field "${entryField}" must be an object.`);
273
- assertOnlyKeys(entry, ["sizeKey", "extraConnections"], entryField);
273
+ assertOnlyKeys(entry, ["sizeKey", "size", "extraConnections"], entryField);
274
+ if ((entry.sizeKey === undefined) === (entry.size === undefined)) {
275
+ throw new Error(`Deployment profile field "${entryField}" must set either sizeKey, the runtimeConfig key that caps the pool, ` +
276
+ "or size, the cap the application sets itself.");
277
+ }
278
+ const extraConnections = entry.extraConnections ?? 0;
279
+ if (typeof extraConnections !== "number" || !Number.isInteger(extraConnections) || extraConnections < 0) {
280
+ throw new Error(`Deployment profile field "${entryField}.extraConnections" must be a non-negative integer.`);
281
+ }
282
+ if (entry.size !== undefined) {
283
+ const size = entry.size;
284
+ if (typeof size !== "number" || !Number.isInteger(size) || size < 1 || size > MAX_POSTGRES_CONNECTIONS) {
285
+ throw new Error(`Deployment profile field "${entryField}.size" must be an integer from 1 to ${MAX_POSTGRES_CONNECTIONS}.`);
286
+ }
287
+ return { size, extraConnections };
288
+ }
274
289
  const sizeKey = requiredString(entry, "sizeKey");
275
290
  if (!/^[A-Z][A-Z0-9_]*$/.test(sizeKey) || PODOKIT_POOL_KEYS.includes(sizeKey) || seen.has(sizeKey)) {
276
291
  throw new Error(`Deployment profile field "${entryField}.sizeKey" must name another pool's runtimeConfig key, once; ` +
@@ -280,10 +295,6 @@ function parsePostgresBudget(value, field, runtimeConfig) {
280
295
  if (!/^[1-9][0-9]*$/.test(runtimeConfig[sizeKey] ?? "")) {
281
296
  throw new Error(`Deployment profile field "${entryField}.sizeKey" names ${sizeKey}, which runtimeConfig must set to a positive integer.`);
282
297
  }
283
- const extraConnections = entry.extraConnections ?? 0;
284
- if (typeof extraConnections !== "number" || !Number.isInteger(extraConnections) || extraConnections < 0) {
285
- throw new Error(`Deployment profile field "${entryField}.extraConnections" must be a non-negative integer.`);
286
- }
287
298
  return { sizeKey, extraConnections };
288
299
  });
289
300
  }
@@ -105,7 +105,9 @@ Merge added module settings from `.env.example` into an existing `.env`.
105
105
  - e2e (Playwright, ships in `tests/`): `{{rootRun}} test:e2e`. Run the whole suite on more
106
106
  workers with `E2E_WORKERS=2`, never `--workers`. Tag a test `{ tag: "@exclusive" }` when
107
107
  it changes state other tests rely on (a server-wide setting, a seeded account's profile);
108
- tagged tests run alone after the rest. Give each test its own mail and SMS recipients and
108
+ tagged tests run alone after the rest, in a second Playwright run that `test:e2e` starts
109
+ even when the first one failed, so run the suite through `test:e2e`, not
110
+ `bunx playwright test`, on more workers. Give each test its own mail and SMS recipients and
109
111
  never empty the shared Mailpit inbox or SMS sink. See `tests/README.md`. The managed specs
110
112
  read the app's first screen, public pages, and seeded accounts from the owned
111
113
  `tests/helpers/app-paths.ts` (and `app-accounts.ts` with admin-dashboard): change those
@@ -18,7 +18,7 @@
18
18
  },
19
19
  "dependencies": {
20
20
  "@podosoft/podokit-contracts": "^0.6.0",
21
- "@podosoft/podokit-runtime": "^0.5.0",
21
+ "@podosoft/podokit-runtime": "^0.5.1",
22
22
  "@elysia/cors": "^1.4.2",
23
23
  "@elysia/openapi": "^1.4.15",
24
24
  "dotenv": "^16.4.5",
@@ -2,6 +2,7 @@ import { readFileSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
3
  import { createApp } from "../src/app";
4
4
  import { validateEnv } from "../src/config/env.validation";
5
+ import { PROVIDERS } from "../src/config/providers";
5
6
  import {
6
7
  assertApiContract,
7
8
  assertApplicationRoutes,
@@ -30,7 +31,7 @@ try {
30
31
  }
31
32
  const document: unknown = await response.json();
32
33
  const modules = manifest.modules.map((module) => module.name);
33
- assertApiContract(document, manifest.template, modules);
34
+ assertApiContract(document, manifest.template, modules, PROVIDERS);
34
35
  assertApplicationRoutes(document, app.routes, APP_DOCUMENTED_ROUTES);
35
36
  process.stdout.write(
36
37
  `Verified ${documentedApiRoutes(document).size} documented API operations for ${manifest.template}, ` +
@@ -4,14 +4,17 @@ import { tmpdir } from "node:os";
4
4
  import { dirname, join } from "node:path";
5
5
 
6
6
  const BUILD = join(__dirname, "../scripts/build.mjs");
7
- const EXTENSIONS = join(__dirname, "../scripts/build.extensions.mjs");
7
+ // The application's own build.extensions.mjs names entry points this temporary
8
+ // layout does not contain, so each build starts without application targets. A
9
+ // test that needs some passes its own scripts/build.extensions.mjs.
10
+ const NO_APP_TARGETS = "export const appBuildTargets = [];\n";
8
11
 
9
12
  /** Builds an API layout made of `files` with the app's build script. */
10
13
  async function build(files: Record<string, string>): Promise<{ root: string; code: number; stderr: string }> {
11
14
  const root = mkdtempSync(join(tmpdir(), "api-build-"));
12
15
  mkdirSync(join(root, "scripts"));
13
16
  cpSync(BUILD, join(root, "scripts/build.mjs"));
14
- cpSync(EXTENSIONS, join(root, "scripts/build.extensions.mjs"));
17
+ writeFileSync(join(root, "scripts/build.extensions.mjs"), NO_APP_TARGETS);
15
18
  for (const [path, content] of Object.entries(files)) {
16
19
  mkdirSync(dirname(join(root, path)), { recursive: true });
17
20
  writeFileSync(join(root, path), content);
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, it } from "bun:test";
2
+ import { LOCAL_PROVIDER_SELECTIONS, SERVER_PROVIDER_SELECTIONS } from "@podosoft/podokit-runtime";
2
3
  import {
3
4
  assertApiContract,
4
5
  assertApplicationRoutes,
@@ -12,7 +13,7 @@ import {
12
13
 
13
14
  describe("API contract inventory", () => {
14
15
  it("covers the core, Todo, module, and Better Auth routes", () => {
15
- const expected = expectedApiRoutes("todo", ["auth", "redis", "file-upload"]);
16
+ const expected = expectedApiRoutes("todo", ["auth", "redis", "file-upload"], SERVER_PROVIDER_SELECTIONS);
16
17
 
17
18
  expect(expected).toContain("GET /health/ready");
18
19
  expect(expected).toContain("PATCH /todos/{id}");
@@ -23,7 +24,7 @@ describe("API contract inventory", () => {
23
24
  });
24
25
 
25
26
  it("uses one structural parameter name for every public blog post route", () => {
26
- const expected = expectedApiRoutes("fullstack", ["blog"]);
27
+ const expected = expectedApiRoutes("fullstack", ["blog"], SERVER_PROVIDER_SELECTIONS);
27
28
 
28
29
  expect(expected).toContain("GET /blog/{postRef}");
29
30
  expect(expected).toContain("PATCH /blog/{postRef}");
@@ -32,15 +33,53 @@ describe("API contract inventory", () => {
32
33
  expect(expected).not.toContain("PATCH /blog/{id}");
33
34
  });
34
35
 
36
+ it("expects a provider module's routes only while its provider is selected", () => {
37
+ const modules = [
38
+ "object-storage-s3",
39
+ "object-storage-local",
40
+ "redis",
41
+ "cache-memory",
42
+ "bullmq",
43
+ "jobs-local",
44
+ ];
45
+ const server = expectedApiRoutes("fullstack", modules, SERVER_PROVIDER_SELECTIONS);
46
+ const local = expectedApiRoutes("fullstack", modules, LOCAL_PROVIDER_SELECTIONS);
47
+
48
+ expect(server).toContain("PUT /storage/{key}");
49
+ expect(server).not.toContain("GET /files/content/*");
50
+ expect(server).toContain("PUT /cache/{key}");
51
+ expect(server).toContain("POST /jobs");
52
+ expect(local).toContain("PUT /storage/{key}");
53
+ expect(local).toContain("GET /files/content/*");
54
+ expect(local).not.toContain("PUT /cache/{key}");
55
+ expect(local).not.toContain("GET /cache/{key}");
56
+ expect(local).toContain("GET /jobs/{id}");
57
+
58
+ const s3Document = {
59
+ paths: {
60
+ "/health": { get: {} },
61
+ "/health/ready": { get: {} },
62
+ "/storage/{key}": { get: {}, put: {} },
63
+ "/storage/{key}/presigned": { get: {} },
64
+ },
65
+ };
66
+ const storage = ["object-storage-s3", "object-storage-local"];
67
+ expect(() => assertApiContract(s3Document, "fullstack", storage, SERVER_PROVIDER_SELECTIONS)).not.toThrow();
68
+ expect(() => assertApiContract(s3Document, "fullstack", storage, {
69
+ ...SERVER_PROVIDER_SELECTIONS,
70
+ "object-storage": "local",
71
+ })).toThrow("GET /files/content/*");
72
+ });
73
+
35
74
  it("normalizes OpenAPI operations and reports an exact missing route", () => {
36
75
  const document = { paths: { "/health": { get: {} }, "/health/ready": { get: {} } } };
37
76
 
38
77
  expect(documentedApiRoutes(document)).toEqual(new Set(["GET /health", "GET /health/ready"]));
39
- expect(missingApiRoutes(document, "fullstack", ["redis"])).toEqual([
78
+ expect(missingApiRoutes(document, "fullstack", ["redis"], SERVER_PROVIDER_SELECTIONS)).toEqual([
40
79
  "GET /cache/{key}",
41
80
  "PUT /cache/{key}",
42
81
  ]);
43
- expect(() => assertApiContract(document, "fullstack", ["redis"])).toThrow(
82
+ expect(() => assertApiContract(document, "fullstack", ["redis"], SERVER_PROVIDER_SELECTIONS)).toThrow(
44
83
  "OpenAPI contract is missing routes",
45
84
  );
46
85
  });
@@ -1,3 +1,5 @@
1
+ import type { ProviderCapability, ProviderSelections } from "@podosoft/podokit-runtime";
2
+
1
3
  const OPERATIONS = new Set(["get", "post", "put", "patch", "delete", "options", "head"]);
2
4
 
3
5
  const CORE_ROUTES = [
@@ -81,6 +83,7 @@ const MODULE_ROUTES: Record<string, string[]> = {
81
83
  "DELETE /files/uploads/{id}",
82
84
  ],
83
85
  "job-progress": ["POST /progress"],
86
+ "jobs-local": ["POST /jobs", "GET /jobs/{id}"],
84
87
  "object-storage-local": [
85
88
  "GET /files/content/*",
86
89
  "PUT /storage/{key}",
@@ -96,6 +99,22 @@ const MODULE_ROUTES: Record<string, string[]> = {
96
99
  sse: ["GET /events/stream", "POST /events"],
97
100
  };
98
101
 
102
+ /**
103
+ * The provider each provider module implements. Such a module registers its
104
+ * routes only while its provider is selected, so an app may keep, say, both
105
+ * object-storage modules installed and serve the routes of one.
106
+ */
107
+ export const MODULE_PROVIDERS: Readonly<Record<string, readonly [ProviderCapability, string]>> = {
108
+ bullmq: ["jobs", "bullmq"],
109
+ "cache-memory": ["cache", "memory"],
110
+ "events-memory": ["events", "memory"],
111
+ "events-redis": ["events", "redis"],
112
+ "jobs-local": ["jobs", "local"],
113
+ "object-storage-local": ["object-storage", "local"],
114
+ "object-storage-s3": ["object-storage", "s3"],
115
+ redis: ["cache", "redis"],
116
+ };
117
+
99
118
  interface RuntimeRoute {
100
119
  method?: string;
101
120
  path: string;
@@ -195,10 +214,20 @@ export function documentedApiRoutes(document: unknown): Set<string> {
195
214
  return routes;
196
215
  }
197
216
 
198
- export function expectedApiRoutes(template: string, modules: string[]): Set<string> {
217
+ /**
218
+ * The routes the template and the installed modules serve. A provider module
219
+ * counts only when `providers`, the app's PROVIDERS, selects its provider.
220
+ */
221
+ export function expectedApiRoutes(
222
+ template: string,
223
+ modules: string[],
224
+ providers: Readonly<ProviderSelections>,
225
+ ): Set<string> {
199
226
  const routes = new Set(CORE_ROUTES);
200
227
  for (const route of TEMPLATE_ROUTES[template] ?? []) routes.add(route);
201
228
  for (const module of modules) {
229
+ const provider = MODULE_PROVIDERS[module];
230
+ if (provider && providers[provider[0]] !== provider[1]) continue;
202
231
  for (const route of MODULE_ROUTES[module] ?? []) routes.add(route);
203
232
  }
204
233
  return routes;
@@ -208,15 +237,21 @@ export function missingApiRoutes(
208
237
  document: unknown,
209
238
  template: string,
210
239
  modules: string[],
240
+ providers: Readonly<ProviderSelections>,
211
241
  ): string[] {
212
242
  const documented = documentedApiRoutes(document);
213
- return [...expectedApiRoutes(template, modules)]
243
+ return [...expectedApiRoutes(template, modules, providers)]
214
244
  .filter((route) => !documented.has(route))
215
245
  .sort();
216
246
  }
217
247
 
218
- export function assertApiContract(document: unknown, template: string, modules: string[]): void {
219
- const missing = missingApiRoutes(document, template, modules);
248
+ export function assertApiContract(
249
+ document: unknown,
250
+ template: string,
251
+ modules: string[],
252
+ providers: Readonly<ProviderSelections>,
253
+ ): void {
254
+ const missing = missingApiRoutes(document, template, modules, providers);
220
255
  if (missing.length > 0) {
221
256
  throw new Error(`OpenAPI contract is missing routes:\n${missing.join("\n")}`);
222
257
  }
@@ -32,7 +32,8 @@ Review these fields before planning:
32
32
  - `workloads`: API/web replicas and resources plus an optional worker
33
33
  - `dependencies`: PostgreSQL, Redis, and object storage mode, image, Secret name, and storage.
34
34
  PostgreSQL also takes `maxConnections` (default 100) and `appPools` for pools the
35
- application opens itself; the doctor and the plan check the connection budget
35
+ application opens itself, each sized by a `runtimeConfig` key (`sizeKey`) or a
36
+ fixed `size` when the code sets it; the doctor and the plan check the connection budget
36
37
  against them, so lower `DB_POOL_MAX`/`DB_AUTH_POOL_MAX` or replicas when they warn.
37
38
  - `secrets`: API/optional web Secret names and required key names, plus image-pull Secret
38
39
  - `runtimeConfig`: non-sensitive environment values only
@@ -45,16 +45,26 @@ Tag a test when it changes a server-wide setting (a feature flag, the
45
45
  authentication configuration such as SMTP, social providers, or sign-up
46
46
  approval, the site settings, or a module's settings) or the profile of a seeded
47
47
  account. Each project has an `<name>-exclusive` twin, such as `ui-exclusive`,
48
- that runs only the tagged tests, one at a time. With more than one worker the
49
- twins wait for every other test, so an exclusive test never runs alongside
50
- another one. Never empty the shared mail catcher or SMS sink in a test: send to
51
- an address or number of the test's own instead.
52
-
53
- More than one worker suits the whole suite. When a run selects exclusive tests
54
- with a file name, `--grep`, or `--project`, Playwright also runs every other test
55
- first, because the twins depend on them, and a test that fails there marks the
56
- exclusive tests as not run. Use the default single worker for a few files, and
57
- do not combine `--no-deps` with `E2E_WORKERS` above 1.
48
+ that runs only the tagged tests, one at a time. Never empty the shared mail
49
+ catcher or SMS sink in a test: send to an address or number of the test's own
50
+ instead.
51
+
52
+ The `test:e2e` scripts run `run-e2e.ts`, which hands its arguments (a file,
53
+ `--grep`, `--project`) to Playwright. With more than one worker it runs
54
+ Playwright twice: first every test not tagged `@exclusive` on those workers,
55
+ then the tagged tests on one worker, even when a test failed in the first run.
56
+ The suite fails when either run fails. The second run reports to
57
+ `playwright-report/exclusive` and `test-results/exclusive`:
58
+
59
+ ```bash
60
+ bunx playwright show-report playwright-report/exclusive
61
+ ```
62
+
63
+ A plain `bunx playwright test` with `E2E_WORKERS` above 1 makes one run in which
64
+ the twins depend on every other test: a failing test there leaves the exclusive
65
+ tests not run, and selecting an exclusive test runs every other test first. Use
66
+ `{{rootRun}} test:e2e` on more workers, and do not combine `--no-deps` with
67
+ `E2E_WORKERS` above 1.
58
68
 
59
69
  Application-owned projects in `playwright.projects.cjs` get twins too. A project
60
70
  that another project depends on is a setup project and keeps a single copy.
@@ -0,0 +1,15 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ /**
5
+ * The administrator the admin-dashboard Playwright setup seeds, with the address
6
+ * and password `accounts.ts` reads from `E2E_*` variables and the application's
7
+ * `app-accounts.ts`, or null in an app without that module. Specs of modules
8
+ * that do not require admin-dashboard sign in with this instead of importing
9
+ * `accounts.ts`, which only admin-dashboard installs.
10
+ */
11
+ export async function seededAdmin(): Promise<{ email: string; password: string } | null> {
12
+ if (!existsSync(join(__dirname, "accounts.ts"))) return null;
13
+ const { ADMIN } = await import("./accounts");
14
+ return { email: ADMIN.email, password: ADMIN.password };
15
+ }
@@ -3,9 +3,9 @@
3
3
  "version": "0.0.0",
4
4
  "private": true,
5
5
  "scripts": {
6
- "test:e2e": "bunx playwright test",
7
- "test:e2e:ui": "bunx playwright test --project=ui --project=ui-exclusive",
8
- "test:e2e:api": "bunx playwright test --project=api --project=api-exclusive",
6
+ "test:e2e": "bun run-e2e.ts",
7
+ "test:e2e:ui": "bun run-e2e.ts --project=ui --project=ui-exclusive",
8
+ "test:e2e:api": "bun run-e2e.ts --project=api --project=api-exclusive",
9
9
  "test:e2e:report": "bunx playwright show-report"
10
10
  },
11
11
  "devDependencies": {
@@ -1,14 +1,15 @@
1
1
  import { defineConfig, devices } from "@playwright/test";
2
- import { e2eWorkers, loadPlaywrightProjects, withExclusivePhase } from "./playwright.extensions";
2
+ import { e2eRunSettings, loadPlaywrightProjects, withExclusivePhase } from "./playwright.extensions";
3
3
 
4
4
  // The suite runs against a live stack: web on E2E_BASE_URL (proxying /api/* to
5
5
  // the API). Start it first (see docs/testing.md) — locally via the dev harness,
6
6
  // in CI via scripts/e2e-ci.mjs. Two projects: `api` (request-only, backend e2e)
7
7
  // and `ui` (chromium, pages). Files are routed by suffix (*.api/*.ui.spec.ts).
8
8
  // E2E_WORKERS sets the worker count (default 1); tests tagged @exclusive run in
9
- // `<project>-exclusive` twins after the others (see playwright.extensions.ts).
9
+ // `<project>-exclusive` twins after the others, in a second run when run-e2e.ts
10
+ // starts the suite on more workers (see playwright.extensions.ts).
10
11
  const baseURL = process.env.E2E_BASE_URL ?? "http://localhost:5001";
11
- const workers = e2eWorkers();
12
+ const run = e2eRunSettings();
12
13
  const coreProjects = [
13
14
  { name: "api", testMatch: /.*\.api\.spec\.ts/ },
14
15
  { name: "ui", testMatch: /.*\.ui\.spec\.ts/, use: { ...devices["Desktop Chrome"] } },
@@ -19,13 +20,14 @@ export default defineConfig({
19
20
  fullyParallel: true,
20
21
  forbidOnly: !!process.env.CI,
21
22
  retries: process.env.CI ? 2 : 0,
22
- workers,
23
+ workers: run.workers,
24
+ outputDir: run.outputDir,
23
25
  globalSetup: "./playwright.extensions.ts",
24
- reporter: [["list"], ["html", { open: "never" }]],
26
+ reporter: [["list"], ["html", { open: "never", outputFolder: run.reportFolder }]],
25
27
  use: {
26
28
  baseURL,
27
29
  trace: "on-first-retry",
28
30
  screenshot: "only-on-failure",
29
31
  },
30
- projects: withExclusivePhase([...coreProjects, ...loadPlaywrightProjects(coreProjects)], workers),
32
+ projects: withExclusivePhase([...coreProjects, ...loadPlaywrightProjects(coreProjects)], run.workers, run.phase),
31
33
  });
@@ -17,6 +17,13 @@ const requireFromTests = createRequire(__filename);
17
17
  // the test's tags, separated by spaces.
18
18
  const TAGGED = /(?:^|\s)@exclusive(?=\s|$)/;
19
19
  const UNTAGGED = /^(?![\s\S]*(?:^|\s)@exclusive(?=\s|$))/;
20
+ const EVERY_FILE = /.*/;
21
+
22
+ /**
23
+ * One of the two Playwright runs `run-e2e.ts` makes with more than one worker:
24
+ * `shared` runs the tests not tagged `@exclusive`, `exclusive` the tagged ones.
25
+ */
26
+ export type E2ePhase = "shared" | "exclusive";
20
27
 
21
28
  function projectName(project: unknown): string | null {
22
29
  if (typeof project !== "object" || project === null || !("name" in project)) return null;
@@ -69,29 +76,74 @@ export function e2eWorkers(value = process.env.E2E_WORKERS): number {
69
76
  return workers;
70
77
  }
71
78
 
79
+ /** The run `run-e2e.ts` started, from `E2E_PHASE`, or undefined for a single run. */
80
+ export function e2ePhase(value = process.env.E2E_PHASE): E2ePhase | undefined {
81
+ if (value === undefined || value === "") return undefined;
82
+ if (value === "shared" || value === "exclusive") return value;
83
+ throw new Error(`E2E_PHASE must be "shared" or "exclusive", got "${value}".`);
84
+ }
85
+
86
+ /**
87
+ * Workers and output folders for this Playwright run. The exclusive phase runs
88
+ * on one worker and keeps its results and HTML report in `exclusive/` folders,
89
+ * because each run empties its own.
90
+ */
91
+ export function e2eRunSettings(
92
+ phase = e2ePhase(),
93
+ workers = e2eWorkers(),
94
+ ): { phase: E2ePhase | undefined; workers: number; outputDir: string; reportFolder: string } {
95
+ return phase === "exclusive"
96
+ ? { phase, workers: 1, outputDir: "test-results/exclusive", reportFolder: "playwright-report/exclusive" }
97
+ : { phase, workers, outputDir: "test-results", reportFolder: "playwright-report" };
98
+ }
99
+
100
+ /**
101
+ * The Playwright runs for `run-e2e.ts` with these command-line arguments. One
102
+ * worker needs a single run. With more workers, the tests not tagged
103
+ * `@exclusive` run first and the tagged ones second, in a run of their own that
104
+ * starts even when the first one failed. Either run may find no tests: a filter
105
+ * can select tests of one kind only, and an app may have no exclusive tests.
106
+ */
107
+ export function e2eRuns(args: string[], workers = e2eWorkers()): Array<{ phase?: E2ePhase; args: string[] }> {
108
+ if (workers === 1) return [{ args }];
109
+ return [
110
+ { phase: "shared", args: args.length > 0 ? ["--pass-with-no-tests", ...args] : args },
111
+ { phase: "exclusive", args: ["--pass-with-no-tests", ...args] },
112
+ ];
113
+ }
114
+
72
115
  /**
73
116
  * Splits each test project into one that skips tests tagged `@exclusive` and an
74
117
  * `<name>-exclusive` twin that runs only them on one worker. Projects that
75
118
  * another project depends on, or that tear one down, are setup and cleanup
76
119
  * projects and stay as they are.
77
120
  *
78
- * With more than one worker the twins depend on every other test project, and
79
- * each twin on the one before it, so an exclusive test never runs alongside
80
- * another test. A dependency runs in full, though: a run that selects only
81
- * exclusive tests, with a file, `--grep`, or `--project`, then also runs every
82
- * other test first, and a test that fails there skips the exclusive tests. Use
83
- * more than one worker for the whole suite. With one worker no dependency is
84
- * needed, so filtered runs stay small.
121
+ * With one worker no ordering is needed. With more, `run-e2e.ts` runs
122
+ * Playwright twice: in the `shared` phase the twins find no tests, and in the
123
+ * `exclusive` phase, on one worker, the other test projects find none. A single
124
+ * run with more than one worker (no phase) makes the twins depend on every
125
+ * other test project, and each twin on the one before it, so an exclusive test
126
+ * still never runs alongside another test; but there a failing test skips every
127
+ * twin after it, and a filter that selects exclusive tests runs all other tests
128
+ * first.
85
129
  */
86
- export function withExclusivePhase(projects: Projects, workers: number): Projects {
130
+ export function withExclusivePhase(projects: Projects, workers: number, phase?: E2ePhase): Projects {
87
131
  const support = new Set(
88
132
  projects.flatMap((project) => [...(project.dependencies ?? []), ...(project.teardown ? [project.teardown] : [])]),
89
133
  );
90
134
  const tested = projects.filter((project) => !support.has(projectName(project) ?? ""));
91
135
  const shared = new Map(
92
- tested.map((project) => [project, { ...project, grepInvert: [...patterns(project.grepInvert), TAGGED] }]),
136
+ tested.map((project) => [
137
+ project,
138
+ {
139
+ ...project,
140
+ grepInvert: [...patterns(project.grepInvert), TAGGED],
141
+ ...(phase === "exclusive" ? { testIgnore: EVERY_FILE } : {}),
142
+ },
143
+ ]),
93
144
  );
94
145
 
146
+ const chained = workers > 1 && phase === undefined;
95
147
  let before = [...shared.values()].map((project) => projectName(project) ?? "");
96
148
  const twins = tested.map((project) => {
97
149
  const name = `${projectName(project) ?? ""}-exclusive`;
@@ -100,7 +152,8 @@ export function withExclusivePhase(projects: Projects, workers: number): Project
100
152
  name,
101
153
  grepInvert: [...patterns(project.grepInvert), UNTAGGED],
102
154
  workers: 1,
103
- dependencies: workers > 1 ? [...(project.dependencies ?? []), ...before] : project.dependencies,
155
+ dependencies: chained ? [...(project.dependencies ?? []), ...before] : project.dependencies,
156
+ ...(phase === "shared" ? { testIgnore: EVERY_FILE } : {}),
104
157
  };
105
158
  before = [name];
106
159
  return twin;
@@ -119,13 +172,21 @@ export function withExclusivePhase(projects: Projects, workers: number): Project
119
172
  /**
120
173
  * Global setup: refuses more workers than `E2E_WORKERS` allows for. `--workers`
121
174
  * changes the count after the projects were built, so with it the exclusive
122
- * twins would run alongside the other tests.
175
+ * twins would run alongside the other tests. Warns when more than one worker
176
+ * runs without `run-e2e.ts`, where a failing test skips the exclusive tests.
123
177
  */
124
- export default function checkWorkers(config: FullConfig): void {
178
+ export default function checkWorkers(config: FullConfig, warn = console.warn): void {
125
179
  if (config.workers > 1 && e2eWorkers() === 1) {
126
180
  throw new Error(
127
181
  `Set the worker count with E2E_WORKERS=${config.workers} instead of --workers. ` +
128
182
  "Tests tagged @exclusive run after the others only when E2E_WORKERS is above 1.",
129
183
  );
130
184
  }
185
+ if (config.workers > 1 && e2ePhase() === undefined) {
186
+ warn(
187
+ "E2E_WORKERS is above 1 in a single Playwright run: a failing test makes Playwright skip " +
188
+ "the tests tagged @exclusive. Run the suite with `bun run test:e2e` (run-e2e.ts), which " +
189
+ "runs them in a second run instead.",
190
+ );
191
+ }
131
192
  }
@@ -0,0 +1,28 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { e2eRuns } from "./playwright.extensions";
3
+
4
+ // Runs the suite with the arguments it is given, such as `--grep` or a file.
5
+ // With E2E_WORKERS above 1, Playwright runs twice: the tests not tagged
6
+ // @exclusive on that many workers, then the tagged ones on one worker. The
7
+ // second run starts even when the first one failed, so a failing test cannot
8
+ // hide the exclusive tests, and the exit code is that of the first failed run.
9
+ const runs = e2eRuns(process.argv.slice(2));
10
+ let status = 0;
11
+ for (const run of runs) {
12
+ if (run.phase) {
13
+ console.log(
14
+ run.phase === "shared"
15
+ ? "\nRunning the tests not tagged @exclusive\n"
16
+ : "\nRunning the tests tagged @exclusive on one worker (report: playwright-report/exclusive)\n",
17
+ );
18
+ }
19
+ const result = spawnSync("bunx", ["playwright", "test", ...run.args], {
20
+ stdio: "inherit",
21
+ env: run.phase ? { ...process.env, E2E_PHASE: run.phase } : process.env,
22
+ });
23
+ if (result.error) throw result.error;
24
+ // Playwright exits with 130 when it is interrupted: stop instead of starting the next run.
25
+ if (result.signal !== null || result.status === 130) process.exit(result.status ?? 130);
26
+ if (status === 0) status = result.status ?? 1;
27
+ }
28
+ process.exit(status);
@@ -13,8 +13,20 @@ vi.mock("./app-nav", async () => {
13
13
  };
14
14
  });
15
15
 
16
+ // Stands in for the entries installed modules add to registry.svelte.ts, so the
17
+ // expected order does not depend on which modules an application installs.
18
+ vi.mock("./registry.svelte.js", async () => {
19
+ const { default: icon } = await import("@lucide/svelte/icons/newspaper");
20
+ return {
21
+ moduleNavEntries: [
22
+ { href: "/admin/blog", label: () => "Blog", icon, adminOnly: true },
23
+ { href: "/admin/reports", label: () => "Reports", icon },
24
+ ],
25
+ };
26
+ });
27
+
16
28
  describe("admin navigation", () => {
17
- it("places the application's entries before Settings", () => {
29
+ it("places the application's entries, then module entries, before Settings", () => {
18
30
  const hrefs = adminNavEntries({ auditLog: true }, "admin").map((entry) => entry.href);
19
31
 
20
32
  expect(hrefs).toEqual([
@@ -24,6 +36,8 @@ describe("admin navigation", () => {
24
36
  "/admin/audit",
25
37
  "/admin/specs",
26
38
  "/admin/help",
39
+ "/admin/blog",
40
+ "/admin/reports",
27
41
  "/admin/settings",
28
42
  ]);
29
43
  expect(adminNavEntries(undefined, "admin").find((entry) => entry.href === "/admin/specs")?.icon).toBe(
@@ -31,9 +45,9 @@ describe("admin navigation", () => {
31
45
  );
32
46
  });
33
47
 
34
- it("hides admin-only application entries from other roles", () => {
48
+ it("hides admin-only application and module entries from other roles", () => {
35
49
  const hrefs = adminNavEntries(undefined, "moderator").map((entry) => entry.href);
36
50
 
37
- expect(hrefs).toEqual(["/admin", "/admin/help"]);
51
+ expect(hrefs).toEqual(["/admin", "/admin/help", "/admin/reports"]);
38
52
  });
39
53
  });
@@ -13,7 +13,7 @@
13
13
  <DropdownMenu.Root>
14
14
  <DropdownMenu.Trigger>
15
15
  {#snippet child({ props })}
16
- <Button {...props} variant="ghost" size="icon" aria-label={i18n.t.theme.toggle}>
16
+ <Button {...props} variant="ghost" size="icon" aria-label={i18n.t.theme.toggle} data-testid="theme-toggle">
17
17
  <SunIcon class="size-4 dark:hidden" />
18
18
  <MoonIcon class="hidden size-4 dark:block" />
19
19
  </Button>
@@ -1,9 +1,18 @@
1
1
  import type { Page } from "@playwright/test";
2
2
 
3
- // Wait for SvelteKit to attach client event handlers before driving forms. The
4
- // root layout adds this marker from onMount, so no fixed timing assumption is
5
- // needed even while the development server compiles a route for the first time.
3
+ /**
4
+ * Waits until SvelteKit has attached the current document's event handlers. The
5
+ * root layout adds this marker from onMount, so no fixed timing assumption is
6
+ * needed even while the development server compiles a route for the first time.
7
+ * Call it after `page.reload()` too: the reloaded document is visible, and
8
+ * clickable, before its handlers exist, so an early click does nothing.
9
+ */
10
+ export async function hydrated(page: Page): Promise<void> {
11
+ await page.locator('html[data-hydrated="true"]').waitFor();
12
+ }
13
+
14
+ /** Opens `path` and waits for hydration before a test drives the page. */
6
15
  export async function ready(page: Page, path: string): Promise<void> {
7
16
  await page.goto(path);
8
- await page.locator('html[data-hydrated="true"]').waitFor();
17
+ await hydrated(page);
9
18
  }
@@ -1,5 +1,5 @@
1
1
  import { defineConfig, devices } from "@playwright/test";
2
- import { e2eWorkers, loadPlaywrightProjects, withExclusivePhase } from "./playwright.extensions";
2
+ import { e2eRunSettings, loadPlaywrightProjects, withExclusivePhase } from "./playwright.extensions";
3
3
 
4
4
  // admin-dashboard overlay: adds a `setup` project that seeds admin/user sessions
5
5
  // (storageState) which the `ui` project reuses, and a `cleanup` project that
@@ -7,9 +7,10 @@ import { e2eWorkers, loadPlaywrightProjects, withExclusivePhase } from "./playwr
7
7
  // E2E_BASE_URL. Tests share one backend and database: E2E_WORKERS sets the
8
8
  // worker count (default 1), and tests tagged @exclusive, which change state the
9
9
  // others rely on, run one at a time in `<project>-exclusive` twins after the
10
- // others (see playwright.extensions.ts). Each file runs on one worker in order.
10
+ // others, in a second run when run-e2e.ts starts the suite on more workers (see
11
+ // playwright.extensions.ts). Each file runs on one worker in order.
11
12
  const baseURL = process.env.E2E_BASE_URL ?? "http://localhost:5001";
12
- const workers = e2eWorkers();
13
+ const run = e2eRunSettings();
13
14
  const coreProjects = [
14
15
  { name: "api", testMatch: /.*\.api\.spec\.ts/, dependencies: ["setup"] },
15
16
  { name: "setup", testMatch: /.*\.setup\.ts/, teardown: "cleanup" },
@@ -27,9 +28,10 @@ export default defineConfig({
27
28
  fullyParallel: false,
28
29
  forbidOnly: !!process.env.CI,
29
30
  retries: process.env.CI ? 2 : 0,
30
- workers,
31
+ workers: run.workers,
32
+ outputDir: run.outputDir,
31
33
  globalSetup: "./playwright.extensions.ts",
32
- reporter: [["list"], ["html", { open: "never" }]],
34
+ reporter: [["list"], ["html", { open: "never", outputFolder: run.reportFolder }]],
33
35
  use: { baseURL, trace: "on-first-retry", screenshot: "only-on-failure" },
34
- projects: withExclusivePhase([...coreProjects, ...loadPlaywrightProjects(coreProjects)], workers),
36
+ projects: withExclusivePhase([...coreProjects, ...loadPlaywrightProjects(coreProjects)], run.workers, run.phase),
35
37
  });
@@ -1,29 +1,35 @@
1
- import { expect, test } from "@playwright/test";
2
- import { ready } from "../helpers/hydration";
1
+ import { expect, test, type Page } from "@playwright/test";
2
+ import { hydrated, ready } from "../helpers/hydration";
3
+ import { paths } from "../helpers/paths";
4
+
5
+ async function chooseTheme(page: Page, mode: "Light" | "Dark" | "System"): Promise<void> {
6
+ await page.getByTestId("theme-toggle").click();
7
+ await page.getByRole("menuitem", { name: mode }).click();
8
+ }
3
9
 
4
10
  // mode-watcher marks <html> with `.dark` or `.light`, so component libraries
5
11
  // that follow either class, and fall back to the OS setting otherwise, match the
6
- // theme the visitor chose rather than the system one.
12
+ // theme the visitor chose rather than the system one. The managed ThemeToggle
13
+ // renders on the page app-paths.ts names as the account menu page.
7
14
  test("the chosen theme is named on <html> in both modes", async ({ page }) => {
15
+ test.skip(paths.accountMenu === null, "no page renders the theme toggle (tests/helpers/app-paths.ts)");
8
16
  await page.emulateMedia({ colorScheme: "dark" });
9
- await ready(page, "/account");
17
+ await ready(page, paths.accountMenu ?? "/");
10
18
  const html = page.locator("html");
11
19
 
12
- await page.getByRole("button", { name: "Toggle theme" }).click();
13
- await page.getByRole("menuitem", { name: "Light" }).click();
20
+ await chooseTheme(page, "Light");
14
21
  await expect(html).toHaveClass(/(^|\s)light(\s|$)/);
15
22
  await expect(html).not.toHaveClass(/(^|\s)dark(\s|$)/);
16
23
 
17
24
  // The class is set before the page renders, not only after a choice.
18
25
  await page.reload();
19
26
  await expect(html).toHaveClass(/(^|\s)light(\s|$)/);
27
+ await hydrated(page);
20
28
 
21
- await page.getByRole("button", { name: "Toggle theme" }).click();
22
- await page.getByRole("menuitem", { name: "Dark" }).click();
29
+ await chooseTheme(page, "Dark");
23
30
  await expect(html).toHaveClass(/(^|\s)dark(\s|$)/);
24
31
  await expect(html).not.toHaveClass(/(^|\s)light(\s|$)/);
25
32
 
26
- await page.getByRole("button", { name: "Toggle theme" }).click();
27
- await page.getByRole("menuitem", { name: "System" }).click();
33
+ await chooseTheme(page, "System");
28
34
  await expect(html).toHaveClass(/(^|\s)dark(\s|$)/);
29
35
  });
@@ -136,6 +136,9 @@ describe("BullMqJobQueue schedules", () => {
136
136
  await expect(queue.schedule("bad", "report", {}, { cron: "61 * * * *" })).rejects.toThrow();
137
137
  await expect(queue.schedule("bad", "report", {}, { cron: "0 3 * * *", timezone: "Mars/Olympus" })).rejects.toThrow();
138
138
  await expect(queue.schedule("bad", "report", {}, { everyMs: 10 })).rejects.toThrow("at least 1000");
139
+ await expect(queue.schedule("bad", "report", {}, { everyMs: 1_000 }, { keepFailed: 1.5 })).rejects.toThrow(
140
+ "keepFailed must be a whole number of at least 0",
141
+ );
139
142
  } finally {
140
143
  await queue.close();
141
144
  }
@@ -183,4 +186,51 @@ describe.skipIf(!process.env.REDIS_URL && !process.env.REDIS_HOST)("BullMqJobQue
183
186
  await inspect.close();
184
187
  }
185
188
  });
189
+
190
+ it("limits the finished jobs a schedule's runs leave, 100 completed and 1000 failed by default", async () => {
191
+ const name = `schedule-test-${crypto.randomUUID()}`;
192
+ const queue = new BullMqJobQueue(producerConnection(REDIS));
193
+ const inspect = new Queue(name, { connection: redisConnection(REDIS) });
194
+ try {
195
+ await queue.connect();
196
+ await queue.schedule("default", name, {}, { cron: "0 3 * * *" });
197
+ await queue.schedule("custom", name, {}, { everyMs: 3_600_000 }, { keepCompleted: 0, keepFailed: 20 });
198
+ expect((await inspect.getJobScheduler("default"))?.template?.opts).toMatchObject({
199
+ removeOnComplete: { count: 100 },
200
+ removeOnFail: { count: 1_000 },
201
+ });
202
+ expect((await inspect.getJobScheduler("custom"))?.template?.opts).toMatchObject({
203
+ removeOnComplete: { count: 0 },
204
+ removeOnFail: { count: 20 },
205
+ });
206
+ } finally {
207
+ await queue.close();
208
+ await inspect.obliterate({ force: true });
209
+ await inspect.close();
210
+ }
211
+ });
212
+
213
+ it("removes finished runs beyond the schedule's limits", async () => {
214
+ const name = `schedule-test-${crypto.randomUUID()}`;
215
+ const queue = new BullMqJobQueue(producerConnection(REDIS));
216
+ const inspect = new Queue(name, { connection: redisConnection(REDIS) });
217
+ let runs = 0;
218
+ try {
219
+ await queue.connect();
220
+ await queue.process(name, () => {
221
+ runs += 1;
222
+ if (runs % 2 === 0) throw new Error("expected failure");
223
+ });
224
+ await queue.schedule("tick", name, {}, { everyMs: 1_000 }, { keepCompleted: 1, keepFailed: 1 });
225
+ for (let waited = 0; runs < 4 && waited < 10_000; waited += 100) await Bun.sleep(100);
226
+ expect(runs).toBeGreaterThanOrEqual(4);
227
+ // The last run's job moves to its finished set just after the handler returns.
228
+ await Bun.sleep(200);
229
+ expect(await inspect.getJobCounts("completed", "failed")).toEqual({ completed: 1, failed: 1 });
230
+ } finally {
231
+ await queue.close();
232
+ await inspect.obliterate({ force: true });
233
+ await inspect.close();
234
+ }
235
+ }, 15_000);
186
236
  });
@@ -10,6 +10,9 @@ import {
10
10
  type JobSnapshot,
11
11
  type JobStatus,
12
12
  type RepeatOptions,
13
+ type ScheduleOptions,
14
+ type ScheduleRetention,
15
+ scheduleRetention,
13
16
  type Unsubscribe,
14
17
  } from "@podosoft/podokit-runtime";
15
18
  import type { SupervisedConnection } from "../core/services";
@@ -56,6 +59,7 @@ interface Schedule {
56
59
  name: string;
57
60
  payload: unknown;
58
61
  repeat: RepeatOptions;
62
+ retention: ScheduleRetention;
59
63
  }
60
64
 
61
65
  /** Rejects a schedule BullMQ could not run, before it is kept. */
@@ -141,13 +145,20 @@ export class BullMqJobQueue implements JobQueue, SupervisedConnection {
141
145
  };
142
146
  }
143
147
 
144
- async schedule(id: string, name: string, payload: unknown, repeat: RepeatOptions): Promise<void> {
148
+ async schedule(
149
+ id: string,
150
+ name: string,
151
+ payload: unknown,
152
+ repeat: RepeatOptions,
153
+ options?: ScheduleOptions,
154
+ ): Promise<void> {
145
155
  assertSchedule(repeat);
156
+ const retention = scheduleRetention(options);
146
157
  const previous = this.schedules.get(id);
147
158
  // A scheduler lives in its queue, so a schedule that moves leaves the old one.
148
159
  if (previous && previous.name !== name) this.removals.set(id, previous.name);
149
160
  if (this.removals.get(id) === name) this.removals.delete(id);
150
- this.schedules.set(id, { name, payload, repeat });
161
+ this.schedules.set(id, { name, payload, repeat, retention });
151
162
  await this.whenConnected(async (client) => {
152
163
  if (this.removals.has(id)) await this.removeSchedule(client, id);
153
164
  await this.applySchedule(client, id);
@@ -196,10 +207,19 @@ export class BullMqJobQueue implements JobQueue, SupervisedConnection {
196
207
  const schedule = this.schedules.get(id);
197
208
  if (!schedule) return;
198
209
  const { cron, everyMs, timezone } = schedule.repeat;
210
+ // A count above 0 trims the queue's whole completed or failed set to that
211
+ // many jobs; 0 removes only the finished run.
199
212
  await this.queue(schedule.name, client).upsertJobScheduler(
200
213
  id,
201
214
  cron !== undefined ? { pattern: cron, tz: timezone ?? "UTC" } : { every: everyMs },
202
- { name: schedule.name, data: schedule.payload },
215
+ {
216
+ name: schedule.name,
217
+ data: schedule.payload,
218
+ opts: {
219
+ removeOnComplete: { count: schedule.retention.keepCompleted },
220
+ removeOnFail: { count: schedule.retention.keepFailed },
221
+ },
222
+ },
203
223
  );
204
224
  }
205
225
 
@@ -149,6 +149,93 @@ describe("local job schedules", () => {
149
149
  await expect(queue.schedule("bad", "report", {}, { cron: "61 * * * *" })).rejects.toThrow();
150
150
  await expect(queue.schedule("bad", "report", {}, { cron: "0 3 * * *", timezone: "Mars/Olympus" })).rejects.toThrow();
151
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");
152
239
  queue.close();
153
240
  });
154
241
  });
@@ -9,6 +9,9 @@ import {
9
9
  type JobSnapshot,
10
10
  type JobStatus,
11
11
  type RepeatOptions,
12
+ type ScheduleOptions,
13
+ type ScheduleRetention,
14
+ scheduleRetention,
12
15
  type Unsubscribe,
13
16
  } from "@podosoft/podokit-runtime";
14
17
 
@@ -42,9 +45,15 @@ interface Schedule {
42
45
  name: string;
43
46
  payload: unknown;
44
47
  repeat: RepeatOptions;
48
+ retention: ScheduleRetention;
45
49
  cancel?: () => void;
46
50
  }
47
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
+
48
57
  // setTimeout fires at once for a delay above 2^31 - 1 ms, so longer waits are split.
49
58
  const MAX_TIMER_DELAY_MS = 2 ** 31 - 1;
50
59
 
@@ -160,13 +169,20 @@ export class LocalJobQueue implements JobQueue {
160
169
  });
161
170
  }
162
171
 
163
- async schedule(id: string, name: string, payload: unknown, repeat: RepeatOptions): Promise<void> {
172
+ async schedule(
173
+ id: string,
174
+ name: string,
175
+ payload: unknown,
176
+ repeat: RepeatOptions,
177
+ options?: ScheduleOptions,
178
+ ): Promise<void> {
164
179
  assertRepeatOptions(repeat);
180
+ const retention = scheduleRetention(options);
165
181
  // Rejects an invalid cron expression or time zone now rather than at the first run.
166
182
  nextScheduledTime(repeat, this.now());
167
183
  if (JSON.stringify(payload) === undefined) throw new Error("Job payload must be JSON serializable");
168
184
  this.schedules.get(id)?.cancel?.();
169
- const schedule: Schedule = { id, name, payload, repeat };
185
+ const schedule: Schedule = { id, name, payload, repeat, retention };
170
186
  this.schedules.set(id, schedule);
171
187
  if (this.started) this.arm(schedule);
172
188
  }
@@ -205,6 +221,7 @@ export class LocalJobQueue implements JobQueue {
205
221
  SET "status" = 'completed', "updatedAt" = ${completedAt}, "error" = NULL
206
222
  WHERE "id" = ${row.id}
207
223
  `;
224
+ await this.removeFinished(row, "completed");
208
225
  } catch (error) {
209
226
  await this.fail(row, error);
210
227
  }
@@ -227,7 +244,7 @@ export class LocalJobQueue implements JobQueue {
227
244
  const at = nextScheduledTime(schedule.repeat, after);
228
245
  schedule.cancel = this.waitUntil(at, () => {
229
246
  if (!this.started || this.schedules.get(schedule.id) !== schedule) return;
230
- void this.enqueue(schedule.name, schedule.payload, { deduplicationKey: `schedule:${schedule.id}:${at}` })
247
+ void this.enqueue(schedule.name, schedule.payload, { deduplicationKey: `${SCHEDULE_KEY}${schedule.id}:${at}` })
231
248
  .catch((error: unknown) => {
232
249
  const message = error instanceof Error ? error.message : String(error);
233
250
  process.stderr.write(`Add scheduled job failed: ${schedule.id} ${message}\n`);
@@ -269,6 +286,45 @@ export class LocalJobQueue implements JobQueue {
269
286
  CREATE INDEX IF NOT EXISTS "podokit_job_pending"
270
287
  ON "podokit_job" ("status", "availableAt", "createdAt")
271
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
+ `;
272
328
  }
273
329
 
274
330
  private poll(delay: number): void {
@@ -296,6 +352,7 @@ export class LocalJobQueue implements JobQueue {
296
352
  "error" = ${message.slice(0, 2_000)}
297
353
  WHERE "id" = ${row.id}
298
354
  `;
355
+ if (!retry) await this.removeFinished(row, "failed");
299
356
  }
300
357
 
301
358
  private snapshot(row: JobRow): JobSnapshot {
@@ -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: process.env.E2E_ADMIN_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,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: process.env.E2E_ADMIN_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,14 +1,9 @@
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 secondaryApi = process.env.E2E_SECONDARY_API_URL;
5
6
  const origin = { origin: base };
6
- // The administrator seeded by the admin-dashboard Playwright setup. Inlined so this
7
- // spec also compiles in apps that do not install that module.
8
- const admin = {
9
- email: process.env.E2E_ADMIN_EMAIL || "admin@example.com",
10
- password: process.env.E2E_ADMIN_PASSWORD || "Podokit3e-Str0ng!pw",
11
- };
12
7
 
13
8
  type Playwright = PlaywrightWorkerArgs["playwright"];
14
9
 
@@ -39,6 +34,8 @@ async function signUp(ctx: APIRequestContext): Promise<SessionUser | undefined>
39
34
 
40
35
  /** Resolves false when no administrator session is available. */
41
36
  async function signInAdmin(ctx: APIRequestContext): Promise<boolean> {
37
+ const admin = await seededAdmin();
38
+ if (!admin) return false;
42
39
  const response = await ctx.post("/api/auth/sign-in/email", { data: admin });
43
40
  return response.ok() && ((await sessionUser(ctx))?.roles ?? []).includes("admin");
44
41
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@podosoft/podokit",
3
- "version": "1.6.0",
3
+ "version": "1.6.1",
4
4
  "description": "An opinionated Bun and Elysia full-stack TypeScript starter toolkit and CLI with SvelteKit, TailwindCSS, shadcn-svelte, Docker, and k3s.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -45,7 +45,7 @@
45
45
  "test": "vitest run"
46
46
  },
47
47
  "dependencies": {
48
- "@podosoft/podokit-runtime": "^0.5.0",
48
+ "@podosoft/podokit-runtime": "^0.5.1",
49
49
  "@podosoft/podokit-template-engine": "^0.4.2"
50
50
  }
51
51
  }