@podosoft/podokit 1.5.0 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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 +56 -2
- package/dist/deploy-schema.js +130 -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 +28 -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 +5 -1
- 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 +118 -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 +29 -0
- package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +39 -2
- 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 +12 -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 +40 -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/package.json +2 -2
- package/dist/templates/fullstack/tests/playwright.config.ts +7 -3
- package/dist/templates/fullstack/tests/playwright.extensions.ts +88 -1
- 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 +39 -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/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/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 +11 -5
- 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 +29 -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 +57 -1
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.ts +93 -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 +87 -1
- package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.ts +93 -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 +1 -1
- 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 +1 -1
- 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 +1 -1
- 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
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,89 @@ 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
|
+
queue.close();
|
|
153
|
+
});
|
|
154
|
+
});
|
|
@@ -1,11 +1,14 @@
|
|
|
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,
|
|
9
12
|
type Unsubscribe,
|
|
10
13
|
} from "@podosoft/podokit-runtime";
|
|
11
14
|
|
|
@@ -22,15 +25,52 @@ interface JobRow {
|
|
|
22
25
|
error: string | null;
|
|
23
26
|
}
|
|
24
27
|
|
|
28
|
+
export interface ScheduleTimer {
|
|
29
|
+
/** Runs `task` once after `delayMs`; the returned function cancels it. */
|
|
30
|
+
after(delayMs: number, task: () => void): () => void;
|
|
31
|
+
}
|
|
32
|
+
|
|
25
33
|
export interface LocalJobQueueOptions {
|
|
26
34
|
pollIntervalMs?: number;
|
|
27
35
|
now?: () => number;
|
|
36
|
+
/** Waits for schedules; an unref'd setTimeout by default. */
|
|
37
|
+
timer?: ScheduleTimer;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface Schedule {
|
|
41
|
+
id: string;
|
|
42
|
+
name: string;
|
|
43
|
+
payload: unknown;
|
|
44
|
+
repeat: RepeatOptions;
|
|
45
|
+
cancel?: () => void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// setTimeout fires at once for a delay above 2^31 - 1 ms, so longer waits are split.
|
|
49
|
+
const MAX_TIMER_DELAY_MS = 2 ** 31 - 1;
|
|
50
|
+
|
|
51
|
+
const unrefTimer: ScheduleTimer = {
|
|
52
|
+
after: (delayMs, task) => {
|
|
53
|
+
const timer = setTimeout(task, delayMs);
|
|
54
|
+
timer.unref?.();
|
|
55
|
+
return () => clearTimeout(timer);
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** The first time after `after` (epoch milliseconds) that `repeat` adds a job. */
|
|
60
|
+
export function nextScheduledTime(repeat: RepeatOptions, after: number): number {
|
|
61
|
+
if (repeat.everyMs !== undefined) return (Math.floor(after / repeat.everyMs) + 1) * repeat.everyMs;
|
|
62
|
+
return cronParser
|
|
63
|
+
.parseExpression(repeat.cron ?? "", { currentDate: new Date(after), tz: repeat.timezone ?? "UTC" })
|
|
64
|
+
.next()
|
|
65
|
+
.getTime();
|
|
28
66
|
}
|
|
29
67
|
|
|
30
68
|
export class LocalJobQueue implements JobQueue {
|
|
31
69
|
private readonly handlers = new Map<string, JobHandler>();
|
|
70
|
+
private readonly schedules = new Map<string, Schedule>();
|
|
32
71
|
private readonly pollIntervalMs: number;
|
|
33
72
|
private readonly now: () => number;
|
|
73
|
+
private readonly scheduleTimer: ScheduleTimer;
|
|
34
74
|
private timer?: ReturnType<typeof setTimeout>;
|
|
35
75
|
private started = false;
|
|
36
76
|
private initialized?: Promise<void>;
|
|
@@ -44,6 +84,7 @@ export class LocalJobQueue implements JobQueue {
|
|
|
44
84
|
throw new Error("pollIntervalMs must be an integer of at least 10");
|
|
45
85
|
}
|
|
46
86
|
this.now = options.now ?? Date.now;
|
|
87
|
+
this.scheduleTimer = options.timer ?? unrefTimer;
|
|
47
88
|
}
|
|
48
89
|
|
|
49
90
|
initialize(): Promise<void> {
|
|
@@ -63,7 +104,8 @@ export class LocalJobQueue implements JobQueue {
|
|
|
63
104
|
if (this.started) return;
|
|
64
105
|
await this.initialize();
|
|
65
106
|
this.started = true;
|
|
66
|
-
this.
|
|
107
|
+
this.poll(0);
|
|
108
|
+
for (const schedule of this.schedules.values()) this.arm(schedule);
|
|
67
109
|
}
|
|
68
110
|
|
|
69
111
|
async enqueue(
|
|
@@ -118,6 +160,22 @@ export class LocalJobQueue implements JobQueue {
|
|
|
118
160
|
});
|
|
119
161
|
}
|
|
120
162
|
|
|
163
|
+
async schedule(id: string, name: string, payload: unknown, repeat: RepeatOptions): Promise<void> {
|
|
164
|
+
assertRepeatOptions(repeat);
|
|
165
|
+
// Rejects an invalid cron expression or time zone now rather than at the first run.
|
|
166
|
+
nextScheduledTime(repeat, this.now());
|
|
167
|
+
if (JSON.stringify(payload) === undefined) throw new Error("Job payload must be JSON serializable");
|
|
168
|
+
this.schedules.get(id)?.cancel?.();
|
|
169
|
+
const schedule: Schedule = { id, name, payload, repeat };
|
|
170
|
+
this.schedules.set(id, schedule);
|
|
171
|
+
if (this.started) this.arm(schedule);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async unschedule(id: string): Promise<void> {
|
|
175
|
+
this.schedules.get(id)?.cancel?.();
|
|
176
|
+
this.schedules.delete(id);
|
|
177
|
+
}
|
|
178
|
+
|
|
121
179
|
async runOnce(): Promise<boolean> {
|
|
122
180
|
await this.initialize();
|
|
123
181
|
const now = this.now();
|
|
@@ -157,9 +215,41 @@ export class LocalJobQueue implements JobQueue {
|
|
|
157
215
|
this.started = false;
|
|
158
216
|
if (this.timer) clearTimeout(this.timer);
|
|
159
217
|
this.timer = undefined;
|
|
218
|
+
for (const schedule of this.schedules.values()) schedule.cancel?.();
|
|
160
219
|
this.handlers.clear();
|
|
161
220
|
}
|
|
162
221
|
|
|
222
|
+
/**
|
|
223
|
+
* Adds the schedule's next job when its time comes. The job id names the
|
|
224
|
+
* schedule and the time, so a job is added once for each time.
|
|
225
|
+
*/
|
|
226
|
+
private arm(schedule: Schedule, after = this.now()): void {
|
|
227
|
+
const at = nextScheduledTime(schedule.repeat, after);
|
|
228
|
+
schedule.cancel = this.waitUntil(at, () => {
|
|
229
|
+
if (!this.started || this.schedules.get(schedule.id) !== schedule) return;
|
|
230
|
+
void this.enqueue(schedule.name, schedule.payload, { deduplicationKey: `schedule:${schedule.id}:${at}` })
|
|
231
|
+
.catch((error: unknown) => {
|
|
232
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
233
|
+
process.stderr.write(`Add scheduled job failed: ${schedule.id} ${message}\n`);
|
|
234
|
+
});
|
|
235
|
+
this.arm(schedule, Math.max(this.now(), at));
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
private waitUntil(at: number, task: () => void): () => void {
|
|
240
|
+
let cancel = (): void => undefined;
|
|
241
|
+
const step = (): void => {
|
|
242
|
+
const delay = at - this.now();
|
|
243
|
+
if (delay <= 0) {
|
|
244
|
+
task();
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
cancel = this.scheduleTimer.after(Math.min(delay, MAX_TIMER_DELAY_MS), step);
|
|
248
|
+
};
|
|
249
|
+
cancel = this.scheduleTimer.after(Math.max(0, Math.min(at - this.now(), MAX_TIMER_DELAY_MS)), step);
|
|
250
|
+
return () => cancel();
|
|
251
|
+
}
|
|
252
|
+
|
|
163
253
|
private async createTables(): Promise<void> {
|
|
164
254
|
await this.sql`
|
|
165
255
|
CREATE TABLE IF NOT EXISTS "podokit_job" (
|
|
@@ -181,7 +271,7 @@ export class LocalJobQueue implements JobQueue {
|
|
|
181
271
|
`;
|
|
182
272
|
}
|
|
183
273
|
|
|
184
|
-
private
|
|
274
|
+
private poll(delay: number): void {
|
|
185
275
|
if (!this.started) return;
|
|
186
276
|
this.timer = setTimeout(() => {
|
|
187
277
|
void this.runOnce()
|
|
@@ -189,7 +279,7 @@ export class LocalJobQueue implements JobQueue {
|
|
|
189
279
|
const message = error instanceof Error ? error.message : String(error);
|
|
190
280
|
process.stderr.write(`Process local job failed: ${message}\n`);
|
|
191
281
|
})
|
|
192
|
-
.finally(() => this.
|
|
282
|
+
.finally(() => this.poll(this.pollIntervalMs));
|
|
193
283
|
}, delay);
|
|
194
284
|
this.timer.unref?.();
|
|
195
285
|
}
|
|
@@ -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
|
@@ -5,7 +5,7 @@ const origin = { origin: base };
|
|
|
5
5
|
// The administrator seeded by the admin-dashboard Playwright setup. Inlined so this
|
|
6
6
|
// spec also compiles in apps that do not install that module.
|
|
7
7
|
const admin = {
|
|
8
|
-
email: "admin@example.com",
|
|
8
|
+
email: process.env.E2E_ADMIN_EMAIL || "admin@example.com",
|
|
9
9
|
password: process.env.E2E_ADMIN_PASSWORD || "Podokit3e-Str0ng!pw",
|
|
10
10
|
};
|
|
11
11
|
|
|
@@ -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:
|
|
@@ -5,7 +5,7 @@ const origin = { origin: base };
|
|
|
5
5
|
// The administrator seeded by the admin-dashboard Playwright setup. Inlined so this
|
|
6
6
|
// spec also compiles in apps that do not install that module.
|
|
7
7
|
const admin = {
|
|
8
|
-
email: "admin@example.com",
|
|
8
|
+
email: process.env.E2E_ADMIN_EMAIL || "admin@example.com",
|
|
9
9
|
password: process.env.E2E_ADMIN_PASSWORD || "Podokit3e-Str0ng!pw",
|
|
10
10
|
};
|
|
11
11
|
|
|
@@ -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({
|
|
@@ -6,7 +6,7 @@ const origin = { origin: base };
|
|
|
6
6
|
// The administrator seeded by the admin-dashboard Playwright setup. Inlined so this
|
|
7
7
|
// spec also compiles in apps that do not install that module.
|
|
8
8
|
const admin = {
|
|
9
|
-
email: "admin@example.com",
|
|
9
|
+
email: process.env.E2E_ADMIN_EMAIL || "admin@example.com",
|
|
10
10
|
password: process.env.E2E_ADMIN_PASSWORD || "Podokit3e-Str0ng!pw",
|
|
11
11
|
};
|
|
12
12
|
|
|
@@ -87,7 +87,11 @@ Merge added module settings from `.env.example` into an existing `.env`.
|
|
|
87
87
|
Better Auth schema before the application migrations. `{{apiRun}} migration:run` runs
|
|
88
88
|
only the application migrations and never creates Better Auth tables;
|
|
89
89
|
`{{apiRun}} migration:revert` reverts the latest application migration.
|
|
90
|
-
- e2e (Playwright, ships in `tests/`): `{{rootRun}} test:e2e`.
|
|
90
|
+
- e2e (Playwright, ships in `tests/`): `{{rootRun}} test:e2e`. Run the whole suite on more
|
|
91
|
+
workers with `E2E_WORKERS=2`, never `--workers`. Tag a test `{ tag: "@exclusive" }` when
|
|
92
|
+
it changes state other tests rely on (a server-wide setting, a seeded account's profile);
|
|
93
|
+
tagged tests run alone after the rest. Give each test its own mail and SMS recipients and
|
|
94
|
+
never empty the shared Mailpit inbox or SMS sink. See `tests/README.md`.
|
|
91
95
|
- Playwright officially runs on Node, so `bunx playwright` respects its Node
|
|
92
96
|
shebang. Node LTS is a test-tool dependency only; API, web, worker, migrations,
|
|
93
97
|
builds, and unit tests run on Bun.
|
|
@@ -55,8 +55,9 @@ cp .env.example .env
|
|
|
55
55
|
```
|
|
56
56
|
|
|
57
57
|
Open the URL printed by the command; a new project defaults to
|
|
58
|
-
**http://{{
|
|
59
|
-
source of truth if you change the hostname
|
|
58
|
+
**http://{{devHostname}}**. The committed `.podokit/dev.json` is the
|
|
59
|
+
source of truth if you change the hostname: `podo dev` sets `CORS_ORIGIN` and
|
|
60
|
+
`BETTER_AUTH_URL` for the web and API containers from it. The first running PodoKit project starts
|
|
60
61
|
one user-level Traefik gateway on `127.0.0.1:80`; additional projects reuse it and
|
|
61
62
|
route by that hostname. Even a single app uses
|
|
62
63
|
the same topology, with one route and no project-specific host port.
|
|
@@ -164,5 +165,6 @@ PodoKit [deployment guide](https://github.com/podosoft-dev/podokit/blob/main/doc
|
|
|
164
165
|
For a Docker Compose deployment with API WebSockets, list their exact paths under
|
|
165
166
|
`exposure.webSocketPaths`. The driver keeps one published port, routes those paths
|
|
166
167
|
to API, and sends every other request to web. Large-upload routes listed under
|
|
167
|
-
`exposure.apiPaths` reach the API the same way, with `/api` removed.
|
|
168
|
-
|
|
168
|
+
`exposure.apiPaths` reach the API the same way, with `/api` removed. Set `exposure.edgeProxies` to the
|
|
169
|
+
number of reverse proxies in front of the deployment, and with the gateway list only their direct CIDR
|
|
170
|
+
blocks in `exposure.trustedProxyCidrs`.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Line-based unified diff for `podo diff <path>`. It is dependency-free and
|
|
3
|
+
* meant for source files of a few thousand lines; above `MAX_CELLS` it reports
|
|
4
|
+
* the whole file as replaced instead of building the LCS table.
|
|
5
|
+
*/
|
|
6
|
+
export interface UnifiedDiffOptions {
|
|
7
|
+
fromLabel: string;
|
|
8
|
+
toLabel: string;
|
|
9
|
+
context?: number;
|
|
10
|
+
}
|
|
11
|
+
/** Unified diff from `from` to `to`, or an empty string when they are equal. */
|
|
12
|
+
export declare function unifiedDiff(from: string, to: string, options: UnifiedDiffOptions): string;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Line-based unified diff for `podo diff <path>`. It is dependency-free and
|
|
4
|
+
* meant for source files of a few thousand lines; above `MAX_CELLS` it reports
|
|
5
|
+
* the whole file as replaced instead of building the LCS table.
|
|
6
|
+
*/
|
|
7
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
|
+
exports.unifiedDiff = unifiedDiff;
|
|
9
|
+
const MAX_CELLS = 25_000_000;
|
|
10
|
+
function splitLines(text) {
|
|
11
|
+
if (text === "")
|
|
12
|
+
return [];
|
|
13
|
+
const lines = text.split("\n");
|
|
14
|
+
if (lines[lines.length - 1] === "")
|
|
15
|
+
lines.pop();
|
|
16
|
+
return lines;
|
|
17
|
+
}
|
|
18
|
+
function operations(a, b) {
|
|
19
|
+
let start = 0;
|
|
20
|
+
while (start < a.length && start < b.length && a[start] === b[start])
|
|
21
|
+
start += 1;
|
|
22
|
+
let endA = a.length;
|
|
23
|
+
let endB = b.length;
|
|
24
|
+
while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) {
|
|
25
|
+
endA -= 1;
|
|
26
|
+
endB -= 1;
|
|
27
|
+
}
|
|
28
|
+
const head = a.slice(0, start).map((line) => ({ kind: " ", line }));
|
|
29
|
+
const tail = a.slice(endA).map((line) => ({ kind: " ", line }));
|
|
30
|
+
const midA = a.slice(start, endA);
|
|
31
|
+
const midB = b.slice(start, endB);
|
|
32
|
+
const m = midA.length;
|
|
33
|
+
const n = midB.length;
|
|
34
|
+
const middle = [];
|
|
35
|
+
if ((m + 1) * (n + 1) > MAX_CELLS) {
|
|
36
|
+
middle.push(...midA.map((line) => ({ kind: "-", line })));
|
|
37
|
+
middle.push(...midB.map((line) => ({ kind: "+", line })));
|
|
38
|
+
return [...head, ...middle, ...tail];
|
|
39
|
+
}
|
|
40
|
+
const width = n + 1;
|
|
41
|
+
const table = new Uint32Array((m + 1) * width);
|
|
42
|
+
for (let i = m - 1; i >= 0; i -= 1) {
|
|
43
|
+
for (let j = n - 1; j >= 0; j -= 1) {
|
|
44
|
+
table[i * width + j] = midA[i] === midB[j]
|
|
45
|
+
? table[(i + 1) * width + j + 1] + 1
|
|
46
|
+
: Math.max(table[(i + 1) * width + j], table[i * width + j + 1]);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
let i = 0;
|
|
50
|
+
let j = 0;
|
|
51
|
+
while (i < m || j < n) {
|
|
52
|
+
if (i < m && j < n && midA[i] === midB[j]) {
|
|
53
|
+
middle.push({ kind: " ", line: midA[i] });
|
|
54
|
+
i += 1;
|
|
55
|
+
j += 1;
|
|
56
|
+
}
|
|
57
|
+
else if (i < m && (j === n || table[(i + 1) * width + j] >= table[i * width + j + 1])) {
|
|
58
|
+
middle.push({ kind: "-", line: midA[i] });
|
|
59
|
+
i += 1;
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
middle.push({ kind: "+", line: midB[j] });
|
|
63
|
+
j += 1;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return [...head, ...middle, ...tail];
|
|
67
|
+
}
|
|
68
|
+
function range(start, count) {
|
|
69
|
+
if (count === 0)
|
|
70
|
+
return `${start},0`;
|
|
71
|
+
return count === 1 ? `${start + 1}` : `${start + 1},${count}`;
|
|
72
|
+
}
|
|
73
|
+
/** Unified diff from `from` to `to`, or an empty string when they are equal. */
|
|
74
|
+
function unifiedDiff(from, to, options) {
|
|
75
|
+
if (from === to)
|
|
76
|
+
return "";
|
|
77
|
+
const context = options.context ?? 3;
|
|
78
|
+
const ops = operations(splitLines(from), splitLines(to));
|
|
79
|
+
const changed = ops.flatMap((op, index) => (op.kind === " " ? [] : [index]));
|
|
80
|
+
const out = [`--- ${options.fromLabel}`, `+++ ${options.toLabel}`];
|
|
81
|
+
if (!changed.length) {
|
|
82
|
+
// Only the trailing newline differs.
|
|
83
|
+
out.push(from.endsWith("\n") ? "\\ the template ends with a newline" : "\\ the template has no newline at end of file", to.endsWith("\n") ? "\\ the file ends with a newline" : "\\ the file has no newline at end of file");
|
|
84
|
+
return `${out.join("\n")}\n`;
|
|
85
|
+
}
|
|
86
|
+
let hunkStart = 0;
|
|
87
|
+
while (hunkStart < changed.length) {
|
|
88
|
+
let hunkEnd = hunkStart;
|
|
89
|
+
while (hunkEnd + 1 < changed.length &&
|
|
90
|
+
changed[hunkEnd + 1] - changed[hunkEnd] <= context * 2 + 1) {
|
|
91
|
+
hunkEnd += 1;
|
|
92
|
+
}
|
|
93
|
+
const first = Math.max(0, changed[hunkStart] - context);
|
|
94
|
+
const last = Math.min(ops.length - 1, changed[hunkEnd] + context);
|
|
95
|
+
let fromLine = 0;
|
|
96
|
+
let toLine = 0;
|
|
97
|
+
for (let index = 0; index < first; index += 1) {
|
|
98
|
+
if (ops[index].kind !== "+")
|
|
99
|
+
fromLine += 1;
|
|
100
|
+
if (ops[index].kind !== "-")
|
|
101
|
+
toLine += 1;
|
|
102
|
+
}
|
|
103
|
+
const body = ops.slice(first, last + 1);
|
|
104
|
+
const fromCount = body.filter((op) => op.kind !== "+").length;
|
|
105
|
+
const toCount = body.filter((op) => op.kind !== "-").length;
|
|
106
|
+
out.push(`@@ -${range(fromLine, fromCount)} +${range(toLine, toCount)} @@`);
|
|
107
|
+
out.push(...body.map((op) => `${op.kind}${op.line}`));
|
|
108
|
+
hunkStart = hunkEnd + 1;
|
|
109
|
+
}
|
|
110
|
+
return `${out.join("\n")}\n`;
|
|
111
|
+
}
|