@cosmicdrift/kumiko-samples 0.363.0 → 0.364.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 +1 -1
- package/packages/bundled-features/package.json +7 -1
- package/packages/bundled-features/src/audit/changes.json +6 -0
- package/packages/bundled-features/src/audit/handlers/list.query.ts +10 -5
- package/packages/bundled-features/src/auth-email-password/bootstrap.ts +29 -8
- package/packages/bundled-features/src/auth-email-password/changes.json +37 -0
- package/packages/bundled-features/src/auth-email-password/email-rate-limits.ts +16 -0
- package/packages/bundled-features/src/auth-email-password/handlers/change-password.write.ts +6 -2
- package/packages/bundled-features/src/auth-email-password/handlers/logout.write.ts +7 -2
- package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +2 -1
- package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +2 -1
- package/packages/bundled-features/src/auth-email-password/web/auth-gate.tsx +24 -5
- package/packages/bundled-features/src/billing-foundation/changes.json +36 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/confirmation-mail.ts +57 -5
- package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-gate.ts +22 -1
- package/packages/bundled-features/src/billing-foundation/consumer-protection/mail-notifications.ts +4 -1
- package/packages/bundled-features/src/billing-foundation/consumer-protection/register-termination.ts +11 -1
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-mail.ts +9 -3
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-receipt-limit.ts +41 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-record.ts +10 -1
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-request.ts +35 -19
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-texts.ts +9 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/withdrawal-period.ts +35 -0
- package/packages/bundled-features/src/billing-foundation/handlers/create-checkout-session.write.ts +11 -3
- package/packages/bundled-features/src/billing-foundation/handlers/process-event.write.ts +10 -4
- package/packages/bundled-features/src/billing-foundation/i18n.ts +3 -0
- package/packages/bundled-features/src/billing-foundation/subscription-tier-sync.ts +17 -5
- package/packages/bundled-features/src/billing-foundation/types.ts +7 -2
- package/packages/bundled-features/src/cap-counter/calendar-cap-usage-reservation.ts +2 -0
- package/packages/bundled-features/src/cap-counter/changes.json +6 -0
- package/packages/bundled-features/src/cap-counter/enforce-cap.ts +75 -5
- package/packages/bundled-features/src/cap-counter/index.ts +1 -0
- package/packages/bundled-features/src/cap-counter/with-cap-enforcement.ts +3 -0
- package/packages/bundled-features/src/channel-email/email-channel.ts +17 -4
- package/packages/bundled-features/src/config/changes.json +6 -0
- package/packages/bundled-features/src/config/handlers/set.write.ts +2 -2
- package/packages/bundled-features/src/config/resolver.ts +15 -12
- package/packages/bundled-features/src/config/write-helpers.ts +23 -0
- package/packages/bundled-features/src/crypto-shredding/changes.json +6 -0
- package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +48 -27
- package/packages/bundled-features/src/custom-fields/changes.json +6 -0
- package/packages/bundled-features/src/custom-fields/handlers/update-tenant-field.write.ts +0 -1
- package/packages/bundled-features/src/delivery/attachment-ref.ts +21 -0
- package/packages/bundled-features/src/delivery/changes.json +18 -0
- package/packages/bundled-features/src/delivery/channel-context.ts +31 -8
- package/packages/bundled-features/src/delivery/index.ts +2 -0
- package/packages/bundled-features/src/delivery/jobs.ts +51 -14
- package/packages/bundled-features/src/delivery/types.ts +7 -1
- package/packages/bundled-features/src/file-derivatives/handlers/public-variant.query.ts +1 -1
- package/packages/bundled-features/src/file-foundation/changes.json +6 -0
- package/packages/bundled-features/src/file-foundation/read-file-ref-content.ts +6 -3
- package/packages/bundled-features/src/files-tenant-data/changes.json +6 -0
- package/packages/bundled-features/src/files-tenant-data/hooks.ts +4 -0
- package/packages/bundled-features/src/inbound-mail-foundation/changes.json +6 -0
- package/packages/bundled-features/src/inbound-mail-foundation/oauth-access-token.ts +13 -8
- package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +3 -1
- package/packages/bundled-features/src/jobs/changes.json +19 -0
- package/packages/bundled-features/src/jobs/db/queries/stale-run-sweep.ts +12 -4
- package/packages/bundled-features/src/jobs/feature.ts +7 -4
- package/packages/bundled-features/src/jobs/handlers/list.query.ts +3 -1
- package/packages/bundled-features/src/jobs/handlers/stale-run-sweep.job.ts +5 -2
- package/packages/bundled-features/src/jobs/job-run-logger.ts +52 -17
- package/packages/bundled-features/src/jobs/tenant-job-run-table.ts +7 -2
- package/packages/bundled-features/src/ledger/changes.json +7 -0
- package/packages/bundled-features/src/ledger/entity.ts +3 -1
- package/packages/bundled-features/src/renderer-foundation/types.ts +2 -0
- package/packages/bundled-features/src/renderer-pdf/changes.json +12 -0
- package/packages/bundled-features/src/renderer-pdf/render-table-pdf.ts +162 -82
- package/packages/bundled-features/src/secrets/changes.json +6 -0
- package/packages/bundled-features/src/secrets/feature.ts +4 -3
- package/packages/bundled-features/src/secrets/secrets-context.ts +23 -3
- package/packages/bundled-features/src/shared/changes.json +6 -0
- package/packages/bundled-features/src/shared/chunked-entity-migration.ts +4 -1
- package/packages/bundled-features/src/shared/mail-template-body.ts +17 -0
- package/packages/bundled-features/src/subscription-stripe/changes.json +6 -0
- package/packages/bundled-features/src/subscription-stripe/plugin-methods.ts +19 -11
- package/packages/bundled-features/src/subscription-stripe/verify-webhook.ts +3 -1
- package/packages/bundled-features/src/template-resolver/README.md +4 -2
- package/packages/bundled-features/src/template-resolver/api.ts +3 -2
- package/packages/bundled-features/src/template-resolver/changes.json +68 -0
- package/packages/bundled-features/src/template-resolver/compile-document-body.ts +77 -13
- package/packages/bundled-features/src/template-resolver/constants.ts +12 -2
- package/packages/bundled-features/src/template-resolver/document-schema.ts +26 -4
- package/packages/bundled-features/src/template-resolver/feature.ts +4 -1
- package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +31 -13
- package/packages/bundled-features/src/template-resolver/handlers/diff-to-system.query.ts +5 -44
- package/packages/bundled-features/src/template-resolver/handlers/discard-draft.write.ts +52 -0
- package/packages/bundled-features/src/template-resolver/handlers/export-bundle.query.ts +56 -1
- package/packages/bundled-features/src/template-resolver/handlers/find-by-id.query.ts +6 -3
- package/packages/bundled-features/src/template-resolver/handlers/layout-source.query.ts +73 -30
- package/packages/bundled-features/src/template-resolver/handlers/list.query.ts +14 -11
- package/packages/bundled-features/src/template-resolver/handlers/override-diff.ts +49 -0
- package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +90 -17
- package/packages/bundled-features/src/template-resolver/handlers/resource-upload.write.ts +1 -1
- package/packages/bundled-features/src/template-resolver/handlers/send-test.write.ts +39 -10
- package/packages/bundled-features/src/template-resolver/handlers/template-gates.ts +14 -2
- package/packages/bundled-features/src/template-resolver/handlers/template-write.ts +54 -4
- package/packages/bundled-features/src/template-resolver/handlers/tenant-overrides.query.ts +96 -14
- package/packages/bundled-features/src/template-resolver/handlers/toggle-status.write.ts +51 -24
- package/packages/bundled-features/src/template-resolver/handlers/upsert-tenant.write.ts +1 -1
- package/packages/bundled-features/src/template-resolver/handlers/usage.query.ts +16 -2
- package/packages/bundled-features/src/template-resolver/index.ts +12 -1
- package/packages/bundled-features/src/template-resolver/layout-appearance.ts +40 -0
- package/packages/bundled-features/src/template-resolver/pdf-constants.ts +12 -0
- package/packages/bundled-features/src/template-resolver/qualified-names.ts +4 -0
- package/packages/bundled-features/src/template-resolver/seeding.ts +25 -4
- package/packages/bundled-features/src/template-resolver/store-template-resource.ts +2 -2
- package/packages/bundled-features/src/template-resolver/table.ts +13 -1
- package/packages/bundled-features/src/template-resolver/template-draft.ts +106 -0
- package/packages/bundled-features/src/template-resolver/template-meta.ts +87 -24
- package/packages/bundled-features/src/template-resolver/template-preview.ts +159 -56
- package/packages/bundled-features/src/template-resolver/template-resources.ts +44 -4
- package/packages/bundled-features/src/template-resolver/template-usage.ts +31 -11
- package/packages/bundled-features/src/template-resolver/template-validation.ts +1 -0
- package/packages/bundled-features/src/template-resolver/web/client-plugin.tsx +12 -8
- package/packages/bundled-features/src/template-resolver/web/document-editor-extensions.ts +63 -10
- package/packages/bundled-features/src/template-resolver/web/document-editor.tsx +12 -3
- package/packages/bundled-features/src/template-resolver/web/document-tiptap-editor.tsx +349 -134
- package/packages/bundled-features/src/template-resolver/web/document-toolbar-blocks.ts +11 -0
- package/packages/bundled-features/src/template-resolver/web/i18n.ts +25 -4
- package/packages/bundled-features/src/template-resolver/web/index.ts +7 -0
- package/packages/bundled-features/src/template-resolver/web/placeholder-line-editor.tsx +10 -3
- package/packages/bundled-features/src/tier-engine/changes.json +6 -0
- package/packages/bundled-features/src/tier-engine/feature.ts +11 -3
- package/packages/bundled-features/src/user/changes.json +6 -0
- package/packages/bundled-features/src/user/resolve-display-names.ts +8 -15
- package/packages/bundled-features/src/user/seeding.ts +19 -5
- package/packages/bundled-features/src/user-data-rights/changes.json +6 -0
- package/packages/bundled-features/src/user-profile/changes.json +1 -1
- package/packages/bundled-features/src/waitlist/options.ts +1 -1
- package/packages/bundled-features/src/workflow-runner/changes.json +6 -0
- package/packages/bundled-features/src/workflow-runner/event-trigger.ts +2 -1
- package/packages/bundled-features/src/workflow-runner/handlers/resume-run.write.ts +7 -1
- package/samples/apps/ui-walkthrough/package.json +9 -0
- package/samples/apps/ui-walkthrough/playwright.config.ts +40 -7
- package/samples/apps/ui-walkthrough/src/app/seed-templates.ts +87 -2
- package/samples/apps/ui-walkthrough/src/app/template-seed-data.ts +246 -30
- package/samples/apps/ui-walkthrough/src/features/billing/feature.ts +4 -0
- package/samples/apps/ui-walkthrough/src/run-config.ts +4 -0
- package/samples/apps/use-all-bundled/feature-manifest.json +5 -3
|
@@ -140,14 +140,17 @@ export function createOAuthAccessTokenManager(
|
|
|
140
140
|
const secretKey = inboundCredentialSecretKey(account.id);
|
|
141
141
|
return transaction(deps.db, async (tx) => {
|
|
142
142
|
await acquireNamespacedAdvisoryLock(tx, OAUTH_REFRESH_LOCK_NAMESPACE, lockKey(account));
|
|
143
|
-
|
|
143
|
+
// Secret read/write join the lock transaction: a second pool connection per refresh
|
|
144
|
+
// would exhaust a small pool when many accounts refresh at once.
|
|
145
|
+
const secrets = deps.secrets.withRunner(tx);
|
|
146
|
+
const stored = await secrets.get(account.tenantId, secretKey);
|
|
144
147
|
if (!stored) {
|
|
145
148
|
throw new InboundAuthError("no refresh token stored for account");
|
|
146
149
|
}
|
|
147
150
|
const currentRefreshToken = stored.reveal();
|
|
148
151
|
const tokens = await refreshWithTimeout(plugin, account, currentRefreshToken);
|
|
149
152
|
if (tokens.refreshToken && tokens.refreshToken !== currentRefreshToken) {
|
|
150
|
-
await
|
|
153
|
+
await secrets.set(
|
|
151
154
|
account.tenantId,
|
|
152
155
|
secretKey,
|
|
153
156
|
tokens.refreshToken,
|
|
@@ -180,12 +183,14 @@ export function createOAuthAccessTokenManager(
|
|
|
180
183
|
async storeRefreshToken(account, refreshToken) {
|
|
181
184
|
await transaction(deps.db, async (tx) => {
|
|
182
185
|
await acquireNamespacedAdvisoryLock(tx, OAUTH_REFRESH_LOCK_NAMESPACE, lockKey(account));
|
|
183
|
-
await deps.secrets
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
186
|
+
await deps.secrets
|
|
187
|
+
.withRunner(tx)
|
|
188
|
+
.set(
|
|
189
|
+
account.tenantId,
|
|
190
|
+
inboundCredentialSecretKey(account.id),
|
|
191
|
+
refreshToken,
|
|
192
|
+
inboundRefreshTokenSecretOptions(account.id),
|
|
193
|
+
);
|
|
189
194
|
});
|
|
190
195
|
cache.delete(account.id);
|
|
191
196
|
},
|
|
@@ -231,8 +231,10 @@ export function createInboundMailSupervisor(
|
|
|
231
231
|
: row;
|
|
232
232
|
const decryptedAddress = decrypted["address"];
|
|
233
233
|
// Falling back to row.address would hand the ciphertext to a provider.
|
|
234
|
+
// One undecryptable account must not stop polling of every other tenant's accounts.
|
|
234
235
|
if (typeof decryptedAddress !== "string") {
|
|
235
|
-
|
|
236
|
+
log(`inbound-mail: account ${row.id} skipped — address did not decrypt to text`);
|
|
237
|
+
continue;
|
|
236
238
|
}
|
|
237
239
|
records.push({
|
|
238
240
|
id: row.id,
|
|
@@ -1,4 +1,23 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.364.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "jobs list query caps the page size at 200",
|
|
6
|
+
"detail": "limit above 200 is rejected with a validation error."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.364.0",
|
|
10
|
+
"type": "fix",
|
|
11
|
+
"title": "Stale-run sweep uses the job-run retention window for queued runs",
|
|
12
|
+
"detail": "The sweep now only fails queued runs that are older than the configured retention days instead of every queued run past the stale cutoff, so freshly queued runs of a long backlog are no longer failed."
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"version": "0.364.0",
|
|
16
|
+
"type": "fix",
|
|
17
|
+
"title": "Tenant-visible job runs no longer collide across tenants, jobs and lanes",
|
|
18
|
+
"detail": "store_tenant_job_runs was unique on bull_job_id alone, but BullMQ ids count per queue, so a job on the api lane and one on the worker lane (or two tenants after a Redis reset) shared an id and overwrote each other's row. The unique index now covers (tenant_id, job_name, bull_job_id), every write filters by all three, and an enqueue replaces a finished row left over under the same key. onJobDropped now receives { tenantId } as a third argument.",
|
|
19
|
+
"migration": "Run `kumiko-schema generate` in your app to add the migration that creates store_tenant_job_runs_tenant_job_bull_job_id_unique and drops store_tenant_job_runs_bull_job_id_unique (generated as a commented DROP INDEX; uncomment it). A custom onJobDropped callback passed to the job runner now gets a third argument { tenantId }."
|
|
20
|
+
},
|
|
2
21
|
{
|
|
3
22
|
"version": "0.360.0",
|
|
4
23
|
"type": "fix",
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import { deleteManyBatched, selectMany, updateMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
16
16
|
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
17
17
|
import { mapWithConcurrency } from "../../../shared/index.js";
|
|
18
|
+
import { DEFAULT_JOB_RUN_RETENTION_DAYS } from "../../handlers/retention-cleanup.job.js";
|
|
18
19
|
import { encryptFailureError } from "../../job-run-logger.js";
|
|
19
20
|
import { jobRunsTable } from "../../job-run-table.js";
|
|
20
21
|
import { tenantJobRunsTable } from "../../tenant-job-run-table.js";
|
|
@@ -31,18 +32,24 @@ export type StaleRunSweepResult = {
|
|
|
31
32
|
readonly tenantQueuedDropped: number;
|
|
32
33
|
};
|
|
33
34
|
|
|
35
|
+
// A queued tenant row can legitimately wait much longer than a run takes (backlog, delay/backoff,
|
|
36
|
+
// sequential requeue), so it is only treated as lost once it is older than the retention window,
|
|
37
|
+
// not after the running-timeout.
|
|
34
38
|
export async function markStaleJobRunsFailed(
|
|
35
39
|
db: DbConnection,
|
|
36
40
|
timeoutHours: number,
|
|
41
|
+
queuedRetentionDays: number = DEFAULT_JOB_RUN_RETENTION_DAYS,
|
|
37
42
|
): Promise<StaleRunSweepResult> {
|
|
38
43
|
const cutoff = Temporal.Now.instant().subtract({ hours: timeoutHours });
|
|
44
|
+
const queuedCutoff = Temporal.Now.instant().subtract({ hours: queuedRetentionDays * 24 });
|
|
39
45
|
const now = Temporal.Now.instant();
|
|
40
46
|
|
|
41
47
|
// Fetch the matched rows first: `error` carries the same per-triggering-
|
|
42
48
|
// user subject annotation as job-run-logger.ts's onJobFailed path
|
|
43
49
|
// (personal: { of: "triggeredById" } on job-run-table.ts), so it is encrypted
|
|
44
|
-
// under
|
|
45
|
-
//
|
|
50
|
+
// under the triggering user's DEK, or the system tenant key when
|
|
51
|
+
// triggeredById is null (whenAbsent: "tenant", fw#3654). Runs are grouped by
|
|
52
|
+
// that subject: one encrypt and one updateMany per subject instead of per run.
|
|
46
53
|
const stale = await selectMany<{ id: string; triggeredById: string | null }>(db, jobRunsTable, {
|
|
47
54
|
status: "running",
|
|
48
55
|
startedAt: { lt: cutoff },
|
|
@@ -86,7 +93,8 @@ export async function markStaleJobRunsFailed(
|
|
|
86
93
|
|
|
87
94
|
// Tenant-visible run state follows the same clock: a running row whose
|
|
88
95
|
// worker died is a failure, a queued row that never started (job lost from
|
|
89
|
-
// Redis, enqueue hook without a run) would otherwise show "waiting" forever
|
|
96
|
+
// Redis, enqueue hook without a run) would otherwise show "waiting" forever;
|
|
97
|
+
// it is dropped once it has outlived the retention window.
|
|
90
98
|
const tenantRunsFailed = await updateMany(
|
|
91
99
|
db,
|
|
92
100
|
tenantJobRunsTable,
|
|
@@ -96,7 +104,7 @@ export async function markStaleJobRunsFailed(
|
|
|
96
104
|
const tenantQueuedStale = await deleteManyBatched(
|
|
97
105
|
db,
|
|
98
106
|
tenantJobRunsTable,
|
|
99
|
-
{ status: "queued", updatedAt: { lt:
|
|
107
|
+
{ status: "queued", updatedAt: { lt: queuedCutoff } },
|
|
100
108
|
{ limit: STALE_DELETE_BATCH_SIZE },
|
|
101
109
|
);
|
|
102
110
|
|
|
@@ -36,7 +36,8 @@ import { tenantJobRunsTableMeta } from "./tenant-job-run-table.js";
|
|
|
36
36
|
|
|
37
37
|
export type JobsFeatureOptions = {
|
|
38
38
|
// How long a job run (and its logs) stays in store_job_runs/
|
|
39
|
-
// store_job_run_logs before the daily retention-cleanup job deletes it.
|
|
39
|
+
// store_job_run_logs before the daily retention-cleanup job deletes it. Also the age after
|
|
40
|
+
// which a tenant-visible "queued" row counts as lost and is dropped by the stale-run sweep.
|
|
40
41
|
readonly retentionDays?: number;
|
|
41
42
|
// How long a run can sit at status "running" before the hourly
|
|
42
43
|
// stale-run-sweep job marks it "failed" (#2246 — a process kill mid-run
|
|
@@ -51,7 +52,7 @@ export function createJobsFeature(options: JobsFeatureOptions = {}): FeatureDefi
|
|
|
51
52
|
const staleRunTimeoutHours = options.staleRunTimeoutHours ?? DEFAULT_JOB_RUN_STALE_TIMEOUT_HOURS;
|
|
52
53
|
return defineFeature("jobs", (r) => {
|
|
53
54
|
r.describe(
|
|
54
|
-
"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
|
|
55
|
+
"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 `queued` rows older than `retentionDays`.",
|
|
55
56
|
);
|
|
56
57
|
r.uiHints({
|
|
57
58
|
displayLabel: "Jobs · Audit & Operator UI",
|
|
@@ -61,7 +62,9 @@ export function createJobsFeature(options: JobsFeatureOptions = {}): FeatureDefi
|
|
|
61
62
|
r.systemScope();
|
|
62
63
|
r.storeTable(jobRunsTableMeta, {
|
|
63
64
|
reason: "direct_write.job_runs",
|
|
64
|
-
// payload/error carry personal:{of:"triggeredById"} (job-run-table.ts)
|
|
65
|
+
// payload/error carry personal:{of:"triggeredById"} (job-run-table.ts): encrypted under the
|
|
66
|
+
// triggering user's DEK, or the system tenant key when triggeredById is null
|
|
67
|
+
// (whenAbsent: "tenant", fw#3654).
|
|
65
68
|
// job-run-logger.ts manually encrypts both before every insert/update
|
|
66
69
|
// (encryptStartedPayload/encryptFailureError) since this store bypasses
|
|
67
70
|
// the executor's automatic PII pipeline (#2243).
|
|
@@ -115,7 +118,7 @@ export function createJobsFeature(options: JobsFeatureOptions = {}): FeatureDefi
|
|
|
115
118
|
r.job(
|
|
116
119
|
"stale-run-sweep",
|
|
117
120
|
{ trigger: { cron: "0 * * * *" }, concurrency: "skip" },
|
|
118
|
-
createStaleRunSweepJob(staleRunTimeoutHours),
|
|
121
|
+
createStaleRunSweepJob(staleRunTimeoutHours, retentionDays),
|
|
119
122
|
);
|
|
120
123
|
|
|
121
124
|
const handlers = {
|
|
@@ -8,6 +8,8 @@ import { jobRunsTable } from "../job-run-table.js";
|
|
|
8
8
|
|
|
9
9
|
const KMS_POOL_CONCURRENCY = 4;
|
|
10
10
|
const DEFAULT_PAGE_SIZE = 50;
|
|
11
|
+
// The offset cursor re-reads everything up to the page end, so the page size bounds that read.
|
|
12
|
+
const MAX_LIST_LIMIT = 200;
|
|
11
13
|
|
|
12
14
|
const jobRunStatusSchema = z.enum(["queued", "running", "completed", "failed"]);
|
|
13
15
|
|
|
@@ -68,7 +70,7 @@ export const listQuery = defineQueryHandler({
|
|
|
68
70
|
.optional(),
|
|
69
71
|
sort: z.enum(["jobName", "status", "startedAt", "duration"]).optional(),
|
|
70
72
|
sortDirection: z.enum(["asc", "desc"]).optional(),
|
|
71
|
-
limit: z.number().int().min(1).optional(),
|
|
73
|
+
limit: z.number().int().min(1).max(MAX_LIST_LIMIT).optional(),
|
|
72
74
|
cursor: z.string().optional(),
|
|
73
75
|
totalCount: z.boolean().optional(),
|
|
74
76
|
}),
|
|
@@ -13,7 +13,10 @@ import { markStaleJobRunsFailed } from "../db/queries/stale-run-sweep.js";
|
|
|
13
13
|
// Configurable per-app via JobsFeatureOptions.staleRunTimeoutHours.
|
|
14
14
|
export const DEFAULT_JOB_RUN_STALE_TIMEOUT_HOURS = 24;
|
|
15
15
|
|
|
16
|
-
export function createStaleRunSweepJob(
|
|
16
|
+
export function createStaleRunSweepJob(
|
|
17
|
+
timeoutHours: number,
|
|
18
|
+
queuedRetentionDays: number,
|
|
19
|
+
): JobHandlerFn {
|
|
17
20
|
return async (_payload, ctx) => {
|
|
18
21
|
if (!ctx.systemDb) {
|
|
19
22
|
throw new InternalError({
|
|
@@ -24,7 +27,7 @@ export function createStaleRunSweepJob(timeoutHours: number): JobHandlerFn {
|
|
|
24
27
|
const db = ctx.systemDb.unsafeRaw(
|
|
25
28
|
"stale-run sweep marks stuck runs of every tenant as failed",
|
|
26
29
|
) as DbConnection; // @cast-boundary db-operator — DbRunner narrows to DbConnection, jobs never run inside a DbTx
|
|
27
|
-
const result = await markStaleJobRunsFailed(db, timeoutHours);
|
|
30
|
+
const result = await markStaleJobRunsFailed(db, timeoutHours, queuedRetentionDays);
|
|
28
31
|
ctx.log?.info?.(`[jobs:stale-run-sweep] complete: ${JSON.stringify(result)}`);
|
|
29
32
|
};
|
|
30
33
|
}
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
import { acquireNamespacedAdvisoryLock, type DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
20
20
|
import { type Registry, SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
21
21
|
import type {
|
|
22
|
+
JobDroppedMeta,
|
|
22
23
|
JobLogEntry,
|
|
23
24
|
JobMeta,
|
|
24
25
|
JobOutcomeMeta,
|
|
@@ -169,6 +170,7 @@ async function encryptStartedPayload(
|
|
|
169
170
|
const TENANT_JOB_FAILURE_LOCK_NAMESPACE = 0x746a666c;
|
|
170
171
|
// 'tjrl': serialises the finished-row write + prune per tenant run key.
|
|
171
172
|
const TENANT_JOB_RUN_LOCK_NAMESPACE = 0x746a726c;
|
|
173
|
+
const TENANT_RUN_CONFLICT_KEYS = ["tenantId", "jobName", "bullJobId"] as const;
|
|
172
174
|
|
|
173
175
|
// fw#3079 — which row the tenant-visible failure record lives in: one per
|
|
174
176
|
// (tenant, job, subject), so the next outcome of the same work replaces or
|
|
@@ -227,8 +229,11 @@ async function clearTenantJobFailure(
|
|
|
227
229
|
}
|
|
228
230
|
|
|
229
231
|
// fw#3616 — tenant-visible run state. One row per BullMQ job in
|
|
230
|
-
// store_tenant_job_runs
|
|
231
|
-
//
|
|
232
|
+
// store_tenant_job_runs, addressed by (tenant, job name, bullJobId): BullMQ
|
|
233
|
+
// ids are only unique per queue (api and worker lane both count from 1) and
|
|
234
|
+
// restart after a Redis reset, so the id alone must never pick a row. The
|
|
235
|
+
// writes below only touch rows of that triple, except pruning older finished
|
|
236
|
+
// rows of the same key.
|
|
232
237
|
type TenantRunKey = {
|
|
233
238
|
readonly tenantId: string;
|
|
234
239
|
readonly jobName: string;
|
|
@@ -255,14 +260,27 @@ async function recordTenantRunQueued(
|
|
|
255
260
|
// skip: system-tenant run, no tenant-scoped query reads it
|
|
256
261
|
if (!key) return;
|
|
257
262
|
const now = Temporal.Now.instant();
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
263
|
+
await transaction(db, async (tx) => {
|
|
264
|
+
// A finished row under the same id is left over from before a Redis reset
|
|
265
|
+
// (ids restart at 1), not this job. Only rows that went through this hook
|
|
266
|
+
// (queuedAt set) qualify: a fast worker can finish before the hook runs,
|
|
267
|
+
// and that row is this job's own and must stay.
|
|
268
|
+
await deleteMany(tx, tenantJobRunsTable, {
|
|
269
|
+
tenantId: key.tenantId,
|
|
270
|
+
jobName,
|
|
271
|
+
bullJobId,
|
|
272
|
+
status: { in: ["completed", "failed"] },
|
|
273
|
+
queuedAt: { gt: Temporal.Instant.fromEpochMilliseconds(0) },
|
|
274
|
+
});
|
|
275
|
+
// DO NOTHING: a fast worker can report the start before this enqueue hook
|
|
276
|
+
// returns, and a repeated jobId (debounce, fan-out dedup) is the same job.
|
|
277
|
+
await insertOnConflictDoNothing(
|
|
278
|
+
tx,
|
|
279
|
+
tenantJobRunsTable,
|
|
280
|
+
{ ...key, bullJobId, status: "queued", queuedAt: now, updatedAt: now },
|
|
281
|
+
{ conflictKeys: [...TENANT_RUN_CONFLICT_KEYS] },
|
|
282
|
+
);
|
|
283
|
+
});
|
|
266
284
|
}
|
|
267
285
|
|
|
268
286
|
async function recordTenantRunStarted(
|
|
@@ -282,7 +300,7 @@ async function recordTenantRunStarted(
|
|
|
282
300
|
tenantJobRunsTable,
|
|
283
301
|
{ ...key, bullJobId, status: "running", startedAt: now, updatedAt: now },
|
|
284
302
|
{
|
|
285
|
-
conflictKeys: [
|
|
303
|
+
conflictKeys: [...TENANT_RUN_CONFLICT_KEYS],
|
|
286
304
|
update: { status: "running", startedAt: now, finishedAt: null, updatedAt: now },
|
|
287
305
|
},
|
|
288
306
|
);
|
|
@@ -308,7 +326,7 @@ async function recordTenantRunOutcome(
|
|
|
308
326
|
db,
|
|
309
327
|
tenantJobRunsTable,
|
|
310
328
|
{ status: "queued", startedAt: null, finishedAt: null, updatedAt: now },
|
|
311
|
-
{ bullJobId },
|
|
329
|
+
{ tenantId: key.tenantId, jobName, bullJobId },
|
|
312
330
|
);
|
|
313
331
|
// skip: retry pending, the row stays active until the final attempt
|
|
314
332
|
return;
|
|
@@ -327,7 +345,10 @@ async function recordTenantRunOutcome(
|
|
|
327
345
|
tx,
|
|
328
346
|
tenantJobRunsTable,
|
|
329
347
|
{ ...key, bullJobId, status: result, finishedAt: now, updatedAt: now },
|
|
330
|
-
{
|
|
348
|
+
{
|
|
349
|
+
conflictKeys: [...TENANT_RUN_CONFLICT_KEYS],
|
|
350
|
+
update: { status: result, finishedAt: now, updatedAt: now },
|
|
351
|
+
},
|
|
331
352
|
);
|
|
332
353
|
await deleteMany(tx, tenantJobRunsTable, {
|
|
333
354
|
...key,
|
|
@@ -337,8 +358,18 @@ async function recordTenantRunOutcome(
|
|
|
337
358
|
});
|
|
338
359
|
}
|
|
339
360
|
|
|
340
|
-
async function dropTenantRunQueued(
|
|
341
|
-
|
|
361
|
+
async function dropTenantRunQueued(
|
|
362
|
+
db: DbConnection,
|
|
363
|
+
jobName: string,
|
|
364
|
+
bullJobId: string,
|
|
365
|
+
meta: JobDroppedMeta,
|
|
366
|
+
): Promise<void> {
|
|
367
|
+
await deleteMany(db, tenantJobRunsTable, {
|
|
368
|
+
tenantId: meta.tenantId,
|
|
369
|
+
jobName,
|
|
370
|
+
bullJobId,
|
|
371
|
+
status: "queued",
|
|
372
|
+
});
|
|
342
373
|
}
|
|
343
374
|
|
|
344
375
|
const log = createFallbackLogger("job-run-logger");
|
|
@@ -458,8 +489,12 @@ export function createJobRunLogger(opts: JobRunLoggerOptions): JobRunLoggerCallb
|
|
|
458
489
|
);
|
|
459
490
|
},
|
|
460
491
|
|
|
461
|
-
onJobDropped: async (jobName: string, bullJobId: string) => {
|
|
462
|
-
await isolateTenantRunWrite(
|
|
492
|
+
onJobDropped: async (jobName: string, bullJobId: string, meta: JobDroppedMeta) => {
|
|
493
|
+
await isolateTenantRunWrite(
|
|
494
|
+
() => dropTenantRunQueued(db, jobName, bullJobId, meta),
|
|
495
|
+
jobName,
|
|
496
|
+
bullJobId,
|
|
497
|
+
);
|
|
463
498
|
},
|
|
464
499
|
|
|
465
500
|
onJobStart: async (jobName: string, bullJobId: string, meta: JobMeta) => {
|
|
@@ -21,7 +21,7 @@ export type TenantJobRunStatus = "queued" | "running" | "completed" | "failed";
|
|
|
21
21
|
// job is invisible there. This table holds no payload, error text or logs by
|
|
22
22
|
// design — the tenant learns only that work is waiting, running or finished.
|
|
23
23
|
//
|
|
24
|
-
// One row per BullMQ job. Active rows (queued/running) are kept as they are;
|
|
24
|
+
// One row per BullMQ job and (tenant, job name). Active rows (queued/running) are kept as they are;
|
|
25
25
|
// a finished row replaces earlier finished rows of the same (tenant, job,
|
|
26
26
|
// subject), so only the latest outcome stays. `subject` is the canonical JSON
|
|
27
27
|
// of the job's declared subjectFields, or NULL for none — stored in clear, so
|
|
@@ -41,7 +41,12 @@ export const tenantJobRunsTable = pgTable(
|
|
|
41
41
|
updatedAt: instant("updated_at").default(sql`now()`).notNull(),
|
|
42
42
|
},
|
|
43
43
|
(t) => [
|
|
44
|
-
|
|
44
|
+
// BullMQ ids are unique per queue only; the id alone would let two tenants collide.
|
|
45
|
+
uniqueIndex("store_tenant_job_runs_tenant_job_bull_job_id_unique").on(
|
|
46
|
+
t.tenantId,
|
|
47
|
+
t.jobName,
|
|
48
|
+
t.bullJobId,
|
|
49
|
+
),
|
|
45
50
|
// Finishing a run prunes older outcomes by this key; the tenant query reads by it.
|
|
46
51
|
index("store_tenant_job_runs_tenant_job_subject_idx").on(t.tenantId, t.jobName, t.subject),
|
|
47
52
|
// Stale sweep and retention filter on age alone.
|
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.364.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "Document the duplicate check required before the (tenant_id, source_ref) unique index",
|
|
6
|
+
"detail": "The unique index on read_ledger_schedules (tenant_id, source_ref) is created in place and fails on existing duplicate pairs, and a projection rebuild over duplicate events fails the same way. Replay handling for duplicate events is not part of this change.",
|
|
7
|
+
"migration": "Before upgrading, check each database for duplicates and clean them up: SELECT tenant_id, source_ref, count(*) FROM read_ledger_schedules WHERE source_ref IS NOT NULL GROUP BY 1, 2 HAVING count(*) > 1; Any row returned makes the unique index migration abort."
|
|
8
|
+
},
|
|
2
9
|
{
|
|
3
10
|
"version": "0.354.0",
|
|
4
11
|
"type": "fix",
|
|
@@ -176,7 +176,9 @@ export const scheduleEntity = createEntity({
|
|
|
176
176
|
},
|
|
177
177
|
// Find-then-create by sourceRef races across tabs/devices; only the DB closes
|
|
178
178
|
// that gap. Partial because sourceRef is optional. A new name (not the old
|
|
179
|
-
// non-unique default) keeps the migration an in-place CREATE UNIQUE INDEX
|
|
179
|
+
// non-unique default) keeps the migration an in-place CREATE UNIQUE INDEX;
|
|
180
|
+
// that statement, like a projection rebuild, fails on existing duplicate
|
|
181
|
+
// (tenant_id, source_ref) pairs, so duplicates must be cleaned up beforehand.
|
|
180
182
|
indexes: [
|
|
181
183
|
{ columns: ["tenantId", "subjectType", "subjectId"] },
|
|
182
184
|
{
|
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.364.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "renderTablePdf rejects tables above 10000 rows, 30 columns or 5000 characters per cell",
|
|
6
|
+
"detail": "The table document schema now caps rows at 10000, columns at 30 and each cell string at 5000 characters. Larger input fails with invalid_payload instead of exhausting memory during layout."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.364.0",
|
|
10
|
+
"type": "fix",
|
|
11
|
+
"title": "render-table-pdf validates input and paginates its intro",
|
|
12
|
+
"detail": "Table documents reject negative margins and unusable column widths, normalize CRLF and paginate the intro text and the first header row."
|
|
13
|
+
},
|
|
2
14
|
{
|
|
3
15
|
"version": "0.360.0",
|
|
4
16
|
"type": "improvement",
|