@cosmicdrift/kumiko-samples 0.363.0 → 0.365.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/LICENSE +1 -1
- package/package.json +1 -1
- package/packages/bundled-features/package.json +7 -1
- package/packages/bundled-features/src/audit/changes.json +18 -0
- package/packages/bundled-features/src/audit/feature.ts +1 -1
- package/packages/bundled-features/src/audit/handlers/list.query.ts +10 -5
- package/packages/bundled-features/src/audit/run-escape-hatch-retention.ts +63 -11
- package/packages/bundled-features/src/auth-email-password/bootstrap.ts +30 -11
- package/packages/bundled-features/src/auth-email-password/changes.json +55 -0
- package/packages/bundled-features/src/auth-email-password/email-rate-limits.ts +16 -0
- package/packages/bundled-features/src/auth-email-password/feature.ts +4 -7
- package/packages/bundled-features/src/auth-email-password/handlers/change-password.write.ts +6 -2
- package/packages/bundled-features/src/auth-email-password/handlers/invite-accept-with-login.write.ts +2 -3
- package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +6 -4
- 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/switch-tenant-mfa-gate.write.ts +2 -2
- package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +2 -1
- package/packages/bundled-features/src/auth-email-password/magic-link-mail.ts +7 -3
- package/packages/bundled-features/src/auth-email-password/mail-i18n.ts +10 -0
- package/packages/bundled-features/src/auth-email-password/web/auth-gate.tsx +24 -5
- package/packages/bundled-features/src/billing-foundation/changes.json +48 -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 +18 -2
- 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 +9 -0
- package/packages/bundled-features/src/billing-foundation/subscription-tier-sync.ts +27 -7
- package/packages/bundled-features/src/billing-foundation/types.ts +7 -2
- package/packages/bundled-features/src/cap-counter/book-cap-usage.ts +20 -8
- package/packages/bundled-features/src/cap-counter/calendar-cap-usage-reservation.ts +2 -0
- package/packages/bundled-features/src/cap-counter/cap-reservation.ts +7 -2
- package/packages/bundled-features/src/cap-counter/changes.json +19 -0
- package/packages/bundled-features/src/cap-counter/enforce-cap.ts +75 -5
- package/packages/bundled-features/src/cap-counter/handlers/increment-rolling.write.ts +2 -0
- 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/cap-overview/changes.json +6 -0
- package/packages/bundled-features/src/cap-overview/handlers/tenant-caps-list.query.ts +2 -1
- package/packages/bundled-features/src/cap-overview/handlers/tenant-options.query.ts +2 -1
- package/packages/bundled-features/src/channel-discord/discord-channel.ts +4 -2
- package/packages/bundled-features/src/channel-email/changes.json +18 -0
- package/packages/bundled-features/src/channel-email/email-channel.ts +47 -24
- package/packages/bundled-features/src/channel-email/smtp-transport.ts +7 -1
- package/packages/bundled-features/src/channel-slack/slack-channel.ts +4 -2
- package/packages/bundled-features/src/channel-teams/teams-channel.ts +4 -2
- 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 +54 -27
- package/packages/bundled-features/src/custom-fields/changes.json +6 -0
- package/packages/bundled-features/src/custom-fields/db/queries/retention.ts +2 -2
- package/packages/bundled-features/src/custom-fields/handlers/update-tenant-field.write.ts +0 -1
- package/packages/bundled-features/src/data-retention/run-retention-cleanup.ts +4 -5
- package/packages/bundled-features/src/delivery/attachment-ref.ts +39 -1
- package/packages/bundled-features/src/delivery/changes.json +30 -0
- package/packages/bundled-features/src/delivery/channel-context.ts +31 -8
- package/packages/bundled-features/src/delivery/delivery-service.ts +93 -92
- 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/document-ingest-foundation/changes.json +6 -0
- package/packages/bundled-features/src/feature-toggles/changes.json +7 -0
- package/packages/bundled-features/src/feature-toggles/toggle-runtime.ts +26 -12
- 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/files-tenant-data/index.ts +3 -1
- package/packages/bundled-features/src/form-draft/db/queries/owned-file-refs.ts +12 -3
- package/packages/bundled-features/src/form-draft/handlers/discard.write.ts +1 -1
- 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 +31 -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 +56 -18
- 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/metrics/changes.json +6 -0
- package/packages/bundled-features/src/metrics/filters.ts +28 -12
- package/packages/bundled-features/src/personal-access-tokens/changes.json +6 -0
- package/packages/bundled-features/src/personal-access-tokens/revoke-for-user.ts +7 -8
- 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 +12 -0
- package/packages/bundled-features/src/secrets/feature.ts +4 -3
- package/packages/bundled-features/src/secrets/handlers/delete.write.ts +3 -1
- package/packages/bundled-features/src/secrets/secrets-context.ts +23 -3
- package/packages/bundled-features/src/secrets/write-gate.ts +8 -2
- 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/index.ts +8 -0
- package/packages/bundled-features/src/shared/mail-template-body.ts +17 -0
- package/packages/bundled-features/src/shared/resource-image-formats.ts +31 -0
- package/packages/bundled-features/src/shared/sharp-input-limits.ts +7 -8
- package/packages/bundled-features/src/step-dispatcher/webhook-runner.ts +2 -2
- 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 +149 -0
- package/packages/bundled-features/src/template-resolver/compile-document-body.ts +148 -18
- package/packages/bundled-features/src/template-resolver/compile-steps-block.ts +68 -0
- package/packages/bundled-features/src/template-resolver/constants.ts +23 -3
- package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +3 -0
- package/packages/bundled-features/src/template-resolver/document-schema.ts +33 -5
- package/packages/bundled-features/src/template-resolver/document-steps.ts +114 -0
- package/packages/bundled-features/src/template-resolver/feature.ts +16 -1
- package/packages/bundled-features/src/template-resolver/handlers/bundle-import.ts +81 -39
- package/packages/bundled-features/src/template-resolver/handlers/collection-set.write.ts +4 -3
- package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +121 -53
- package/packages/bundled-features/src/template-resolver/handlers/diff-to-system.query.ts +6 -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/import-bundle-system.write.ts +25 -15
- package/packages/bundled-features/src/template-resolver/handlers/import-bundle.write.ts +12 -1
- 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 +102 -19
- package/packages/bundled-features/src/template-resolver/handlers/resource-upload.write.ts +49 -10
- 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/handlers/validate.query.ts +4 -1
- package/packages/bundled-features/src/template-resolver/index.ts +18 -2
- package/packages/bundled-features/src/template-resolver/layout-appearance.ts +56 -0
- package/packages/bundled-features/src/template-resolver/mail-constants.ts +4 -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/resource-image.ts +9 -16
- package/packages/bundled-features/src/template-resolver/seeding.ts +27 -4
- package/packages/bundled-features/src/template-resolver/store-template-resource.ts +390 -124
- package/packages/bundled-features/src/template-resolver/table.ts +13 -1
- package/packages/bundled-features/src/template-resolver/template-bundle.ts +4 -0
- package/packages/bundled-features/src/template-resolver/template-draft.ts +106 -0
- package/packages/bundled-features/src/template-resolver/template-meta.ts +95 -24
- package/packages/bundled-features/src/template-resolver/template-preview.ts +160 -57
- package/packages/bundled-features/src/template-resolver/template-resources.ts +3 -4
- package/packages/bundled-features/src/template-resolver/template-usage.ts +31 -11
- package/packages/bundled-features/src/template-resolver/template-validation.ts +16 -9
- 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 +97 -10
- package/packages/bundled-features/src/template-resolver/web/document-editor.tsx +15 -5
- package/packages/bundled-features/src/template-resolver/web/document-tiptap-editor.tsx +405 -145
- package/packages/bundled-features/src/template-resolver/web/document-toolbar-blocks.ts +17 -3
- package/packages/bundled-features/src/template-resolver/web/i18n.ts +32 -4
- package/packages/bundled-features/src/template-resolver/web/index.ts +8 -0
- package/packages/bundled-features/src/template-resolver/web/placeholder-line-editor.tsx +23 -7
- package/packages/bundled-features/src/tenant/changes.json +6 -0
- package/packages/bundled-features/src/tenant/handlers/update-member-roles.write.ts +3 -8
- package/packages/bundled-features/src/tenant/seeding.ts +33 -27
- package/packages/bundled-features/src/tenant-handover/move-entity-graph.ts +14 -3
- package/packages/bundled-features/src/tenant-lifecycle/changes.json +6 -0
- package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +9 -1
- package/packages/bundled-features/src/tenant-lifecycle/stages.ts +3 -2
- package/packages/bundled-features/src/tier-engine/changes.json +18 -0
- package/packages/bundled-features/src/tier-engine/constants.ts +9 -0
- package/packages/bundled-features/src/tier-engine/entity.ts +9 -4
- package/packages/bundled-features/src/tier-engine/feature.ts +11 -3
- package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +8 -5
- package/packages/bundled-features/src/tier-engine/index.ts +2 -0
- package/packages/bundled-features/src/user/changes.json +6 -0
- package/packages/bundled-features/src/user/constants.ts +1 -0
- package/packages/bundled-features/src/user/invited-global-roles.ts +2 -4
- 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 +12 -0
- package/packages/bundled-features/src/user-data-rights/email-templates.ts +0 -1
- package/packages/bundled-features/src/user-data-rights/i18n.ts +5 -0
- package/packages/bundled-features/src/user-data-rights/lib/update-user-lifecycle.ts +13 -2
- package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +7 -1
- package/packages/bundled-features/src/user-profile/changes.json +1 -1
- package/packages/bundled-features/src/waitlist/changes.json +18 -0
- package/packages/bundled-features/src/waitlist/feature.ts +1 -1
- package/packages/bundled-features/src/waitlist/handlers/invite.write.ts +35 -3
- package/packages/bundled-features/src/waitlist/handlers/reject.write.ts +2 -17
- package/packages/bundled-features/src/waitlist/handlers/submit.write.ts +33 -9
- package/packages/bundled-features/src/waitlist/i18n.ts +9 -0
- package/packages/bundled-features/src/waitlist/lib.ts +20 -0
- 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/feature.ts +1 -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 +18 -1
- package/samples/apps/use-all-bundled/README.md +3 -13
- package/samples/apps/use-all-bundled/feature-manifest.json +7 -5
- package/samples/apps/use-all-bundled/scripts/check-coverage.ts +34 -118
- package/samples/apps/use-all-bundled/src/app/notes-feature.ts +1 -1
- package/samples/apps/use-all-bundled/src/run-config.ts +0 -3
- package/samples/recipes/auth-mfa/README.md +2 -1
- package/samples/recipes/auth-mfa/src/feature.ts +1 -0
- package/samples/recipes/custom-handlers/README.md +4 -3
- package/samples/recipes/custom-handlers/src/feature.ts +21 -12
- package/samples/recipes/dashboard-stats/README.md +38 -0
- package/samples/recipes/dashboard-stats/package.json +8 -0
- package/samples/recipes/dashboard-stats/src/feature.ts +104 -0
- package/samples/recipes/legal-pages/README.md +2 -2
- package/samples/recipes/managed-pages/README.md +2 -2
- package/samples/recipes/pdf-audit-export/README.md +2 -1
- package/samples/recipes/pdf-audit-export/package.json +1 -1
- package/samples/recipes/pdf-audit-export/src/feature.ts +68 -48
- package/samples/recipes/workflow-engine/src/db/queries/suspended-runs.ts +2 -2
- package/samples/apps/use-all-bundled/schema/generate.ts +0 -617
- package/samples/apps/use-all-bundled/schema/migrations/meta/0000_snapshot.json +0 -3055
- package/samples/apps/use-all-bundled/schema/migrations/meta/_journal.json +0 -13
- package/samples/apps/use-all-bundled/schema/schema.generated.ts +0 -94
- package/samples/apps/use-all-bundled/schema/schema.ts +0 -9
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.364.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "readFileRefContent reads handed-over files; fileContextForTenant takes the context tenant",
|
|
6
|
+
"detail": "readFileRefContent, readTemplateResource and the public variant query read bytes through FileContext.refFromFileRef, so a fileRef moved by a tenant handover is readable again. fileContextForTenant gains a `contextTenantId` argument: without a provider resolver it returns undefined for any other tenant instead of the context's files, which are bound to a different key space."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.292.0",
|
|
4
10
|
"type": "improvement",
|
|
@@ -22,16 +22,19 @@ type FileRefRow = {
|
|
|
22
22
|
readonly size: number | bigint | string;
|
|
23
23
|
};
|
|
24
24
|
|
|
25
|
-
// Tenant-bound on purpose:
|
|
25
|
+
// Tenant-bound on purpose: without a resolver `ctx.files` only reaches the storage of the
|
|
26
|
+
// context's own tenant (`contextTenantId`), so a request for another tenant's files is
|
|
27
|
+
// undefined instead of silently reading through the wrong tenant's storage.
|
|
26
28
|
export function fileContextForTenant(
|
|
27
29
|
ctx: {
|
|
28
30
|
readonly _fileProviderResolver?: FileProviderResolver | undefined;
|
|
29
31
|
readonly files?: FileContext | undefined;
|
|
30
32
|
},
|
|
33
|
+
contextTenantId: string,
|
|
31
34
|
tenantId: string,
|
|
32
35
|
): FileContext | undefined {
|
|
33
36
|
const resolver = ctx._fileProviderResolver;
|
|
34
|
-
if (resolver === undefined) return ctx.files;
|
|
37
|
+
if (resolver === undefined) return tenantId === contextTenantId ? ctx.files : undefined;
|
|
35
38
|
return fileContextFromResolver(resolver, tenantId);
|
|
36
39
|
}
|
|
37
40
|
|
|
@@ -81,6 +84,6 @@ export async function readFileRefContent(args: {
|
|
|
81
84
|
if (files === undefined) {
|
|
82
85
|
throw new Error("readFileRefContent: no file storage is available for the file's tenant");
|
|
83
86
|
}
|
|
84
|
-
const content = await files.
|
|
87
|
+
const content = await files.refFromFileRef(row).read();
|
|
85
88
|
return { content, contentType: row.mimeType, tenantId: row.tenantId, size };
|
|
86
89
|
}
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.364.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "Tenant destroy hooks make progress even when the deadline already passed",
|
|
6
|
+
"detail": "The chunked migration takes a minBatches option and the file destroy hooks process one chunk per tick, so a slow list call no longer makes every run time out empty."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.354.0",
|
|
4
10
|
"type": "fix",
|
|
@@ -129,6 +129,7 @@ export const fileRefTenantDestroyHook: TenantDataDestroyHook = async (ctx) => {
|
|
|
129
129
|
},
|
|
130
130
|
maxFailures: 1,
|
|
131
131
|
deadlineAt: ctx.deadlineAt ?? Number.POSITIVE_INFINITY,
|
|
132
|
+
minBatches: 1,
|
|
132
133
|
onRowError: (_row, err) => {
|
|
133
134
|
firstFailure = err instanceof Error ? err.message : String(err);
|
|
134
135
|
},
|
|
@@ -222,6 +223,9 @@ export const fileRefStorageDestroyHook: StorageProviderDestroyTenantHook = async
|
|
|
222
223
|
},
|
|
223
224
|
maxFailures: 1,
|
|
224
225
|
deadlineAt: ctx.deadlineAt ?? Number.POSITIVE_INFINITY,
|
|
226
|
+
// list() and the survivor lookup above can use up the whole budget; one chunk per tick
|
|
227
|
+
// still deletes, so the stage never reports "no progress".
|
|
228
|
+
minBatches: 1,
|
|
225
229
|
onRowError: (_key, err) => {
|
|
226
230
|
firstFailure = err instanceof Error ? err.message : String(err);
|
|
227
231
|
},
|
|
@@ -27,8 +27,10 @@ import { fileRefStorageDestroyHook, fileRefTenantDestroyHook } from "./hooks.js"
|
|
|
27
27
|
const SWEEP_ORPHANED_DERIVATIVES_ESCAPE_HATCH_REASON =
|
|
28
28
|
"iterates every tenant and checks fileRef owners per tenant";
|
|
29
29
|
|
|
30
|
+
export const FILES_TENANT_DATA_FEATURE = "files-tenant-data" as const;
|
|
31
|
+
|
|
30
32
|
export function createFilesTenantDataFeature(): FeatureDefinition {
|
|
31
|
-
return defineFeature(
|
|
33
|
+
return defineFeature(FILES_TENANT_DATA_FEATURE, (r) => {
|
|
32
34
|
r.describe(
|
|
33
35
|
"GDPR coverage for the `files` feature's `fileRef` entity: tenant-destroy row purge (EXT_TENANT_DATA) and full storage-prefix binary wipe (EXT_STORAGE_PROVIDER), plus a manual backfill/GC job that sweeps derivatives orphaned by a forget/tenant-destroy that ran before #2461 wired binary cleanup into those flows. Requires `tenant-lifecycle`, optionalRequires `files`.",
|
|
34
36
|
);
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
instantToTimestamptzParam,
|
|
3
|
+
unsafeReadRetrying,
|
|
4
|
+
} from "@cosmicdrift/kumiko-framework/bun-db";
|
|
5
|
+
import { type DbRunner, toPostgresArrayLiteral } from "@cosmicdrift/kumiko-framework/db";
|
|
3
6
|
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
7
|
import type { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
5
8
|
|
|
@@ -68,7 +71,13 @@ export async function filterOwnedFileRefs(
|
|
|
68
71
|
WHERE "tenant_id" = $1 AND "inserted_by_id" = $2
|
|
69
72
|
AND "storage_key" = ANY($3::text[]) AND "is_deleted" = false
|
|
70
73
|
AND ($5::boolean OR "inserted_at" > $4::timestamptz)`,
|
|
71
|
-
[
|
|
74
|
+
[
|
|
75
|
+
tenantId,
|
|
76
|
+
ownerId,
|
|
77
|
+
toPostgresArrayLiteral(candidateKeys),
|
|
78
|
+
instantToTimestamptzParam(draftInsertedAt),
|
|
79
|
+
isCreateMode,
|
|
80
|
+
],
|
|
72
81
|
);
|
|
73
82
|
return rows.map((row) => ({ id: row.id, storageKey: row.storage_key }));
|
|
74
83
|
}
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.364.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "Watch supervisor skips and logs accounts whose address does not decrypt",
|
|
6
|
+
"detail": "Previously a single such account made the whole account snapshot throw."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.340.0",
|
|
4
10
|
"type": "improvement",
|
|
@@ -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,35 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.365.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "Finished tenant run reads its timestamp under the lock",
|
|
6
|
+
"detail": "The run that finishes last in lock order now carries the latest finishedAt, so a stale timestamp can no longer make the newest run the row that gets pruned."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.365.0",
|
|
10
|
+
"type": "fix",
|
|
11
|
+
"title": "Older migration notes that say \"kumiko migrate generate\" mean kumiko-schema generate",
|
|
12
|
+
"detail": "Migration notes of the delivery, jobs, metrics and sessions features name a command that does not exist, `kumiko migrate generate`. Run `kumiko-schema generate <name>` and then `kumiko-schema apply` instead. The published notes stay as they were."
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"version": "0.364.0",
|
|
16
|
+
"type": "fix",
|
|
17
|
+
"title": "jobs list query caps the page size at 200",
|
|
18
|
+
"detail": "limit above 200 is rejected with a validation error."
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"version": "0.364.0",
|
|
22
|
+
"type": "fix",
|
|
23
|
+
"title": "Stale-run sweep uses the job-run retention window for queued runs",
|
|
24
|
+
"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."
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"version": "0.364.0",
|
|
28
|
+
"type": "fix",
|
|
29
|
+
"title": "Tenant-visible job runs no longer collide across tenants, jobs and lanes",
|
|
30
|
+
"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.",
|
|
31
|
+
"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 }."
|
|
32
|
+
},
|
|
2
33
|
{
|
|
3
34
|
"version": "0.360.0",
|
|
4
35
|
"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;
|
|
@@ -323,11 +341,17 @@ async function recordTenantRunOutcome(
|
|
|
323
341
|
TENANT_JOB_RUN_LOCK_NAMESPACE,
|
|
324
342
|
JSON.stringify([key.tenantId, key.jobName, key.subject]),
|
|
325
343
|
);
|
|
344
|
+
// Read under the lock: the run that finishes last in lock order must also carry the
|
|
345
|
+
// latest finishedAt, otherwise a stale timestamp makes it the row that gets pruned.
|
|
346
|
+
const finishedAt = Temporal.Now.instant();
|
|
326
347
|
await upsertOnConflict(
|
|
327
348
|
tx,
|
|
328
349
|
tenantJobRunsTable,
|
|
329
|
-
{ ...key, bullJobId, status: result, finishedAt
|
|
330
|
-
{
|
|
350
|
+
{ ...key, bullJobId, status: result, finishedAt, updatedAt: finishedAt },
|
|
351
|
+
{
|
|
352
|
+
conflictKeys: [...TENANT_RUN_CONFLICT_KEYS],
|
|
353
|
+
update: { status: result, finishedAt, updatedAt: finishedAt },
|
|
354
|
+
},
|
|
331
355
|
);
|
|
332
356
|
await deleteMany(tx, tenantJobRunsTable, {
|
|
333
357
|
...key,
|
|
@@ -337,8 +361,18 @@ async function recordTenantRunOutcome(
|
|
|
337
361
|
});
|
|
338
362
|
}
|
|
339
363
|
|
|
340
|
-
async function dropTenantRunQueued(
|
|
341
|
-
|
|
364
|
+
async function dropTenantRunQueued(
|
|
365
|
+
db: DbConnection,
|
|
366
|
+
jobName: string,
|
|
367
|
+
bullJobId: string,
|
|
368
|
+
meta: JobDroppedMeta,
|
|
369
|
+
): Promise<void> {
|
|
370
|
+
await deleteMany(db, tenantJobRunsTable, {
|
|
371
|
+
tenantId: meta.tenantId,
|
|
372
|
+
jobName,
|
|
373
|
+
bullJobId,
|
|
374
|
+
status: "queued",
|
|
375
|
+
});
|
|
342
376
|
}
|
|
343
377
|
|
|
344
378
|
const log = createFallbackLogger("job-run-logger");
|
|
@@ -458,8 +492,12 @@ export function createJobRunLogger(opts: JobRunLoggerOptions): JobRunLoggerCallb
|
|
|
458
492
|
);
|
|
459
493
|
},
|
|
460
494
|
|
|
461
|
-
onJobDropped: async (jobName: string, bullJobId: string) => {
|
|
462
|
-
await isolateTenantRunWrite(
|
|
495
|
+
onJobDropped: async (jobName: string, bullJobId: string, meta: JobDroppedMeta) => {
|
|
496
|
+
await isolateTenantRunWrite(
|
|
497
|
+
() => dropTenantRunQueued(db, jobName, bullJobId, meta),
|
|
498
|
+
jobName,
|
|
499
|
+
bullJobId,
|
|
500
|
+
);
|
|
463
501
|
},
|
|
464
502
|
|
|
465
503
|
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,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.365.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "Metric integer filters reject fractions and out-of-range values; feature-name constants exported",
|
|
6
|
+
"detail": "Filters on smallint, integer and bigint columns now fail validation for decimals and values outside the column range instead of a Postgres cast error. FILES_FEATURE and FILES_TENANT_DATA_FEATURE are exported and reused by compose-features."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.349.0",
|
|
4
10
|
"type": "improvement",
|
|
@@ -7,26 +7,34 @@ export const RESERVED_PAYLOAD_KEYS: readonly string[] = ["range", "timeZone", "t
|
|
|
7
7
|
|
|
8
8
|
// Dashboard select options always send strings.
|
|
9
9
|
const NUMERIC_STRING = /^-?\d+(\.\d+)?$/;
|
|
10
|
+
const INTEGER_STRING = /^-?\d+$/;
|
|
10
11
|
const MAX_FILTER_STRING_LENGTH = 200;
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
"
|
|
19
|
-
|
|
20
|
-
|
|
12
|
+
const INTEGER_RANGES = {
|
|
13
|
+
smallint: { min: -32_768, max: 32_767 },
|
|
14
|
+
integer: { min: -2_147_483_648, max: 2_147_483_647 },
|
|
15
|
+
// bigint is bound as a JS number, so only the exactly representable range is accepted.
|
|
16
|
+
bigint: { min: Number.MIN_SAFE_INTEGER, max: Number.MAX_SAFE_INTEGER },
|
|
17
|
+
} as const;
|
|
18
|
+
const PG_INTEGER_KINDS: Readonly<Record<string, keyof typeof INTEGER_RANGES>> = {
|
|
19
|
+
smallint: "smallint",
|
|
20
|
+
int2: "smallint",
|
|
21
|
+
integer: "integer",
|
|
22
|
+
int4: "integer",
|
|
23
|
+
serial: "integer",
|
|
24
|
+
bigint: "bigint",
|
|
25
|
+
bigserial: "bigint",
|
|
26
|
+
};
|
|
21
27
|
|
|
22
|
-
export type FilterValueKind = "uuid" | "number" | "string";
|
|
28
|
+
export type FilterValueKind = "uuid" | "number" | "string" | keyof typeof INTEGER_RANGES;
|
|
23
29
|
|
|
24
30
|
/** Undefined = the column type cannot be filtered by a payload value without risking a Postgres cast error. */
|
|
25
31
|
export function filterValueKindOf(pgType: string | undefined): FilterValueKind | undefined {
|
|
26
32
|
if (pgType === undefined) return undefined;
|
|
27
33
|
if (pgType === "uuid") return "uuid";
|
|
28
34
|
if (pgType === "text") return "string";
|
|
29
|
-
|
|
35
|
+
const integerKind = PG_INTEGER_KINDS[pgType];
|
|
36
|
+
if (integerKind !== undefined) return integerKind;
|
|
37
|
+
if (pgType === "double precision" || pgType.startsWith("numeric")) return "number";
|
|
30
38
|
return undefined;
|
|
31
39
|
}
|
|
32
40
|
|
|
@@ -44,8 +52,16 @@ export function metricFilterEntries(metric: MetricDefinition): readonly MetricFi
|
|
|
44
52
|
);
|
|
45
53
|
}
|
|
46
54
|
|
|
55
|
+
function integerValueSchema(range: { readonly min: number; readonly max: number }): z.ZodType {
|
|
56
|
+
const bounded = z.number().int().min(range.min).max(range.max);
|
|
57
|
+
return z.union([bounded, z.string().regex(INTEGER_STRING).transform(Number).pipe(bounded)]);
|
|
58
|
+
}
|
|
59
|
+
|
|
47
60
|
function filterValueSchema(kind: FilterValueKind): z.ZodType {
|
|
48
61
|
if (kind === "uuid") return z.uuid();
|
|
62
|
+
if (kind === "smallint" || kind === "integer" || kind === "bigint") {
|
|
63
|
+
return integerValueSchema(INTEGER_RANGES[kind]);
|
|
64
|
+
}
|
|
49
65
|
if (kind === "number")
|
|
50
66
|
return z.union([z.number(), z.string().regex(NUMERIC_STRING).transform(Number)]);
|
|
51
67
|
return z.string().min(1).max(MAX_FILTER_STRING_LENGTH);
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.365.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "revokeAllPatTokensForUser detects begin() the same way as the transaction helpers",
|
|
6
|
+
"detail": "The transaction probe looked for begin() directly on the handle, so a pool wrapped by a legacy Drizzle connection fell back to a non-atomic update plus append. It now uses runnerCanBeginTransaction from bun-db, which unwraps the client first."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.347.0",
|
|
4
10
|
"type": "improvement",
|