@cosmicdrift/kumiko-samples 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/package.json +1 -1
- package/packages/bundled-features/package.json +18 -2
- package/packages/bundled-features/src/agent-tools/agent-doc-lint.ts +66 -0
- package/packages/bundled-features/src/agent-tools/agent-manifest.ts +11 -5
- package/packages/bundled-features/src/agent-tools/feature.ts +13 -2
- package/packages/bundled-features/src/agent-tools/index.ts +6 -1
- package/packages/bundled-features/src/agent-tools/tool-dispatch.ts +1 -1
- package/packages/bundled-features/src/auth-email-password/changes.json +24 -0
- package/packages/bundled-features/src/auth-email-password/handlers/change-password.write.ts +1 -0
- package/packages/bundled-features/src/auth-email-password/handlers/invite-accept-with-login.write.ts +3 -2
- package/packages/bundled-features/src/auth-email-password/handlers/invite-accept.write.ts +3 -2
- package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +21 -0
- package/packages/bundled-features/src/auth-email-password/handlers/invite-signup-complete.write.ts +4 -1
- package/packages/bundled-features/src/auth-email-password/handlers/login.write.ts +1 -0
- package/packages/bundled-features/src/auth-email-password/handlers/reset-password.write.ts +1 -0
- package/packages/bundled-features/src/auth-email-password/handlers/signup-confirm.write.ts +5 -0
- package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +53 -96
- package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +1 -0
- package/packages/bundled-features/src/auth-email-password/handlers/verify-email.write.ts +1 -0
- package/packages/bundled-features/src/auth-email-password/index.ts +12 -0
- package/packages/bundled-features/src/auth-email-password/seeding.ts +38 -7
- package/packages/bundled-features/src/auth-email-password/signup-activation.ts +83 -0
- package/packages/bundled-features/src/auth-email-password/signup-token-store.ts +9 -3
- package/packages/bundled-features/src/auth-email-password/web/auth-gate.tsx +7 -7
- package/packages/bundled-features/src/auth-email-password/web/auth-redirect.ts +15 -0
- package/packages/bundled-features/src/auth-email-password/web/login-screen.tsx +2 -2
- package/packages/bundled-features/src/auth-email-password/web/user-menu.tsx +1 -1
- package/packages/bundled-features/src/auth-mfa/changes.json +7 -0
- package/packages/bundled-features/src/auth-mfa/feature.ts +5 -0
- package/packages/bundled-features/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +1 -0
- package/packages/bundled-features/src/auth-mfa/handlers/verify.write.ts +1 -0
- package/packages/bundled-features/src/auth-mfa/web/mfa-recovery-codes-reveal.tsx +6 -8
- package/packages/bundled-features/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +18 -21
- package/packages/bundled-features/src/auth-mfa/web/mfa-verify-screen.tsx +2 -2
- package/packages/bundled-features/src/billing-foundation/billing-info-query.ts +4 -4
- package/packages/bundled-features/src/billing-foundation/changes.json +6 -0
- package/packages/bundled-features/src/channel-in-app/changes.json +8 -1
- package/packages/bundled-features/src/channel-in-app/in-app-channel.ts +1 -1
- package/packages/bundled-features/src/channel-in-app/tables.ts +18 -12
- package/packages/bundled-features/src/crypto-shredding/changes.json +12 -0
- package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +154 -69
- package/packages/bundled-features/src/delivery/changes.json +6 -0
- package/packages/bundled-features/src/delivery/db/queries/address-opt-outs.ts +18 -12
- package/packages/bundled-features/src/delivery/delivery-service.ts +103 -16
- package/packages/bundled-features/src/delivery/unsubscribe.ts +81 -13
- package/packages/bundled-features/src/feature-toggles/feature.ts +2 -3
- package/packages/bundled-features/src/feature-toggles/toggle-runtime.ts +34 -5
- package/packages/bundled-features/src/feature-toggles/toggle-sync-signal.ts +31 -8
- package/packages/bundled-features/src/file-derivatives/handlers/public-variant.query.ts +10 -1
- package/packages/bundled-features/src/files/changes.json +24 -0
- package/packages/bundled-features/src/files-tenant-data/changes.json +5 -0
- package/packages/bundled-features/src/files-tenant-data/handlers/sweep-orphaned-derivatives.job.ts +4 -0
- package/packages/bundled-features/src/files-tenant-data/hooks.ts +72 -23
- package/packages/bundled-features/src/folders/changes.json +6 -0
- package/packages/bundled-features/src/folders/handlers/delete-folder.write.ts +41 -9
- package/packages/bundled-features/src/foundation-shared/index.ts +1 -0
- package/packages/bundled-features/src/foundation-shared/mail-host-policy.ts +16 -4
- package/packages/bundled-features/src/inbound-provider-imap/changes.json +6 -0
- package/packages/bundled-features/src/inbound-provider-imap/feature.ts +2 -0
- package/packages/bundled-features/src/inbound-provider-imap/imap-client.ts +3 -4
- package/packages/bundled-features/src/jobs/changes.json +30 -0
- package/packages/bundled-features/src/jobs/constants.ts +1 -0
- package/packages/bundled-features/src/jobs/db/queries/retention.ts +12 -0
- package/packages/bundled-features/src/jobs/db/queries/stale-run-sweep.ts +26 -2
- package/packages/bundled-features/src/jobs/feature.ts +7 -1
- package/packages/bundled-features/src/jobs/handlers/list.query.ts +1 -1
- package/packages/bundled-features/src/jobs/handlers/tenant-failures.query.ts +1 -1
- package/packages/bundled-features/src/jobs/handlers/tenant-runs.query.ts +94 -0
- package/packages/bundled-features/src/jobs/index.ts +2 -0
- package/packages/bundled-features/src/jobs/job-run-logger.ts +177 -10
- package/packages/bundled-features/src/jobs/tenant-job-failure-table.ts +27 -8
- package/packages/bundled-features/src/jobs/tenant-job-run-table.ts +55 -0
- package/packages/bundled-features/src/ledger/changes.json +23 -0
- package/packages/bundled-features/src/ledger/entity.ts +13 -5
- package/packages/bundled-features/src/ledger/feature.ts +3 -0
- package/packages/bundled-features/src/ledger/handlers/confirm-schedule-period.write.ts +15 -3
- package/packages/bundled-features/src/ledger/handlers/create-transaction.write.ts +1 -0
- package/packages/bundled-features/src/ledger/handlers/reverse-transaction.write.ts +1 -0
- package/packages/bundled-features/src/ledger/schedule-subject-pair.ts +27 -0
- package/packages/bundled-features/src/mail-transport-smtp/feature.ts +4 -15
- package/packages/bundled-features/src/notes-history/changes.json +13 -0
- package/packages/bundled-features/src/notes-history/entity.ts +4 -0
- package/packages/bundled-features/src/personal-access-tokens/handlers/create.write.ts +1 -0
- package/packages/bundled-features/src/sessions/feature.ts +21 -8
- package/packages/bundled-features/src/sessions/handlers/detail.query.ts +34 -33
- package/packages/bundled-features/src/sessions/handlers/list.query.ts +68 -65
- package/packages/bundled-features/src/sessions/screens.ts +10 -8
- package/packages/bundled-features/src/shared/encrypt-for-direct-write.ts +15 -8
- package/packages/bundled-features/src/shared/index.ts +2 -1
- package/packages/bundled-features/src/shared/parent-visibility.ts +31 -1
- package/packages/bundled-features/src/shared/row-bound-grant.ts +18 -2
- package/packages/bundled-features/src/shared/session-locale-field.ts +7 -0
- package/packages/bundled-features/src/template-resolver/api.ts +3 -3
- package/packages/bundled-features/src/template-resolver/changes.json +5 -0
- package/packages/bundled-features/src/template-resolver/handlers/list.query.ts +2 -2
- package/packages/bundled-features/src/tenant/changes.json +12 -0
- package/packages/bundled-features/src/tenant/feature.ts +8 -1
- package/packages/bundled-features/src/tenant/handlers/toggle-enabled.write.ts +2 -0
- package/packages/bundled-features/src/tenant/handlers/update-member-roles.write.ts +1 -0
- package/packages/bundled-features/src/tenant/invitation-table.ts +6 -0
- package/packages/bundled-features/src/tenant/invited-membership.ts +45 -12
- package/packages/bundled-features/src/tenant-handover/changes.json +6 -0
- package/packages/bundled-features/src/tenant-handover/transfer-graph.ts +14 -96
- package/packages/bundled-features/src/tenant-lifecycle/boot-checks.ts +16 -0
- package/packages/bundled-features/src/tenant-lifecycle/changes.json +22 -0
- package/packages/bundled-features/src/tenant-lifecycle/constants.ts +8 -0
- package/packages/bundled-features/src/tenant-lifecycle/events.ts +6 -0
- package/packages/bundled-features/src/tenant-lifecycle/feature.ts +13 -1
- package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +100 -29
- package/packages/bundled-features/src/tenant-lifecycle/stages.ts +41 -9
- package/packages/bundled-features/src/tenant-lifecycle/testing.ts +58 -0
- package/packages/bundled-features/src/tier-engine/changes.json +5 -0
- package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +1 -0
- package/packages/bundled-features/src/user/changes.json +15 -0
- package/packages/bundled-features/src/user/handlers/detail.query.ts +1 -0
- package/packages/bundled-features/src/user/handlers/list.query.ts +3 -0
- package/packages/bundled-features/src/user/handlers/update.write.ts +1 -0
- package/packages/bundled-features/src/user/seeding.ts +34 -0
- package/packages/bundled-features/src/user-data-rights/changes.json +6 -0
- package/packages/bundled-features/src/user-data-rights/handlers/request-deletion.write.ts +1 -0
- package/packages/bundled-features/src/user-data-rights/index.ts +1 -0
- package/packages/bundled-features/src/user-data-rights/schema/download-attempt.ts +17 -9
- package/packages/bundled-features/src/user-profile/handlers/change-email.write.ts +8 -2
- package/packages/bundled-features/src/workflow-runner/event-trigger.ts +72 -14
- package/packages/bundled-features/src/workflow-runner/handlers/resume-run.write.ts +6 -1
- package/packages/bundled-features/src/workflow-runner/runner.ts +8 -2
- package/samples/apps/showcase/src/features/items/feature.ts +2 -1
- package/samples/apps/styleguide/src/features/widgets/feature.ts +83 -0
- package/samples/apps/styleguide/src/features/widgets/i18n.ts +12 -0
- package/samples/apps/styleguide/src/features/widgets/web/Widgets.tsx +4 -0
- package/samples/apps/ui-walkthrough/package.json +3 -0
- package/samples/apps/use-all-bundled/README.md +1 -2
- package/samples/apps/use-all-bundled/feature-manifest.json +1 -1
- package/samples/apps/user-data-rights-demo/README.md +1 -3
- package/samples/recipes/legal-pages/README.md +3 -4
- package/samples/recipes/managed-pages/README.md +2 -2
- package/samples/recipes/seed-migration/README.md +0 -2
|
@@ -55,6 +55,7 @@ import {
|
|
|
55
55
|
type StorageProviderHookCtx,
|
|
56
56
|
type TenantDataDestroyHook,
|
|
57
57
|
type TenantDataHookCtx,
|
|
58
|
+
type TenantDestroyHookResult,
|
|
58
59
|
type TenantId,
|
|
59
60
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
60
61
|
import {
|
|
@@ -64,6 +65,7 @@ import {
|
|
|
64
65
|
storageKeyStemPrefix,
|
|
65
66
|
tenantStoragePrefixes,
|
|
66
67
|
} from "@cosmicdrift/kumiko-framework/files";
|
|
68
|
+
import { type ChunkedMigrationResult, runChunkedMigration } from "../shared/index.js";
|
|
67
69
|
|
|
68
70
|
const crud = createEventStoreExecutor(fileRefsTable, fileRefEntity, { entityName: "fileRef" });
|
|
69
71
|
|
|
@@ -72,6 +74,8 @@ const crud = createEventStoreExecutor(fileRefsTable, fileRefEntity, { entityName
|
|
|
72
74
|
// or a single unbounded array for a tenant with a huge prefix.
|
|
73
75
|
const SURVIVOR_CHECK_BATCH_SIZE = 500;
|
|
74
76
|
|
|
77
|
+
const DESTROY_CHUNK_SIZE = 500;
|
|
78
|
+
|
|
75
79
|
function chunk<T>(items: readonly T[], size: number): T[][] {
|
|
76
80
|
const chunks: T[][] = [];
|
|
77
81
|
for (let i = 0; i < items.length; i += size) chunks.push(items.slice(i, i + size));
|
|
@@ -82,27 +86,54 @@ function isUnderOwnPrefix(storageKey: string, prefixes: readonly string[]): bool
|
|
|
82
86
|
return prefixes.some((prefix) => storageKey.startsWith(prefix));
|
|
83
87
|
}
|
|
84
88
|
|
|
89
|
+
function toDestroyHookResult(
|
|
90
|
+
result: ChunkedMigrationResult,
|
|
91
|
+
failureMessage: () => string | undefined,
|
|
92
|
+
): TenantDestroyHookResult {
|
|
93
|
+
// A failure on the last item ends the run as "done" (nothing left to batch),
|
|
94
|
+
// so the failed count, not the stop reason, decides fail-closed.
|
|
95
|
+
if (result.failed > 0) {
|
|
96
|
+
throw new Error(failureMessage() ?? "files-tenant-data: destroy chunk failed");
|
|
97
|
+
}
|
|
98
|
+
return { done: result.stoppedReason === "done", processed: result.migrated };
|
|
99
|
+
}
|
|
100
|
+
|
|
85
101
|
export const fileRefTenantDestroyHook: TenantDataDestroyHook = async (ctx) => {
|
|
86
|
-
const rows = await ctx.db.selectMany<{ id: string; storageKey: string }>(fileRefsTable, {
|
|
87
|
-
tenantId: ctx.tenantId,
|
|
88
|
-
});
|
|
89
102
|
const ownPrefixes = tenantStoragePrefixes(ctx.tenantId);
|
|
90
103
|
const user = createSystemUser(ctx.tenantId);
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
104
|
+
let firstFailure: string | undefined;
|
|
105
|
+
// Converges without a cursor: forget() hard-deletes, so each batch query
|
|
106
|
+
// returns the next rows.
|
|
107
|
+
const result = await runChunkedMigration<{ id: string; storageKey: string }>({
|
|
108
|
+
nextBatch: () =>
|
|
109
|
+
ctx.db.selectMany<{ id: string; storageKey: string }>(
|
|
110
|
+
fileRefsTable,
|
|
111
|
+
{ tenantId: ctx.tenantId },
|
|
112
|
+
{ limit: DESTROY_CHUNK_SIZE },
|
|
113
|
+
),
|
|
114
|
+
migrateRow: async (row) => {
|
|
115
|
+
if (!isUnderOwnPrefix(row.storageKey, ownPrefixes)) {
|
|
116
|
+
await deleteHandedOverBinary(ctx, row.storageKey);
|
|
117
|
+
}
|
|
118
|
+
const forgotten = await crud.forget({ id: row.id }, user, ctx.db);
|
|
119
|
+
// Executor writes return {isSuccess:false} instead of throwing — a
|
|
120
|
+
// discarded result would report this destroy stage "succeeded" while the
|
|
121
|
+
// row (and its PII fileName) survives. Throw so the pipeline's
|
|
122
|
+
// retry/abandon handling sees it.
|
|
123
|
+
if (!forgotten.isSuccess) {
|
|
124
|
+
throw new Error(
|
|
125
|
+
`files-tenant-data: failed to forget fileRef ${row.id} for tenant ${ctx.tenantId}: ${forgotten.error.message}`,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
return "migrated";
|
|
129
|
+
},
|
|
130
|
+
maxFailures: 1,
|
|
131
|
+
deadlineAt: ctx.deadlineAt ?? Number.POSITIVE_INFINITY,
|
|
132
|
+
onRowError: (_row, err) => {
|
|
133
|
+
firstFailure = err instanceof Error ? err.message : String(err);
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
return toDestroyHookResult(result, () => firstFailure);
|
|
106
137
|
};
|
|
107
138
|
|
|
108
139
|
// The bytes live under the tenant that uploaded them (`<tenantId>/…` or
|
|
@@ -173,15 +204,33 @@ export const fileRefStorageDestroyHook: StorageProviderDestroyTenantHook = async
|
|
|
173
204
|
// it and retries up to the stage's attempt cap before abandoning the destroy
|
|
174
205
|
// (list+delete are idempotent, so a retry converges rather than
|
|
175
206
|
// double-deleting or erroring on a missing key).
|
|
207
|
+
let processed = 0;
|
|
176
208
|
for (const prefix of tenantStoragePrefixes(tenantId)) {
|
|
177
209
|
const keys = await provider.list(prefix);
|
|
178
210
|
const keptStemPrefixes = await survivorStemPrefixes(ctx.db, keys);
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
211
|
+
const deletableKeys = keys.filter(
|
|
212
|
+
(key) => !keptStemPrefixes.some((stemPrefix) => key.startsWith(stemPrefix)),
|
|
213
|
+
);
|
|
214
|
+
const pending = [...deletableKeys];
|
|
215
|
+
let firstFailure: string | undefined;
|
|
216
|
+
const result = await runChunkedMigration<string>({
|
|
217
|
+
nextBatch: async () => pending.splice(0, DESTROY_CHUNK_SIZE),
|
|
218
|
+
migrateRow: async (key) => {
|
|
219
|
+
assertSafeStorageKey(key);
|
|
220
|
+
await provider.delete(key);
|
|
221
|
+
return "migrated";
|
|
222
|
+
},
|
|
223
|
+
maxFailures: 1,
|
|
224
|
+
deadlineAt: ctx.deadlineAt ?? Number.POSITIVE_INFINITY,
|
|
225
|
+
onRowError: (_key, err) => {
|
|
226
|
+
firstFailure = err instanceof Error ? err.message : String(err);
|
|
227
|
+
},
|
|
228
|
+
});
|
|
229
|
+
const hookResult = toDestroyHookResult(result, () => firstFailure);
|
|
230
|
+
processed += result.migrated;
|
|
231
|
+
if (!hookResult.done) return { done: false, processed };
|
|
184
232
|
}
|
|
233
|
+
return { done: true, processed };
|
|
185
234
|
};
|
|
186
235
|
|
|
187
236
|
// Any listed key that is itself a surviving row's exact storageKey means a
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.354.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "folder:delete removes assignments whose host entity is gone instead of refusing forever",
|
|
6
|
+
"detail": "An assignment pointing at a hard-deleted, soft-deleted or unregistered host blocked folder:delete permanently with folder_has_assignments, because clear-folder needs a visible host and could not remove it. folder:delete now removes such assignments through the event-store executor (normal delete events) before the check, without the caller's read gate. Assignments of live hosts, including ones the caller cannot see, still block with folder_has_assignments. A refused delete removes nothing. The cleanup runs lazily at folder:delete, not in a host-delete hook, so restoring a soft-deleted host before then keeps its folder."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.246.0",
|
|
4
10
|
"type": "improvement",
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { AccessRule, WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
2
|
import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
3
|
import * as z from "zod";
|
|
4
|
+
import { hostRowIsGone } from "../../shared/index.js";
|
|
4
5
|
import { DEFAULT_FOLDER_ACCESS } from "../constants.js";
|
|
5
6
|
import { folderAssignmentExecutor, folderExecutor } from "../executor.js";
|
|
6
7
|
|
|
@@ -13,6 +14,12 @@ const deleteFolderPayloadSchema = z.object({ id: z.uuid() });
|
|
|
13
14
|
// see 658/1). Block server-side instead: this is the same "referential
|
|
14
15
|
// integrity has no FK here" reasoning set-folder.write.ts already applies to
|
|
15
16
|
// folderId on assignment-creation.
|
|
17
|
+
//
|
|
18
|
+
// Assignments whose host is gone (hard-deleted, soft-deleted or unregistered)
|
|
19
|
+
// are cleaned up here instead of blocking forever: clear-folder can't reach
|
|
20
|
+
// them (it requires a visible host). A host-delete hook would break restore,
|
|
21
|
+
// so the cleanup runs lazily at folder:delete.
|
|
22
|
+
const ASSIGNMENT_PAGE_SIZE = 200;
|
|
16
23
|
export function createDeleteFolderHandler(
|
|
17
24
|
access: AccessRule = DEFAULT_FOLDER_ACCESS,
|
|
18
25
|
): WriteHandlerDef {
|
|
@@ -21,19 +28,44 @@ export function createDeleteFolderHandler(
|
|
|
21
28
|
schema: deleteFolderPayloadSchema,
|
|
22
29
|
access,
|
|
23
30
|
description:
|
|
24
|
-
"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.",
|
|
31
|
+
"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.",
|
|
25
32
|
agent: { risk: "high" },
|
|
26
33
|
handler: async (event, ctx) => {
|
|
27
34
|
const payload = event.payload as { id: string }; // @cast-boundary engine-payload
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
35
|
+
const orphanAssignmentIds: string[] = [];
|
|
36
|
+
let cursor: string | undefined;
|
|
37
|
+
do {
|
|
38
|
+
const page = await folderAssignmentExecutor.list(
|
|
39
|
+
{
|
|
40
|
+
filter: { field: "folderId", op: "eq", value: payload.id },
|
|
41
|
+
limit: ASSIGNMENT_PAGE_SIZE,
|
|
42
|
+
cursor,
|
|
43
|
+
},
|
|
44
|
+
event.user,
|
|
45
|
+
ctx.db,
|
|
36
46
|
);
|
|
47
|
+
for (const row of page.rows) {
|
|
48
|
+
const gone = await hostRowIsGone(
|
|
49
|
+
ctx.registry,
|
|
50
|
+
String(row["entityType"]),
|
|
51
|
+
String(row["entityId"]),
|
|
52
|
+
ctx.db,
|
|
53
|
+
);
|
|
54
|
+
if (!gone) {
|
|
55
|
+
return writeFailure(
|
|
56
|
+
new UnprocessableError("folder_has_assignments", {
|
|
57
|
+
details: { folderId: payload.id },
|
|
58
|
+
}),
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
orphanAssignmentIds.push(String(row["id"]));
|
|
62
|
+
}
|
|
63
|
+
cursor = page.nextCursor ?? undefined;
|
|
64
|
+
} while (cursor !== undefined);
|
|
65
|
+
|
|
66
|
+
for (const id of orphanAssignmentIds) {
|
|
67
|
+
const removed = await folderAssignmentExecutor.delete({ id }, event.user, ctx.db);
|
|
68
|
+
if (!removed.isSuccess) return removed;
|
|
37
69
|
}
|
|
38
70
|
return folderExecutor.delete({ id: payload.id }, event.user, ctx.db);
|
|
39
71
|
},
|
|
@@ -10,18 +10,30 @@
|
|
|
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
|
|
|
18
16
|
import type { lookup } from "node:dns/promises";
|
|
19
17
|
import { isIP } from "node:net";
|
|
20
18
|
import { resolvePublicHostname } from "@cosmicdrift/kumiko-framework/http";
|
|
19
|
+
import * as z from "zod";
|
|
21
20
|
import { isHostAllowlisted, readHostAllowlistFromEnv } from "./host-allowlist.js";
|
|
22
21
|
|
|
23
22
|
export const MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR = "KUMIKO_MAIL_ALLOWED_PRIVATE_HOSTS";
|
|
24
23
|
|
|
24
|
+
// One schema instance, registered by every feature that reads the variable:
|
|
25
|
+
// composeEnvSchema accepts the same field instance from several features, so
|
|
26
|
+
// an app mounting only one of them still gets the variable validated and
|
|
27
|
+
// documented.
|
|
28
|
+
export const mailAllowedPrivateHostsEnvSchema = z.object({
|
|
29
|
+
[MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR]: z
|
|
30
|
+
.string()
|
|
31
|
+
.optional()
|
|
32
|
+
.describe(
|
|
33
|
+
"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.",
|
|
34
|
+
),
|
|
35
|
+
});
|
|
36
|
+
|
|
25
37
|
export function readAllowedPrivateMailHostsFromEnv(
|
|
26
38
|
env: Readonly<Record<string, string | undefined>> = process.env,
|
|
27
39
|
): readonly string[] {
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.354.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "inbound-provider-imap declares the MAIL_ALLOWED_PRIVATE_HOSTS env schema",
|
|
6
|
+
"migration": "No action needed. An app that mounts both mail-transport-smtp and inbound-provider-imap now composes one shared field."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.320.0",
|
|
4
10
|
"type": "breaking",
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
// (credential-document.ts) — kein Tenant-Config, weil mehrere Accounts
|
|
24
24
|
// pro Tenant verschiedene Hosts haben.
|
|
25
25
|
|
|
26
|
+
import { mailAllowedPrivateHostsEnvSchema } from "@cosmicdrift/kumiko-bundled-features/foundation-shared";
|
|
26
27
|
import {
|
|
27
28
|
INBOUND_MAIL_PROVIDER_EXTENSION,
|
|
28
29
|
InboundAuthError,
|
|
@@ -282,5 +283,6 @@ export const inboundProviderImapFeature = defineFeature(FEATURE_NAME, (r) => {
|
|
|
282
283
|
});
|
|
283
284
|
r.requires("inbound-mail-foundation");
|
|
284
285
|
r.requires("secrets");
|
|
286
|
+
r.envSchema(mailAllowedPrivateHostsEnvSchema);
|
|
285
287
|
r.useExtension(INBOUND_MAIL_PROVIDER_EXTENSION, IMAP_PROVIDER_KEY, imapInboundMailPlugin);
|
|
286
288
|
});
|
|
@@ -39,10 +39,9 @@ const IMAP_HOST_UNREACHABLE_MESSAGE = "IMAP host is not reachable or not allowed
|
|
|
39
39
|
|
|
40
40
|
// Operator escape hatch for an internal relay or a dev/test IMAP server
|
|
41
41
|
// (greenmail): KUMIKO_MAIL_ALLOWED_PRIVATE_HOSTS, the same operator env
|
|
42
|
-
// var mail-transport-smtp
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
// never grant themselves the private-host bypass.
|
|
42
|
+
// var mail-transport-smtp registers (both share one schema instance, see
|
|
43
|
+
// foundation-shared), never a tenant-config value, so a tenant can never
|
|
44
|
+
// grant themselves the private-host bypass.
|
|
46
45
|
//
|
|
47
46
|
// mailHostLookup is a test-only DNS seam — production leaves it undefined,
|
|
48
47
|
// so resolveMailConnectTarget uses the real resolver. Module-global state:
|
|
@@ -1,4 +1,34 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.354.0",
|
|
4
|
+
"type": "breaking",
|
|
5
|
+
"title": "jobs: store_tenant_job_failures holds at most one row per (tenant, job, subject)",
|
|
6
|
+
"migration": "store_tenant_job_failures now enforces one row per (tenant_id, job_name, subject) through two partial unique indexes; parallel final failures serialize on an advisory lock. kumiko schema generate does NOT produce the dedup, and the unique index would fail on existing duplicates, so hand-edit the generated migration to contain exactly: DELETE FROM \"store_tenant_job_failures\" a USING \"store_tenant_job_failures\" b WHERE a.\"tenant_id\" = b.\"tenant_id\" AND a.\"job_name\" = b.\"job_name\" AND a.\"subject\" IS NOT DISTINCT FROM b.\"subject\" AND (a.\"failed_at\", a.\"id\") < (b.\"failed_at\", b.\"id\"); CREATE UNIQUE INDEX IF NOT EXISTS \"store_tenant_job_failures_tenant_job_subject_uidx\" ON \"store_tenant_job_failures\" (\"tenant_id\", \"job_name\", \"subject\") WHERE subject IS NOT NULL; CREATE UNIQUE INDEX IF NOT EXISTS \"store_tenant_job_failures_tenant_job_null_subject_uidx\" ON \"store_tenant_job_failures\" (\"tenant_id\", \"job_name\") WHERE subject IS NULL; DROP INDEX IF EXISTS \"store_tenant_job_failures_tenant_job_subject_idx\"; The DELETE keeps the newest row per key (by failed_at, then id)."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.354.0",
|
|
10
|
+
"type": "improvement",
|
|
11
|
+
"title": "Tenant-visible job run state: r.job({ tenantVisibleRun }) plus jobs:query:tenant-runs",
|
|
12
|
+
"detail": "A job that declares `tenantVisibleRun` keeps its queued, running, completed and failed state per tenant and subject in the new table `store_tenant_job_runs`, and the tenant reads it through `jobs:query:tenant-runs` (payload `{ jobName?, subject?, limit? }`, own tenant only). Each row is `{ jobName, subject, status, queuedAt, startedAt, finishedAt }`: every queued or running run plus the latest completed or failed one per job and subject, active runs first. No payload, error text or log ever reaches the tenant. A failed attempt that is retried shows as `queued` again. The stale-run sweep and `retention-cleanup` also cover the new table. `JobRunnerOptions` gains `onJobQueued` and `onJobDropped`.",
|
|
13
|
+
"migration": "New store table. Run `kumiko-schema generate <name>` and apply the migration with `kumiko-schema apply` - `store_tenant_job_runs` is created empty and stays empty until a job declares `tenantVisibleRun`. No change needed for apps that do not opt in."
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"version": "0.354.0",
|
|
17
|
+
"type": "fix",
|
|
18
|
+
"title": "jobs:query:list and jobs:query:failures reject a fractional limit, and migration hints name `kumiko-schema generate`",
|
|
19
|
+
"detail": "Migration hints name the real `kumiko-schema generate` command, and the jobs list queries reject fractional `limit` values\nThe generated migration header and the recipe READMEs pointed at a non-existent `kumiko migrate generate`. `jobs:query:list` and `jobs:query:failures` now validate `limit` as an integer (at least 1), so `limit: 2.5` fails validation instead of reaching the database query."
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"version": "0.354.0",
|
|
23
|
+
"type": "fix",
|
|
24
|
+
"title": "store_tenant_job_failures gets three indexes and jobs:query:failures lists one record per job and subject",
|
|
25
|
+
"migration": "Run `kumiko schema generate`; it emits in-place CREATE INDEX statements with no DROP and no rebuild."
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"version": "0.354.0",
|
|
29
|
+
"type": "fix",
|
|
30
|
+
"title": "The job worker starts consuming only after the boot gates passed"
|
|
31
|
+
},
|
|
2
32
|
{
|
|
3
33
|
"version": "0.343.0",
|
|
4
34
|
"type": "improvement",
|
|
@@ -14,6 +14,7 @@ import { deleteManyBatched } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
|
14
14
|
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
15
15
|
import { jobRunLogsTable, jobRunsTable } from "../../job-run-table.js";
|
|
16
16
|
import { tenantJobFailuresTable } from "../../tenant-job-failure-table.js";
|
|
17
|
+
import { tenantJobRunsTable } from "../../tenant-job-run-table.js";
|
|
17
18
|
|
|
18
19
|
const RETENTION_DELETE_BATCH_SIZE = 500;
|
|
19
20
|
|
|
@@ -21,6 +22,7 @@ export type JobRunRetentionResult = {
|
|
|
21
22
|
readonly runsDeleted: number;
|
|
22
23
|
readonly logsDeleted: number;
|
|
23
24
|
readonly tenantFailuresDeleted: number;
|
|
25
|
+
readonly tenantRunsDeleted: number;
|
|
24
26
|
};
|
|
25
27
|
|
|
26
28
|
export async function deleteStaleJobRuns(
|
|
@@ -52,9 +54,19 @@ export async function deleteStaleJobRuns(
|
|
|
52
54
|
{ limit: RETENTION_DELETE_BATCH_SIZE },
|
|
53
55
|
);
|
|
54
56
|
|
|
57
|
+
// Only finished rows: active ones are the stale sweep's business, and a
|
|
58
|
+
// still-running job older than the window must not vanish from the tenant.
|
|
59
|
+
const tenantRunsResult = await deleteManyBatched(
|
|
60
|
+
db,
|
|
61
|
+
tenantJobRunsTable,
|
|
62
|
+
{ status: { in: ["completed", "failed"] }, updatedAt: { lt: cutoff } },
|
|
63
|
+
{ limit: RETENTION_DELETE_BATCH_SIZE },
|
|
64
|
+
);
|
|
65
|
+
|
|
55
66
|
return {
|
|
56
67
|
runsDeleted: runsResult.deleted,
|
|
57
68
|
logsDeleted: logsResult.deleted,
|
|
58
69
|
tenantFailuresDeleted: tenantFailuresResult.deleted,
|
|
70
|
+
tenantRunsDeleted: tenantRunsResult.deleted,
|
|
59
71
|
};
|
|
60
72
|
}
|
|
@@ -12,19 +12,23 @@
|
|
|
12
12
|
// the run becomes retriable via the existing jobs:write:retry gate
|
|
13
13
|
// (retry.write.ts only allows retry from "failed").
|
|
14
14
|
|
|
15
|
-
import { selectMany, updateMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
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
18
|
import { encryptFailureError } from "../../job-run-logger.js";
|
|
19
19
|
import { jobRunsTable } from "../../job-run-table.js";
|
|
20
|
+
import { tenantJobRunsTable } from "../../tenant-job-run-table.js";
|
|
20
21
|
|
|
21
22
|
const KMS_POOL_CONCURRENCY = 4;
|
|
23
|
+
const STALE_DELETE_BATCH_SIZE = 500;
|
|
22
24
|
|
|
23
25
|
export const STALE_JOB_RUN_ERROR =
|
|
24
26
|
"job run exceeded the stale-run timeout without a completion signal (likely a crashed worker process)";
|
|
25
27
|
|
|
26
28
|
export type StaleRunSweepResult = {
|
|
27
29
|
readonly runsMarkedFailed: number;
|
|
30
|
+
readonly tenantRunsMarkedFailed: number;
|
|
31
|
+
readonly tenantQueuedDropped: number;
|
|
28
32
|
};
|
|
29
33
|
|
|
30
34
|
export async function markStaleJobRunsFailed(
|
|
@@ -80,5 +84,25 @@ export async function markStaleJobRunsFailed(
|
|
|
80
84
|
);
|
|
81
85
|
const runsMarkedFailed = updatedPerSubject.reduce((sum, count) => sum + count, 0);
|
|
82
86
|
|
|
83
|
-
|
|
87
|
+
// Tenant-visible run state follows the same clock: a running row whose
|
|
88
|
+
// 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.
|
|
90
|
+
const tenantRunsFailed = await updateMany(
|
|
91
|
+
db,
|
|
92
|
+
tenantJobRunsTable,
|
|
93
|
+
{ status: "failed", finishedAt: now, updatedAt: now },
|
|
94
|
+
{ status: "running", startedAt: { lt: cutoff } },
|
|
95
|
+
);
|
|
96
|
+
const tenantQueuedStale = await deleteManyBatched(
|
|
97
|
+
db,
|
|
98
|
+
tenantJobRunsTable,
|
|
99
|
+
{ status: "queued", updatedAt: { lt: cutoff } },
|
|
100
|
+
{ limit: STALE_DELETE_BATCH_SIZE },
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
runsMarkedFailed,
|
|
105
|
+
tenantRunsMarkedFailed: tenantRunsFailed.length,
|
|
106
|
+
tenantQueuedDropped: tenantQueuedStale.deleted,
|
|
107
|
+
};
|
|
84
108
|
}
|
|
@@ -27,10 +27,12 @@ import {
|
|
|
27
27
|
DEFAULT_JOB_RUN_STALE_TIMEOUT_HOURS,
|
|
28
28
|
} from "./handlers/stale-run-sweep.job.js";
|
|
29
29
|
import { tenantFailuresQuery } from "./handlers/tenant-failures.query.js";
|
|
30
|
+
import { tenantRunsQuery } from "./handlers/tenant-runs.query.js";
|
|
30
31
|
import { triggerWrite } from "./handlers/trigger.write.js";
|
|
31
32
|
import { JOBS_I18N } from "./i18n.js";
|
|
32
33
|
import { jobRunLogsTableMeta, jobRunsTableMeta } from "./job-run-table.js";
|
|
33
34
|
import { tenantJobFailuresTableMeta } from "./tenant-job-failure-table.js";
|
|
35
|
+
import { tenantJobRunsTableMeta } from "./tenant-job-run-table.js";
|
|
34
36
|
|
|
35
37
|
export type JobsFeatureOptions = {
|
|
36
38
|
// How long a job run (and its logs) stays in store_job_runs/
|
|
@@ -49,7 +51,7 @@ export function createJobsFeature(options: JobsFeatureOptions = {}): FeatureDefi
|
|
|
49
51
|
const staleRunTimeoutHours = options.staleRunTimeoutHours ?? DEFAULT_JOB_RUN_STALE_TIMEOUT_HOURS;
|
|
50
52
|
return defineFeature("jobs", (r) => {
|
|
51
53
|
r.describe(
|
|
52
|
-
"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).",
|
|
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 its stuck `queued` rows.",
|
|
53
55
|
);
|
|
54
56
|
r.uiHints({
|
|
55
57
|
displayLabel: "Jobs · Audit & Operator UI",
|
|
@@ -71,6 +73,9 @@ export function createJobsFeature(options: JobsFeatureOptions = {}): FeatureDefi
|
|
|
71
73
|
r.storeTable(tenantJobFailuresTableMeta, {
|
|
72
74
|
reason: "direct_write.tenant_job_failures",
|
|
73
75
|
});
|
|
76
|
+
r.storeTable(tenantJobRunsTableMeta, {
|
|
77
|
+
reason: "direct_write.tenant_job_runs",
|
|
78
|
+
});
|
|
74
79
|
|
|
75
80
|
// Framework-provided rebuild job — available whenever `jobs` is composed; enqueueProjectionRebuild dispatches it.
|
|
76
81
|
r.job(
|
|
@@ -123,6 +128,7 @@ export function createJobsFeature(options: JobsFeatureOptions = {}): FeatureDefi
|
|
|
123
128
|
detail: r.queryHandler(detailQuery),
|
|
124
129
|
catalog: r.queryHandler(catalogQuery),
|
|
125
130
|
failures: r.queryHandler(tenantFailuresQuery),
|
|
131
|
+
tenantRuns: r.queryHandler(tenantRunsQuery),
|
|
126
132
|
};
|
|
127
133
|
|
|
128
134
|
const systemAdminAccess = { roles: ["SystemAdmin"] as const };
|
|
@@ -68,7 +68,7 @@ export const listQuery = defineQueryHandler({
|
|
|
68
68
|
.optional(),
|
|
69
69
|
sort: z.enum(["jobName", "status", "startedAt", "duration"]).optional(),
|
|
70
70
|
sortDirection: z.enum(["asc", "desc"]).optional(),
|
|
71
|
-
limit: z.number().optional(),
|
|
71
|
+
limit: z.number().int().min(1).optional(),
|
|
72
72
|
cursor: z.string().optional(),
|
|
73
73
|
totalCount: z.boolean().optional(),
|
|
74
74
|
}),
|
|
@@ -37,7 +37,7 @@ export const tenantFailuresQuery = defineQueryHandler({
|
|
|
37
37
|
schema: z.object({
|
|
38
38
|
jobName: z.string().optional(),
|
|
39
39
|
subject: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional(),
|
|
40
|
-
limit: z.number().min(1).max(200).optional(),
|
|
40
|
+
limit: z.number().int().min(1).max(200).optional(),
|
|
41
41
|
}),
|
|
42
42
|
// Every membership rank: a failure record carries a job name and a
|
|
43
43
|
// translation key, nothing a team member of the tenant may not see.
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
+
import { access, defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
4
|
+
import { serializeJobSubject } from "@cosmicdrift/kumiko-framework/jobs";
|
|
5
|
+
import { parseJsonSafe } from "@cosmicdrift/kumiko-framework/utils";
|
|
6
|
+
import * as z from "zod";
|
|
7
|
+
import { type TenantJobRunStatus, tenantJobRunsTable } from "../tenant-job-run-table.js";
|
|
8
|
+
|
|
9
|
+
type TenantJobRunRow = {
|
|
10
|
+
readonly tenantId: string;
|
|
11
|
+
readonly jobName: string;
|
|
12
|
+
readonly subject: string | null;
|
|
13
|
+
readonly status: TenantJobRunStatus;
|
|
14
|
+
readonly queuedAt: Temporal.Instant | null;
|
|
15
|
+
readonly startedAt: Temporal.Instant | null;
|
|
16
|
+
readonly finishedAt: Temporal.Instant | null;
|
|
17
|
+
readonly updatedAt: Temporal.Instant;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const DEFAULT_LIMIT = 50;
|
|
21
|
+
|
|
22
|
+
function isSubjectEntry(value: unknown): value is [string, unknown] {
|
|
23
|
+
return Array.isArray(value) && typeof value[0] === "string";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function parseSubject(stored: string | null): Record<string, unknown> | null {
|
|
27
|
+
if (stored === null) return null;
|
|
28
|
+
// A corrupt subject must not fail the whole list; parseJsonSafe only
|
|
29
|
+
// survives a SyntaxError, so the shape needs its own check.
|
|
30
|
+
const parsed = parseJsonSafe<unknown>(stored, null);
|
|
31
|
+
if (!Array.isArray(parsed)) return null;
|
|
32
|
+
return Object.fromEntries(parsed.filter(isSubjectEntry));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const tenantRunsQuery = defineQueryHandler({
|
|
36
|
+
name: "tenant-runs",
|
|
37
|
+
description:
|
|
38
|
+
"Lists the calling tenant's own job runs that opted in via tenantVisibleRun — per job and subject every queued or running run plus the latest completed or failed one, active runs first, then newest first; each row carries only status and queuedAt/startedAt/finishedAt, never payloads, error text or logs; the optional subject filter matches exactly: all declared subjectFields with the same JSON types as the job payload; use it to show whether work for a subject is waiting, running or done, and to tell a failed run from a finished one.",
|
|
39
|
+
schema: z.object({
|
|
40
|
+
jobName: z.string().optional(),
|
|
41
|
+
subject: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional(),
|
|
42
|
+
limit: z.number().int().min(1).max(200).optional(),
|
|
43
|
+
}),
|
|
44
|
+
// Every membership rank: a run state carries a job name, a status and
|
|
45
|
+
// times, nothing a team member of the tenant may not see.
|
|
46
|
+
access: { roles: access.roles("User", "Editor", ...access.admin) },
|
|
47
|
+
handler: async (query, ctx) => {
|
|
48
|
+
if (!ctx.systemDb) {
|
|
49
|
+
throw new InternalError({
|
|
50
|
+
message:
|
|
51
|
+
"jobs:query:tenant-runs requires ctx.systemDb (feature must declare r.systemScope())",
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
// assertTenantMatch returns the same unfiltered system-mode db — the
|
|
55
|
+
// explicit tenantId below is the filter, assertRowsTenant the second net.
|
|
56
|
+
const db = ctx.systemDb.assertTenantMatch(query.user.tenantId);
|
|
57
|
+
const where: Record<string, unknown> = { tenantId: [query.user.tenantId] };
|
|
58
|
+
if (query.payload.jobName) where["jobName"] = query.payload.jobName;
|
|
59
|
+
if (query.payload.subject) where["subject"] = serializeJobSubject(query.payload.subject);
|
|
60
|
+
const limit = query.payload.limit ?? DEFAULT_LIMIT;
|
|
61
|
+
const newestFirst = { col: "updatedAt", direction: "desc" } as const;
|
|
62
|
+
const activeRows = await selectMany<TenantJobRunRow>(
|
|
63
|
+
db,
|
|
64
|
+
tenantJobRunsTable,
|
|
65
|
+
{ ...where, status: { in: ["queued", "running"] } },
|
|
66
|
+
{ orderBy: newestFirst, limit },
|
|
67
|
+
);
|
|
68
|
+
const finishedRows = await selectMany<TenantJobRunRow>(
|
|
69
|
+
db,
|
|
70
|
+
tenantJobRunsTable,
|
|
71
|
+
{ ...where, status: { in: ["completed", "failed"] } },
|
|
72
|
+
{ orderBy: newestFirst, limit },
|
|
73
|
+
);
|
|
74
|
+
const seenFinishedKeys = new Set<string>();
|
|
75
|
+
const finishedNewestPerKey = finishedRows.filter((row) => {
|
|
76
|
+
const key = JSON.stringify([row.jobName, row.subject]);
|
|
77
|
+
if (seenFinishedKeys.has(key)) return false;
|
|
78
|
+
seenFinishedKeys.add(key);
|
|
79
|
+
return true;
|
|
80
|
+
});
|
|
81
|
+
const activeFirst = [...activeRows, ...finishedNewestPerKey];
|
|
82
|
+
return {
|
|
83
|
+
rows: ctx.systemDb.assertRowsTenant(activeFirst.slice(0, limit), "tenantId").map((row) => ({
|
|
84
|
+
jobName: row.jobName,
|
|
85
|
+
subject: parseSubject(row.subject),
|
|
86
|
+
status: row.status,
|
|
87
|
+
queuedAt: row.queuedAt,
|
|
88
|
+
startedAt: row.startedAt,
|
|
89
|
+
finishedAt: row.finishedAt,
|
|
90
|
+
})),
|
|
91
|
+
nextCursor: null,
|
|
92
|
+
};
|
|
93
|
+
},
|
|
94
|
+
});
|
|
@@ -5,3 +5,5 @@ export { createJobRunLogger } from "./job-run-logger.js";
|
|
|
5
5
|
export type { JobLogLevel, JobRunStatus } from "./job-run-table.js";
|
|
6
6
|
export { jobRunLogsTable, jobRunsTable } from "./job-run-table.js";
|
|
7
7
|
export { tenantJobFailuresTable } from "./tenant-job-failure-table.js";
|
|
8
|
+
export type { TenantJobRunStatus } from "./tenant-job-run-table.js";
|
|
9
|
+
export { tenantJobRunsTable } from "./tenant-job-run-table.js";
|