@cosmicdrift/kumiko-bundled-features 0.352.0 → 0.354.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-tools/agent-doc-lint.d.ts +2 -0
- package/dist/agent-tools/agent-doc-lint.js +61 -1
- package/dist/agent-tools/agent-manifest.d.ts +1 -0
- package/dist/agent-tools/agent-manifest.js +17 -5
- package/dist/agent-tools/feature.js +7 -2
- package/dist/agent-tools/index.d.ts +1 -1
- package/dist/agent-tools/index.js +1 -1
- package/dist/agent-tools/manifest-deny-qns.d.ts +3 -0
- package/dist/agent-tools/manifest-deny-qns.js +17 -0
- package/dist/agent-tools/tool-catalog.d.ts +2 -1
- package/dist/agent-tools/tool-catalog.js +4 -1
- package/dist/agent-tools/tool-dispatch.js +1 -1
- package/dist/audit/handlers/list.query.js +13 -8
- package/dist/auth-email-password/handlers/change-password.write.js +1 -0
- package/dist/auth-email-password/handlers/invite-accept-with-login.write.js +2 -2
- package/dist/auth-email-password/handlers/invite-accept.write.js +2 -2
- package/dist/auth-email-password/handlers/invite-create.write.js +17 -0
- package/dist/auth-email-password/handlers/invite-signup-complete.write.js +4 -1
- package/dist/auth-email-password/handlers/login.write.js +1 -0
- package/dist/auth-email-password/handlers/reset-password.write.js +1 -0
- package/dist/auth-email-password/handlers/signup-confirm.write.js +6 -4
- package/dist/auth-email-password/handlers/signup-request.write.js +51 -77
- package/dist/auth-email-password/handlers/token-request-handler.js +1 -0
- package/dist/auth-email-password/handlers/verify-email.write.js +1 -0
- package/dist/auth-email-password/index.d.ts +2 -0
- package/dist/auth-email-password/index.js +2 -0
- package/dist/auth-email-password/seeding.d.ts +2 -0
- package/dist/auth-email-password/seeding.js +30 -8
- package/dist/auth-email-password/signup-activation.d.ts +24 -0
- package/dist/auth-email-password/signup-activation.js +41 -0
- package/dist/auth-email-password/signup-token-store.d.ts +5 -0
- package/dist/auth-email-password/signup-token-store.js +8 -3
- package/dist/auth-email-password/web/auth-gate.d.ts +2 -2
- package/dist/auth-email-password/web/auth-gate.js +5 -5
- package/dist/auth-email-password/web/auth-redirect.d.ts +1 -0
- package/dist/auth-email-password/web/auth-redirect.js +14 -0
- package/dist/auth-email-password/web/login-screen.js +2 -2
- package/dist/auth-email-password/web/user-menu.js +1 -1
- package/dist/auth-mfa/feature.js +5 -0
- package/dist/auth-mfa/handlers/enable-confirm-preauth.write.js +1 -0
- package/dist/auth-mfa/handlers/verify.write.js +1 -0
- package/dist/auth-mfa/schema/user-mfa.d.ts +6 -0
- package/dist/auth-mfa/screens.js +2 -2
- package/dist/auth-mfa/web/mfa-recovery-codes-reveal.js +1 -1
- package/dist/auth-mfa/web/mfa-setup-preauth-screen.d.ts +1 -1
- package/dist/auth-mfa/web/mfa-setup-preauth-screen.js +3 -3
- package/dist/auth-mfa/web/mfa-verify-screen.d.ts +1 -1
- package/dist/auth-mfa/web/mfa-verify-screen.js +1 -1
- package/dist/billing-foundation/billing-info-query.d.ts +5 -2
- package/dist/billing-foundation/checkout-core.d.ts +7 -0
- package/dist/billing-foundation/checkout-core.js +7 -2
- package/dist/billing-foundation/entities.d.ts +8 -0
- package/dist/billing-foundation/handlers/start-plan-checkout.write.js +7 -1
- package/dist/billing-foundation/projection.d.ts +8 -0
- package/dist/cap-counter/entity.d.ts +1 -0
- package/dist/cap-overview/access.js +2 -9
- package/dist/cap-overview/handlers/tenant-caps-list.query.js +2 -2
- package/dist/cap-overview/handlers/tenant-options.query.js +4 -5
- package/dist/channel-in-app/in-app-channel.js +1 -1
- package/dist/channel-in-app/tables.js +4 -2
- package/dist/compliance-profiles/schema/profile-selection.d.ts +10 -8
- package/dist/compliance-profiles-ops/handlers/tenants-missing-profile.query.js +2 -4
- package/dist/config/handlers/values.query.d.ts +3 -1
- package/dist/config/handlers/values.query.js +7 -2
- package/dist/config/table.d.ts +6 -0
- package/dist/crypto-shredding/handlers/forget-subject.write.d.ts +1 -0
- package/dist/crypto-shredding/handlers/forget-subject.write.js +110 -61
- package/dist/custom-fields/entity.d.ts +4 -0
- package/dist/data-retention/schema/tenant-retention-override.d.ts +4 -0
- package/dist/delivery/db/queries/address-opt-outs.d.ts +3 -1
- package/dist/delivery/db/queries/address-opt-outs.js +12 -7
- package/dist/delivery/delivery-service.js +60 -12
- package/dist/delivery/public-names.js +1 -1
- package/dist/delivery/tables.d.ts +6 -0
- package/dist/delivery/unsubscribe.js +66 -14
- package/dist/document-ingest-foundation/entity.d.ts +4 -0
- package/dist/feature-toggles/feature.js +2 -3
- package/dist/feature-toggles/toggle-runtime.d.ts +5 -1
- package/dist/feature-toggles/toggle-runtime.js +30 -6
- package/dist/feature-toggles/toggle-sync-signal.d.ts +3 -2
- package/dist/feature-toggles/toggle-sync-signal.js +29 -10
- package/dist/file-derivatives/handlers/public-variant.query.d.ts +1 -0
- package/dist/file-derivatives/handlers/public-variant.query.js +7 -1
- package/dist/files-tenant-data/handlers/sweep-orphaned-derivatives.job.js +5 -0
- package/dist/files-tenant-data/hooks.js +61 -24
- package/dist/folders/entity.d.ts +8 -0
- package/dist/folders/feature.js +2 -10
- package/dist/folders/handlers/delete-folder.write.js +31 -4
- package/dist/form-draft/db/queries/cleanup.d.ts +1 -1
- package/dist/form-draft/db/queries/cleanup.js +4 -3
- package/dist/form-draft/entity.d.ts +2 -0
- package/dist/form-draft/handlers/cleanup.job.d.ts +5 -1
- package/dist/form-draft/handlers/cleanup.job.js +11 -5
- package/dist/foundation-shared/index.d.ts +1 -1
- package/dist/foundation-shared/index.js +1 -1
- package/dist/foundation-shared/mail-host-policy.d.ts +4 -0
- package/dist/foundation-shared/mail-host-policy.js +13 -4
- package/dist/inbound-mail-foundation/entities.d.ts +25 -0
- package/dist/inbound-mail-foundation/projection.d.ts +20 -0
- package/dist/inbound-provider-imap/feature.js +2 -0
- package/dist/inbound-provider-imap/imap-client.js +3 -4
- package/dist/jobs/constants.d.ts +1 -0
- package/dist/jobs/constants.js +1 -0
- package/dist/jobs/db/queries/retention.d.ts +1 -0
- package/dist/jobs/db/queries/retention.js +5 -0
- package/dist/jobs/db/queries/stale-run-sweep.d.ts +2 -0
- package/dist/jobs/db/queries/stale-run-sweep.js +13 -2
- package/dist/jobs/feature.js +7 -1
- package/dist/jobs/handlers/list.query.d.ts +2 -1
- package/dist/jobs/handlers/list.query.js +40 -15
- package/dist/jobs/handlers/tenant-failures.query.js +1 -1
- package/dist/jobs/handlers/tenant-runs.query.d.ts +17 -0
- package/dist/jobs/handlers/tenant-runs.query.js +72 -0
- package/dist/jobs/i18n.js +1 -0
- package/dist/jobs/index.d.ts +2 -0
- package/dist/jobs/index.js +1 -0
- package/dist/jobs/job-run-logger.d.ts +2 -2
- package/dist/jobs/job-run-logger.js +98 -9
- package/dist/jobs/job-run-table.d.ts +6 -0
- package/dist/jobs/tenant-job-failure-table.js +15 -2
- package/dist/jobs/tenant-job-run-table.d.ts +4 -0
- package/dist/jobs/tenant-job-run-table.js +34 -0
- package/dist/ledger/entity.d.ts +24 -8
- package/dist/ledger/entity.js +17 -5
- package/dist/ledger/feature.js +3 -0
- package/dist/ledger/handlers/confirm-schedule-period.write.js +9 -3
- package/dist/ledger/handlers/create-transaction.write.js +1 -0
- package/dist/ledger/handlers/reverse-transaction.write.js +1 -0
- package/dist/ledger/schedule-subject-pair.d.ts +2 -0
- package/dist/ledger/schedule-subject-pair.js +24 -0
- package/dist/ledger/schemas.js +8 -2
- package/dist/mail-transport-smtp/feature.d.ts +3 -4
- package/dist/mail-transport-smtp/feature.js +4 -13
- package/dist/managed-pages/feature.js +28 -10
- package/dist/managed-pages/table.d.ts +12 -0
- package/dist/notes-history/entity.d.ts +12 -0
- package/dist/notes-history/entity.js +4 -0
- package/dist/notes-history/feature.js +2 -10
- package/dist/personal-access-tokens/handlers/create.write.js +1 -0
- package/dist/personal-access-tokens/schema/api-token.d.ts +6 -0
- package/dist/personal-access-tokens/screens.js +14 -3
- package/dist/secrets/table.d.ts +1 -0
- package/dist/seo/feature.js +28 -6
- package/dist/sessions/feature.d.ts +1 -0
- package/dist/sessions/feature.js +9 -8
- package/dist/sessions/handlers/detail.query.d.ts +1 -1
- package/dist/sessions/handlers/detail.query.js +2 -2
- package/dist/sessions/handlers/list.query.d.ts +1 -1
- package/dist/sessions/handlers/list.query.js +5 -3
- package/dist/sessions/schema/user-session.d.ts +4 -0
- package/dist/sessions/screens.d.ts +2 -2
- package/dist/sessions/screens.js +6 -7
- package/dist/shared/assert-no-where-rule-in-ownership-write.d.ts +2 -0
- package/dist/shared/assert-no-where-rule-in-ownership-write.js +18 -0
- package/dist/shared/encrypt-for-direct-write.js +10 -2
- package/dist/shared/index.d.ts +4 -3
- package/dist/shared/index.js +4 -3
- package/dist/shared/parent-visibility.d.ts +2 -1
- package/dist/shared/parent-visibility.js +21 -0
- package/dist/shared/rate-limited-text-response.d.ts +2 -0
- package/dist/shared/rate-limited-text-response.js +9 -0
- package/dist/shared/row-bound-grant.js +16 -2
- package/dist/shared/session-locale-field.d.ts +1 -0
- package/dist/shared/session-locale-field.js +6 -0
- package/dist/tags/entity.d.ts +9 -0
- package/dist/tags/feature.js +2 -10
- package/dist/template-resolver/api.d.ts +2 -2
- package/dist/template-resolver/handlers/list.query.d.ts +2 -0
- package/dist/template-resolver/handlers/list.query.js +2 -2
- package/dist/template-resolver/table.d.ts +48 -30
- package/dist/template-resolver/user-content-table.d.ts +34 -20
- package/dist/tenant/decrypt-tenant-names.d.ts +3 -0
- package/dist/tenant/decrypt-tenant-names.js +10 -0
- package/dist/tenant/feature.js +7 -1
- package/dist/tenant/handlers/me.query.js +5 -1
- package/dist/tenant/handlers/memberships.query.js +3 -2
- package/dist/tenant/handlers/tenant-directory.query.js +9 -3
- package/dist/tenant/handlers/toggle-enabled.write.js +2 -0
- package/dist/tenant/handlers/update-member-roles.write.js +1 -0
- package/dist/tenant/index.d.ts +1 -0
- package/dist/tenant/index.js +1 -0
- package/dist/tenant/invitation-table.d.ts +54 -8
- package/dist/tenant/invitation-table.js +6 -1
- package/dist/tenant/invited-membership.d.ts +8 -6
- package/dist/tenant/invited-membership.js +28 -7
- package/dist/tenant/membership-table.d.ts +4 -0
- package/dist/tenant/schema/tenant.d.ts +18 -10
- package/dist/tenant/schema/tenant.js +4 -6
- package/dist/tenant/screens.js +1 -1
- package/dist/tenant-handover/move-entity-graph.js +11 -18
- package/dist/tenant-handover/transfer-graph.d.ts +2 -21
- package/dist/tenant-handover/transfer-graph.js +8 -75
- package/dist/tenant-lifecycle/boot-checks.d.ts +1 -0
- package/dist/tenant-lifecycle/boot-checks.js +13 -0
- package/dist/tenant-lifecycle/constants.d.ts +3 -0
- package/dist/tenant-lifecycle/constants.js +5 -0
- package/dist/tenant-lifecycle/events.d.ts +5 -0
- package/dist/tenant-lifecycle/events.js +5 -0
- package/dist/tenant-lifecycle/feature.js +6 -3
- package/dist/tenant-lifecycle/run-tenant-destroy.d.ts +10 -5
- package/dist/tenant-lifecycle/run-tenant-destroy.js +59 -17
- package/dist/tenant-lifecycle/stages.d.ts +8 -1
- package/dist/tenant-lifecycle/stages.js +33 -4
- package/dist/tenant-lifecycle/testing.d.ts +8 -0
- package/dist/tenant-lifecycle/testing.js +42 -0
- package/dist/tier-engine/entity.d.ts +2 -0
- package/dist/tier-engine/handlers/set-tenant-tier.write.js +1 -0
- package/dist/tier-engine/tier-resolver.d.ts +2 -0
- package/dist/user/handlers/detail.query.js +2 -0
- package/dist/user/handlers/list.query.js +6 -2
- package/dist/user/handlers/update.write.js +1 -0
- package/dist/user/schema/user.d.ts +22 -8
- package/dist/user/seeding.d.ts +1 -0
- package/dist/user/seeding.js +20 -0
- package/dist/user-data-rights/boot-checks.js +5 -0
- package/dist/user-data-rights/handlers/confirm-deletion-by-token.write.js +2 -2
- package/dist/user-data-rights/handlers/request-deletion.write.js +1 -0
- package/dist/user-data-rights/index.d.ts +1 -0
- package/dist/user-data-rights/index.js +1 -0
- package/dist/user-data-rights/schema/download-attempt.d.ts +14 -0
- package/dist/user-data-rights/schema/download-attempt.js +17 -9
- package/dist/user-data-rights/schema/download-token.d.ts +8 -0
- package/dist/user-data-rights/schema/export-job.d.ts +16 -8
- package/dist/user-profile/handlers/change-email.write.js +8 -2
- package/dist/waitlist/entity.d.ts +22 -6
- package/dist/workflow-runner/event-trigger.d.ts +1 -0
- package/dist/workflow-runner/event-trigger.js +63 -15
- package/dist/workflow-runner/handlers/resume-run.write.js +8 -2
- package/dist/workflow-runner/runner.d.ts +2 -1
- package/dist/workflow-runner/runner.js +5 -1
- package/package.json +35 -10
- package/src/agent-tools/changes.json +12 -0
- package/src/audit/changes.json +6 -0
- package/src/auth-email-password/changes.json +24 -0
- package/src/auth-mfa/changes.json +7 -0
- package/src/billing-foundation/changes.json +6 -0
- package/src/channel-in-app/changes.json +8 -1
- package/src/crypto-shredding/changes.json +12 -0
- package/src/delivery/changes.json +6 -0
- package/src/files/changes.json +24 -0
- package/src/files-tenant-data/changes.json +5 -0
- package/src/folders/changes.json +6 -0
- package/src/form-draft/changes.json +7 -1
- package/src/inbound-provider-imap/changes.json +6 -0
- package/src/jobs/changes.json +30 -0
- package/src/ledger/changes.json +23 -0
- package/src/managed-pages/changes.json +6 -0
- package/src/notes-history/changes.json +13 -0
- package/src/seo/changes.json +5 -0
- package/src/template-resolver/changes.json +5 -0
- package/src/tenant/changes.json +19 -0
- package/src/tenant-handover/changes.json +12 -0
- package/src/tenant-lifecycle/changes.json +22 -0
- package/src/tier-engine/changes.json +5 -0
- package/src/user/changes.json +15 -0
- package/src/user-data-rights/changes.json +6 -0
- package/src/workflow-runner/changes.json +5 -0
- package/dist/shared/has-where-rule.d.ts +0 -2
- package/dist/shared/has-where-rule.js +0 -10
|
@@ -11,11 +11,13 @@
|
|
|
11
11
|
// schema/migration change, no web filter-dropdown/status-enum update, and
|
|
12
12
|
// the run becomes retriable via the existing jobs:write:retry gate
|
|
13
13
|
// (retry.write.ts only allows retry from "failed").
|
|
14
|
-
import { selectMany, updateMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
14
|
+
import { deleteManyBatched, selectMany, updateMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
15
15
|
import { mapWithConcurrency } from "../../../shared/index.js";
|
|
16
16
|
import { encryptFailureError } from "../../job-run-logger.js";
|
|
17
17
|
import { jobRunsTable } from "../../job-run-table.js";
|
|
18
|
+
import { tenantJobRunsTable } from "../../tenant-job-run-table.js";
|
|
18
19
|
const KMS_POOL_CONCURRENCY = 4;
|
|
20
|
+
const STALE_DELETE_BATCH_SIZE = 500;
|
|
19
21
|
export const STALE_JOB_RUN_ERROR = "job run exceeded the stale-run timeout without a completion signal (likely a crashed worker process)";
|
|
20
22
|
export async function markStaleJobRunsFailed(db, timeoutHours) {
|
|
21
23
|
const cutoff = Temporal.Now.instant().subtract({ hours: timeoutHours });
|
|
@@ -57,5 +59,14 @@ export async function markStaleJobRunsFailed(db, timeoutHours) {
|
|
|
57
59
|
return updated.length;
|
|
58
60
|
});
|
|
59
61
|
const runsMarkedFailed = updatedPerSubject.reduce((sum, count) => sum + count, 0);
|
|
60
|
-
|
|
62
|
+
// Tenant-visible run state follows the same clock: a running row whose
|
|
63
|
+
// worker died is a failure, a queued row that never started (job lost from
|
|
64
|
+
// Redis, enqueue hook without a run) would otherwise show "waiting" forever.
|
|
65
|
+
const tenantRunsFailed = await updateMany(db, tenantJobRunsTable, { status: "failed", finishedAt: now, updatedAt: now }, { status: "running", startedAt: { lt: cutoff } });
|
|
66
|
+
const tenantQueuedStale = await deleteManyBatched(db, tenantJobRunsTable, { status: "queued", updatedAt: { lt: cutoff } }, { limit: STALE_DELETE_BATCH_SIZE });
|
|
67
|
+
return {
|
|
68
|
+
runsMarkedFailed,
|
|
69
|
+
tenantRunsMarkedFailed: tenantRunsFailed.length,
|
|
70
|
+
tenantQueuedDropped: tenantQueuedStale.deleted,
|
|
71
|
+
};
|
|
61
72
|
}
|
package/dist/jobs/feature.js
CHANGED
|
@@ -9,15 +9,17 @@ import { createRetentionCleanupJob, DEFAULT_JOB_RUN_RETENTION_DAYS, } from "./ha
|
|
|
9
9
|
import { retryWrite } from "./handlers/retry.write.js";
|
|
10
10
|
import { createStaleRunSweepJob, DEFAULT_JOB_RUN_STALE_TIMEOUT_HOURS, } from "./handlers/stale-run-sweep.job.js";
|
|
11
11
|
import { tenantFailuresQuery } from "./handlers/tenant-failures.query.js";
|
|
12
|
+
import { tenantRunsQuery } from "./handlers/tenant-runs.query.js";
|
|
12
13
|
import { triggerWrite } from "./handlers/trigger.write.js";
|
|
13
14
|
import { JOBS_I18N } from "./i18n.js";
|
|
14
15
|
import { jobRunLogsTableMeta, jobRunsTableMeta } from "./job-run-table.js";
|
|
15
16
|
import { tenantJobFailuresTableMeta } from "./tenant-job-failure-table.js";
|
|
17
|
+
import { tenantJobRunsTableMeta } from "./tenant-job-run-table.js";
|
|
16
18
|
export function createJobsFeature(options = {}) {
|
|
17
19
|
const retentionDays = options.retentionDays ?? DEFAULT_JOB_RUN_RETENTION_DAYS;
|
|
18
20
|
const staleRunTimeoutHours = options.staleRunTimeoutHours ?? DEFAULT_JOB_RUN_STALE_TIMEOUT_HOURS;
|
|
19
21
|
return defineFeature("jobs", (r) => {
|
|
20
|
-
r.describe("Persistence and operator tooling for background jobs registered via `r.job(...)`. Every job execution writes directly into `store_job_runs` (current status + duration) and `store_job_run_logs` (per-line log rows) from the BullMQ callbacks — no event stream in between (#2243). A daily `retention-cleanup` job deletes runs (and their logs) older than `retentionDays`; an hourly `stale-run-sweep` job marks runs stuck at status `running` past `staleRunTimeoutHours` as `failed` (#2246 — a crashed worker never fires the completion callback, so nothing else ever revisits the row). Exposes `jobs:write:trigger` (manual run) and `jobs:write:retry` (operator retry of a failed run), plus `jobs:query:list`, `jobs:query:details`, and `jobs:query:catalog` (manual jobs) for the operator UI. A job that declares `tenantVisibleFailure` also records its last failed attempt per tenant and subject in `store_tenant_job_failures`, which the tenant itself reads through `jobs:query:failures` — a translation key only, never the provider's message (fw#3079).");
|
|
22
|
+
r.describe("Persistence and operator tooling for background jobs registered via `r.job(...)`. Every job execution writes directly into `store_job_runs` (current status + duration) and `store_job_run_logs` (per-line log rows) from the BullMQ callbacks — no event stream in between (#2243). A daily `retention-cleanup` job deletes runs (and their logs) older than `retentionDays`; an hourly `stale-run-sweep` job marks runs stuck at status `running` past `staleRunTimeoutHours` as `failed` (#2246 — a crashed worker never fires the completion callback, so nothing else ever revisits the row). Exposes `jobs:write:trigger` (manual run) and `jobs:write:retry` (operator retry of a failed run), plus `jobs:query:list`, `jobs:query:details`, and `jobs:query:catalog` (manual jobs) for the operator UI. A job that declares `tenantVisibleFailure` also records its last failed attempt per tenant and subject in `store_tenant_job_failures`, which the tenant itself reads through `jobs:query:failures` — a translation key only, never the provider's message (fw#3079). A job that declares `tenantVisibleRun` also keeps its run state per tenant and subject in `store_tenant_job_runs` (queued, running, plus the latest completed or failed run, no payload, error text or logs), which the tenant reads through `jobs:query:tenant-runs` (fw#3616); the stale-run sweep fails its stuck `running` rows and drops its stuck `queued` rows.");
|
|
21
23
|
r.uiHints({
|
|
22
24
|
displayLabel: "Jobs · Audit & Operator UI",
|
|
23
25
|
category: "operations",
|
|
@@ -38,6 +40,9 @@ export function createJobsFeature(options = {}) {
|
|
|
38
40
|
r.storeTable(tenantJobFailuresTableMeta, {
|
|
39
41
|
reason: "direct_write.tenant_job_failures",
|
|
40
42
|
});
|
|
43
|
+
r.storeTable(tenantJobRunsTableMeta, {
|
|
44
|
+
reason: "direct_write.tenant_job_runs",
|
|
45
|
+
});
|
|
41
46
|
// Framework-provided rebuild job — available whenever `jobs` is composed; enqueueProjectionRebuild dispatches it.
|
|
42
47
|
r.job("projectionRebuild", { trigger: { manual: true }, concurrency: "skip", schema: projectionRebuildPayloadSchema }, projectionRebuildJob);
|
|
43
48
|
// Retroactive search backfill (#1206/#1215) — manual + perTenant, so one
|
|
@@ -68,6 +73,7 @@ export function createJobsFeature(options = {}) {
|
|
|
68
73
|
detail: r.queryHandler(detailQuery),
|
|
69
74
|
catalog: r.queryHandler(catalogQuery),
|
|
70
75
|
failures: r.queryHandler(tenantFailuresQuery),
|
|
76
|
+
tenantRuns: r.queryHandler(tenantRunsQuery),
|
|
71
77
|
};
|
|
72
78
|
const systemAdminAccess = { roles: ["SystemAdmin"] };
|
|
73
79
|
r.translations({ keys: JOBS_I18N });
|
|
@@ -28,9 +28,10 @@ export declare const listQuery: import("@cosmicdrift/kumiko-types/define-handler
|
|
|
28
28
|
desc: "desc";
|
|
29
29
|
}>>;
|
|
30
30
|
limit: z.ZodOptional<z.ZodNumber>;
|
|
31
|
+
cursor: z.ZodOptional<z.ZodString>;
|
|
31
32
|
totalCount: z.ZodOptional<z.ZodBoolean>;
|
|
32
33
|
}, z.core.$strip>, {
|
|
33
34
|
rows: any[];
|
|
34
|
-
nextCursor: null;
|
|
35
|
+
nextCursor: string | null;
|
|
35
36
|
total?: number | undefined;
|
|
36
37
|
}, import("@cosmicdrift/kumiko-types/event-type-map").KumikoEventTypeMap>;
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { countWhere, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
+
import { decodeCursor, encodeCursor } from "@cosmicdrift/kumiko-framework/db";
|
|
2
3
|
import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
-
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
4
|
+
import { InternalError, ValidationError } from "@cosmicdrift/kumiko-framework/errors";
|
|
4
5
|
import * as z from "zod";
|
|
5
6
|
import { decryptStoredPii, mapWithConcurrency } from "../../shared/index.js";
|
|
6
7
|
import { jobRunsTable } from "../job-run-table.js";
|
|
7
8
|
const KMS_POOL_CONCURRENCY = 4;
|
|
9
|
+
const DEFAULT_PAGE_SIZE = 50;
|
|
8
10
|
const jobRunStatusSchema = z.enum(["queued", "running", "completed", "failed"]);
|
|
9
11
|
async function decryptRunRow(row) {
|
|
10
12
|
let result = row;
|
|
@@ -19,6 +21,26 @@ async function decryptRunRow(row) {
|
|
|
19
21
|
}
|
|
20
22
|
return result;
|
|
21
23
|
}
|
|
24
|
+
function buildListWhere(payload) {
|
|
25
|
+
const where = {};
|
|
26
|
+
if (payload.jobName)
|
|
27
|
+
where["jobName"] = payload.jobName;
|
|
28
|
+
const statuses = payload.filters?.[0]?.value ?? (payload.status ? [payload.status] : []);
|
|
29
|
+
if (statuses.length === 1)
|
|
30
|
+
where["status"] = statuses[0];
|
|
31
|
+
else if (statuses.length > 1)
|
|
32
|
+
where["status"] = { in: statuses };
|
|
33
|
+
return where;
|
|
34
|
+
}
|
|
35
|
+
function parseOffsetCursor(cursor) {
|
|
36
|
+
const offset = cursor ? Number(decodeCursor(cursor)) : 0;
|
|
37
|
+
if (!Number.isInteger(offset) || offset < 0) {
|
|
38
|
+
throw new ValidationError({
|
|
39
|
+
fields: [{ path: "cursor", code: "invalid_cursor", i18nKey: "jobs.errors.invalidCursor" }],
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
return offset;
|
|
43
|
+
}
|
|
22
44
|
export const listQuery = defineQueryHandler({
|
|
23
45
|
name: "list",
|
|
24
46
|
description: "Lists job runs across all tenants newest-first, optionally filtered by job name and status; use it to check whether a job ran and whether it succeeded.",
|
|
@@ -34,7 +56,8 @@ export const listQuery = defineQueryHandler({
|
|
|
34
56
|
.optional(),
|
|
35
57
|
sort: z.enum(["jobName", "status", "startedAt", "duration"]).optional(),
|
|
36
58
|
sortDirection: z.enum(["asc", "desc"]).optional(),
|
|
37
|
-
limit: z.number().optional(),
|
|
59
|
+
limit: z.number().int().min(1).optional(),
|
|
60
|
+
cursor: z.string().optional(),
|
|
38
61
|
totalCount: z.boolean().optional(),
|
|
39
62
|
}),
|
|
40
63
|
access: { roles: ["SystemAdmin"] },
|
|
@@ -45,27 +68,29 @@ export const listQuery = defineQueryHandler({
|
|
|
45
68
|
});
|
|
46
69
|
}
|
|
47
70
|
const db = ctx.systemDb.unsafeRaw("cross-tenant job monitoring: SystemAdmin lists job runs of every tenant");
|
|
48
|
-
const where =
|
|
49
|
-
if (query.payload.jobName)
|
|
50
|
-
where["jobName"] = query.payload.jobName;
|
|
51
|
-
const statusFilter = query.payload.filters?.find((filter) => filter.field === "status");
|
|
52
|
-
const statuses = statusFilter?.value ?? (query.payload.status ? [query.payload.status] : []);
|
|
53
|
-
if (statuses.length === 1)
|
|
54
|
-
where["status"] = statuses[0];
|
|
55
|
-
else if (statuses.length > 1)
|
|
56
|
-
where["status"] = { in: statuses };
|
|
71
|
+
const where = buildListWhere(query.payload);
|
|
57
72
|
const sortColumn = query.payload.sort ?? "startedAt";
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
73
|
+
const limit = query.payload.limit ?? DEFAULT_PAGE_SIZE;
|
|
74
|
+
const offset = parseOffsetCursor(query.payload.cursor);
|
|
75
|
+
const direction = query.payload.sortDirection ?? "desc";
|
|
76
|
+
// selectMany has no OFFSET: read through the end of the page plus one probe
|
|
77
|
+
// row, then slice. `id` as tiebreaker keeps pages stable for non-unique sorts.
|
|
78
|
+
const fetched = await selectMany(db, jobRunsTable, where, {
|
|
79
|
+
orderBy: [
|
|
80
|
+
{ col: sortColumn, direction },
|
|
81
|
+
{ col: "id", direction },
|
|
82
|
+
],
|
|
83
|
+
limit: offset + limit + 1,
|
|
61
84
|
});
|
|
85
|
+
const hasMore = fetched.length > offset + limit;
|
|
86
|
+
const rows = fetched.slice(offset, offset + limit);
|
|
62
87
|
// countWhere reruns the SAME `where` without the limit — `rows.length` is
|
|
63
88
|
// capped at the page size and would silently undercount the total.
|
|
64
89
|
const total = query.payload.totalCount === true ? await countWhere(db, jobRunsTable, where) : undefined;
|
|
65
90
|
// payload/error are stored encrypted under the triggering user's DEK (#799, #2307).
|
|
66
91
|
return {
|
|
67
92
|
rows: await mapWithConcurrency(rows, KMS_POOL_CONCURRENCY, decryptRunRow),
|
|
68
|
-
nextCursor: null,
|
|
93
|
+
nextCursor: hasMore ? encodeCursor(String(offset + limit)) : null,
|
|
69
94
|
...(total !== undefined && { total }),
|
|
70
95
|
};
|
|
71
96
|
},
|
|
@@ -26,7 +26,7 @@ export const tenantFailuresQuery = defineQueryHandler({
|
|
|
26
26
|
schema: z.object({
|
|
27
27
|
jobName: z.string().optional(),
|
|
28
28
|
subject: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional(),
|
|
29
|
-
limit: z.number().min(1).max(200).optional(),
|
|
29
|
+
limit: z.number().int().min(1).max(200).optional(),
|
|
30
30
|
}),
|
|
31
31
|
// Every membership rank: a failure record carries a job name and a
|
|
32
32
|
// translation key, nothing a team member of the tenant may not see.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import * as z from "zod";
|
|
2
|
+
import { type TenantJobRunStatus } from "../tenant-job-run-table.js";
|
|
3
|
+
export declare const tenantRunsQuery: import("@cosmicdrift/kumiko-types/define-handler").QueryHandlerDefinition<"tenant-runs", z.ZodObject<{
|
|
4
|
+
jobName: z.ZodOptional<z.ZodString>;
|
|
5
|
+
subject: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean]>>>;
|
|
6
|
+
limit: z.ZodOptional<z.ZodNumber>;
|
|
7
|
+
}, z.core.$strip>, {
|
|
8
|
+
rows: {
|
|
9
|
+
jobName: string;
|
|
10
|
+
subject: Record<string, unknown> | null;
|
|
11
|
+
status: TenantJobRunStatus;
|
|
12
|
+
queuedAt: Temporal.Instant | null;
|
|
13
|
+
startedAt: Temporal.Instant | null;
|
|
14
|
+
finishedAt: Temporal.Instant | null;
|
|
15
|
+
}[];
|
|
16
|
+
nextCursor: null;
|
|
17
|
+
}, import("@cosmicdrift/kumiko-types/event-type-map").KumikoEventTypeMap>;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
+
import { access, defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
4
|
+
import { serializeJobSubject } from "@cosmicdrift/kumiko-framework/jobs";
|
|
5
|
+
import { parseJsonSafe } from "@cosmicdrift/kumiko-framework/utils";
|
|
6
|
+
import * as z from "zod";
|
|
7
|
+
import { tenantJobRunsTable } from "../tenant-job-run-table.js";
|
|
8
|
+
const DEFAULT_LIMIT = 50;
|
|
9
|
+
function isSubjectEntry(value) {
|
|
10
|
+
return Array.isArray(value) && typeof value[0] === "string";
|
|
11
|
+
}
|
|
12
|
+
function parseSubject(stored) {
|
|
13
|
+
if (stored === null)
|
|
14
|
+
return null;
|
|
15
|
+
// A corrupt subject must not fail the whole list; parseJsonSafe only
|
|
16
|
+
// survives a SyntaxError, so the shape needs its own check.
|
|
17
|
+
const parsed = parseJsonSafe(stored, null);
|
|
18
|
+
if (!Array.isArray(parsed))
|
|
19
|
+
return null;
|
|
20
|
+
return Object.fromEntries(parsed.filter(isSubjectEntry));
|
|
21
|
+
}
|
|
22
|
+
export const tenantRunsQuery = defineQueryHandler({
|
|
23
|
+
name: "tenant-runs",
|
|
24
|
+
description: "Lists the calling tenant's own job runs that opted in via tenantVisibleRun — per job and subject every queued or running run plus the latest completed or failed one, active runs first, then newest first; each row carries only status and queuedAt/startedAt/finishedAt, never payloads, error text or logs; the optional subject filter matches exactly: all declared subjectFields with the same JSON types as the job payload; use it to show whether work for a subject is waiting, running or done, and to tell a failed run from a finished one.",
|
|
25
|
+
schema: z.object({
|
|
26
|
+
jobName: z.string().optional(),
|
|
27
|
+
subject: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional(),
|
|
28
|
+
limit: z.number().int().min(1).max(200).optional(),
|
|
29
|
+
}),
|
|
30
|
+
// Every membership rank: a run state carries a job name, a status and
|
|
31
|
+
// times, nothing a team member of the tenant may not see.
|
|
32
|
+
access: { roles: access.roles("User", "Editor", ...access.admin) },
|
|
33
|
+
handler: async (query, ctx) => {
|
|
34
|
+
if (!ctx.systemDb) {
|
|
35
|
+
throw new InternalError({
|
|
36
|
+
message: "jobs:query:tenant-runs requires ctx.systemDb (feature must declare r.systemScope())",
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
// assertTenantMatch returns the same unfiltered system-mode db — the
|
|
40
|
+
// explicit tenantId below is the filter, assertRowsTenant the second net.
|
|
41
|
+
const db = ctx.systemDb.assertTenantMatch(query.user.tenantId);
|
|
42
|
+
const where = { tenantId: [query.user.tenantId] };
|
|
43
|
+
if (query.payload.jobName)
|
|
44
|
+
where["jobName"] = query.payload.jobName;
|
|
45
|
+
if (query.payload.subject)
|
|
46
|
+
where["subject"] = serializeJobSubject(query.payload.subject);
|
|
47
|
+
const limit = query.payload.limit ?? DEFAULT_LIMIT;
|
|
48
|
+
const newestFirst = { col: "updatedAt", direction: "desc" };
|
|
49
|
+
const activeRows = await selectMany(db, tenantJobRunsTable, { ...where, status: { in: ["queued", "running"] } }, { orderBy: newestFirst, limit });
|
|
50
|
+
const finishedRows = await selectMany(db, tenantJobRunsTable, { ...where, status: { in: ["completed", "failed"] } }, { orderBy: newestFirst, limit });
|
|
51
|
+
const seenFinishedKeys = new Set();
|
|
52
|
+
const finishedNewestPerKey = finishedRows.filter((row) => {
|
|
53
|
+
const key = JSON.stringify([row.jobName, row.subject]);
|
|
54
|
+
if (seenFinishedKeys.has(key))
|
|
55
|
+
return false;
|
|
56
|
+
seenFinishedKeys.add(key);
|
|
57
|
+
return true;
|
|
58
|
+
});
|
|
59
|
+
const activeFirst = [...activeRows, ...finishedNewestPerKey];
|
|
60
|
+
return {
|
|
61
|
+
rows: ctx.systemDb.assertRowsTenant(activeFirst.slice(0, limit), "tenantId").map((row) => ({
|
|
62
|
+
jobName: row.jobName,
|
|
63
|
+
subject: parseSubject(row.subject),
|
|
64
|
+
status: row.status,
|
|
65
|
+
queuedAt: row.queuedAt,
|
|
66
|
+
startedAt: row.startedAt,
|
|
67
|
+
finishedAt: row.finishedAt,
|
|
68
|
+
})),
|
|
69
|
+
nextCursor: null,
|
|
70
|
+
};
|
|
71
|
+
},
|
|
72
|
+
});
|
package/dist/jobs/i18n.js
CHANGED
|
@@ -33,6 +33,7 @@ export const JOBS_I18N = {
|
|
|
33
33
|
"jobs.detail.logs": { en: "Logs" },
|
|
34
34
|
"jobs.detail.retry": { en: "Retry" },
|
|
35
35
|
"jobs.trigger.title": { en: "Run a job" },
|
|
36
|
+
"jobs.errors.invalidCursor": { en: "The pagination cursor is invalid." },
|
|
36
37
|
"jobs.trigger.submit": { en: "Run" },
|
|
37
38
|
"jobs.errors.unknownJob": { en: "Unknown job." },
|
|
38
39
|
"jobs.errors.notManual": {
|
package/dist/jobs/index.d.ts
CHANGED
|
@@ -5,3 +5,5 @@ export { createJobRunLogger } from "./job-run-logger.js";
|
|
|
5
5
|
export type { JobLogLevel, JobRunStatus } from "./job-run-table.js";
|
|
6
6
|
export { jobRunLogsTable, jobRunsTable } from "./job-run-table.js";
|
|
7
7
|
export { tenantJobFailuresTable } from "./tenant-job-failure-table.js";
|
|
8
|
+
export type { TenantJobRunStatus } from "./tenant-job-run-table.js";
|
|
9
|
+
export { tenantJobRunsTable } from "./tenant-job-run-table.js";
|
package/dist/jobs/index.js
CHANGED
|
@@ -3,3 +3,4 @@ export { createJobsFeature } from "./feature.js";
|
|
|
3
3
|
export { createJobRunLogger } from "./job-run-logger.js";
|
|
4
4
|
export { jobRunLogsTable, jobRunsTable } from "./job-run-table.js";
|
|
5
5
|
export { tenantJobFailuresTable } from "./tenant-job-failure-table.js";
|
|
6
|
+
export { tenantJobRunsTable } from "./tenant-job-run-table.js";
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
2
2
|
import { type Registry } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
3
|
import type { JobRunnerOptions } from "@cosmicdrift/kumiko-framework/jobs";
|
|
4
4
|
export type JobRunLoggerOptions = {
|
|
5
5
|
readonly db: DbConnection;
|
|
6
6
|
readonly registry: Registry;
|
|
7
7
|
};
|
|
8
|
-
export type JobRunLoggerCallbacks = Pick<JobRunnerOptions, "onJobStart" | "onJobComplete" | "onJobFailed">;
|
|
8
|
+
export type JobRunLoggerCallbacks = Pick<JobRunnerOptions, "onJobStart" | "onJobComplete" | "onJobFailed" | "onJobQueued" | "onJobDropped">;
|
|
9
9
|
export declare function encryptFailureError(error: string, triggeredById: string | null): Promise<string>;
|
|
10
10
|
export declare function createJobRunLogger(opts: JobRunLoggerOptions): JobRunLoggerCallbacks;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { deleteMany, fetchOne, insertMany, insertOne, updateMany, } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
1
|
+
import { deleteMany, fetchOne, insertMany, insertOnConflictDoNothing, insertOne, transaction, updateMany, upsertOnConflict, } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
2
|
import { configuredPiiSubjectKms, encryptPiiValueForSubject, KeyErasedError, PII_ERASED_SENTINEL, } from "@cosmicdrift/kumiko-framework/crypto";
|
|
3
|
+
import { acquireNamespacedAdvisoryLock } from "@cosmicdrift/kumiko-framework/db";
|
|
3
4
|
import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
5
|
import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
|
|
5
6
|
import { generateId } from "@cosmicdrift/kumiko-framework/utils";
|
|
@@ -8,6 +9,7 @@ import { runCompletedSchema, runFailedSchema, runStartedSchema } from "./events.
|
|
|
8
9
|
import { parseJobInstant } from "./job-instant.js";
|
|
9
10
|
import { jobRunLogsTable, jobRunsTable } from "./job-run-table.js";
|
|
10
11
|
import { tenantJobFailuresTable } from "./tenant-job-failure-table.js";
|
|
12
|
+
import { tenantJobRunsTable } from "./tenant-job-run-table.js";
|
|
11
13
|
// Matches PgKmsAdapter's default pool size (see tenant/handlers/*.query.ts) —
|
|
12
14
|
// bounds concurrent getOrCreateDek calls so a large log batch doesn't claim
|
|
13
15
|
// every connection in the pool.
|
|
@@ -92,6 +94,9 @@ async function encryptStartedPayload(payload, triggeredById) {
|
|
|
92
94
|
return payload;
|
|
93
95
|
return encryptOrSentinel(kms, triggeredById, payload, "payload");
|
|
94
96
|
}
|
|
97
|
+
// pg_advisory_xact_lock namespace (int4): 'tjfl' as ASCII, disjoint from the
|
|
98
|
+
// framework's other fixed advisory-lock keys.
|
|
99
|
+
const TENANT_JOB_FAILURE_LOCK_NAMESPACE = 0x746a666c;
|
|
95
100
|
// fw#3079 — which row the tenant-visible failure record lives in: one per
|
|
96
101
|
// (tenant, job, subject), so the next outcome of the same work replaces or
|
|
97
102
|
// clears it. `subject` is null for a job that declares no subjectFields.
|
|
@@ -112,14 +117,18 @@ async function recordTenantJobFailure(db, jobName, outcome) {
|
|
|
112
117
|
// may still resolve.
|
|
113
118
|
if (!where || !messageKey || outcome?.finalAttempt !== true)
|
|
114
119
|
return;
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
|
|
119
|
-
await
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
120
|
+
// Delete-then-insert under a per-key lock instead of an upsert: the two
|
|
121
|
+
// partial unique indexes (subject NULL vs. set) are no ON CONFLICT target.
|
|
122
|
+
// The lock makes the later of two racing final failures replace the earlier
|
|
123
|
+
// one; the indexes are the backstop against any writer that skips the lock.
|
|
124
|
+
await transaction(db, async (tx) => {
|
|
125
|
+
await acquireNamespacedAdvisoryLock(tx, TENANT_JOB_FAILURE_LOCK_NAMESPACE, JSON.stringify([where["tenantId"], where["jobName"], where["subject"]]));
|
|
126
|
+
await deleteMany(tx, tenantJobFailuresTable, where);
|
|
127
|
+
await insertOne(tx, tenantJobFailuresTable, {
|
|
128
|
+
...where,
|
|
129
|
+
messageKey,
|
|
130
|
+
failedAt: Temporal.Now.instant(),
|
|
131
|
+
});
|
|
123
132
|
});
|
|
124
133
|
}
|
|
125
134
|
async function clearTenantJobFailure(db, jobName, outcome) {
|
|
@@ -129,7 +138,78 @@ async function clearTenantJobFailure(db, jobName, outcome) {
|
|
|
129
138
|
return;
|
|
130
139
|
await deleteMany(db, tenantJobFailuresTable, where);
|
|
131
140
|
}
|
|
141
|
+
function tenantRunKey(jobName, tenantId, subject) {
|
|
142
|
+
// skip: tenant-less run (cron resolves to SYSTEM_TENANT_ID) — no tenant-scoped query could read it
|
|
143
|
+
if (tenantId === SYSTEM_TENANT_ID)
|
|
144
|
+
return null;
|
|
145
|
+
return { tenantId, jobName, subject };
|
|
146
|
+
}
|
|
147
|
+
async function recordTenantRunQueued(db, jobName, bullJobId, meta) {
|
|
148
|
+
const key = tenantRunKey(jobName, meta.tenantId, meta.subject);
|
|
149
|
+
// skip: system-tenant run, no tenant-scoped query reads it
|
|
150
|
+
if (!key)
|
|
151
|
+
return;
|
|
152
|
+
const now = Temporal.Now.instant();
|
|
153
|
+
// DO NOTHING: a fast worker can report the start before this enqueue hook
|
|
154
|
+
// returns, and a repeated jobId (debounce, fan-out dedup) is the same job.
|
|
155
|
+
await insertOnConflictDoNothing(db, tenantJobRunsTable, { ...key, bullJobId, status: "queued", queuedAt: now, updatedAt: now }, { conflictKeys: ["bullJobId"] });
|
|
156
|
+
}
|
|
157
|
+
async function recordTenantRunStarted(db, jobName, bullJobId, meta) {
|
|
158
|
+
// skip: job did not opt in to tenantVisibleRun
|
|
159
|
+
if (!meta.tenantVisibleRun)
|
|
160
|
+
return;
|
|
161
|
+
const key = tenantRunKey(jobName, meta.tenantVisibleRun.tenantId, meta.tenantVisibleRun.subject);
|
|
162
|
+
// skip: system-tenant run, no tenant-scoped query reads it
|
|
163
|
+
if (!key)
|
|
164
|
+
return;
|
|
165
|
+
const now = Temporal.Now.instant();
|
|
166
|
+
await upsertOnConflict(db, tenantJobRunsTable, { ...key, bullJobId, status: "running", startedAt: now, updatedAt: now }, {
|
|
167
|
+
conflictKeys: ["bullJobId"],
|
|
168
|
+
update: { status: "running", startedAt: now, finishedAt: null, updatedAt: now },
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
async function recordTenantRunOutcome(db, jobName, bullJobId, outcome, result) {
|
|
172
|
+
// skip: job did not opt in to tenantVisibleRun
|
|
173
|
+
if (!outcome?.tenantVisibleRun)
|
|
174
|
+
return;
|
|
175
|
+
const key = tenantRunKey(jobName, outcome.tenantId, outcome.tenantVisibleRun.subject);
|
|
176
|
+
// skip: system-tenant run, no tenant-scoped query reads it
|
|
177
|
+
if (!key)
|
|
178
|
+
return;
|
|
179
|
+
const now = Temporal.Now.instant();
|
|
180
|
+
// A failed attempt BullMQ will retry goes back to waiting: the tenant keeps
|
|
181
|
+
// seeing pending work, not a failure the next attempt may still resolve.
|
|
182
|
+
if (result === "failed" && !outcome.finalAttempt) {
|
|
183
|
+
await updateMany(db, tenantJobRunsTable, { status: "queued", startedAt: null, finishedAt: null, updatedAt: now }, { bullJobId });
|
|
184
|
+
// skip: retry pending, the row stays active until the final attempt
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
await upsertOnConflict(db, tenantJobRunsTable, { ...key, bullJobId, status: result, finishedAt: now, updatedAt: now }, { conflictKeys: ["bullJobId"], update: { status: result, finishedAt: now, updatedAt: now } });
|
|
188
|
+
await deleteMany(db, tenantJobRunsTable, {
|
|
189
|
+
...key,
|
|
190
|
+
status: { in: ["completed", "failed"] },
|
|
191
|
+
bullJobId: { ne: bullJobId },
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
async function dropTenantRunQueued(db, bullJobId) {
|
|
195
|
+
await deleteMany(db, tenantJobRunsTable, { bullJobId, status: "queued" });
|
|
196
|
+
}
|
|
132
197
|
const log = createFallbackLogger("job-run-logger");
|
|
198
|
+
// The tenant run state is an optional side write: a throw here must not
|
|
199
|
+
// fail the enqueue, skip the run-row write or turn a completed run into a
|
|
200
|
+
// BullMQ retry.
|
|
201
|
+
async function isolateTenantRunWrite(write, jobName, bullJobId) {
|
|
202
|
+
try {
|
|
203
|
+
await write();
|
|
204
|
+
}
|
|
205
|
+
catch (e) {
|
|
206
|
+
log.error("tenant job run state write failed", {
|
|
207
|
+
jobName,
|
|
208
|
+
bullJobId,
|
|
209
|
+
error: e instanceof Error ? e.message : String(e),
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
}
|
|
133
213
|
// The tenant failure record is an optional side write: a throw here must not
|
|
134
214
|
// skip the run-row update or turn a completed run into a BullMQ retry.
|
|
135
215
|
async function isolateTenantFailureWrite(write, jobName, bullJobId) {
|
|
@@ -191,7 +271,14 @@ export function createJobRunLogger(opts) {
|
|
|
191
271
|
return { runId, triggeredById };
|
|
192
272
|
}
|
|
193
273
|
return {
|
|
274
|
+
onJobQueued: async (jobName, bullJobId, meta) => {
|
|
275
|
+
await isolateTenantRunWrite(() => recordTenantRunQueued(db, jobName, bullJobId, meta), jobName, bullJobId);
|
|
276
|
+
},
|
|
277
|
+
onJobDropped: async (jobName, bullJobId) => {
|
|
278
|
+
await isolateTenantRunWrite(() => dropTenantRunQueued(db, bullJobId), jobName, bullJobId);
|
|
279
|
+
},
|
|
194
280
|
onJobStart: async (jobName, bullJobId, meta) => {
|
|
281
|
+
await isolateTenantRunWrite(() => recordTenantRunStarted(db, jobName, bullJobId, meta), jobName, bullJobId);
|
|
195
282
|
const runId = generateId();
|
|
196
283
|
const triggeredById = meta.triggeredById ?? null;
|
|
197
284
|
cachePut(bullJobId, runId, triggeredById);
|
|
@@ -227,6 +314,7 @@ export function createJobRunLogger(opts) {
|
|
|
227
314
|
// clears the tenant's failure record even when the run row itself is
|
|
228
315
|
// unreachable (the state-loss return below).
|
|
229
316
|
await isolateTenantFailureWrite(() => clearTenantJobFailure(db, jobName, outcome), jobName, bullJobId);
|
|
317
|
+
await isolateTenantRunWrite(() => recordTenantRunOutcome(db, jobName, bullJobId, outcome, "completed"), jobName, bullJobId);
|
|
230
318
|
const resolved = await resolveRun(bullJobId);
|
|
231
319
|
// skip: state loss between start + complete (worker restart, cache
|
|
232
320
|
// evicted AND DB has no matching bull_job_id). Rare edge case; we
|
|
@@ -272,6 +360,7 @@ export function createJobRunLogger(opts) {
|
|
|
272
360
|
// Mirror of onJobComplete: recorded independently of the run row, so a
|
|
273
361
|
// tenant still learns their job failed if the row is unreachable.
|
|
274
362
|
await isolateTenantFailureWrite(() => recordTenantJobFailure(db, jobName, outcome), jobName, bullJobId);
|
|
363
|
+
await isolateTenantRunWrite(() => recordTenantRunOutcome(db, jobName, bullJobId, outcome, "failed"), jobName, bullJobId);
|
|
275
364
|
const resolved = await resolveRun(bullJobId);
|
|
276
365
|
// skip: same rare state-loss case as in onJobComplete — drop the
|
|
277
366
|
// failure write rather than forge a run row from scratch.
|
|
@@ -6,6 +6,7 @@ export declare const jobRunEntity: import("@cosmicdrift/kumiko-types/fields").En
|
|
|
6
6
|
readonly type: "text";
|
|
7
7
|
readonly description?: string;
|
|
8
8
|
readonly maxLength?: number;
|
|
9
|
+
readonly minLength?: number;
|
|
9
10
|
readonly required?: boolean;
|
|
10
11
|
readonly searchable?: boolean;
|
|
11
12
|
readonly sortable?: boolean;
|
|
@@ -27,6 +28,7 @@ export declare const jobRunEntity: import("@cosmicdrift/kumiko-types/fields").En
|
|
|
27
28
|
readonly type: "text";
|
|
28
29
|
readonly description?: string;
|
|
29
30
|
readonly maxLength?: number;
|
|
31
|
+
readonly minLength?: number;
|
|
30
32
|
readonly required?: boolean;
|
|
31
33
|
readonly searchable?: boolean;
|
|
32
34
|
readonly sortable?: boolean;
|
|
@@ -48,6 +50,7 @@ export declare const jobRunEntity: import("@cosmicdrift/kumiko-types/fields").En
|
|
|
48
50
|
readonly type: "text";
|
|
49
51
|
readonly description?: string;
|
|
50
52
|
readonly maxLength?: number;
|
|
53
|
+
readonly minLength?: number;
|
|
51
54
|
readonly required?: boolean;
|
|
52
55
|
readonly searchable?: boolean;
|
|
53
56
|
readonly sortable?: boolean;
|
|
@@ -69,6 +72,7 @@ export declare const jobRunEntity: import("@cosmicdrift/kumiko-types/fields").En
|
|
|
69
72
|
readonly type: "text";
|
|
70
73
|
readonly description?: string;
|
|
71
74
|
readonly maxLength?: number;
|
|
75
|
+
readonly minLength?: number;
|
|
72
76
|
readonly required?: boolean;
|
|
73
77
|
readonly searchable?: boolean;
|
|
74
78
|
readonly sortable?: boolean;
|
|
@@ -90,6 +94,7 @@ export declare const jobRunEntity: import("@cosmicdrift/kumiko-types/fields").En
|
|
|
90
94
|
readonly type: "text";
|
|
91
95
|
readonly description?: string;
|
|
92
96
|
readonly maxLength?: number;
|
|
97
|
+
readonly minLength?: number;
|
|
93
98
|
readonly required?: boolean;
|
|
94
99
|
readonly searchable?: boolean;
|
|
95
100
|
readonly sortable?: boolean;
|
|
@@ -179,6 +184,7 @@ export declare const jobRunEntity: import("@cosmicdrift/kumiko-types/fields").En
|
|
|
179
184
|
readonly type: "text";
|
|
180
185
|
readonly description?: string;
|
|
181
186
|
readonly maxLength?: number;
|
|
187
|
+
readonly minLength?: number;
|
|
182
188
|
readonly required?: boolean;
|
|
183
189
|
readonly searchable?: boolean;
|
|
184
190
|
readonly sortable?: boolean;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { requireEntityTableMeta } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
-
import { instant, table as pgTable, serial, sql, text, uuid, } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import { index, instant, table as pgTable, serial, sql, text, uniqueIndex, uuid, } from "@cosmicdrift/kumiko-framework/db";
|
|
3
3
|
// The tenant's own view of a failed job (fw#3079). Direct-write store like
|
|
4
4
|
// store_job_runs: job-run-logger.ts writes it from the BullMQ callbacks,
|
|
5
5
|
// outside any dispatcher transaction.
|
|
@@ -21,5 +21,18 @@ export const tenantJobFailuresTable = pgTable("store_tenant_job_failures", {
|
|
|
21
21
|
subject: text("subject"),
|
|
22
22
|
messageKey: text("message_key").notNull(),
|
|
23
23
|
failedAt: instant("failed_at").default(sql `now()`).notNull(),
|
|
24
|
-
})
|
|
24
|
+
}, (t) => [
|
|
25
|
+
// One row per key, also the lookup for the delete before every write;
|
|
26
|
+
// subject NULL needs its own partial index because NULLs
|
|
27
|
+
// are distinct in a plain unique index.
|
|
28
|
+
uniqueIndex("store_tenant_job_failures_tenant_job_subject_uidx")
|
|
29
|
+
.on(t.tenantId, t.jobName, t.subject)
|
|
30
|
+
.where(sql `subject IS NOT NULL`),
|
|
31
|
+
uniqueIndex("store_tenant_job_failures_tenant_job_null_subject_uidx")
|
|
32
|
+
.on(t.tenantId, t.jobName)
|
|
33
|
+
.where(sql `subject IS NULL`),
|
|
34
|
+
// The tenant list sorts by failed_at; retention filters on it alone.
|
|
35
|
+
index("store_tenant_job_failures_tenant_failed_at_idx").on(t.tenantId, t.failedAt),
|
|
36
|
+
index("store_tenant_job_failures_failed_at_idx").on(t.failedAt),
|
|
37
|
+
]);
|
|
25
38
|
export const tenantJobFailuresTableMeta = requireEntityTableMeta(tenantJobFailuresTable, "tenantJobFailuresTable");
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { type EntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
export type TenantJobRunStatus = "queued" | "running" | "completed" | "failed";
|
|
3
|
+
export declare const tenantJobRunsTable: import("@cosmicdrift/kumiko-types/schema-table-types").SchemaTable;
|
|
4
|
+
export declare const tenantJobRunsTableMeta: EntityTableMeta;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { requireEntityTableMeta } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
+
import { index, instant, table as pgTable, serial, sql, text, uniqueIndex, uuid, } from "@cosmicdrift/kumiko-framework/db";
|
|
3
|
+
// The tenant's own view of a job's run state (fw#3616). Direct-write store
|
|
4
|
+
// like store_job_runs, written by job-run-logger.ts from the runner callbacks.
|
|
5
|
+
//
|
|
6
|
+
// store_job_runs cannot serve the tenant: it anchors every run on the system
|
|
7
|
+
// tenant, has no subject, and gets its row only when a run starts, so a queued
|
|
8
|
+
// job is invisible there. This table holds no payload, error text or logs by
|
|
9
|
+
// design — the tenant learns only that work is waiting, running or finished.
|
|
10
|
+
//
|
|
11
|
+
// One row per BullMQ job. Active rows (queued/running) are kept as they are;
|
|
12
|
+
// a finished row replaces earlier finished rows of the same (tenant, job,
|
|
13
|
+
// subject), so only the latest outcome stays. `subject` is the canonical JSON
|
|
14
|
+
// of the job's declared subjectFields, or NULL for none — stored in clear, so
|
|
15
|
+
// a job must not declare a PII field as its subject.
|
|
16
|
+
export const tenantJobRunsTable = pgTable("store_tenant_job_runs", {
|
|
17
|
+
id: serial("id").primaryKey(),
|
|
18
|
+
tenantId: uuid("tenant_id").notNull(),
|
|
19
|
+
jobName: text("job_name").notNull(),
|
|
20
|
+
subject: text("subject"),
|
|
21
|
+
bullJobId: text("bull_job_id").notNull(),
|
|
22
|
+
status: text("status").notNull(),
|
|
23
|
+
queuedAt: instant("queued_at"),
|
|
24
|
+
startedAt: instant("started_at"),
|
|
25
|
+
finishedAt: instant("finished_at"),
|
|
26
|
+
updatedAt: instant("updated_at").default(sql `now()`).notNull(),
|
|
27
|
+
}, (t) => [
|
|
28
|
+
uniqueIndex("store_tenant_job_runs_bull_job_id_unique").on(t.bullJobId),
|
|
29
|
+
// Finishing a run prunes older outcomes by this key; the tenant query reads by it.
|
|
30
|
+
index("store_tenant_job_runs_tenant_job_subject_idx").on(t.tenantId, t.jobName, t.subject),
|
|
31
|
+
// Stale sweep and retention filter on age alone.
|
|
32
|
+
index("store_tenant_job_runs_updated_at_idx").on(t.updatedAt),
|
|
33
|
+
]);
|
|
34
|
+
export const tenantJobRunsTableMeta = requireEntityTableMeta(tenantJobRunsTable, "tenantJobRunsTable");
|