@cosmicdrift/kumiko-bundled-features 0.1.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/package.json +90 -0
- package/src/audit/__tests__/audit.integration.ts +328 -0
- package/src/audit/constants.ts +7 -0
- package/src/audit/feature.ts +23 -0
- package/src/audit/handlers/list.query.ts +98 -0
- package/src/audit/index.ts +2 -0
- package/src/auth-email-password/__tests__/account-lockout-no-redis.integration.ts +149 -0
- package/src/auth-email-password/__tests__/account-lockout.integration.ts +308 -0
- package/src/auth-email-password/__tests__/auth-claims.integration.ts +512 -0
- package/src/auth-email-password/__tests__/auth.integration.ts +610 -0
- package/src/auth-email-password/__tests__/confirm-token-flow.test.ts +67 -0
- package/src/auth-email-password/__tests__/email-templates.test.ts +106 -0
- package/src/auth-email-password/__tests__/email-verification.integration.ts +327 -0
- package/src/auth-email-password/__tests__/identity-v3-hash.test.ts +174 -0
- package/src/auth-email-password/__tests__/identity-v3-login.integration.ts +150 -0
- package/src/auth-email-password/__tests__/invite-flow.integration.ts +458 -0
- package/src/auth-email-password/__tests__/multi-roles.integration.ts +256 -0
- package/src/auth-email-password/__tests__/password-reset.integration.ts +346 -0
- package/src/auth-email-password/__tests__/public-routes-rate-limit.integration.ts +144 -0
- package/src/auth-email-password/__tests__/seed-admin.integration.ts +176 -0
- package/src/auth-email-password/__tests__/session-callbacks.integration.ts +310 -0
- package/src/auth-email-password/__tests__/session-strict-mode.integration.ts +101 -0
- package/src/auth-email-password/__tests__/signed-token.test.ts +78 -0
- package/src/auth-email-password/__tests__/signup-flow.integration.ts +259 -0
- package/src/auth-email-password/auth-user-row.ts +41 -0
- package/src/auth-email-password/constants.ts +101 -0
- package/src/auth-email-password/email-templates.ts +283 -0
- package/src/auth-email-password/feature.ts +140 -0
- package/src/auth-email-password/handlers/change-password.write.ts +58 -0
- package/src/auth-email-password/handlers/confirm-token-flow.ts +191 -0
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +203 -0
- package/src/auth-email-password/handlers/invite-accept.write.ts +189 -0
- package/src/auth-email-password/handlers/invite-create.write.ts +145 -0
- package/src/auth-email-password/handlers/invite-signup-complete.write.ts +192 -0
- package/src/auth-email-password/handlers/login.write.ts +208 -0
- package/src/auth-email-password/handlers/logout.write.ts +12 -0
- package/src/auth-email-password/handlers/request-email-verification.write.ts +29 -0
- package/src/auth-email-password/handlers/request-password-reset.write.ts +31 -0
- package/src/auth-email-password/handlers/reset-password.write.ts +61 -0
- package/src/auth-email-password/handlers/signup-confirm.write.ts +170 -0
- package/src/auth-email-password/handlers/signup-request.write.ts +104 -0
- package/src/auth-email-password/handlers/token-request-handler.ts +114 -0
- package/src/auth-email-password/handlers/verify-email.write.ts +62 -0
- package/src/auth-email-password/i18n.ts +211 -0
- package/src/auth-email-password/identity-v3-hash.ts +97 -0
- package/src/auth-email-password/index.ts +35 -0
- package/src/auth-email-password/invite-token-store.ts +92 -0
- package/src/auth-email-password/lockout-store.ts +118 -0
- package/src/auth-email-password/password-hashing.ts +43 -0
- package/src/auth-email-password/reset-token.ts +28 -0
- package/src/auth-email-password/seeding.ts +183 -0
- package/src/auth-email-password/signed-token.ts +85 -0
- package/src/auth-email-password/signup-token-store.ts +104 -0
- package/src/auth-email-password/stream-tenant.ts +31 -0
- package/src/auth-email-password/testing.ts +5 -0
- package/src/auth-email-password/token-burn-store.ts +57 -0
- package/src/auth-email-password/verification-token.ts +27 -0
- package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +51 -0
- package/src/auth-email-password/web/__tests__/forgot-password-screen.test.tsx +80 -0
- package/src/auth-email-password/web/__tests__/login-screen.test.tsx +94 -0
- package/src/auth-email-password/web/__tests__/reset-password-screen.test.tsx +108 -0
- package/src/auth-email-password/web/__tests__/session-roles.test.ts +54 -0
- package/src/auth-email-password/web/__tests__/tenant-switcher.test.tsx +100 -0
- package/src/auth-email-password/web/__tests__/test-utils.tsx +73 -0
- package/src/auth-email-password/web/__tests__/user-menu.test.tsx +55 -0
- package/src/auth-email-password/web/__tests__/verify-email-screen.test.tsx +59 -0
- package/src/auth-email-password/web/auth-client.ts +350 -0
- package/src/auth-email-password/web/auth-form-primitives.tsx +70 -0
- package/src/auth-email-password/web/auth-gate.tsx +33 -0
- package/src/auth-email-password/web/client-plugin.ts +48 -0
- package/src/auth-email-password/web/default-topbar-actions.tsx +47 -0
- package/src/auth-email-password/web/forgot-password-screen.tsx +110 -0
- package/src/auth-email-password/web/index.ts +56 -0
- package/src/auth-email-password/web/invite-accept-screen.tsx +220 -0
- package/src/auth-email-password/web/login-screen.tsx +150 -0
- package/src/auth-email-password/web/reset-password-screen.tsx +152 -0
- package/src/auth-email-password/web/session.tsx +171 -0
- package/src/auth-email-password/web/signup-complete-screen.tsx +150 -0
- package/src/auth-email-password/web/signup-screen.tsx +130 -0
- package/src/auth-email-password/web/tenant-switcher.tsx +116 -0
- package/src/auth-email-password/web/use-shell-user.ts +34 -0
- package/src/auth-email-password/web/user-menu.tsx +89 -0
- package/src/auth-email-password/web/verify-email-screen.tsx +102 -0
- package/src/billing-foundation/__tests__/billing-foundation.integration.ts +568 -0
- package/src/billing-foundation/__tests__/feature.test.ts +110 -0
- package/src/billing-foundation/__tests__/webhook-handler.test.ts +199 -0
- package/src/billing-foundation/aggregate-id.ts +21 -0
- package/src/billing-foundation/constants.ts +70 -0
- package/src/billing-foundation/entities.ts +50 -0
- package/src/billing-foundation/events.ts +71 -0
- package/src/billing-foundation/feature.ts +122 -0
- package/src/billing-foundation/get-subscription-for-tenant.ts +39 -0
- package/src/billing-foundation/handlers/create-checkout-session.write.ts +79 -0
- package/src/billing-foundation/handlers/create-portal-session.write.ts +73 -0
- package/src/billing-foundation/handlers/list-subscriptions.query.ts +20 -0
- package/src/billing-foundation/handlers/process-event.write.ts +160 -0
- package/src/billing-foundation/index.ts +42 -0
- package/src/billing-foundation/projection.ts +135 -0
- package/src/billing-foundation/types.ts +157 -0
- package/src/billing-foundation/webhook-handler.ts +184 -0
- package/src/cap-counter/__tests__/cap-counter.integration.ts +566 -0
- package/src/cap-counter/__tests__/enforce-cap.test.ts +422 -0
- package/src/cap-counter/__tests__/with-cap-enforcement.integration.ts +265 -0
- package/src/cap-counter/aggregate-id.ts +61 -0
- package/src/cap-counter/constants.ts +32 -0
- package/src/cap-counter/enforce-cap.ts +404 -0
- package/src/cap-counter/entity.ts +48 -0
- package/src/cap-counter/feature.ts +90 -0
- package/src/cap-counter/handlers/get-counter.query.ts +43 -0
- package/src/cap-counter/handlers/increment-rolling.write.ts +79 -0
- package/src/cap-counter/handlers/increment.write.ts +92 -0
- package/src/cap-counter/handlers/mark-soft-warned.write.ts +57 -0
- package/src/cap-counter/index.ts +34 -0
- package/src/cap-counter/with-cap-enforcement.ts +179 -0
- package/src/channel-email/email-channel.ts +48 -0
- package/src/channel-email/feature.ts +15 -0
- package/src/channel-email/index.ts +4 -0
- package/src/channel-email/smtp-transport.ts +65 -0
- package/src/channel-email/types.ts +34 -0
- package/src/channel-in-app/constants.ts +11 -0
- package/src/channel-in-app/feature.ts +30 -0
- package/src/channel-in-app/handlers/inbox.query.ts +28 -0
- package/src/channel-in-app/handlers/mark-all-read.write.ts +21 -0
- package/src/channel-in-app/handlers/mark-read.write.ts +32 -0
- package/src/channel-in-app/handlers/unread-count.query.ts +20 -0
- package/src/channel-in-app/in-app-channel.ts +44 -0
- package/src/channel-in-app/index.ts +4 -0
- package/src/channel-in-app/tables.ts +22 -0
- package/src/channel-push/feature.ts +15 -0
- package/src/channel-push/index.ts +3 -0
- package/src/channel-push/push-channel.ts +33 -0
- package/src/channel-push/types.ts +22 -0
- package/src/config/__tests__/app-overrides.test.ts +118 -0
- package/src/config/__tests__/config.integration.ts +1246 -0
- package/src/config/constants.ts +23 -0
- package/src/config/feature.ts +117 -0
- package/src/config/handlers/__tests__/prepare-config-write.test.ts +209 -0
- package/src/config/handlers/reset.write.ts +45 -0
- package/src/config/handlers/schema.query.ts +22 -0
- package/src/config/handlers/set.write.ts +93 -0
- package/src/config/handlers/values.query.ts +43 -0
- package/src/config/index.ts +15 -0
- package/src/config/resolver.ts +283 -0
- package/src/config/table.ts +35 -0
- package/src/config/write-helpers.ts +268 -0
- package/src/delivery/__tests__/delivery-events.integration.ts +166 -0
- package/src/delivery/__tests__/delivery.integration.ts +1405 -0
- package/src/delivery/constants.ts +33 -0
- package/src/delivery/delivery-service.ts +489 -0
- package/src/delivery/events.ts +18 -0
- package/src/delivery/feature.ts +70 -0
- package/src/delivery/handlers/log.query.ts +21 -0
- package/src/delivery/handlers/preferences.query.ts +18 -0
- package/src/delivery/handlers/set-preference.write.ts +28 -0
- package/src/delivery/index.ts +35 -0
- package/src/delivery/tables.ts +74 -0
- package/src/delivery/testing.ts +47 -0
- package/src/delivery/types.ts +71 -0
- package/src/delivery/unsubscribe.ts +99 -0
- package/src/delivery/upsert-preference.ts +145 -0
- package/src/feature-toggles/__tests__/feature-toggles.integration.ts +687 -0
- package/src/feature-toggles/constants.ts +20 -0
- package/src/feature-toggles/events.ts +18 -0
- package/src/feature-toggles/feature.ts +98 -0
- package/src/feature-toggles/global-feature-state-table.ts +28 -0
- package/src/feature-toggles/handlers/list.query.ts +26 -0
- package/src/feature-toggles/handlers/registered.query.ts +56 -0
- package/src/feature-toggles/handlers/set.write.ts +158 -0
- package/src/feature-toggles/index.ts +9 -0
- package/src/feature-toggles/toggle-runtime.ts +73 -0
- package/src/file-foundation/__tests__/feature.test.ts +35 -0
- package/src/file-foundation/__tests__/file-foundation.integration.ts +235 -0
- package/src/file-foundation/feature.ts +123 -0
- package/src/file-foundation/index.ts +7 -0
- package/src/file-provider-inmemory/__tests__/feature.test.ts +35 -0
- package/src/file-provider-inmemory/feature.ts +73 -0
- package/src/file-provider-inmemory/index.ts +3 -0
- package/src/file-provider-s3/__tests__/feature.test.ts +54 -0
- package/src/file-provider-s3/feature.ts +169 -0
- package/src/file-provider-s3/index.ts +3 -0
- package/src/files-provider-s3/__tests__/env-helper.test.ts +161 -0
- package/src/files-provider-s3/__tests__/s3-provider.integration.ts +134 -0
- package/src/files-provider-s3/__tests__/s3-provider.test.ts +36 -0
- package/src/files-provider-s3/env-helper.ts +49 -0
- package/src/files-provider-s3/index.ts +3 -0
- package/src/files-provider-s3/s3-provider.ts +114 -0
- package/src/foundation-shared/config-helpers.ts +67 -0
- package/src/foundation-shared/index.ts +4 -0
- package/src/jobs/__tests__/job-system-user.integration.ts +194 -0
- package/src/jobs/__tests__/jobs-events.integration.ts +143 -0
- package/src/jobs/__tests__/jobs-feature.integration.ts +342 -0
- package/src/jobs/constants.ts +21 -0
- package/src/jobs/events.ts +39 -0
- package/src/jobs/feature.ts +150 -0
- package/src/jobs/handlers/detail.query.ts +30 -0
- package/src/jobs/handlers/list.query.ts +36 -0
- package/src/jobs/handlers/retry.write.ts +69 -0
- package/src/jobs/handlers/trigger.write.ts +39 -0
- package/src/jobs/index.ts +5 -0
- package/src/jobs/job-run-logger.ts +213 -0
- package/src/jobs/job-run-table.ts +55 -0
- package/src/legal-pages/README.md +195 -0
- package/src/legal-pages/__tests__/legal-pages.integration.ts +361 -0
- package/src/legal-pages/constants.ts +36 -0
- package/src/legal-pages/feature.ts +187 -0
- package/src/legal-pages/index.ts +13 -0
- package/src/legal-pages/markdown.ts +69 -0
- package/src/mail-foundation/__tests__/feature.test.ts +46 -0
- package/src/mail-foundation/__tests__/mail-foundation.integration.ts +247 -0
- package/src/mail-foundation/feature.ts +160 -0
- package/src/mail-foundation/index.ts +14 -0
- package/src/mail-transport-inmemory/__tests__/feature.test.ts +37 -0
- package/src/mail-transport-inmemory/feature.ts +90 -0
- package/src/mail-transport-inmemory/index.ts +3 -0
- package/src/mail-transport-smtp/__tests__/feature.test.ts +61 -0
- package/src/mail-transport-smtp/feature.ts +182 -0
- package/src/mail-transport-smtp/index.ts +3 -0
- package/src/rate-limiting/__tests__/rate-limiting.integration.ts +84 -0
- package/src/rate-limiting/constants.ts +9 -0
- package/src/rate-limiting/feature.ts +16 -0
- package/src/rate-limiting/handlers/status.query.ts +52 -0
- package/src/rate-limiting/index.ts +2 -0
- package/src/renderer-simple/__tests__/simple-renderer.test.ts +97 -0
- package/src/renderer-simple/feature.ts +12 -0
- package/src/renderer-simple/index.ts +2 -0
- package/src/renderer-simple/simple-renderer.ts +72 -0
- package/src/secrets/__tests__/rotate.integration.ts +176 -0
- package/src/secrets/__tests__/secrets-events.integration.ts +125 -0
- package/src/secrets/__tests__/secrets.integration.ts +118 -0
- package/src/secrets/feature.ts +84 -0
- package/src/secrets/handlers/delete.write.ts +20 -0
- package/src/secrets/handlers/list.query.ts +38 -0
- package/src/secrets/handlers/rotate.job.ts +193 -0
- package/src/secrets/handlers/set.write.ts +50 -0
- package/src/secrets/index.ts +16 -0
- package/src/secrets/secrets-context.ts +296 -0
- package/src/secrets/table.ts +68 -0
- package/src/sessions/__tests__/cleanup.integration.ts +175 -0
- package/src/sessions/__tests__/password-auto-revoke.integration.ts +202 -0
- package/src/sessions/__tests__/sessions.integration.ts +472 -0
- package/src/sessions/__tests__/test-helpers.ts +66 -0
- package/src/sessions/constants.ts +43 -0
- package/src/sessions/feature.ts +84 -0
- package/src/sessions/handlers/cleanup.job.ts +109 -0
- package/src/sessions/handlers/list.query.ts +35 -0
- package/src/sessions/handlers/mine.query.ts +37 -0
- package/src/sessions/handlers/revoke-all-others.write.ts +42 -0
- package/src/sessions/handlers/revoke.write.ts +76 -0
- package/src/sessions/index.ts +17 -0
- package/src/sessions/schema/index.ts +5 -0
- package/src/sessions/schema/user-session.ts +67 -0
- package/src/sessions/session-callbacks.ts +110 -0
- package/src/sessions/testing.ts +42 -0
- package/src/subscription-mollie/__tests__/feature.test.ts +106 -0
- package/src/subscription-mollie/__tests__/mollie-foundation.integration.ts +421 -0
- package/src/subscription-mollie/__tests__/verify-webhook.test.ts +388 -0
- package/src/subscription-mollie/constants.ts +33 -0
- package/src/subscription-mollie/feature.ts +144 -0
- package/src/subscription-mollie/index.ts +13 -0
- package/src/subscription-mollie/plugin-methods.ts +79 -0
- package/src/subscription-mollie/verify-webhook.ts +244 -0
- package/src/subscription-stripe/__tests__/feature.test.ts +98 -0
- package/src/subscription-stripe/__tests__/plugin-methods.test.ts +161 -0
- package/src/subscription-stripe/__tests__/stripe-foundation.integration.ts +315 -0
- package/src/subscription-stripe/__tests__/verify-webhook.test.ts +306 -0
- package/src/subscription-stripe/constants.ts +20 -0
- package/src/subscription-stripe/feature.ts +120 -0
- package/src/subscription-stripe/index.ts +14 -0
- package/src/subscription-stripe/plugin-methods.ts +91 -0
- package/src/subscription-stripe/verify-webhook.ts +235 -0
- package/src/tenant/__tests__/multi-tenant.integration.ts +278 -0
- package/src/tenant/__tests__/seed-testing.integration.ts +229 -0
- package/src/tenant/__tests__/tenant.integration.ts +347 -0
- package/src/tenant/command-schemas.ts +37 -0
- package/src/tenant/constants.ts +37 -0
- package/src/tenant/feature.ts +109 -0
- package/src/tenant/handlers/active-tenant-ids.query.ts +19 -0
- package/src/tenant/handlers/add-member.write.ts +53 -0
- package/src/tenant/handlers/cancel-invitation.write.ts +87 -0
- package/src/tenant/handlers/create.write.ts +21 -0
- package/src/tenant/handlers/disable.write.ts +18 -0
- package/src/tenant/handlers/invitations.query.ts +31 -0
- package/src/tenant/handlers/list.query.ts +17 -0
- package/src/tenant/handlers/me.query.ts +17 -0
- package/src/tenant/handlers/members.query.ts +22 -0
- package/src/tenant/handlers/memberships.query.ts +24 -0
- package/src/tenant/handlers/remove-member.write.ts +40 -0
- package/src/tenant/handlers/resolve-user-ids.query.ts +43 -0
- package/src/tenant/handlers/update-member-roles.write.ts +54 -0
- package/src/tenant/handlers/update.write.ts +20 -0
- package/src/tenant/index.ts +12 -0
- package/src/tenant/invitation-table.ts +93 -0
- package/src/tenant/membership-table.ts +35 -0
- package/src/tenant/schema/index.ts +5 -0
- package/src/tenant/schema/tenant.ts +27 -0
- package/src/tenant/seeding.ts +155 -0
- package/src/tenant/testing.ts +8 -0
- package/src/text-content/README.md +190 -0
- package/src/text-content/__tests__/text-content.integration.ts +415 -0
- package/src/text-content/api.ts +92 -0
- package/src/text-content/constants.ts +19 -0
- package/src/text-content/feature.ts +29 -0
- package/src/text-content/handlers/by-slug.query.ts +55 -0
- package/src/text-content/handlers/set.write.ts +118 -0
- package/src/text-content/index.ts +14 -0
- package/src/text-content/seeding.ts +91 -0
- package/src/text-content/table.ts +45 -0
- package/src/tier-engine/__tests__/compose-app.test.ts +182 -0
- package/src/tier-engine/__tests__/drift.test.ts +42 -0
- package/src/tier-engine/__tests__/tier-engine.integration.ts +241 -0
- package/src/tier-engine/aggregate-id.ts +27 -0
- package/src/tier-engine/compose-app.ts +150 -0
- package/src/tier-engine/constants.ts +15 -0
- package/src/tier-engine/entity.ts +30 -0
- package/src/tier-engine/feature.ts +72 -0
- package/src/tier-engine/handlers/active-tier.query.ts +23 -0
- package/src/tier-engine/index.ts +22 -0
- package/src/user/__tests__/seed-testing.integration.ts +127 -0
- package/src/user/__tests__/user.integration.ts +198 -0
- package/src/user/command-schemas.ts +15 -0
- package/src/user/constants.ts +23 -0
- package/src/user/feature.ts +32 -0
- package/src/user/handlers/create.write.ts +54 -0
- package/src/user/handlers/detail.query.ts +9 -0
- package/src/user/handlers/find-for-auth.query.ts +38 -0
- package/src/user/handlers/list.query.ts +8 -0
- package/src/user/handlers/me.query.ts +15 -0
- package/src/user/handlers/update.write.ts +54 -0
- package/src/user/index.ts +4 -0
- package/src/user/schema/index.ts +5 -0
- package/src/user/schema/user.ts +69 -0
- package/src/user/seeding.ts +93 -0
- package/src/user/testing.ts +5 -0
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
import { buildServer, type JwtHelper } from "@cosmicdrift/kumiko-framework/api";
|
|
2
|
+
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
3
|
+
import {
|
|
4
|
+
createRegistry,
|
|
5
|
+
defineFeature,
|
|
6
|
+
type SessionUser,
|
|
7
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
8
|
+
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
9
|
+
import { createJobRunner, type JobRunner } from "@cosmicdrift/kumiko-framework/jobs";
|
|
10
|
+
import {
|
|
11
|
+
createTestDb,
|
|
12
|
+
createTestRedis,
|
|
13
|
+
createTestUser,
|
|
14
|
+
pushTables,
|
|
15
|
+
type TestDb,
|
|
16
|
+
type TestRedis,
|
|
17
|
+
TestUsers,
|
|
18
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
19
|
+
import { sleep } from "@cosmicdrift/kumiko-framework/testing";
|
|
20
|
+
import type { Hono } from "hono";
|
|
21
|
+
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
|
22
|
+
import { JobHandlers, JobQueries } from "../constants";
|
|
23
|
+
import { createJobsFeature } from "../feature";
|
|
24
|
+
import { createJobRunLogger } from "../job-run-logger";
|
|
25
|
+
import { jobRunLogsTable, jobRunsTable } from "../job-run-table";
|
|
26
|
+
|
|
27
|
+
// --- Setup ---
|
|
28
|
+
|
|
29
|
+
let testDb: TestDb;
|
|
30
|
+
let testRedis: TestRedis;
|
|
31
|
+
let db: DbConnection;
|
|
32
|
+
let app: Hono;
|
|
33
|
+
let jwt: JwtHelper;
|
|
34
|
+
let jobRunner: JobRunner;
|
|
35
|
+
|
|
36
|
+
const systemAdmin = TestUsers.systemAdmin;
|
|
37
|
+
const normalUser = createTestUser({ id: 2, roles: ["User"] });
|
|
38
|
+
const JWT_SECRET = "jobs-feature-test-secret-minimum-32-chars!!";
|
|
39
|
+
|
|
40
|
+
// Track job executions
|
|
41
|
+
const jobExecutions: Array<{ name: string; payload: Record<string, unknown> }> = [];
|
|
42
|
+
|
|
43
|
+
// Test feature with jobs
|
|
44
|
+
const appFeature = defineFeature("app", (r) => {
|
|
45
|
+
// A job that succeeds and logs
|
|
46
|
+
r.job("syncData", { trigger: { manual: true } }, async (payload, ctx) => {
|
|
47
|
+
ctx.log?.info("Starting sync...");
|
|
48
|
+
jobExecutions.push({ name: "app:job:sync-data", payload });
|
|
49
|
+
ctx.log?.info(`Synced ${Object.keys(payload).length} fields`);
|
|
50
|
+
ctx.log?.info("Sync complete");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// A job that fails
|
|
54
|
+
r.job("failingImport", { trigger: { manual: true } }, async (_payload, ctx) => {
|
|
55
|
+
ctx.log?.info("Connecting to import source...");
|
|
56
|
+
throw new Error("import source unreachable");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// A boot job
|
|
60
|
+
r.job("warmCache", { trigger: { manual: true }, runOnBoot: true }, async (payload) => {
|
|
61
|
+
jobExecutions.push({ name: "app:job:warm-cache", payload });
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const jobsFeature = createJobsFeature();
|
|
66
|
+
|
|
67
|
+
beforeAll(async () => {
|
|
68
|
+
testDb = await createTestDb();
|
|
69
|
+
testRedis = await createTestRedis();
|
|
70
|
+
db = testDb.db;
|
|
71
|
+
|
|
72
|
+
const registry = createRegistry([appFeature, jobsFeature]);
|
|
73
|
+
|
|
74
|
+
// jobRuns + jobRunLogs are projection tables (auto-pushed by
|
|
75
|
+
// pushTables via the registry-declared inline projections in jobs-feature).
|
|
76
|
+
// We need events + archived_streams for the ES writes the job-runner's
|
|
77
|
+
// logger does.
|
|
78
|
+
await pushTables(db, { jobRunsTable, jobRunLogsTable });
|
|
79
|
+
await createEventsTable(db);
|
|
80
|
+
|
|
81
|
+
const redisUrl = `redis://${testRedis.redis.options.host}:${testRedis.redis.options.port}/${testRedis.redis.options.db}`;
|
|
82
|
+
const logger = createJobRunLogger({ db, registry });
|
|
83
|
+
|
|
84
|
+
jobRunner = createJobRunner({
|
|
85
|
+
registry,
|
|
86
|
+
context: { db }, // jobRunner wired in after creation
|
|
87
|
+
redisUrl,
|
|
88
|
+
consumerLane: "worker",
|
|
89
|
+
queueNamePrefix: `kumiko-jobs-feature-test-${Date.now()}`,
|
|
90
|
+
...logger,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// Wire jobRunner into context after creation
|
|
94
|
+
const context = { db, registry, jobRunner };
|
|
95
|
+
|
|
96
|
+
const server = buildServer({
|
|
97
|
+
registry,
|
|
98
|
+
context,
|
|
99
|
+
jwtSecret: JWT_SECRET,
|
|
100
|
+
});
|
|
101
|
+
app = server.app;
|
|
102
|
+
jwt = server.jwt;
|
|
103
|
+
|
|
104
|
+
await jobRunner.start();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
afterAll(async () => {
|
|
108
|
+
await jobRunner.stop();
|
|
109
|
+
await testDb.cleanup();
|
|
110
|
+
await testRedis.cleanup();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// --- Helpers ---
|
|
114
|
+
|
|
115
|
+
async function req(
|
|
116
|
+
method: string,
|
|
117
|
+
path: string,
|
|
118
|
+
user: SessionUser,
|
|
119
|
+
body?: unknown,
|
|
120
|
+
): Promise<Response> {
|
|
121
|
+
const token = await jwt.sign(user);
|
|
122
|
+
const init: RequestInit = {
|
|
123
|
+
method,
|
|
124
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
|
125
|
+
};
|
|
126
|
+
if (body) init.body = JSON.stringify(body);
|
|
127
|
+
return app.request(path, init);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function write(user: SessionUser, type: string, payload: unknown) {
|
|
131
|
+
const res = await req("POST", "/api/write", user, { type, payload });
|
|
132
|
+
return res.json();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function query(user: SessionUser, type: string, payload: unknown) {
|
|
136
|
+
const res = await req("POST", "/api/query", user, { type, payload });
|
|
137
|
+
return res.json();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// --- Scenario 1: Trigger a job, verify it runs and gets logged ---
|
|
141
|
+
|
|
142
|
+
describe("scenario 1: trigger job → JobRun logged", () => {
|
|
143
|
+
test("SystemAdmin triggers a job via API", async () => {
|
|
144
|
+
const result = await write(systemAdmin, JobHandlers.trigger, {
|
|
145
|
+
jobName: "app:job:sync-data",
|
|
146
|
+
payload: { source: "crm" },
|
|
147
|
+
});
|
|
148
|
+
expect(result.isSuccess).toBe(true);
|
|
149
|
+
expect(result.data.jobName).toBe("app:job:sync-data");
|
|
150
|
+
expect(result.data.bullJobId).toBeDefined();
|
|
151
|
+
|
|
152
|
+
// Wait for BullMQ to process
|
|
153
|
+
await sleep(1000);
|
|
154
|
+
|
|
155
|
+
// Job actually ran
|
|
156
|
+
const executed = jobExecutions.filter((e) => e.name === "app:job:sync-data");
|
|
157
|
+
expect(executed.length).toBeGreaterThanOrEqual(1);
|
|
158
|
+
expect(executed[0]?.payload).toEqual({ source: "crm" });
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("JobRun is logged in DB with status=completed", async () => {
|
|
162
|
+
const result = await query(systemAdmin, JobQueries.list, { jobName: "app:job:sync-data" });
|
|
163
|
+
const runs = result.data.rows;
|
|
164
|
+
expect(runs.length).toBeGreaterThanOrEqual(1);
|
|
165
|
+
|
|
166
|
+
const run = runs[0];
|
|
167
|
+
expect(run.status).toBe("completed");
|
|
168
|
+
expect(run.duration).toBeGreaterThanOrEqual(0);
|
|
169
|
+
expect(run.finishedAt).toBeDefined();
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// --- Scenario 2: Failed job → status=failed with error ---
|
|
174
|
+
|
|
175
|
+
describe("scenario 2: failed job gets logged", () => {
|
|
176
|
+
test("trigger a failing job", async () => {
|
|
177
|
+
const result = await write(systemAdmin, JobHandlers.trigger, {
|
|
178
|
+
jobName: "app:job:failing-import",
|
|
179
|
+
});
|
|
180
|
+
expect(result.isSuccess).toBe(true);
|
|
181
|
+
|
|
182
|
+
// Wait for BullMQ to process and fail
|
|
183
|
+
await sleep(1500);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("JobRun has status=failed with error message", async () => {
|
|
187
|
+
const result = await query(systemAdmin, JobQueries.list, {
|
|
188
|
+
jobName: "app:job:failing-import",
|
|
189
|
+
status: "failed",
|
|
190
|
+
});
|
|
191
|
+
const runs = result.data.rows;
|
|
192
|
+
expect(runs.length).toBeGreaterThanOrEqual(1);
|
|
193
|
+
|
|
194
|
+
const run = runs[0];
|
|
195
|
+
expect(run.status).toBe("failed");
|
|
196
|
+
expect(run.error).toContain("import source unreachable");
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// --- Scenario 3: jobs.list with filters ---
|
|
201
|
+
|
|
202
|
+
describe("scenario 3: jobs.list filters", () => {
|
|
203
|
+
test("list all runs", async () => {
|
|
204
|
+
const result = await query(systemAdmin, JobQueries.list, {});
|
|
205
|
+
expect(result.data.rows.length).toBeGreaterThanOrEqual(2);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("filter by status=completed", async () => {
|
|
209
|
+
const result = await query(systemAdmin, JobQueries.list, { status: "completed" });
|
|
210
|
+
for (const run of result.data.rows) {
|
|
211
|
+
expect(run.status).toBe("completed");
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("filter by jobName", async () => {
|
|
216
|
+
const result = await query(systemAdmin, JobQueries.list, { jobName: "app:job:sync-data" });
|
|
217
|
+
for (const run of result.data.rows) {
|
|
218
|
+
expect(run.jobName).toBe("app:job:sync-data");
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// --- Scenario 4: jobs.detail ---
|
|
224
|
+
|
|
225
|
+
describe("scenario 4: jobs.detail", () => {
|
|
226
|
+
test("returns single job run with all fields", async () => {
|
|
227
|
+
// Get first run from list
|
|
228
|
+
const listResult = await query(systemAdmin, JobQueries.list, { jobName: "app:job:sync-data" });
|
|
229
|
+
const runId = listResult.data.rows[0].id;
|
|
230
|
+
|
|
231
|
+
const result = await query(systemAdmin, JobQueries.details, { runId });
|
|
232
|
+
const run = result.data;
|
|
233
|
+
expect(run).not.toBeNull();
|
|
234
|
+
expect(run.id).toBe(runId);
|
|
235
|
+
expect(run.jobName).toBe("app:job:sync-data");
|
|
236
|
+
expect(run.status).toBe("completed");
|
|
237
|
+
expect(run.duration).toBeDefined();
|
|
238
|
+
expect(run.startedAt).toBeDefined();
|
|
239
|
+
expect(run.finishedAt).toBeDefined();
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("detail includes log entries from ctx.log()", async () => {
|
|
243
|
+
const listResult = await query(systemAdmin, JobQueries.list, { jobName: "app:job:sync-data" });
|
|
244
|
+
const runId = listResult.data.rows[0].id;
|
|
245
|
+
|
|
246
|
+
const result = await query(systemAdmin, JobQueries.details, { runId });
|
|
247
|
+
const run = result.data;
|
|
248
|
+
|
|
249
|
+
expect(run.logs).toBeDefined();
|
|
250
|
+
expect(run.logs.length).toBe(3);
|
|
251
|
+
expect(run.logs[0].level).toBe("info");
|
|
252
|
+
expect(run.logs[0].message).toBe("Starting sync...");
|
|
253
|
+
expect(run.logs[2].message).toBe("Sync complete");
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test("failed job detail includes log entries before error", async () => {
|
|
257
|
+
const listResult = await query(systemAdmin, JobQueries.list, {
|
|
258
|
+
jobName: "app:job:failing-import",
|
|
259
|
+
status: "failed",
|
|
260
|
+
});
|
|
261
|
+
const runId = listResult.data.rows[0].id;
|
|
262
|
+
|
|
263
|
+
const result = await query(systemAdmin, JobQueries.details, { runId });
|
|
264
|
+
const run = result.data;
|
|
265
|
+
|
|
266
|
+
expect(run.logs.length).toBeGreaterThanOrEqual(2);
|
|
267
|
+
// First log is the info from before the error
|
|
268
|
+
expect(run.logs[0].message).toBe("Connecting to import source...");
|
|
269
|
+
// Last log is the error itself
|
|
270
|
+
const errorLog = run.logs.find((l: Record<string, unknown>) => l["level"] === "error");
|
|
271
|
+
expect(errorLog).toBeDefined();
|
|
272
|
+
expect(errorLog.message).toContain("import source unreachable");
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("returns null for non-existent run", async () => {
|
|
276
|
+
// Post-ES: runId is a uuid-string aggregate-id. A random v4 is
|
|
277
|
+
// guaranteed not to exist — same "miss" intent as the pre-ES `99999`.
|
|
278
|
+
const result = await query(systemAdmin, JobQueries.details, {
|
|
279
|
+
runId: "00000000-0000-4000-8000-000000099999",
|
|
280
|
+
});
|
|
281
|
+
expect(result.data).toBeNull();
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
// --- Scenario 5: jobs.retry ---
|
|
286
|
+
|
|
287
|
+
describe("scenario 5: retry failed job", () => {
|
|
288
|
+
test("retry creates a new job run", async () => {
|
|
289
|
+
// Find the failed run
|
|
290
|
+
const listResult = await query(systemAdmin, JobQueries.list, {
|
|
291
|
+
jobName: "app:job:failing-import",
|
|
292
|
+
status: "failed",
|
|
293
|
+
});
|
|
294
|
+
const failedRunId = listResult.data.rows[0].id;
|
|
295
|
+
|
|
296
|
+
// Retry it
|
|
297
|
+
const result = await write(systemAdmin, JobHandlers.retry, { runId: failedRunId });
|
|
298
|
+
expect(result.isSuccess).toBe(true);
|
|
299
|
+
expect(result.data.jobName).toBe("app:job:failing-import");
|
|
300
|
+
expect(result.data.retriedFromRunId).toBe(failedRunId);
|
|
301
|
+
|
|
302
|
+
// Wait for BullMQ to process (will fail again)
|
|
303
|
+
await sleep(1500);
|
|
304
|
+
|
|
305
|
+
// Should have a new run (also failed)
|
|
306
|
+
const afterList = await query(systemAdmin, JobQueries.list, {
|
|
307
|
+
jobName: "app:job:failing-import",
|
|
308
|
+
});
|
|
309
|
+
expect(afterList.data.rows.length).toBeGreaterThanOrEqual(2);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test("retry on non-failed job is rejected", async () => {
|
|
313
|
+
const listResult = await query(systemAdmin, JobQueries.list, { status: "completed" });
|
|
314
|
+
if (listResult.data.rows.length > 0) {
|
|
315
|
+
const completedRunId = listResult.data.rows[0].id;
|
|
316
|
+
const result = await write(systemAdmin, JobHandlers.retry, { runId: completedRunId });
|
|
317
|
+
expect(result.isSuccess).toBe(false);
|
|
318
|
+
expect(result.error.code).toBe("unprocessable");
|
|
319
|
+
expect(result.error.details).toMatchObject({ reason: "only_failed_jobs_can_be_retried" });
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
// --- Access control ---
|
|
325
|
+
|
|
326
|
+
describe("access control", () => {
|
|
327
|
+
test("normal user cannot trigger jobs", async () => {
|
|
328
|
+
const res = await req("POST", "/api/write", normalUser, {
|
|
329
|
+
type: JobHandlers.trigger,
|
|
330
|
+
payload: { jobName: "app:job:sync-data" },
|
|
331
|
+
});
|
|
332
|
+
expect(res.status).toBe(403);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
test("normal user cannot list job runs", async () => {
|
|
336
|
+
const res = await req("POST", "/api/query", normalUser, {
|
|
337
|
+
type: JobQueries.list,
|
|
338
|
+
payload: {},
|
|
339
|
+
});
|
|
340
|
+
expect(res.status).not.toBe(200);
|
|
341
|
+
});
|
|
342
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Feature name
|
|
2
|
+
export const JOBS_FEATURE = "jobs" as const;
|
|
3
|
+
|
|
4
|
+
// Qualified write handler names (QN format: scope:type:name)
|
|
5
|
+
export const JobHandlers = {
|
|
6
|
+
trigger: "jobs:write:trigger",
|
|
7
|
+
retry: "jobs:write:retry",
|
|
8
|
+
} as const;
|
|
9
|
+
|
|
10
|
+
// Qualified query handler names (QN format: scope:type:name)
|
|
11
|
+
export const JobQueries = {
|
|
12
|
+
list: "jobs:query:list",
|
|
13
|
+
details: "jobs:query:details",
|
|
14
|
+
} as const;
|
|
15
|
+
|
|
16
|
+
// Error codes
|
|
17
|
+
export const JobErrors = {
|
|
18
|
+
unknownJob: "unknown_job",
|
|
19
|
+
notFound: "not_found",
|
|
20
|
+
onlyFailedCanRetry: "only_failed_jobs_can_be_retried",
|
|
21
|
+
} as const;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Event-payload schemas for the jobRun aggregate. Shared between
|
|
2
|
+
// jobs-feature.ts (registers them via r.defineEvent and consumes them
|
|
3
|
+
// in the inline-projections) and job-run-logger.ts (parses payloads
|
|
4
|
+
// before low-level append() so out-of-dispatcher writes stay as
|
|
5
|
+
// type-safe as ctx.appendEvent writes).
|
|
6
|
+
//
|
|
7
|
+
// Keeping them in a separate module avoids the circular import between
|
|
8
|
+
// jobs-feature.ts (imports the logger) and job-run-logger.ts.
|
|
9
|
+
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
|
|
12
|
+
export const jobLogEntrySchema = z.object({
|
|
13
|
+
level: z.enum(["info", "warn", "error"]),
|
|
14
|
+
message: z.string(),
|
|
15
|
+
timestamp: z.string(),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export const runStartedSchema = z.object({
|
|
19
|
+
jobName: z.string(),
|
|
20
|
+
bullJobId: z.string(),
|
|
21
|
+
status: z.literal("running"),
|
|
22
|
+
payload: z.string().nullable(),
|
|
23
|
+
triggeredById: z.string().nullable(),
|
|
24
|
+
startedAt: z.string(),
|
|
25
|
+
attempt: z.number(),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
export const runCompletedSchema = z.object({
|
|
29
|
+
duration: z.number(),
|
|
30
|
+
finishedAt: z.string(),
|
|
31
|
+
logs: z.array(jobLogEntrySchema),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
export const runFailedSchema = z.object({
|
|
35
|
+
duration: z.number(),
|
|
36
|
+
finishedAt: z.string(),
|
|
37
|
+
error: z.string(),
|
|
38
|
+
logs: z.array(jobLogEntrySchema),
|
|
39
|
+
});
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import {
|
|
2
|
+
defineApply,
|
|
3
|
+
defineFeature,
|
|
4
|
+
type FeatureDefinition,
|
|
5
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
6
|
+
import { eq } from "drizzle-orm";
|
|
7
|
+
import type { z } from "zod";
|
|
8
|
+
// Event-payload schemas live in a sibling module so the logger can import
|
|
9
|
+
// them without the cycle jobs-feature ↔ job-run-logger. The logger parses
|
|
10
|
+
// payloads against these schemas before low-level append() — that's what
|
|
11
|
+
// keeps out-of-dispatcher writes as type-safe as ctx.appendEvent.
|
|
12
|
+
import { runCompletedSchema, runFailedSchema, runStartedSchema } from "./events";
|
|
13
|
+
import { detailQuery } from "./handlers/detail.query";
|
|
14
|
+
import { listQuery } from "./handlers/list.query";
|
|
15
|
+
import { retryWrite } from "./handlers/retry.write";
|
|
16
|
+
import { triggerWrite } from "./handlers/trigger.write";
|
|
17
|
+
import {
|
|
18
|
+
JOB_RUN_COMPLETED_EVENT,
|
|
19
|
+
JOB_RUN_FAILED_EVENT,
|
|
20
|
+
JOB_RUN_STARTED_EVENT,
|
|
21
|
+
} from "./job-run-logger";
|
|
22
|
+
import { jobRunLogsTable, jobRunsTable } from "./job-run-table";
|
|
23
|
+
|
|
24
|
+
export function createJobsFeature(): FeatureDefinition {
|
|
25
|
+
return defineFeature("jobs", (r) => {
|
|
26
|
+
r.systemScope();
|
|
27
|
+
// Events-only aggregate: "jobRun" has no r.entity registration, because
|
|
28
|
+
// the entire lifecycle is driven by BullMQ-callback → r.defineEvent
|
|
29
|
+
// (no executor, no CRUD). The boot-validator accepts the two
|
|
30
|
+
// projections below because every apply-key is a registered
|
|
31
|
+
// domain-event.
|
|
32
|
+
r.defineEvent("run-started", runStartedSchema);
|
|
33
|
+
r.defineEvent("run-completed", runCompletedSchema);
|
|
34
|
+
r.defineEvent("run-failed", runFailedSchema);
|
|
35
|
+
|
|
36
|
+
// Inline projection: status-row in jobRunsTable. Runs in same TX as
|
|
37
|
+
// the event-append (the logger calls runProjectionsForEvent manually
|
|
38
|
+
// because the BullMQ-callback path has no dispatcher-ctx).
|
|
39
|
+
r.projection({
|
|
40
|
+
name: "job-runs",
|
|
41
|
+
source: "jobRun",
|
|
42
|
+
table: jobRunsTable,
|
|
43
|
+
apply: {
|
|
44
|
+
[JOB_RUN_STARTED_EVENT]: defineApply<z.infer<typeof runStartedSchema>>(
|
|
45
|
+
async (event, tx) => {
|
|
46
|
+
const p = event.payload;
|
|
47
|
+
await tx.insert(jobRunsTable).values({
|
|
48
|
+
id: event.aggregateId,
|
|
49
|
+
tenantId: event.tenantId,
|
|
50
|
+
version: event.version,
|
|
51
|
+
insertedAt: event.createdAt,
|
|
52
|
+
insertedById: event.metadata?.userId ?? "system",
|
|
53
|
+
jobName: p.jobName,
|
|
54
|
+
bullJobId: p.bullJobId,
|
|
55
|
+
status: p.status,
|
|
56
|
+
payload: p.payload,
|
|
57
|
+
attempt: p.attempt,
|
|
58
|
+
startedAt: Temporal.Instant.from(p.startedAt),
|
|
59
|
+
triggeredById: p.triggeredById,
|
|
60
|
+
});
|
|
61
|
+
},
|
|
62
|
+
),
|
|
63
|
+
[JOB_RUN_COMPLETED_EVENT]: defineApply<z.infer<typeof runCompletedSchema>>(
|
|
64
|
+
async (event, tx) => {
|
|
65
|
+
const p = event.payload;
|
|
66
|
+
await tx
|
|
67
|
+
.update(jobRunsTable)
|
|
68
|
+
.set({
|
|
69
|
+
status: "completed",
|
|
70
|
+
duration: p.duration,
|
|
71
|
+
finishedAt: Temporal.Instant.from(p.finishedAt),
|
|
72
|
+
version: event.version,
|
|
73
|
+
modifiedAt: event.createdAt,
|
|
74
|
+
modifiedById: event.metadata?.userId ?? "system",
|
|
75
|
+
})
|
|
76
|
+
.where(eq(jobRunsTable.id, event.aggregateId));
|
|
77
|
+
},
|
|
78
|
+
),
|
|
79
|
+
[JOB_RUN_FAILED_EVENT]: defineApply<z.infer<typeof runFailedSchema>>(async (event, tx) => {
|
|
80
|
+
const p = event.payload;
|
|
81
|
+
await tx
|
|
82
|
+
.update(jobRunsTable)
|
|
83
|
+
.set({
|
|
84
|
+
status: "failed",
|
|
85
|
+
error: p.error,
|
|
86
|
+
duration: p.duration,
|
|
87
|
+
finishedAt: Temporal.Instant.from(p.finishedAt),
|
|
88
|
+
version: event.version,
|
|
89
|
+
modifiedAt: event.createdAt,
|
|
90
|
+
modifiedById: event.metadata?.userId ?? "system",
|
|
91
|
+
})
|
|
92
|
+
.where(eq(jobRunsTable.id, event.aggregateId));
|
|
93
|
+
}),
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// Second inline projection — same source, different table. Expands
|
|
98
|
+
// the batched logs array from completed/failed events into N rows
|
|
99
|
+
// per run in jobRunLogsTable.
|
|
100
|
+
r.projection({
|
|
101
|
+
name: "job-run-logs",
|
|
102
|
+
source: "jobRun",
|
|
103
|
+
table: jobRunLogsTable,
|
|
104
|
+
apply: {
|
|
105
|
+
[JOB_RUN_COMPLETED_EVENT]: defineApply<z.infer<typeof runCompletedSchema>>(
|
|
106
|
+
async (event, tx) => {
|
|
107
|
+
const p = event.payload;
|
|
108
|
+
// skip: empty log batch — the worker ran silent. No child rows
|
|
109
|
+
// to insert; the completed-event alone already updated the run's
|
|
110
|
+
// status via the sibling job-runs projection.
|
|
111
|
+
if (p.logs.length === 0) return;
|
|
112
|
+
await tx.insert(jobRunLogsTable).values(
|
|
113
|
+
p.logs.map((log) => ({
|
|
114
|
+
runId: event.aggregateId,
|
|
115
|
+
level: log.level,
|
|
116
|
+
message: log.message,
|
|
117
|
+
timestamp: Temporal.Instant.from(log.timestamp),
|
|
118
|
+
})),
|
|
119
|
+
);
|
|
120
|
+
},
|
|
121
|
+
),
|
|
122
|
+
[JOB_RUN_FAILED_EVENT]: defineApply<z.infer<typeof runFailedSchema>>(async (event, tx) => {
|
|
123
|
+
const p = event.payload;
|
|
124
|
+
// skip: empty log batch — the worker ran silent (mirror of completed)
|
|
125
|
+
if (p.logs.length === 0) return;
|
|
126
|
+
await tx.insert(jobRunLogsTable).values(
|
|
127
|
+
p.logs.map((log) => ({
|
|
128
|
+
runId: event.aggregateId,
|
|
129
|
+
level: log.level,
|
|
130
|
+
message: log.message,
|
|
131
|
+
timestamp: Temporal.Instant.from(log.timestamp),
|
|
132
|
+
})),
|
|
133
|
+
);
|
|
134
|
+
}),
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const handlers = {
|
|
139
|
+
trigger: r.writeHandler(triggerWrite),
|
|
140
|
+
retry: r.writeHandler(retryWrite),
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const queries = {
|
|
144
|
+
list: r.queryHandler(listQuery),
|
|
145
|
+
detail: r.queryHandler(detailQuery),
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
return { handlers, queries };
|
|
149
|
+
});
|
|
150
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { fetchOne } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { eq } from "drizzle-orm";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { jobRunLogsTable, jobRunsTable } from "../job-run-table";
|
|
6
|
+
|
|
7
|
+
export const detailQuery = defineQueryHandler({
|
|
8
|
+
name: "details",
|
|
9
|
+
// Post-ES: runId is the uuid aggregate-id of the jobRun event-stream.
|
|
10
|
+
// Pre-ES callers passed the serial row-id; the migration is breaking
|
|
11
|
+
// for API callers (intentional — jobs is framework-ops, no external
|
|
12
|
+
// contract). z.uuid() guards against accidental number-id passing.
|
|
13
|
+
schema: z.object({ runId: z.uuid() }),
|
|
14
|
+
access: { roles: ["SystemAdmin"] },
|
|
15
|
+
handler: async (query, ctx) => {
|
|
16
|
+
const db = ctx.db;
|
|
17
|
+
|
|
18
|
+
const row = await fetchOne(db, jobRunsTable, eq(jobRunsTable.id, query.payload.runId));
|
|
19
|
+
|
|
20
|
+
if (!row) return null;
|
|
21
|
+
|
|
22
|
+
const logs = await db
|
|
23
|
+
.select()
|
|
24
|
+
.from(jobRunLogsTable)
|
|
25
|
+
.where(eq(jobRunLogsTable.runId, query.payload.runId))
|
|
26
|
+
.orderBy(jobRunLogsTable.id);
|
|
27
|
+
|
|
28
|
+
return { ...row, logs };
|
|
29
|
+
},
|
|
30
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { and, desc, eq } from "drizzle-orm";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { type JobRunStatus, jobRunsTable } from "../job-run-table";
|
|
5
|
+
|
|
6
|
+
export const listQuery = defineQueryHandler({
|
|
7
|
+
name: "list",
|
|
8
|
+
schema: z.object({
|
|
9
|
+
jobName: z.string().optional(),
|
|
10
|
+
status: z.enum(["queued", "running", "completed", "failed"]).optional(),
|
|
11
|
+
limit: z.number().optional(),
|
|
12
|
+
}),
|
|
13
|
+
access: { roles: ["SystemAdmin"] },
|
|
14
|
+
handler: async (query, ctx) => {
|
|
15
|
+
const db = ctx.db;
|
|
16
|
+
const conditions = [];
|
|
17
|
+
|
|
18
|
+
if (query.payload.jobName) {
|
|
19
|
+
conditions.push(eq(jobRunsTable.jobName, query.payload.jobName));
|
|
20
|
+
}
|
|
21
|
+
if (query.payload.status) {
|
|
22
|
+
conditions.push(eq(jobRunsTable.status, query.payload.status as JobRunStatus));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const limit = query.payload.limit ?? 50;
|
|
26
|
+
|
|
27
|
+
const rows = await db
|
|
28
|
+
.select()
|
|
29
|
+
.from(jobRunsTable)
|
|
30
|
+
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
|
31
|
+
.orderBy(desc(jobRunsTable.id))
|
|
32
|
+
.limit(limit);
|
|
33
|
+
|
|
34
|
+
return { rows };
|
|
35
|
+
},
|
|
36
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { fetchOne } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import {
|
|
4
|
+
NotFoundError,
|
|
5
|
+
UnprocessableError,
|
|
6
|
+
writeFailure,
|
|
7
|
+
} from "@cosmicdrift/kumiko-framework/errors";
|
|
8
|
+
import type { JobRunner } from "@cosmicdrift/kumiko-framework/jobs";
|
|
9
|
+
import { parseJsonOrThrow } from "@cosmicdrift/kumiko-framework/utils";
|
|
10
|
+
import { eq } from "drizzle-orm";
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
import { JobErrors } from "../constants";
|
|
13
|
+
import { jobRunsTable } from "../job-run-table";
|
|
14
|
+
|
|
15
|
+
type JobRunRow = {
|
|
16
|
+
readonly status: string;
|
|
17
|
+
readonly jobName: string;
|
|
18
|
+
readonly payload: string | null;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export const retryWrite = defineWriteHandler({
|
|
22
|
+
name: "retry",
|
|
23
|
+
// Post-ES: runId is the uuid aggregate-id. See detail.query for the
|
|
24
|
+
// rationale — jobs is framework-ops, callers are admin tooling only.
|
|
25
|
+
schema: z.object({ runId: z.uuid() }),
|
|
26
|
+
access: { roles: ["SystemAdmin"] },
|
|
27
|
+
handler: async (event, ctx) => {
|
|
28
|
+
const db = ctx.db;
|
|
29
|
+
// @cast-boundary engine-payload — JobRunner attached by app-boot via ctx-extension
|
|
30
|
+
const jobRunner = ctx["jobRunner"] as JobRunner;
|
|
31
|
+
|
|
32
|
+
const run = await fetchOne<JobRunRow>(
|
|
33
|
+
db,
|
|
34
|
+
jobRunsTable,
|
|
35
|
+
eq(jobRunsTable.id, event.payload.runId),
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
if (!run) {
|
|
39
|
+
return writeFailure(
|
|
40
|
+
new NotFoundError("jobRun", event.payload.runId, {
|
|
41
|
+
i18nKey: "jobs.errors.notFound",
|
|
42
|
+
}),
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (run.status !== "failed") {
|
|
47
|
+
return writeFailure(
|
|
48
|
+
new UnprocessableError(JobErrors.onlyFailedCanRetry, {
|
|
49
|
+
i18nKey: "jobs.errors.onlyFailedCanRetry",
|
|
50
|
+
details: { status: run.status },
|
|
51
|
+
}),
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const payload = run.payload
|
|
56
|
+
? parseJsonOrThrow<Record<string, unknown>>(
|
|
57
|
+
run.payload,
|
|
58
|
+
`job run ${event.payload.runId} payload`,
|
|
59
|
+
)
|
|
60
|
+
: {};
|
|
61
|
+
|
|
62
|
+
const bullJobId = await jobRunner.dispatch(run.jobName, payload);
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
isSuccess: true,
|
|
66
|
+
data: { jobName: run.jobName, bullJobId, retriedFromRunId: event.payload.runId },
|
|
67
|
+
};
|
|
68
|
+
},
|
|
69
|
+
});
|