@cosmicdrift/kumiko-bundled-features 0.353.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 +9 -3
- 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/tool-dispatch.js +1 -1
- 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 +4 -1
- 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/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/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 +2 -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/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/unsubscribe.js +66 -14
- 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 +58 -22
- package/dist/folders/handlers/delete-folder.write.js +31 -4
- 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-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.js +1 -1
- 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/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/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 +3 -0
- package/dist/ledger/entity.js +13 -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/mail-transport-smtp/feature.d.ts +3 -4
- package/dist/mail-transport-smtp/feature.js +4 -13
- package/dist/notes-history/entity.js +4 -0
- package/dist/personal-access-tokens/handlers/create.write.js +1 -0
- 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/screens.d.ts +2 -2
- package/dist/sessions/screens.js +6 -7
- package/dist/shared/encrypt-for-direct-write.js +10 -2
- package/dist/shared/index.d.ts +2 -2
- package/dist/shared/index.js +2 -2
- package/dist/shared/parent-visibility.d.ts +2 -1
- package/dist/shared/parent-visibility.js +21 -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/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 +8 -0
- package/dist/template-resolver/user-content-table.d.ts +4 -0
- package/dist/tenant/feature.js +7 -1
- package/dist/tenant/handlers/toggle-enabled.write.js +2 -0
- package/dist/tenant/handlers/update-member-roles.write.js +1 -0
- package/dist/tenant/invitation-table.d.ts +40 -0
- 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/schema/tenant.d.ts +2 -0
- 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 +5 -3
- package/dist/tenant-lifecycle/run-tenant-destroy.d.ts +7 -5
- package/dist/tenant-lifecycle/run-tenant-destroy.js +57 -17
- package/dist/tenant-lifecycle/stages.d.ts +6 -1
- package/dist/tenant-lifecycle/stages.js +26 -3
- package/dist/tenant-lifecycle/testing.d.ts +8 -0
- package/dist/tenant-lifecycle/testing.js +42 -0
- package/dist/tier-engine/handlers/set-tenant-tier.write.js +1 -0
- package/dist/user/handlers/detail.query.js +2 -0
- package/dist/user/handlers/list.query.js +4 -0
- package/dist/user/handlers/update.write.js +1 -0
- package/dist/user/schema/user.d.ts +2 -0
- package/dist/user/seeding.d.ts +1 -0
- package/dist/user/seeding.js +20 -0
- 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.js +17 -9
- package/dist/user-data-rights/schema/export-job.d.ts +2 -0
- package/dist/user-profile/handlers/change-email.write.js +8 -2
- package/dist/waitlist/entity.d.ts +2 -0
- package/dist/workflow-runner/event-trigger.d.ts +1 -0
- package/dist/workflow-runner/event-trigger.js +60 -15
- package/dist/workflow-runner/handlers/resume-run.write.js +5 -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/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/inbound-provider-imap/changes.json +6 -0
- package/src/jobs/changes.json +30 -0
- package/src/ledger/changes.json +23 -0
- package/src/notes-history/changes.json +13 -0
- package/src/template-resolver/changes.json +5 -0
- package/src/tenant/changes.json +12 -0
- package/src/tenant-handover/changes.json +6 -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
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
2
|
import { computeEffectiveFeatures, } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
|
|
4
|
+
import { createDefaultCacheSyncBus, } from "@cosmicdrift/kumiko-framework/redis";
|
|
3
5
|
import { globalFeatureStateTable } from "./global-feature-state-table.js";
|
|
6
|
+
import { createCacheSyncToggleSignal } from "./toggle-sync-signal.js";
|
|
7
|
+
const log = createFallbackLogger("kumiko:feature-toggles");
|
|
4
8
|
// Holds the current global-override snapshot in memory and exposes a
|
|
5
9
|
// synchronous reader — the dispatcher's feature-gate calls it on every
|
|
6
10
|
// handler invocation, so this must not do I/O on the hot path. The
|
|
@@ -16,14 +20,16 @@ export class GlobalFeatureToggleRuntime {
|
|
|
16
20
|
db;
|
|
17
21
|
registry;
|
|
18
22
|
syncSignal;
|
|
23
|
+
ownedBus;
|
|
19
24
|
snapshot = new Map();
|
|
20
25
|
// Flips applied while a snapshot load is in flight. The load's SELECT may have
|
|
21
26
|
// started before the flip committed, so its rows can be older than these.
|
|
22
27
|
appliedDuringLoad;
|
|
23
|
-
constructor(db, registry, syncSignal) {
|
|
28
|
+
constructor(db, registry, syncSignal, ownedBus) {
|
|
24
29
|
this.db = db;
|
|
25
30
|
this.registry = registry;
|
|
26
31
|
this.syncSignal = syncSignal;
|
|
32
|
+
this.ownedBus = ownedBus;
|
|
27
33
|
// Every process, including the one that calls broadcastToggle, learns
|
|
28
34
|
// of a flip through this same subscription — mirrors the SSE broker's
|
|
29
35
|
// publish-then-echo-to-self pattern instead of a separate local-apply
|
|
@@ -31,6 +37,15 @@ export class GlobalFeatureToggleRuntime {
|
|
|
31
37
|
this.syncSignal?.onMessage((featureName, enabled) => {
|
|
32
38
|
this.apply(featureName, enabled);
|
|
33
39
|
});
|
|
40
|
+
this.syncSignal?.onResync?.(() => {
|
|
41
|
+
this.refresh().catch((err) => {
|
|
42
|
+
log.error("snapshot reload after sync reconnect failed", { err });
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
// Only the runtime built by createFeatureToggleRuntime without an explicit signal owns one.
|
|
47
|
+
async close() {
|
|
48
|
+
await this.ownedBus?.close();
|
|
34
49
|
}
|
|
35
50
|
async initialize() {
|
|
36
51
|
const pendingFlips = new Map();
|
|
@@ -99,11 +114,20 @@ export class GlobalFeatureToggleRuntime {
|
|
|
99
114
|
return computeEffectiveFeatures(this.registry, reader);
|
|
100
115
|
};
|
|
101
116
|
}
|
|
102
|
-
// Factory for app-boot wiring: instantiate, initialize, return
|
|
103
|
-
//
|
|
104
|
-
//
|
|
117
|
+
// Factory for app-boot wiring: instantiate, initialize, return the runtime (for the
|
|
118
|
+
// set-handler to refresh; its effectiveFeatures feeds createDispatcher). Without an explicit
|
|
119
|
+
// syncSignal the runtime builds one on the framework cache-sync bus: Redis-backed when
|
|
120
|
+
// REDIS_URL is set, process-local otherwise. close() releases a bus it created.
|
|
105
121
|
export async function createFeatureToggleRuntime(db, registry, syncSignal) {
|
|
106
|
-
const
|
|
107
|
-
|
|
122
|
+
const ownedBus = syncSignal ? undefined : createDefaultCacheSyncBus();
|
|
123
|
+
const signal = syncSignal ?? (ownedBus ? createCacheSyncToggleSignal(ownedBus) : undefined);
|
|
124
|
+
const runtime = new GlobalFeatureToggleRuntime(db, registry, signal, ownedBus);
|
|
125
|
+
try {
|
|
126
|
+
await runtime.initialize();
|
|
127
|
+
}
|
|
128
|
+
catch (err) {
|
|
129
|
+
await runtime.close();
|
|
130
|
+
throw err;
|
|
131
|
+
}
|
|
108
132
|
return runtime;
|
|
109
133
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { type RedisClientOptions } from "@cosmicdrift/kumiko-framework/redis";
|
|
1
|
+
import { type CacheSyncBus, type RedisClientOptions } from "@cosmicdrift/kumiko-framework/redis";
|
|
2
2
|
import type { ToggleSyncSignal } from "./toggle-runtime.js";
|
|
3
3
|
export type RedisToggleSyncSignal = ToggleSyncSignal & {
|
|
4
4
|
close(): Promise<void>;
|
|
5
5
|
};
|
|
6
|
-
export declare function createRedisToggleSyncSignal(redisUrl: string, clientOptions?: RedisClientOptions): RedisToggleSyncSignal;
|
|
6
|
+
export declare function createRedisToggleSyncSignal(redisUrl: string, clientOptions?: RedisClientOptions, channelPrefix?: string): RedisToggleSyncSignal;
|
|
7
|
+
export declare function createCacheSyncToggleSignal(bus: CacheSyncBus): ToggleSyncSignal;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { createRedisPubSubSignal, } from "@cosmicdrift/kumiko-framework/redis";
|
|
1
|
+
import { CACHE_SYNC_TOPICS, createRedisPubSubSignal, redisChannelPrefixFromEnv, } from "@cosmicdrift/kumiko-framework/redis";
|
|
2
2
|
// Single fixed channel — unlike the SSE broker there's no per-tenant/
|
|
3
3
|
// per-user variance to multiplex, every toggle flip is global.
|
|
4
|
-
const
|
|
4
|
+
const TOGGLE_SYNC_CHANNEL_NAME = "kumiko:feature-toggles:cache-sync";
|
|
5
5
|
function isTogglePayload(value) {
|
|
6
6
|
return (typeof value === "object" &&
|
|
7
7
|
value !== null &&
|
|
@@ -11,26 +11,25 @@ function isTogglePayload(value) {
|
|
|
11
11
|
// fw#2625: gives toggle-cache-sync the cross-replica transport its shared
|
|
12
12
|
// dispatcher cursor needs — without this, only the one process that won
|
|
13
13
|
// the shared cursor's toggle-set event would ever learn about a flip.
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
export function createRedisToggleSyncSignal(redisUrl, clientOptions) {
|
|
14
|
+
// Standalone Redis transport for callers that want their own connections;
|
|
15
|
+
// createFeatureToggleRuntime builds a cache-sync-bus signal by default.
|
|
16
|
+
export function createRedisToggleSyncSignal(redisUrl, clientOptions, channelPrefix = redisChannelPrefixFromEnv()) {
|
|
17
|
+
const toggleSyncChannel = `${channelPrefix}${TOGGLE_SYNC_CHANNEL_NAME}`;
|
|
19
18
|
const signal = createRedisPubSubSignal({
|
|
20
19
|
redisUrl,
|
|
21
|
-
channelPattern:
|
|
20
|
+
channelPattern: toggleSyncChannel,
|
|
22
21
|
label: "feature-toggles",
|
|
23
22
|
...(clientOptions ? { clientOptions } : {}),
|
|
24
23
|
});
|
|
25
24
|
return {
|
|
26
25
|
publish(featureName, enabled) {
|
|
27
|
-
signal.publish(
|
|
26
|
+
signal.publish(toggleSyncChannel, { featureName, enabled });
|
|
28
27
|
},
|
|
29
28
|
onMessage(listener) {
|
|
30
29
|
signal.onMessage((_channel, payload) => {
|
|
31
30
|
if (!isTogglePayload(payload)) {
|
|
32
31
|
// biome-ignore lint/suspicious/noConsole: ops-visible fallback — no ctx.log in a raw PubSubSignal handler.
|
|
33
|
-
console.error(`[kumiko:feature-toggles] dropping malformed cache-sync message on "${
|
|
32
|
+
console.error(`[kumiko:feature-toggles] dropping malformed cache-sync message on "${toggleSyncChannel}"`);
|
|
34
33
|
// skip: malformed message already logged above, nothing to apply
|
|
35
34
|
return;
|
|
36
35
|
}
|
|
@@ -40,3 +39,23 @@ export function createRedisToggleSyncSignal(redisUrl, clientOptions) {
|
|
|
40
39
|
close: signal.close,
|
|
41
40
|
};
|
|
42
41
|
}
|
|
42
|
+
// Reuses the framework cache-sync bus (local without REDIS_URL, Redis-backed with it) so
|
|
43
|
+
// toggles need no Pub/Sub wiring of their own. The bus applies a publish locally first.
|
|
44
|
+
export function createCacheSyncToggleSignal(bus) {
|
|
45
|
+
return {
|
|
46
|
+
publish(featureName, enabled) {
|
|
47
|
+
bus.publish(CACHE_SYNC_TOPICS.featureToggle, { featureName, enabled });
|
|
48
|
+
},
|
|
49
|
+
onMessage(listener) {
|
|
50
|
+
bus.subscribe(CACHE_SYNC_TOPICS.featureToggle, (payload) => {
|
|
51
|
+
// skip: foreign or malformed payload on the topic
|
|
52
|
+
if (!isTogglePayload(payload))
|
|
53
|
+
return;
|
|
54
|
+
listener(payload.featureName, payload.enabled);
|
|
55
|
+
});
|
|
56
|
+
},
|
|
57
|
+
onResync(listener) {
|
|
58
|
+
bus.onResync(listener);
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { EXT_DERIVATIVE_PUBLIC_PREDICATE, type HandlerContext, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
2
|
import * as z from "zod";
|
|
3
3
|
export type DerivativePublicPredicateArgs = {
|
|
4
|
+
readonly fileRefId: string;
|
|
4
5
|
readonly entityId: string;
|
|
5
6
|
readonly tenantId: TenantId;
|
|
6
7
|
readonly fieldName: string;
|
|
@@ -78,7 +78,13 @@ export const publicVariantQuery = defineQueryHandler({
|
|
|
78
78
|
if (!isDerivativePublicPredicatePlugin(usage.options)) {
|
|
79
79
|
throw new Error(`file-derivatives: "${usage.entityName}" registered ${EXT_DERIVATIVE_PUBLIC_PREDICATE} without an isPublic(args, ctx) — extension options must be a DerivativePublicPredicatePlugin.`);
|
|
80
80
|
}
|
|
81
|
-
const isPublic = await usage.options.isPublic({
|
|
81
|
+
const isPublic = await usage.options.isPublic({
|
|
82
|
+
fileRefId: query.payload.fileRefId,
|
|
83
|
+
entityId,
|
|
84
|
+
tenantId: ctx.user.tenantId,
|
|
85
|
+
fieldName,
|
|
86
|
+
variant: query.payload.variant,
|
|
87
|
+
}, ctx);
|
|
82
88
|
if (!isPublic)
|
|
83
89
|
return null;
|
|
84
90
|
// ctx.files/ctx.derivatives are optional on HandlerContext (only wired
|
|
@@ -99,6 +99,11 @@ export async function sweepOrphanedDerivativesJob(rawPayload, ctx, db) {
|
|
|
99
99
|
const owners = await selectMany(db, fileRefsTable, { tenantId: candidate.tenantId, storageKey: candidate.originalKey }, { limit: 1 });
|
|
100
100
|
if (owners.length > 0)
|
|
101
101
|
return "skipped";
|
|
102
|
+
// Binaries written through ctx.files.ref(key) (and their derive()d
|
|
103
|
+
// variants) never get a fileRef row; an original still sitting in storage
|
|
104
|
+
// means the derivative is not an orphan.
|
|
105
|
+
if (await candidate.provider.exists(candidate.originalKey))
|
|
106
|
+
return "skipped";
|
|
102
107
|
if (dryRun) {
|
|
103
108
|
dryRunWouldDelete++;
|
|
104
109
|
return "skipped";
|
|
@@ -49,11 +49,13 @@ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
|
49
49
|
import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
50
50
|
import { createSystemUser, parseTenantId, } from "@cosmicdrift/kumiko-framework/engine";
|
|
51
51
|
import { assertSafeStorageKey, fileRefEntity, fileRefsTable, storageKeyStemPrefix, tenantStoragePrefixes, } from "@cosmicdrift/kumiko-framework/files";
|
|
52
|
+
import { runChunkedMigration } from "../shared/index.js";
|
|
52
53
|
const crud = createEventStoreExecutor(fileRefsTable, fileRefEntity, { entityName: "fileRef" });
|
|
53
54
|
// Existence-check batch size for fileRefStorageDestroyHook's survivor lookup
|
|
54
55
|
// — bounds each query instead of one `storage_key = ANY(...)` per listed key,
|
|
55
56
|
// or a single unbounded array for a tenant with a huge prefix.
|
|
56
57
|
const SURVIVOR_CHECK_BATCH_SIZE = 500;
|
|
58
|
+
const DESTROY_CHUNK_SIZE = 500;
|
|
57
59
|
function chunk(items, size) {
|
|
58
60
|
const chunks = [];
|
|
59
61
|
for (let i = 0; i < items.length; i += size)
|
|
@@ -63,25 +65,43 @@ function chunk(items, size) {
|
|
|
63
65
|
function isUnderOwnPrefix(storageKey, prefixes) {
|
|
64
66
|
return prefixes.some((prefix) => storageKey.startsWith(prefix));
|
|
65
67
|
}
|
|
68
|
+
function toDestroyHookResult(result, failureMessage) {
|
|
69
|
+
// A failure on the last item ends the run as "done" (nothing left to batch),
|
|
70
|
+
// so the failed count, not the stop reason, decides fail-closed.
|
|
71
|
+
if (result.failed > 0) {
|
|
72
|
+
throw new Error(failureMessage() ?? "files-tenant-data: destroy chunk failed");
|
|
73
|
+
}
|
|
74
|
+
return { done: result.stoppedReason === "done", processed: result.migrated };
|
|
75
|
+
}
|
|
66
76
|
export const fileRefTenantDestroyHook = async (ctx) => {
|
|
67
|
-
const rows = await ctx.db.selectMany(fileRefsTable, {
|
|
68
|
-
tenantId: ctx.tenantId,
|
|
69
|
-
});
|
|
70
77
|
const ownPrefixes = tenantStoragePrefixes(ctx.tenantId);
|
|
71
78
|
const user = createSystemUser(ctx.tenantId);
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
79
|
+
let firstFailure;
|
|
80
|
+
// Converges without a cursor: forget() hard-deletes, so each batch query
|
|
81
|
+
// returns the next rows.
|
|
82
|
+
const result = await runChunkedMigration({
|
|
83
|
+
nextBatch: () => ctx.db.selectMany(fileRefsTable, { tenantId: ctx.tenantId }, { limit: DESTROY_CHUNK_SIZE }),
|
|
84
|
+
migrateRow: async (row) => {
|
|
85
|
+
if (!isUnderOwnPrefix(row.storageKey, ownPrefixes)) {
|
|
86
|
+
await deleteHandedOverBinary(ctx, row.storageKey);
|
|
87
|
+
}
|
|
88
|
+
const forgotten = await crud.forget({ id: row.id }, user, ctx.db);
|
|
89
|
+
// Executor writes return {isSuccess:false} instead of throwing — a
|
|
90
|
+
// discarded result would report this destroy stage "succeeded" while the
|
|
91
|
+
// row (and its PII fileName) survives. Throw so the pipeline's
|
|
92
|
+
// retry/abandon handling sees it.
|
|
93
|
+
if (!forgotten.isSuccess) {
|
|
94
|
+
throw new Error(`files-tenant-data: failed to forget fileRef ${row.id} for tenant ${ctx.tenantId}: ${forgotten.error.message}`);
|
|
95
|
+
}
|
|
96
|
+
return "migrated";
|
|
97
|
+
},
|
|
98
|
+
maxFailures: 1,
|
|
99
|
+
deadlineAt: ctx.deadlineAt ?? Number.POSITIVE_INFINITY,
|
|
100
|
+
onRowError: (_row, err) => {
|
|
101
|
+
firstFailure = err instanceof Error ? err.message : String(err);
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
return toDestroyHookResult(result, () => firstFailure);
|
|
85
105
|
};
|
|
86
106
|
// The bytes live under the tenant that uploaded them (`<tenantId>/…` or
|
|
87
107
|
// `exports/<tenantId>/…`), so with per-tenant storage config that tenant's
|
|
@@ -140,16 +160,32 @@ export const fileRefStorageDestroyHook = async (tenantId, ctx) => {
|
|
|
140
160
|
// it and retries up to the stage's attempt cap before abandoning the destroy
|
|
141
161
|
// (list+delete are idempotent, so a retry converges rather than
|
|
142
162
|
// double-deleting or erroring on a missing key).
|
|
163
|
+
let processed = 0;
|
|
143
164
|
for (const prefix of tenantStoragePrefixes(tenantId)) {
|
|
144
165
|
const keys = await provider.list(prefix);
|
|
145
166
|
const keptStemPrefixes = await survivorStemPrefixes(ctx.db, keys);
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
167
|
+
const deletableKeys = keys.filter((key) => !keptStemPrefixes.some((stemPrefix) => key.startsWith(stemPrefix)));
|
|
168
|
+
const pending = [...deletableKeys];
|
|
169
|
+
let firstFailure;
|
|
170
|
+
const result = await runChunkedMigration({
|
|
171
|
+
nextBatch: async () => pending.splice(0, DESTROY_CHUNK_SIZE),
|
|
172
|
+
migrateRow: async (key) => {
|
|
173
|
+
assertSafeStorageKey(key);
|
|
174
|
+
await provider.delete(key);
|
|
175
|
+
return "migrated";
|
|
176
|
+
},
|
|
177
|
+
maxFailures: 1,
|
|
178
|
+
deadlineAt: ctx.deadlineAt ?? Number.POSITIVE_INFINITY,
|
|
179
|
+
onRowError: (_key, err) => {
|
|
180
|
+
firstFailure = err instanceof Error ? err.message : String(err);
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
const hookResult = toDestroyHookResult(result, () => firstFailure);
|
|
184
|
+
processed += result.migrated;
|
|
185
|
+
if (!hookResult.done)
|
|
186
|
+
return { done: false, processed };
|
|
152
187
|
}
|
|
188
|
+
return { done: true, processed };
|
|
153
189
|
};
|
|
154
190
|
// Any listed key that is itself a surviving row's exact storageKey means a
|
|
155
191
|
// foreign tenant still owns it (see header comment) — its stem prefix then
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
2
2
|
import * as z from "zod";
|
|
3
|
+
import { hostRowIsGone } from "../../shared/index.js";
|
|
3
4
|
import { DEFAULT_FOLDER_ACCESS } from "../constants.js";
|
|
4
5
|
import { folderAssignmentExecutor, folderExecutor } from "../executor.js";
|
|
5
6
|
const deleteFolderPayloadSchema = z.object({ id: z.uuid() });
|
|
@@ -10,18 +11,44 @@ const deleteFolderPayloadSchema = z.object({ id: z.uuid() });
|
|
|
10
11
|
// see 658/1). Block server-side instead: this is the same "referential
|
|
11
12
|
// integrity has no FK here" reasoning set-folder.write.ts already applies to
|
|
12
13
|
// folderId on assignment-creation.
|
|
14
|
+
//
|
|
15
|
+
// Assignments whose host is gone (hard-deleted, soft-deleted or unregistered)
|
|
16
|
+
// are cleaned up here instead of blocking forever: clear-folder can't reach
|
|
17
|
+
// them (it requires a visible host). A host-delete hook would break restore,
|
|
18
|
+
// so the cleanup runs lazily at folder:delete.
|
|
19
|
+
const ASSIGNMENT_PAGE_SIZE = 200;
|
|
13
20
|
export function createDeleteFolderHandler(access = DEFAULT_FOLDER_ACCESS) {
|
|
14
21
|
return {
|
|
15
22
|
name: "folder:delete",
|
|
16
23
|
schema: deleteFolderPayloadSchema,
|
|
17
24
|
access,
|
|
18
|
-
description: "Deletes a folder from the tenant catalog and refuses while any entity is still filed in it; use it once that folder's contents have been unfiled or moved elsewhere.",
|
|
25
|
+
description: "Deletes a folder from the tenant catalog and refuses while any live entity is still filed in it (assignments of deleted hosts are cleaned up); use it once that folder's contents have been unfiled or moved elsewhere.",
|
|
19
26
|
agent: { risk: "high" },
|
|
20
27
|
handler: async (event, ctx) => {
|
|
21
28
|
const payload = event.payload; // @cast-boundary engine-payload
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
29
|
+
const orphanAssignmentIds = [];
|
|
30
|
+
let cursor;
|
|
31
|
+
do {
|
|
32
|
+
const page = await folderAssignmentExecutor.list({
|
|
33
|
+
filter: { field: "folderId", op: "eq", value: payload.id },
|
|
34
|
+
limit: ASSIGNMENT_PAGE_SIZE,
|
|
35
|
+
cursor,
|
|
36
|
+
}, event.user, ctx.db);
|
|
37
|
+
for (const row of page.rows) {
|
|
38
|
+
const gone = await hostRowIsGone(ctx.registry, String(row["entityType"]), String(row["entityId"]), ctx.db);
|
|
39
|
+
if (!gone) {
|
|
40
|
+
return writeFailure(new UnprocessableError("folder_has_assignments", {
|
|
41
|
+
details: { folderId: payload.id },
|
|
42
|
+
}));
|
|
43
|
+
}
|
|
44
|
+
orphanAssignmentIds.push(String(row["id"]));
|
|
45
|
+
}
|
|
46
|
+
cursor = page.nextCursor ?? undefined;
|
|
47
|
+
} while (cursor !== undefined);
|
|
48
|
+
for (const id of orphanAssignmentIds) {
|
|
49
|
+
const removed = await folderAssignmentExecutor.delete({ id }, event.user, ctx.db);
|
|
50
|
+
if (!removed.isSuccess)
|
|
51
|
+
return removed;
|
|
25
52
|
}
|
|
26
53
|
return folderExecutor.delete({ id: payload.id }, event.user, ctx.db);
|
|
27
54
|
},
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
export { requireDefined, requireNonEmpty, requireSecretSet } from "./config-helpers.js";
|
|
2
2
|
export { isHostAllowlisted, readHostAllowlistFromEnv } from "./host-allowlist.js";
|
|
3
|
-
export { BlockedHostError, HostResolutionError, MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR, type MailConnectTarget, type MailHostGuardOptions, readAllowedPrivateMailHostsFromEnv, resolveMailConnectTarget, } from "./mail-host-policy.js";
|
|
3
|
+
export { BlockedHostError, HostResolutionError, MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR, type MailConnectTarget, type MailHostGuardOptions, mailAllowedPrivateHostsEnvSchema, readAllowedPrivateMailHostsFromEnv, resolveMailConnectTarget, } from "./mail-host-policy.js";
|
|
@@ -2,4 +2,4 @@
|
|
|
2
2
|
// Foundation packages (ai-foundation, mail-foundation, file-foundation).
|
|
3
3
|
export { requireDefined, requireNonEmpty, requireSecretSet } from "./config-helpers.js";
|
|
4
4
|
export { isHostAllowlisted, readHostAllowlistFromEnv } from "./host-allowlist.js";
|
|
5
|
-
export { BlockedHostError, HostResolutionError, MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR, readAllowedPrivateMailHostsFromEnv, resolveMailConnectTarget, } from "./mail-host-policy.js";
|
|
5
|
+
export { BlockedHostError, HostResolutionError, MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR, mailAllowedPrivateHostsEnvSchema, readAllowedPrivateMailHostsFromEnv, resolveMailConnectTarget, } from "./mail-host-policy.js";
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import type { lookup } from "node:dns/promises";
|
|
2
|
+
import * as z from "zod";
|
|
2
3
|
export declare const MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR = "KUMIKO_MAIL_ALLOWED_PRIVATE_HOSTS";
|
|
4
|
+
export declare const mailAllowedPrivateHostsEnvSchema: z.ZodObject<{
|
|
5
|
+
KUMIKO_MAIL_ALLOWED_PRIVATE_HOSTS: z.ZodOptional<z.ZodString>;
|
|
6
|
+
}, z.core.$strip>;
|
|
3
7
|
export declare function readAllowedPrivateMailHostsFromEnv(env?: Readonly<Record<string, string | undefined>>): readonly string[];
|
|
4
8
|
export type MailHostGuardOptions = {
|
|
5
9
|
readonly allowedPrivateMailHosts?: readonly string[];
|
|
@@ -10,14 +10,23 @@
|
|
|
10
10
|
// internal relay or a dev/test server (mailpit, greenmail) — deliberately
|
|
11
11
|
// an operator env var (KUMIKO_MAIL_ALLOWED_PRIVATE_HOSTS, read via
|
|
12
12
|
// readAllowedPrivateMailHostsFromEnv), never a tenant-config key, so a
|
|
13
|
-
// tenant can never grant themselves the bypass.
|
|
14
|
-
//
|
|
15
|
-
// smtp's envSchema (see its feature.ts) to avoid the framework's env-var-
|
|
16
|
-
// conflict check tripping when both features are mounted together.
|
|
13
|
+
// tenant can never grant themselves the bypass. Both features register the
|
|
14
|
+
// shared mailAllowedPrivateHostsEnvSchema below.
|
|
17
15
|
import { isIP } from "node:net";
|
|
18
16
|
import { resolvePublicHostname } from "@cosmicdrift/kumiko-framework/http";
|
|
17
|
+
import * as z from "zod";
|
|
19
18
|
import { isHostAllowlisted, readHostAllowlistFromEnv } from "./host-allowlist.js";
|
|
20
19
|
export const MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR = "KUMIKO_MAIL_ALLOWED_PRIVATE_HOSTS";
|
|
20
|
+
// One schema instance, registered by every feature that reads the variable:
|
|
21
|
+
// composeEnvSchema accepts the same field instance from several features, so
|
|
22
|
+
// an app mounting only one of them still gets the variable validated and
|
|
23
|
+
// documented.
|
|
24
|
+
export const mailAllowedPrivateHostsEnvSchema = z.object({
|
|
25
|
+
[MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR]: z
|
|
26
|
+
.string()
|
|
27
|
+
.optional()
|
|
28
|
+
.describe("Comma-separated operator allowlist of private/internal hosts (e.g. a local mailpit/greenmail dev server) that bypass the public-address check for SMTP and IMAP host config. Shared by mail-transport-smtp and inbound-provider-imap — never a tenant-config value."),
|
|
29
|
+
});
|
|
21
30
|
export function readAllowedPrivateMailHostsFromEnv(env = process.env) {
|
|
22
31
|
return readHostAllowlistFromEnv(MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR, env);
|
|
23
32
|
}
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
// Credentials: JSON-Dokument im per-Account-Secret-Slot
|
|
23
23
|
// (credential-document.ts) — kein Tenant-Config, weil mehrere Accounts
|
|
24
24
|
// pro Tenant verschiedene Hosts haben.
|
|
25
|
+
import { mailAllowedPrivateHostsEnvSchema } from "@cosmicdrift/kumiko-bundled-features/foundation-shared";
|
|
25
26
|
import { INBOUND_MAIL_PROVIDER_EXTENSION, InboundAuthError, inboundCredentialSecretKey, } from "@cosmicdrift/kumiko-bundled-features/inbound-mail-foundation";
|
|
26
27
|
import { requireSecretsContext } from "@cosmicdrift/kumiko-bundled-features/secrets";
|
|
27
28
|
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
@@ -225,5 +226,6 @@ export const inboundProviderImapFeature = defineFeature(FEATURE_NAME, (r) => {
|
|
|
225
226
|
});
|
|
226
227
|
r.requires("inbound-mail-foundation");
|
|
227
228
|
r.requires("secrets");
|
|
229
|
+
r.envSchema(mailAllowedPrivateHostsEnvSchema);
|
|
228
230
|
r.useExtension(INBOUND_MAIL_PROVIDER_EXTENSION, IMAP_PROVIDER_KEY, imapInboundMailPlugin);
|
|
229
231
|
});
|
|
@@ -21,10 +21,9 @@ const IMAP_HOST_UNREACHABLE_MESSAGE = "IMAP host is not reachable or not allowed
|
|
|
21
21
|
// =============================================================================
|
|
22
22
|
// Operator escape hatch for an internal relay or a dev/test IMAP server
|
|
23
23
|
// (greenmail): KUMIKO_MAIL_ALLOWED_PRIVATE_HOSTS, the same operator env
|
|
24
|
-
// var mail-transport-smtp
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
// never grant themselves the private-host bypass.
|
|
24
|
+
// var mail-transport-smtp registers (both share one schema instance, see
|
|
25
|
+
// foundation-shared), never a tenant-config value, so a tenant can never
|
|
26
|
+
// grant themselves the private-host bypass.
|
|
28
27
|
//
|
|
29
28
|
// mailHostLookup is a test-only DNS seam — production leaves it undefined,
|
|
30
29
|
// so resolveMailConnectTarget uses the real resolver. Module-global state:
|
package/dist/jobs/constants.d.ts
CHANGED
|
@@ -8,6 +8,7 @@ export declare const JobQueries: {
|
|
|
8
8
|
readonly details: "jobs:query:details";
|
|
9
9
|
readonly catalog: "jobs:query:catalog";
|
|
10
10
|
readonly failures: "jobs:query:failures";
|
|
11
|
+
readonly tenantRuns: "jobs:query:tenant-runs";
|
|
11
12
|
};
|
|
12
13
|
export declare const JobErrors: {
|
|
13
14
|
readonly unknownJob: "unknown_job";
|
package/dist/jobs/constants.js
CHANGED
|
@@ -3,5 +3,6 @@ export type JobRunRetentionResult = {
|
|
|
3
3
|
readonly runsDeleted: number;
|
|
4
4
|
readonly logsDeleted: number;
|
|
5
5
|
readonly tenantFailuresDeleted: number;
|
|
6
|
+
readonly tenantRunsDeleted: number;
|
|
6
7
|
};
|
|
7
8
|
export declare function deleteStaleJobRuns(db: DbConnection, retentionDays: number): Promise<JobRunRetentionResult>;
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import { deleteManyBatched } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
13
13
|
import { jobRunLogsTable, jobRunsTable } from "../../job-run-table.js";
|
|
14
14
|
import { tenantJobFailuresTable } from "../../tenant-job-failure-table.js";
|
|
15
|
+
import { tenantJobRunsTable } from "../../tenant-job-run-table.js";
|
|
15
16
|
const RETENTION_DELETE_BATCH_SIZE = 500;
|
|
16
17
|
export async function deleteStaleJobRuns(db, retentionDays) {
|
|
17
18
|
const cutoff = Temporal.Now.instant().subtract({ hours: retentionDays * 24 });
|
|
@@ -21,9 +22,13 @@ export async function deleteStaleJobRuns(db, retentionDays) {
|
|
|
21
22
|
// they point at is gone by now, and a job whose subject never ran again
|
|
22
23
|
// would otherwise keep its record forever.
|
|
23
24
|
const tenantFailuresResult = await deleteManyBatched(db, tenantJobFailuresTable, { failedAt: { lt: cutoff } }, { limit: RETENTION_DELETE_BATCH_SIZE });
|
|
25
|
+
// Only finished rows: active ones are the stale sweep's business, and a
|
|
26
|
+
// still-running job older than the window must not vanish from the tenant.
|
|
27
|
+
const tenantRunsResult = await deleteManyBatched(db, tenantJobRunsTable, { status: { in: ["completed", "failed"] }, updatedAt: { lt: cutoff } }, { limit: RETENTION_DELETE_BATCH_SIZE });
|
|
24
28
|
return {
|
|
25
29
|
runsDeleted: runsResult.deleted,
|
|
26
30
|
logsDeleted: logsResult.deleted,
|
|
27
31
|
tenantFailuresDeleted: tenantFailuresResult.deleted,
|
|
32
|
+
tenantRunsDeleted: tenantRunsResult.deleted,
|
|
28
33
|
};
|
|
29
34
|
}
|
|
@@ -2,5 +2,7 @@ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
|
2
2
|
export declare const STALE_JOB_RUN_ERROR = "job run exceeded the stale-run timeout without a completion signal (likely a crashed worker process)";
|
|
3
3
|
export type StaleRunSweepResult = {
|
|
4
4
|
readonly runsMarkedFailed: number;
|
|
5
|
+
readonly tenantRunsMarkedFailed: number;
|
|
6
|
+
readonly tenantQueuedDropped: number;
|
|
5
7
|
};
|
|
6
8
|
export declare function markStaleJobRunsFailed(db: DbConnection, timeoutHours: number): Promise<StaleRunSweepResult>;
|
|
@@ -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 });
|
|
@@ -56,7 +56,7 @@ export const listQuery = defineQueryHandler({
|
|
|
56
56
|
.optional(),
|
|
57
57
|
sort: z.enum(["jobName", "status", "startedAt", "duration"]).optional(),
|
|
58
58
|
sortDirection: z.enum(["asc", "desc"]).optional(),
|
|
59
|
-
limit: z.number().optional(),
|
|
59
|
+
limit: z.number().int().min(1).optional(),
|
|
60
60
|
cursor: z.string().optional(),
|
|
61
61
|
totalCount: z.boolean().optional(),
|
|
62
62
|
}),
|
|
@@ -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>;
|