@cosmicdrift/kumiko-bundled-features 0.2.1 → 0.2.3
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/CHANGELOG.md +108 -0
- package/package.json +12 -6
- package/src/auth-email-password/auth-user-row.ts +6 -0
- package/src/auth-email-password/constants.ts +11 -0
- package/src/auth-email-password/handlers/login.write.ts +31 -1
- package/src/auth-email-password/i18n.ts +4 -0
- package/src/compliance-profiles/README.md +88 -0
- package/src/compliance-profiles/__tests__/compliance-profiles.integration.ts +308 -0
- package/src/compliance-profiles/__tests__/seeding.integration.ts +93 -0
- package/src/compliance-profiles/feature.ts +51 -0
- package/src/compliance-profiles/handlers/for-tenant.query.ts +63 -0
- package/src/compliance-profiles/handlers/list-profiles.query.ts +44 -0
- package/src/compliance-profiles/handlers/needs-profile.query.ts +56 -0
- package/src/compliance-profiles/handlers/set-profile.write.ts +146 -0
- package/src/compliance-profiles/handlers/sub-processors.query.ts +43 -0
- package/src/compliance-profiles/index.ts +6 -0
- package/src/compliance-profiles/resolve-for-tenant.ts +61 -0
- package/src/compliance-profiles/schema/profile-selection.ts +52 -0
- package/src/compliance-profiles/seeding.ts +96 -0
- package/src/data-retention/__tests__/data-retention.integration.ts +49 -0
- package/src/data-retention/__tests__/keep-for.test.ts +77 -0
- package/src/data-retention/__tests__/override-schema.test.ts +96 -0
- package/src/data-retention/__tests__/policy-for.integration.ts +172 -0
- package/src/data-retention/__tests__/resolver.test.ts +201 -0
- package/src/data-retention/_internal/parse-override.ts +33 -0
- package/src/data-retention/feature.ts +57 -0
- package/src/data-retention/handlers/policy-for.query.ts +57 -0
- package/src/data-retention/index.ts +18 -0
- package/src/data-retention/keep-for.ts +75 -0
- package/src/data-retention/override-schema.ts +37 -0
- package/src/data-retention/presets.ts +72 -0
- package/src/data-retention/resolve-for-tenant.ts +50 -0
- package/src/data-retention/resolver.ts +107 -0
- package/src/data-retention/schema/tenant-retention-override.ts +47 -0
- package/src/file-foundation/feature.ts +43 -3
- package/src/file-foundation/index.ts +1 -0
- package/src/file-provider-inmemory/feature.ts +6 -3
- package/src/file-provider-s3/feature.ts +8 -10
- package/src/files/README.md +50 -0
- package/src/files/__tests__/files.integration.ts +157 -0
- package/src/files/feature.ts +34 -0
- package/src/files/index.ts +1 -0
- package/src/files/schema/file-ref.ts +58 -0
- package/src/files-provider-s3/s3-provider.ts +89 -0
- package/src/secrets/__tests__/require-secrets-context.test.ts +81 -0
- package/src/secrets/feature.ts +10 -6
- package/src/sessions/constants.ts +4 -0
- package/src/sessions/feature.ts +3 -0
- package/src/sessions/handlers/revoke-all-for-user.write.ts +42 -0
- package/src/tier-engine/__tests__/auto-default-tier.integration.ts +118 -0
- package/src/tier-engine/feature.ts +16 -6
- package/src/user/__tests__/user-status.test.ts +39 -0
- package/src/user/index.ts +11 -1
- package/src/user/schema/user.ts +76 -0
- package/src/user-data-rights/COMPLIANCE.md +182 -0
- package/src/user-data-rights/README.md +109 -0
- package/src/user-data-rights/__tests__/audit-log.integration.ts +199 -0
- package/src/user-data-rights/__tests__/cross-data-matrix.integration.ts +349 -0
- package/src/user-data-rights/__tests__/download.integration.ts +565 -0
- package/src/user-data-rights/__tests__/export-job-idempotency.integration.ts +244 -0
- package/src/user-data-rights/__tests__/export-job-schema.test.ts +163 -0
- package/src/user-data-rights/__tests__/policy-to-strategy.test.ts +30 -0
- package/src/user-data-rights/__tests__/request-cancel-deletion.integration.ts +370 -0
- package/src/user-data-rights/__tests__/request-deletion-callback.integration.ts +179 -0
- package/src/user-data-rights/__tests__/request-export.integration.ts +269 -0
- package/src/user-data-rights/__tests__/restriction-flow.integration.ts +309 -0
- package/src/user-data-rights/__tests__/run-export-jobs.integration.ts +1124 -0
- package/src/user-data-rights/__tests__/run-forget-cleanup.integration.ts +703 -0
- package/src/user-data-rights/__tests__/run-user-export.integration.ts +291 -0
- package/src/user-data-rights/__tests__/token-helpers.test.ts +63 -0
- package/src/user-data-rights/__tests__/user-data-rights.integration.ts +57 -0
- package/src/user-data-rights/__tests__/zip-path.test.ts +119 -0
- package/src/user-data-rights/audit-download.ts +125 -0
- package/src/user-data-rights/feature.ts +309 -0
- package/src/user-data-rights/handlers/cancel-deletion.write.ts +84 -0
- package/src/user-data-rights/handlers/download-by-job.query.ts +209 -0
- package/src/user-data-rights/handlers/download-by-token.query.ts +257 -0
- package/src/user-data-rights/handlers/export-status.query.ts +76 -0
- package/src/user-data-rights/handlers/lift-restriction.write.ts +68 -0
- package/src/user-data-rights/handlers/list-download-attempts.query.ts +53 -0
- package/src/user-data-rights/handlers/my-audit-log.query.ts +63 -0
- package/src/user-data-rights/handlers/request-deletion.write.ts +123 -0
- package/src/user-data-rights/handlers/request-export.write.ts +155 -0
- package/src/user-data-rights/handlers/restrict-account.write.ts +81 -0
- package/src/user-data-rights/handlers/run-forget-cleanup.write.ts +61 -0
- package/src/user-data-rights/i18n.ts +37 -0
- package/src/user-data-rights/index.ts +19 -0
- package/src/user-data-rights/run-export-jobs.ts +878 -0
- package/src/user-data-rights/run-forget-cleanup.ts +334 -0
- package/src/user-data-rights/run-user-export.ts +211 -0
- package/src/user-data-rights/schema/download-attempt.ts +37 -0
- package/src/user-data-rights/schema/download-token.ts +111 -0
- package/src/user-data-rights/schema/export-job.ts +166 -0
- package/src/user-data-rights/token-helpers.ts +67 -0
- package/src/user-data-rights/zip-path.ts +94 -0
- package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.ts +337 -0
- package/src/user-data-rights-defaults/feature.ts +40 -0
- package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +109 -0
- package/src/user-data-rights-defaults/hooks/user.userdata-hook.ts +91 -0
- package/src/user-data-rights-defaults/index.ts +6 -0
|
@@ -0,0 +1,878 @@
|
|
|
1
|
+
// Async Export-Job Worker (S2.U3 Atom 3b) — pure Pipeline-Function.
|
|
2
|
+
//
|
|
3
|
+
// Spec: docs/plans/architecture/user-data-rights.md "Async Export-Pipeline".
|
|
4
|
+
// Cron-getriggert (siehe r.job-Wrap im feature.ts), iteriert ueber alle
|
|
5
|
+
// pending-Jobs in einem Pass, fuehrt pro Job aus:
|
|
6
|
+
//
|
|
7
|
+
// 1. Claim: `crud.update({status: running, startedAt})` mit
|
|
8
|
+
// optimistic-locking via version-Spalte. Wenn 2 Worker-Replicas
|
|
9
|
+
// denselben Job picken wollen, gewinnt der erste, der zweite
|
|
10
|
+
// kriegt VersionConflictError + skippt.
|
|
11
|
+
//
|
|
12
|
+
// 2. Bundle bauen: runUserExport({db, registry, userId, now}) →
|
|
13
|
+
// UserExportBundle (siehe run-user-export.ts). Cross-Tenant-
|
|
14
|
+
// Iteration ueber Memberships ist dort implementiert.
|
|
15
|
+
//
|
|
16
|
+
// 3. Profile-Resolution: `resolveProfileForTenant(requestedFromTenantId)`
|
|
17
|
+
// liefert effective compliance-profile. exportDownloadTtl wird
|
|
18
|
+
// fuer expiresAt-Berechnung gebraucht.
|
|
19
|
+
//
|
|
20
|
+
// 4. Storage-Provider builden: `buildStorageProvider(ctx, requestedFromTenantId)`
|
|
21
|
+
// — file-foundation's createFileProviderForTenant-Pattern.
|
|
22
|
+
// Pro Tenant unterschiedlicher Provider moeglich (S3 vs lokal).
|
|
23
|
+
//
|
|
24
|
+
// 5. ZIP-Stream: `createZipStream(bundleAsZipEntries(bundle))` →
|
|
25
|
+
// AsyncIterable<Uint8Array>. Wir wrappen das in einen tracking-
|
|
26
|
+
// Iterator der bytesWritten zaehlt.
|
|
27
|
+
//
|
|
28
|
+
// 6. Storage-Write: provider.writeStream(storageKey, zipStream).
|
|
29
|
+
// Atomar via tmp+rename (lokal) bzw. multipart-upload (S3).
|
|
30
|
+
//
|
|
31
|
+
// 7. Job=done: `crud.update({status: "done", completedAt, expiresAt,
|
|
32
|
+
// downloadStorageKey, bytesWritten})`.
|
|
33
|
+
//
|
|
34
|
+
// Bei Throw zwischen 2-7: Job=failed mit errorMessage. Storage-Write
|
|
35
|
+
// ist atomar (writeStream rollback bei Error im Source-Stream), keine
|
|
36
|
+
// halb-fertigen ZIPs am final-Pfad.
|
|
37
|
+
//
|
|
38
|
+
// **Stale-Detection:** vor dem Pickup-Pass laeuft ein separater
|
|
39
|
+
// Stale-Pass: Jobs in `running` mit `startedAt + exportStaleTimeoutMinutes
|
|
40
|
+
// < now` werden auf `failed` geflippt. Worker-Crashes / OOM-Kills /
|
|
41
|
+
// Replica-Restarts hinterlassen sonst stuck-running-Jobs.
|
|
42
|
+
//
|
|
43
|
+
// **Storage-Cleanup:** dritter Pass loescht abgelaufene downloadStorageKeys
|
|
44
|
+
// (`expiresAt + exportStorageCleanupGraceHours < now`) — abgelaufene ZIPs
|
|
45
|
+
// auf S3 sollen nicht ewig liegen.
|
|
46
|
+
|
|
47
|
+
import { addDurationSpec } from "@cosmicdrift/kumiko-framework/compliance";
|
|
48
|
+
import type { DbConnection, DbRunner } from "@cosmicdrift/kumiko-framework/db";
|
|
49
|
+
import {
|
|
50
|
+
createEventStoreExecutor,
|
|
51
|
+
createTenantDb,
|
|
52
|
+
fetchOne,
|
|
53
|
+
} from "@cosmicdrift/kumiko-framework/db";
|
|
54
|
+
import type { Registry, TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
55
|
+
import { createSystemUser } from "@cosmicdrift/kumiko-framework/engine";
|
|
56
|
+
import {
|
|
57
|
+
createZipStream,
|
|
58
|
+
type FileStorageProvider,
|
|
59
|
+
type ZipEntry,
|
|
60
|
+
} from "@cosmicdrift/kumiko-framework/files";
|
|
61
|
+
import type { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
62
|
+
import { and, asc, eq, isNotNull, or } from "drizzle-orm";
|
|
63
|
+
import { resolveProfileForTenant } from "../compliance-profiles";
|
|
64
|
+
import { userTable } from "../user";
|
|
65
|
+
import { runUserExport, type UserExportBundle } from "./run-user-export";
|
|
66
|
+
import { exportDownloadTokenEntity, exportDownloadTokensTable } from "./schema/download-token";
|
|
67
|
+
import { EXPORT_JOB_STATUS, exportJobEntity, exportJobsTable } from "./schema/export-job";
|
|
68
|
+
import { generateDownloadToken } from "./token-helpers";
|
|
69
|
+
|
|
70
|
+
type Instant = InstanceType<ReturnType<typeof getTemporal>["Instant"]>;
|
|
71
|
+
|
|
72
|
+
const crud = createEventStoreExecutor(exportJobsTable, exportJobEntity, {
|
|
73
|
+
entityName: "export-job",
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// Atom 4a — separater ES-Executor fuer Download-Tokens. crud.create
|
|
77
|
+
// emittiert `exportDownloadToken.created`-Event in den event-store +
|
|
78
|
+
// projected synchron auf read_export_download_tokens. KEIN direct-INSERT
|
|
79
|
+
// (Memory `feedback_no_fake_dispatcher`).
|
|
80
|
+
//
|
|
81
|
+
// Exportiert weil Atom 4b's download-handler tokenCrud.update fuer
|
|
82
|
+
// Audit-Felder (useCount, lastUsedAt, IP, UA) nutzt — ES-konsistent.
|
|
83
|
+
export const tokenCrud = createEventStoreExecutor(
|
|
84
|
+
exportDownloadTokensTable,
|
|
85
|
+
exportDownloadTokenEntity,
|
|
86
|
+
{ entityName: "export-download-token" },
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Notification-Callback fuer den done-Pfad. Pattern matched
|
|
91
|
+
* auth-routes.PasswordResetConfig.sendResetEmail. Plain-Token bleibt
|
|
92
|
+
* ephemeral (nicht in DB/event-store/jobRunsTable). App-Author wired
|
|
93
|
+
* den Callback an seinen Email-Provider:
|
|
94
|
+
* - existing `delivery.notify` (multi-channel + delivery_attempts-Log)
|
|
95
|
+
* - `mailFoundation.send` direkt
|
|
96
|
+
* - Custom Resend/SES/etc.
|
|
97
|
+
*
|
|
98
|
+
* **Best-effort:** Throw vom Callback bubbelt zum r.job-Wrap; jobs-
|
|
99
|
+
* Feature persistiert den Worker-Run als failed in jobRunsTable. Der
|
|
100
|
+
* Failed-Pfad wird in `jobs/__tests__/jobs-feature.integration.ts`
|
|
101
|
+
* Scenario 2 gepinnt — wir verweisen darauf statt end-zu-end zu
|
|
102
|
+
* duplizieren. Operator sieht's im /jobs-Dashboard, kann via
|
|
103
|
+
* jobs:write:retry den Worker-Run erneut anstossen (Job selbst bleibt
|
|
104
|
+
* done; retry findet keinen pending mehr, aber Audit-Log zeigt den
|
|
105
|
+
* Failure).
|
|
106
|
+
*
|
|
107
|
+
* **Plain-Token-Recovery bei Email-Fail:** plain ist ephemeral. Wenn
|
|
108
|
+
* der Callback throwt, ist der plain verloren. Operator-Eingriff
|
|
109
|
+
* noetig (Token in DB nullen + Job auf pending → next Worker-Run
|
|
110
|
+
* generiert neuen Token). Atom 5b (Re-issue-handler) wird das
|
|
111
|
+
* automatisieren.
|
|
112
|
+
*/
|
|
113
|
+
export type SendExportReadyEmailFn = (args: {
|
|
114
|
+
readonly userId: string;
|
|
115
|
+
readonly userEmail: string;
|
|
116
|
+
readonly tenantId: TenantId;
|
|
117
|
+
readonly jobId: string;
|
|
118
|
+
readonly downloadUrl: string;
|
|
119
|
+
readonly expiresAt: string;
|
|
120
|
+
readonly bytesWritten: number | null;
|
|
121
|
+
}) => Promise<void>;
|
|
122
|
+
|
|
123
|
+
export type SendExportFailedEmailFn = (args: {
|
|
124
|
+
readonly userId: string;
|
|
125
|
+
readonly userEmail: string;
|
|
126
|
+
readonly tenantId: TenantId;
|
|
127
|
+
readonly jobId: string;
|
|
128
|
+
readonly errorMessage: string;
|
|
129
|
+
}) => Promise<void>;
|
|
130
|
+
|
|
131
|
+
export interface RunExportJobsArgs {
|
|
132
|
+
readonly db: DbConnection;
|
|
133
|
+
readonly registry: Registry;
|
|
134
|
+
/**
|
|
135
|
+
* Per-Tenant Storage-Provider-Builder. App-Bootstrap liefert den
|
|
136
|
+
* file-foundation-Resolver via `createFileProviderForTenant` —
|
|
137
|
+
* Worker bekommt eine kleinere Surface ohne HandlerContext-Dep.
|
|
138
|
+
*/
|
|
139
|
+
readonly buildStorageProvider: (tenantId: TenantId) => Promise<FileStorageProvider>;
|
|
140
|
+
/** Now-Injection — Tests pinnen den Wert ohne Date-Mock. */
|
|
141
|
+
readonly now: Instant;
|
|
142
|
+
|
|
143
|
+
/** S2.U3 Atom 5 — Email-Notification beim done-flip. Optional;
|
|
144
|
+
* wenn nicht gesetzt, sendet der Worker keine notification (User
|
|
145
|
+
* kann aber via export-status.query polln + UI-Klick). */
|
|
146
|
+
readonly sendExportReadyEmail?: SendExportReadyEmailFn;
|
|
147
|
+
/** Optional Email beim failed-flip. */
|
|
148
|
+
readonly sendExportFailedEmail?: SendExportFailedEmailFn;
|
|
149
|
+
/** Base-URL fuer den Magic-Link. App-Author setzt das (z.B.
|
|
150
|
+
* "https://app.example.com/user-export/by-token"). Worker baut
|
|
151
|
+
* `${appExportDownloadUrl}?token=<plain>` und reicht das im
|
|
152
|
+
* Callback durch. Required wenn sendExportReadyEmail gesetzt. */
|
|
153
|
+
readonly appExportDownloadUrl?: string;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export interface ExportJobError {
|
|
157
|
+
readonly jobId: string;
|
|
158
|
+
readonly userId: string;
|
|
159
|
+
readonly phase: "claim" | "bundle" | "zip-write" | "complete";
|
|
160
|
+
readonly message: string;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export interface RunExportJobsResult {
|
|
164
|
+
/** Job-IDs die in diesem Pass von pending → done geflippt wurden. */
|
|
165
|
+
readonly completedJobIds: readonly string[];
|
|
166
|
+
/** Job-IDs die in diesem Pass von pending → failed geflippt wurden. */
|
|
167
|
+
readonly failedJobIds: readonly string[];
|
|
168
|
+
/** Job-IDs die als stuck-running auf failed geflippt wurden (Stale-Pass). */
|
|
169
|
+
readonly staleFailedJobIds: readonly string[];
|
|
170
|
+
/** Job-IDs deren downloadStorageKey im Cleanup-Pass geloescht wurde. */
|
|
171
|
+
readonly cleanedJobIds: readonly string[];
|
|
172
|
+
readonly errors: readonly ExportJobError[];
|
|
173
|
+
/**
|
|
174
|
+
* Plain-Tokens fuer in diesem Pass completed-Jobs. Map<jobId, plain>.
|
|
175
|
+
* Atom 5 (Notification) liest das + versendet plain per Email.
|
|
176
|
+
* NIEMALS persistiert — nur in-memory zwischen Worker-Run und
|
|
177
|
+
* Notification-Hook. NACH dem Run ist plain nur via DB-Hash
|
|
178
|
+
* verifizierbar (Atom 4b's Download-Endpoint).
|
|
179
|
+
*/
|
|
180
|
+
readonly tokenByJobId: ReadonlyMap<string, string>;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export async function runExportJobs(args: RunExportJobsArgs): Promise<RunExportJobsResult> {
|
|
184
|
+
const { db, registry, buildStorageProvider, now, sendExportReadyEmail, sendExportFailedEmail } =
|
|
185
|
+
args;
|
|
186
|
+
|
|
187
|
+
// Boot-Misconfig-Check: wer sendExportReadyEmail setzt aber URL
|
|
188
|
+
// vergisst, soll einen klaren Error sehen — VOR dem Pass-Loop, damit
|
|
189
|
+
// der Throw nicht vom per-Job-try/catch (Atom 5.fix3) geschluckt wird.
|
|
190
|
+
// Runtime-Callback-Failures sind best-effort, Boot-Misconfig ist hard-
|
|
191
|
+
// fail (App-Author-Bug, kein Network-Hiccup).
|
|
192
|
+
if (sendExportReadyEmail && args.appExportDownloadUrl === undefined) {
|
|
193
|
+
throw new Error(
|
|
194
|
+
"user-data-rights: sendExportReadyEmail gesetzt aber appExportDownloadUrl fehlt — beide muessen zusammen konfiguriert sein",
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Pass 1: Stale-Detection — running jobs die laenger als das tenant-
|
|
199
|
+
// spezifische exportStaleTimeoutMinutes haengen werden gefailed.
|
|
200
|
+
// Wird VOR dem pickup-pass ausgefuehrt damit ein neuer Worker-Run
|
|
201
|
+
// den vorhergehenden Crash erstmal als failed markiert.
|
|
202
|
+
const staleFailedJobIds = await staleDetectionPass({ db, now });
|
|
203
|
+
|
|
204
|
+
// Pass 2: Pickup pending jobs + Process them.
|
|
205
|
+
const completedJobIds: string[] = [];
|
|
206
|
+
const failedJobIds: string[] = [];
|
|
207
|
+
const errors: ExportJobError[] = [];
|
|
208
|
+
const tokenByJobId = new Map<string, string>();
|
|
209
|
+
|
|
210
|
+
const pendingJobs = await fetchPendingJobs(db);
|
|
211
|
+
for (const job of pendingJobs) {
|
|
212
|
+
const outcome = await processJob({
|
|
213
|
+
db,
|
|
214
|
+
registry,
|
|
215
|
+
buildStorageProvider,
|
|
216
|
+
now,
|
|
217
|
+
job,
|
|
218
|
+
});
|
|
219
|
+
if (outcome.kind === "done") {
|
|
220
|
+
completedJobIds.push(job.id);
|
|
221
|
+
// Worker hat plain im Memory; weitergeben fuer downstream-callbacks
|
|
222
|
+
// + Atom-5-Tests (RunExportJobsResult.tokenByJobId). Plain landet
|
|
223
|
+
// NICHT in Logs/Telemetry/jobRunsTable.
|
|
224
|
+
tokenByJobId.set(job.id, outcome.tokenPlain);
|
|
225
|
+
|
|
226
|
+
// Atom 5: Email-Notification beim done-flip.
|
|
227
|
+
//
|
|
228
|
+
// Best-effort (Atom 5.fix3): Throw fuer Job A darf den Batch nicht
|
|
229
|
+
// abwuergen — Job-Status ist bereits done committed, restliche
|
|
230
|
+
// pending-Jobs muessen noch verarbeitet werden. Throw waere ein
|
|
231
|
+
// Bug: r.job-Wrap markiert den Run failed, retry findet keinen
|
|
232
|
+
// pending-Job mehr (Status=done) → silent miss + ZIP laeuft nach
|
|
233
|
+
// TTL ab ohne dass der User die Email je bekommt. console.warn ist
|
|
234
|
+
// die einzige Operator-Sichtbarkeit (runExportJobs-args fuehren
|
|
235
|
+
// AppContext.log nicht durch — pure-function-Pattern).
|
|
236
|
+
if (sendExportReadyEmail) {
|
|
237
|
+
try {
|
|
238
|
+
await fireExportReadyCallback({
|
|
239
|
+
db,
|
|
240
|
+
job,
|
|
241
|
+
plainToken: outcome.tokenPlain,
|
|
242
|
+
bytesWritten: outcome.bytesWritten,
|
|
243
|
+
expiresAt: outcome.expiresAt,
|
|
244
|
+
appExportDownloadUrl: args.appExportDownloadUrl,
|
|
245
|
+
send: sendExportReadyEmail,
|
|
246
|
+
});
|
|
247
|
+
} catch (err) {
|
|
248
|
+
// biome-ignore lint/suspicious/noConsole: operator-visibility for email-send-failure
|
|
249
|
+
console.warn(
|
|
250
|
+
`[user-data-rights:run-export-jobs] sendExportReadyEmail failed jobId=${job.id} userId=${job.userId} err=${err instanceof Error ? err.message : String(err)}`,
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
} else if (outcome.kind === "failed") {
|
|
255
|
+
failedJobIds.push(job.id);
|
|
256
|
+
errors.push(outcome.error);
|
|
257
|
+
|
|
258
|
+
// Atom 5: Email-Notification beim failed-flip — User soll wissen
|
|
259
|
+
// dass er neuen Export anfordern muss. Best-effort analog
|
|
260
|
+
// sendExportReadyEmail (Atom 5.fix3) — Email-Send-Throw darf den
|
|
261
|
+
// Batch nicht killen.
|
|
262
|
+
if (sendExportFailedEmail) {
|
|
263
|
+
try {
|
|
264
|
+
await fireExportFailedCallback({
|
|
265
|
+
db,
|
|
266
|
+
job,
|
|
267
|
+
errorMessage: outcome.error.message,
|
|
268
|
+
send: sendExportFailedEmail,
|
|
269
|
+
});
|
|
270
|
+
} catch (err) {
|
|
271
|
+
// biome-ignore lint/suspicious/noConsole: operator-visibility for email-send-failure
|
|
272
|
+
console.warn(
|
|
273
|
+
`[user-data-rights:run-export-jobs] sendExportFailedEmail failed jobId=${job.id} userId=${job.userId} err=${err instanceof Error ? err.message : String(err)}`,
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
// "skipped" (claim race lost) wird still passiert — kein error,
|
|
279
|
+
// anderer Worker hat den Job bereits.
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Pass 3: Storage-Cleanup — abgelaufene done-Jobs (expiresAt+grace < now)
|
|
283
|
+
// bekommen ihren downloadStorageKey aus dem Storage-Provider geloescht
|
|
284
|
+
// + im DB-Row genullt.
|
|
285
|
+
const cleanedJobIds = await storageCleanupPass({
|
|
286
|
+
db,
|
|
287
|
+
buildStorageProvider,
|
|
288
|
+
now,
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
return {
|
|
292
|
+
completedJobIds,
|
|
293
|
+
failedJobIds,
|
|
294
|
+
staleFailedJobIds,
|
|
295
|
+
cleanedJobIds,
|
|
296
|
+
errors,
|
|
297
|
+
tokenByJobId,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
interface JobRow {
|
|
302
|
+
readonly id: string;
|
|
303
|
+
readonly version: number;
|
|
304
|
+
readonly userId: string;
|
|
305
|
+
readonly requestedFromTenantId: TenantId;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async function fetchPendingJobs(db: DbRunner): Promise<readonly JobRow[]> {
|
|
309
|
+
// @cast-boundary db-row.
|
|
310
|
+
return (await db
|
|
311
|
+
.select({
|
|
312
|
+
id: exportJobsTable["id"],
|
|
313
|
+
version: exportJobsTable["version"],
|
|
314
|
+
userId: exportJobsTable["userId"],
|
|
315
|
+
requestedFromTenantId: exportJobsTable["requestedFromTenantId"],
|
|
316
|
+
})
|
|
317
|
+
.from(exportJobsTable)
|
|
318
|
+
.where(eq(exportJobsTable["status"], EXPORT_JOB_STATUS.Pending))
|
|
319
|
+
.orderBy(asc(exportJobsTable["requestedAt"]))) as readonly JobRow[];
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
type ProcessOutcome =
|
|
323
|
+
| {
|
|
324
|
+
kind: "done";
|
|
325
|
+
tokenPlain: string;
|
|
326
|
+
expiresAt: Instant;
|
|
327
|
+
bytesWritten: number;
|
|
328
|
+
}
|
|
329
|
+
| { kind: "failed"; error: ExportJobError }
|
|
330
|
+
| { kind: "skipped" }; // claim-race-loss (anderer Worker)
|
|
331
|
+
|
|
332
|
+
async function processJob(args: {
|
|
333
|
+
db: DbConnection;
|
|
334
|
+
registry: Registry;
|
|
335
|
+
buildStorageProvider: (tenantId: TenantId) => Promise<FileStorageProvider>;
|
|
336
|
+
now: Instant;
|
|
337
|
+
job: JobRow;
|
|
338
|
+
}): Promise<ProcessOutcome> {
|
|
339
|
+
const { db, registry, buildStorageProvider, now, job } = args;
|
|
340
|
+
const executor = createSystemUser(job.requestedFromTenantId);
|
|
341
|
+
// crud.update braucht TenantDb fuer Stream-Identity (Memory
|
|
342
|
+
// feedback_event_store_tenant_consistency). system-mode bypassed
|
|
343
|
+
// den auto-tenant-filter; Stream wird ueber requestedFromTenantId
|
|
344
|
+
// konsistent gehalten.
|
|
345
|
+
const tdb = systemTenantDb(db, job.requestedFromTenantId);
|
|
346
|
+
|
|
347
|
+
// Phase 1: Claim — version-bumped update, scheitert via VersionConflict
|
|
348
|
+
// wenn paralleler Worker schon claimed hat.
|
|
349
|
+
//
|
|
350
|
+
// **Path-pre-claim** (Atom 4a.fix): downloadStorageKey wird HIER schon
|
|
351
|
+
// persistiert (deterministischer Pfad aus job.id). Begründung: wenn
|
|
352
|
+
// Worker zwischen ZIP-write und done-flip crashed, hat der Job-Row
|
|
353
|
+
// bereits den Pfad → storageCleanupPass kann den orphan-ZIP via
|
|
354
|
+
// failed-Job-Cleanup-Pass loeschen (sonst forever-leak im S3).
|
|
355
|
+
const storageKey = buildExportStorageKey(job);
|
|
356
|
+
const claimResult = await crud.update(
|
|
357
|
+
{
|
|
358
|
+
id: job.id,
|
|
359
|
+
version: job.version,
|
|
360
|
+
changes: {
|
|
361
|
+
status: EXPORT_JOB_STATUS.Running,
|
|
362
|
+
startedAt: now,
|
|
363
|
+
downloadStorageKey: storageKey,
|
|
364
|
+
},
|
|
365
|
+
},
|
|
366
|
+
executor,
|
|
367
|
+
tdb,
|
|
368
|
+
);
|
|
369
|
+
if (!claimResult.isSuccess) {
|
|
370
|
+
// Race-Loss: anderer Worker hat den Job claimed. Skip silently.
|
|
371
|
+
return { kind: "skipped" };
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Phase 2-6: Bundle bauen + ZIP-streamen + Storage-Write.
|
|
375
|
+
// Errors bis zur completion werden als job=failed materialisiert.
|
|
376
|
+
try {
|
|
377
|
+
const bundle = await runUserExport({
|
|
378
|
+
db,
|
|
379
|
+
registry,
|
|
380
|
+
userId: job.userId,
|
|
381
|
+
now,
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
const profile = await resolveProfileForTenant({
|
|
385
|
+
db,
|
|
386
|
+
tenantId: job.requestedFromTenantId,
|
|
387
|
+
});
|
|
388
|
+
const ttl = profile.profile.userRights.exportDownloadTtl;
|
|
389
|
+
|
|
390
|
+
// Per-Tenant-Provider-Cache: Cross-Tenant-fileRefs (Alice Member von
|
|
391
|
+
// Tenant A + B → File-Refs aus beiden) brauchen pro Tenant separat
|
|
392
|
+
// einen Provider-Build (S3-Bucket pro Tenant, andere config). Job-
|
|
393
|
+
// Tenant ist die Identitaet fuer den write-Pfad; read-Pfade gehen
|
|
394
|
+
// ueber den fileRef.tenantId.
|
|
395
|
+
const providerCache = new Map<TenantId, FileStorageProvider>();
|
|
396
|
+
const cachedProvider = async (tenantId: TenantId): Promise<FileStorageProvider> => {
|
|
397
|
+
let p = providerCache.get(tenantId);
|
|
398
|
+
if (!p) {
|
|
399
|
+
p = await buildStorageProvider(tenantId);
|
|
400
|
+
providerCache.set(tenantId, p);
|
|
401
|
+
}
|
|
402
|
+
return p;
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
const writeProvider = await cachedProvider(job.requestedFromTenantId);
|
|
406
|
+
// writeStream + readStream sind im FileStorageProvider-Type required
|
|
407
|
+
// (Atom 3c.fix Type-Honesty) — keine Runtime-Optional-Checks mehr noetig.
|
|
408
|
+
|
|
409
|
+
const tracker = countingStream(
|
|
410
|
+
createZipStream(bundleToZipEntries(bundle, now, cachedProvider)),
|
|
411
|
+
);
|
|
412
|
+
|
|
413
|
+
await writeProvider.writeStream(storageKey, tracker.stream, {
|
|
414
|
+
mimeType: "application/zip",
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
// Phase 7: Token VOR done-flip (Atom 4a.fix Sequencing).
|
|
418
|
+
//
|
|
419
|
+
// Wenn Token-Create failt, bleibt Job in `running` → catch-Pfad
|
|
420
|
+
// flippt auf failed (monotone Status-Transition). Wenn Token-Create
|
|
421
|
+
// VOR und done-flip NACH: tightes Race-Window in dem ein Worker-
|
|
422
|
+
// Crash zwischen den zwei calls einen Token + ZIP orphans. Beides
|
|
423
|
+
// catched die Stale-Detection im naechsten Pass — Job=failed,
|
|
424
|
+
// Storage-Cleanup-Pass clearted den ZIP (path-pre-claim oben).
|
|
425
|
+
//
|
|
426
|
+
// Plain-Token bleibt im Worker-Memory + wird via RunExportJobsResult
|
|
427
|
+
// an Atom 5 (Notification) weitergegeben. NUR der hash landet in DB.
|
|
428
|
+
// ES via tokenCrud.create — kein direct-INSERT.
|
|
429
|
+
const expiresAt = addDurationSpec(now, ttl);
|
|
430
|
+
const { plain: tokenPlain, hash: tokenHash } = await generateDownloadToken();
|
|
431
|
+
const tokenCreateResult = await tokenCrud.create(
|
|
432
|
+
{
|
|
433
|
+
jobId: job.id,
|
|
434
|
+
tokenHash,
|
|
435
|
+
issuedAt: now,
|
|
436
|
+
expiresAt,
|
|
437
|
+
// Atom 4a.fix: useCount explizit 0 statt default null. 4b's
|
|
438
|
+
// Verify-Pfad incrementiert via `useCount + 1` ohne COALESCE-
|
|
439
|
+
// Defensiv-Code.
|
|
440
|
+
useCount: 0,
|
|
441
|
+
},
|
|
442
|
+
executor,
|
|
443
|
+
tdb,
|
|
444
|
+
);
|
|
445
|
+
if (!tokenCreateResult.isSuccess) {
|
|
446
|
+
// Token-Creation failed VOR done-flip → Job bleibt running.
|
|
447
|
+
// Catch-Pfad flippt auf failed mit klarer Diagnose. Storage-
|
|
448
|
+
// Cleanup-Pass clearted den orphan-ZIP (path bereits via claim
|
|
449
|
+
// persistiert).
|
|
450
|
+
throw new Error(
|
|
451
|
+
`Job ${job.id}: Token-Creation failed before done-flip. ` +
|
|
452
|
+
`${(tokenCreateResult as { error?: { code?: string } }).error?.code ?? "unknown"}`,
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Phase 8: Job=done. expiresAt wurde oben fuer Token gesetzt; identisch
|
|
457
|
+
// hier persistiert (denormalisiert in beiden Tabellen).
|
|
458
|
+
const doneResult = await crud.update(
|
|
459
|
+
{
|
|
460
|
+
id: job.id,
|
|
461
|
+
version: job.version + 1, // +1 weil wir bereits via claim einen update gemacht haben
|
|
462
|
+
changes: {
|
|
463
|
+
status: EXPORT_JOB_STATUS.Done,
|
|
464
|
+
completedAt: now,
|
|
465
|
+
// downloadStorageKey ist beim claim bereits gesetzt — kein
|
|
466
|
+
// Re-Set hier noetig (waere identisch).
|
|
467
|
+
expiresAt,
|
|
468
|
+
bytesWritten: tracker.bytes,
|
|
469
|
+
},
|
|
470
|
+
},
|
|
471
|
+
executor,
|
|
472
|
+
tdb,
|
|
473
|
+
);
|
|
474
|
+
if (!doneResult.isSuccess) {
|
|
475
|
+
// Sehr unerwartet — wir haben gerade claimed (version+1), niemand
|
|
476
|
+
// sollte den Job zwischenzeitlich aendern. Materialisieren als
|
|
477
|
+
// failed damit der Operator das sieht. Token-Row bleibt orphan
|
|
478
|
+
// bis Atom-spaeter-Cleanup; Storage-ZIP wird via failed-Cleanup
|
|
479
|
+
// gecleared.
|
|
480
|
+
throw new Error(
|
|
481
|
+
`Job ${job.id}: failed to flip status=done after successful Token-Create. ` +
|
|
482
|
+
`${(doneResult as { error?: { code?: string } }).error?.code ?? "unknown"}`,
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
return { kind: "done", tokenPlain, expiresAt, bytesWritten: tracker.bytes };
|
|
487
|
+
} catch (e) {
|
|
488
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
489
|
+
// Best-effort failure-flip. Wenn auch das failed (DB down etc.),
|
|
490
|
+
// bleibt der Job in running stehen — Stale-Detection im naechsten
|
|
491
|
+
// Worker-Run faengt ihn dann auf.
|
|
492
|
+
await crud
|
|
493
|
+
.update(
|
|
494
|
+
{
|
|
495
|
+
id: job.id,
|
|
496
|
+
version: job.version + 1,
|
|
497
|
+
changes: {
|
|
498
|
+
status: EXPORT_JOB_STATUS.Failed,
|
|
499
|
+
completedAt: now,
|
|
500
|
+
errorMessage: message,
|
|
501
|
+
},
|
|
502
|
+
},
|
|
503
|
+
executor,
|
|
504
|
+
tdb,
|
|
505
|
+
)
|
|
506
|
+
.catch(() => {
|
|
507
|
+
/* swallow — stale-detection kommt im nachsten Pass */
|
|
508
|
+
});
|
|
509
|
+
return {
|
|
510
|
+
kind: "failed",
|
|
511
|
+
error: {
|
|
512
|
+
jobId: job.id,
|
|
513
|
+
userId: job.userId,
|
|
514
|
+
phase: "zip-write",
|
|
515
|
+
message,
|
|
516
|
+
},
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
async function staleDetectionPass(args: {
|
|
522
|
+
db: DbConnection;
|
|
523
|
+
now: Instant;
|
|
524
|
+
}): Promise<readonly string[]> {
|
|
525
|
+
const { db, now } = args;
|
|
526
|
+
|
|
527
|
+
// **Kein Coarse-Filter** — der vorherige `startedAt <= now-1h` Filter
|
|
528
|
+
// war fragile: profile.exportStaleTimeoutMinutes hat Default 30min und
|
|
529
|
+
// erlaubt per-Tenant-Override auf z.B. 5min. Ein 60min-Coarse-Filter
|
|
530
|
+
// wuerde 30-60min-alte stale-Jobs UNERKANNT lassen. Cron laeuft alle
|
|
531
|
+
// 60s, zu jedem Zeitpunkt sind nur wenige Jobs in `running` —
|
|
532
|
+
// alle fetchen + profile-resolve im Loop ist bezahlbar + korrekt.
|
|
533
|
+
// @cast-boundary db-row.
|
|
534
|
+
const candidates = (await db
|
|
535
|
+
.select({
|
|
536
|
+
id: exportJobsTable["id"],
|
|
537
|
+
version: exportJobsTable["version"],
|
|
538
|
+
userId: exportJobsTable["userId"],
|
|
539
|
+
requestedFromTenantId: exportJobsTable["requestedFromTenantId"],
|
|
540
|
+
startedAt: exportJobsTable["startedAt"],
|
|
541
|
+
})
|
|
542
|
+
.from(exportJobsTable)
|
|
543
|
+
.where(eq(exportJobsTable["status"], EXPORT_JOB_STATUS.Running))) as readonly {
|
|
544
|
+
id: string;
|
|
545
|
+
version: number;
|
|
546
|
+
userId: string;
|
|
547
|
+
requestedFromTenantId: TenantId;
|
|
548
|
+
startedAt: Instant | null;
|
|
549
|
+
}[];
|
|
550
|
+
|
|
551
|
+
const failed: string[] = [];
|
|
552
|
+
for (const c of candidates) {
|
|
553
|
+
const profile = await resolveProfileForTenant({
|
|
554
|
+
db,
|
|
555
|
+
tenantId: c.requestedFromTenantId,
|
|
556
|
+
});
|
|
557
|
+
const cutoffMs =
|
|
558
|
+
now.epochMilliseconds - profile.profile.userRights.exportStaleTimeoutMinutes * 60 * 1000;
|
|
559
|
+
const startedMs = c.startedAt?.epochMilliseconds ?? Number.MAX_SAFE_INTEGER;
|
|
560
|
+
if (startedMs < cutoffMs) {
|
|
561
|
+
const result = await crud.update(
|
|
562
|
+
{
|
|
563
|
+
id: c.id,
|
|
564
|
+
version: c.version,
|
|
565
|
+
changes: {
|
|
566
|
+
status: EXPORT_JOB_STATUS.Failed,
|
|
567
|
+
completedAt: now,
|
|
568
|
+
errorMessage: "stale: worker crashed mid-run",
|
|
569
|
+
},
|
|
570
|
+
},
|
|
571
|
+
createSystemUser(c.requestedFromTenantId),
|
|
572
|
+
systemTenantDb(db, c.requestedFromTenantId),
|
|
573
|
+
);
|
|
574
|
+
if (result.isSuccess) failed.push(c.id);
|
|
575
|
+
// Race-loss (Worker hat das Job parallel completed) → skip
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
return failed;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
async function storageCleanupPass(args: {
|
|
582
|
+
db: DbConnection;
|
|
583
|
+
buildStorageProvider: (tenantId: TenantId) => Promise<FileStorageProvider>;
|
|
584
|
+
now: Instant;
|
|
585
|
+
}): Promise<readonly string[]> {
|
|
586
|
+
const { db, buildStorageProvider, now } = args;
|
|
587
|
+
|
|
588
|
+
// Zwei Cleanup-Pfade:
|
|
589
|
+
//
|
|
590
|
+
// 1. **Done-Jobs** (TTL-expired): expiresAt + grace < now → cleanup.
|
|
591
|
+
// Per-Tenant-Grace aus Profile (default 24h post-expiry damit
|
|
592
|
+
// gleichzeitige Re-Downloads bei Connection-Abbrueche moeglich).
|
|
593
|
+
//
|
|
594
|
+
// 2. **Failed-Jobs mit downloadStorageKey** (Atom 4a.fix orphan-cleanup):
|
|
595
|
+
// Worker hat ZIP geschrieben, dann ist VOR done-flip etwas
|
|
596
|
+
// schiefgegangen (Token-Create-Fail, done-flip-Fail, Stale-
|
|
597
|
+
// Detection mid-write). Job=failed mit gesetztem downloadStorageKey =
|
|
598
|
+
// ZIP-Orphan in Storage, kein User-Pfad zum download.
|
|
599
|
+
//
|
|
600
|
+
// **Strategie failed-Jobs: SOFORTIGE Cleanup ohne Grace.**
|
|
601
|
+
//
|
|
602
|
+
// Trade-off (DSGVO vs Audit-Forensik):
|
|
603
|
+
// - DSGVO: User-Daten-ZIP einer failed-export-Anfrage hat keinen
|
|
604
|
+
// legitimen Aufbewahrungs-Grund — hier ueberwiegt das Recht auf
|
|
605
|
+
// Loeschung
|
|
606
|
+
// - Audit-Forensik: forensische Untersuchung ("was ist im ZIP gelandet,
|
|
607
|
+
// warum failed der Job") wuerde von einer Grace-Periode profitieren —
|
|
608
|
+
// ABER: Token wurde nie ausgegeben (kein User hat das ZIP gesehen),
|
|
609
|
+
// also keine User-Schaden-Forensik. Operator-Audit hat job.errorMessage
|
|
610
|
+
// + Stack-Trace, das reicht
|
|
611
|
+
// → Trade-off zugunsten DSGVO entschieden. Wenn ein Operator forensik
|
|
612
|
+
// braucht, muss er das vor dem Cleanup-Pass capturen (out-of-band).
|
|
613
|
+
//
|
|
614
|
+
// **SQL-Filter:** WHERE-clause auf downloadStorageKey IS NOT NULL filtert
|
|
615
|
+
// bereits in der DB statt im Loop. Bei skalierender DB-Historie (10k+
|
|
616
|
+
// done-jobs nach 30 Tagen) reduziert das den Worker-Roundtrip drastisch.
|
|
617
|
+
//
|
|
618
|
+
// @cast-boundary db-row.
|
|
619
|
+
const candidates = (await db
|
|
620
|
+
.select({
|
|
621
|
+
id: exportJobsTable["id"],
|
|
622
|
+
version: exportJobsTable["version"],
|
|
623
|
+
status: exportJobsTable["status"],
|
|
624
|
+
requestedFromTenantId: exportJobsTable["requestedFromTenantId"],
|
|
625
|
+
downloadStorageKey: exportJobsTable["downloadStorageKey"],
|
|
626
|
+
expiresAt: exportJobsTable["expiresAt"],
|
|
627
|
+
})
|
|
628
|
+
.from(exportJobsTable)
|
|
629
|
+
.where(
|
|
630
|
+
and(
|
|
631
|
+
// Beide Pfade: status in (done, failed) + downloadStorageKey gesetzt.
|
|
632
|
+
// Filter im Loop verfeinert (done braucht expiresAt+grace, failed
|
|
633
|
+
// sofort).
|
|
634
|
+
or(
|
|
635
|
+
eq(exportJobsTable["status"], EXPORT_JOB_STATUS.Done),
|
|
636
|
+
eq(exportJobsTable["status"], EXPORT_JOB_STATUS.Failed),
|
|
637
|
+
),
|
|
638
|
+
isNotNull(exportJobsTable["downloadStorageKey"]),
|
|
639
|
+
),
|
|
640
|
+
)) as readonly {
|
|
641
|
+
id: string;
|
|
642
|
+
version: number;
|
|
643
|
+
status: string;
|
|
644
|
+
requestedFromTenantId: TenantId;
|
|
645
|
+
downloadStorageKey: string | null;
|
|
646
|
+
expiresAt: Instant | null;
|
|
647
|
+
}[];
|
|
648
|
+
|
|
649
|
+
const cleaned: string[] = [];
|
|
650
|
+
for (const c of candidates) {
|
|
651
|
+
if (!c.downloadStorageKey) continue;
|
|
652
|
+
|
|
653
|
+
// Done-Jobs brauchen expiresAt+grace-Check. Failed-Jobs gehen direkt
|
|
654
|
+
// durch (kein User-Pfad → sofort cleanup).
|
|
655
|
+
if (c.status === EXPORT_JOB_STATUS.Done) {
|
|
656
|
+
if (!c.expiresAt) continue;
|
|
657
|
+
const profile = await resolveProfileForTenant({
|
|
658
|
+
db,
|
|
659
|
+
tenantId: c.requestedFromTenantId,
|
|
660
|
+
});
|
|
661
|
+
const cleanupAfter =
|
|
662
|
+
c.expiresAt.epochMilliseconds +
|
|
663
|
+
profile.profile.userRights.exportStorageCleanupGraceHours * 60 * 60 * 1000;
|
|
664
|
+
if (now.epochMilliseconds < cleanupAfter) continue;
|
|
665
|
+
}
|
|
666
|
+
// Failed-Job-Branch: kein TTL-Check, sofort cleanup.
|
|
667
|
+
|
|
668
|
+
// Storage-Datei loeschen + DB-Spalte nullen.
|
|
669
|
+
try {
|
|
670
|
+
const provider = await buildStorageProvider(c.requestedFromTenantId);
|
|
671
|
+
await provider.delete(c.downloadStorageKey);
|
|
672
|
+
} catch {
|
|
673
|
+
// best-effort; wenn Storage-Delete failed, retry beim naechsten Pass
|
|
674
|
+
continue;
|
|
675
|
+
}
|
|
676
|
+
const result = await crud.update(
|
|
677
|
+
{
|
|
678
|
+
id: c.id,
|
|
679
|
+
version: c.version,
|
|
680
|
+
changes: { downloadStorageKey: null },
|
|
681
|
+
},
|
|
682
|
+
createSystemUser(c.requestedFromTenantId),
|
|
683
|
+
systemTenantDb(db, c.requestedFromTenantId),
|
|
684
|
+
);
|
|
685
|
+
if (result.isSuccess) cleaned.push(c.id);
|
|
686
|
+
}
|
|
687
|
+
return cleaned;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// Wrapper: crud.update braucht TenantDb. ExportJob ist tenant-agnostisch
|
|
691
|
+
// (1 Job pro userId), aber der event-store-Stream-Lookup erwartet einen
|
|
692
|
+
// Tenant-Context. system-mode TenantDb bypassed den auto-tenant-filter
|
|
693
|
+
// — wir nutzen `requestedFromTenantId` als Stream-Identity damit der
|
|
694
|
+
// Stream-Counter konsistent bleibt (Memory feedback_event_store_tenant_consistency).
|
|
695
|
+
function systemTenantDb(db: DbConnection, tenantId: TenantId) {
|
|
696
|
+
return createTenantDb(db, tenantId, "system");
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function buildExportStorageKey(job: JobRow): string {
|
|
700
|
+
// Tenant-prefix damit der Storage-Layout pro-Tenant separat liegt.
|
|
701
|
+
// job.id ist UUID → URL-safe.
|
|
702
|
+
return `${job.requestedFromTenantId}/exports/${job.id}.zip`;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/**
|
|
706
|
+
* Konvertiert das UserExportBundle in eine `AsyncIterable<ZipEntry>`
|
|
707
|
+
* fuer createZipStream.
|
|
708
|
+
*
|
|
709
|
+
* Bundle-Layout (Atom 3c):
|
|
710
|
+
* bundle.json — Top-Level-Bundle-Metadata
|
|
711
|
+
* + tenant-sections + flat
|
|
712
|
+
* fileRefs[] mit zipPath
|
|
713
|
+
* pro Datei
|
|
714
|
+
* files/<tenantId>/<fileRefId>-<name> — File-Binaries, Pfad
|
|
715
|
+
* identisch zu fileRef.zipPath
|
|
716
|
+
* im JSON. Bytes via
|
|
717
|
+
* provider.readStream — kein
|
|
718
|
+
* Memory-Spike, auch grosse
|
|
719
|
+
* PDFs streamen durch.
|
|
720
|
+
*
|
|
721
|
+
* `getProvider` ist ein async-cached Resolver pro tenantId. Caller
|
|
722
|
+
* (processJob) baut die Caching-Map damit jeder Tenant nur EINMAL
|
|
723
|
+
* via `buildStorageProvider` materialisiert wird.
|
|
724
|
+
*/
|
|
725
|
+
async function* bundleToZipEntries(
|
|
726
|
+
bundle: UserExportBundle,
|
|
727
|
+
mtime: Instant,
|
|
728
|
+
getProvider: (tenantId: TenantId) => Promise<FileStorageProvider>,
|
|
729
|
+
): AsyncIterable<ZipEntry> {
|
|
730
|
+
// bundle.json zuerst — Reader-Tools koennen das frueh parsen.
|
|
731
|
+
const bundleJson = JSON.stringify(bundle, null, 2);
|
|
732
|
+
yield {
|
|
733
|
+
path: "bundle.json",
|
|
734
|
+
data: oneShot(new TextEncoder().encode(bundleJson)),
|
|
735
|
+
mtime,
|
|
736
|
+
};
|
|
737
|
+
|
|
738
|
+
// File-Binaries: pro fileRef einen ZIP-Entry. data ist der readStream
|
|
739
|
+
// direkt — der ZIP-Builder konsumiert chunk-fuer-chunk, kein Upfront-
|
|
740
|
+
// Memory-Spike pro File. Bei 50 PDFs à 10MB werden trotzdem max. ~64KB
|
|
741
|
+
// (default highWaterMark) gleichzeitig im Heap gehalten.
|
|
742
|
+
for (const ref of bundle.fileRefs) {
|
|
743
|
+
const provider = await getProvider(ref.tenantId);
|
|
744
|
+
// readStream ist required im FileStorageProvider-Type (Atom 3c.fix
|
|
745
|
+
// Type-Honesty) — kein Runtime-Optional-Check noetig.
|
|
746
|
+
yield {
|
|
747
|
+
path: ref.zipPath,
|
|
748
|
+
data: provider.readStream(ref.storageKey),
|
|
749
|
+
mtime,
|
|
750
|
+
};
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
async function* oneShot(bytes: Uint8Array): AsyncIterable<Uint8Array> {
|
|
755
|
+
yield bytes;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/**
|
|
759
|
+
* Wraps an `AsyncIterable<Uint8Array>` und zaehlt total-bytes. Nach
|
|
760
|
+
* `for await` ist `tracker.bytes` der finale Wert. Verwendet fuer
|
|
761
|
+
* bytesWritten im Job-Row.
|
|
762
|
+
*/
|
|
763
|
+
function countingStream(source: AsyncIterable<Uint8Array>): {
|
|
764
|
+
stream: AsyncIterable<Uint8Array>;
|
|
765
|
+
readonly bytes: number;
|
|
766
|
+
} {
|
|
767
|
+
const tracker = { bytes: 0 };
|
|
768
|
+
const stream: AsyncIterable<Uint8Array> = {
|
|
769
|
+
async *[Symbol.asyncIterator]() {
|
|
770
|
+
for await (const chunk of source) {
|
|
771
|
+
tracker.bytes += chunk.byteLength;
|
|
772
|
+
yield chunk;
|
|
773
|
+
}
|
|
774
|
+
},
|
|
775
|
+
};
|
|
776
|
+
return {
|
|
777
|
+
stream,
|
|
778
|
+
get bytes() {
|
|
779
|
+
return tracker.bytes;
|
|
780
|
+
},
|
|
781
|
+
};
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// Atom 5 — userEmail-Lookup fuer notification-callback.
|
|
785
|
+
//
|
|
786
|
+
// **Direct-DB statt queryAs:** Worker-AppContext hat kein queryAs
|
|
787
|
+
// (anders als HandlerContext im request-Pfad). Pattern matched
|
|
788
|
+
// runUserExport's Cross-Tenant-Iteration die ebenso direkt
|
|
789
|
+
// tenantMembershipsTable liest. Cross-feature-API via dispatcher
|
|
790
|
+
// haette einen JobContext-Wrapper gebraucht den der framework noch
|
|
791
|
+
// nicht hat — wenn das mal kommt, ist der hier Refactor-Kandidat.
|
|
792
|
+
//
|
|
793
|
+
// **tenant-agnostic:** userTable.id ist UNIQUE (PK). Cross-Tenant
|
|
794
|
+
// (Alice in Tenant A+B) hat trotzdem nur 1 user-Row mit ihrer
|
|
795
|
+
// Heim-Tenant als tenantId. Lookup ohne tenantId-filter findet sie
|
|
796
|
+
// aus jedem Worker-Tenant-Context.
|
|
797
|
+
async function lookupUserEmail(db: DbConnection, userId: string): Promise<string | null> {
|
|
798
|
+
// @cast-boundary db-row.
|
|
799
|
+
const row = (await fetchOne(db, userTable, eq(userTable["id"], userId))) as {
|
|
800
|
+
email: string | null;
|
|
801
|
+
} | null;
|
|
802
|
+
return row?.email ?? null;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
// Atom 5 — Email-Notification beim done-flip. Best-effort:
|
|
806
|
+
// Throw vom Callback bubbelt zum r.job-handler hoch + Worker-Run wird
|
|
807
|
+
// als failed in jobRunsTable gemerkt. Operator kann via jobs:write:retry
|
|
808
|
+
// den Worker-Run erneut anstossen — der Job selbst bleibt done, das
|
|
809
|
+
// retry findet keinen pending-Job mehr aber die Audit-Log zeigt den
|
|
810
|
+
// Failure.
|
|
811
|
+
async function fireExportReadyCallback(args: {
|
|
812
|
+
readonly db: DbConnection;
|
|
813
|
+
readonly job: JobRow;
|
|
814
|
+
readonly plainToken: string;
|
|
815
|
+
readonly expiresAt: Instant;
|
|
816
|
+
readonly bytesWritten: number;
|
|
817
|
+
readonly appExportDownloadUrl: string | undefined;
|
|
818
|
+
readonly send: SendExportReadyEmailFn;
|
|
819
|
+
}): Promise<void> {
|
|
820
|
+
const userEmail = await lookupUserEmail(args.db, args.job.userId);
|
|
821
|
+
if (!userEmail) {
|
|
822
|
+
// User-Row fehlt (z.B. forget-Pfad mid-export). Skip-Notification mit
|
|
823
|
+
// Operator-Alert via job-run-Log statt Throw — Job bleibt done, User
|
|
824
|
+
// hat ja seinen Token via export-status.query erreichbar (UI-Pfad).
|
|
825
|
+
// **console.warn statt ctx.log:** runExportJobs-args fuehren AppContext.log
|
|
826
|
+
// aktuell nicht durch (Worker-pure-function-Pattern). console.warn ist
|
|
827
|
+
// die einzige Operator-Sichtbarkeit fuer den missing-user-edge-case.
|
|
828
|
+
// Wenn jobs-Feature spaeter ctx.log threadet oder Worker-args erweitert
|
|
829
|
+
// werden, hier Refactor-Kandidat.
|
|
830
|
+
// biome-ignore lint/suspicious/noConsole: operator-visibility for missing-user edge-case
|
|
831
|
+
console.warn(
|
|
832
|
+
`[user-data-rights:run-export-jobs] userId=${args.job.userId} hat kein userEmail — sendExportReadyEmail skipped`,
|
|
833
|
+
);
|
|
834
|
+
// skip: missing user-email row; console.warn above provides operator visibility
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
const baseUrl = args.appExportDownloadUrl;
|
|
838
|
+
if (!baseUrl) {
|
|
839
|
+
throw new Error(
|
|
840
|
+
"user-data-rights: sendExportReadyEmail gesetzt aber appExportDownloadUrl fehlt — beide muessen zusammen konfiguriert sein",
|
|
841
|
+
);
|
|
842
|
+
}
|
|
843
|
+
const downloadUrl = `${baseUrl}?token=${encodeURIComponent(args.plainToken)}`;
|
|
844
|
+
await args.send({
|
|
845
|
+
userId: args.job.userId,
|
|
846
|
+
userEmail,
|
|
847
|
+
tenantId: args.job.requestedFromTenantId,
|
|
848
|
+
jobId: args.job.id,
|
|
849
|
+
downloadUrl,
|
|
850
|
+
expiresAt: args.expiresAt.toString(),
|
|
851
|
+
bytesWritten: args.bytesWritten,
|
|
852
|
+
});
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
// Atom 5 — Email-Notification beim failed-flip.
|
|
856
|
+
async function fireExportFailedCallback(args: {
|
|
857
|
+
readonly db: DbConnection;
|
|
858
|
+
readonly job: JobRow;
|
|
859
|
+
readonly errorMessage: string;
|
|
860
|
+
readonly send: SendExportFailedEmailFn;
|
|
861
|
+
}): Promise<void> {
|
|
862
|
+
const userEmail = await lookupUserEmail(args.db, args.job.userId);
|
|
863
|
+
if (!userEmail) {
|
|
864
|
+
// biome-ignore lint/suspicious/noConsole: operator-visibility
|
|
865
|
+
console.warn(
|
|
866
|
+
`[user-data-rights:run-export-jobs] userId=${args.job.userId} hat kein userEmail — sendExportFailedEmail skipped`,
|
|
867
|
+
);
|
|
868
|
+
// skip: missing user-email row; console.warn above provides operator visibility
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
await args.send({
|
|
872
|
+
userId: args.job.userId,
|
|
873
|
+
userEmail,
|
|
874
|
+
tenantId: args.job.requestedFromTenantId,
|
|
875
|
+
jobId: args.job.id,
|
|
876
|
+
errorMessage: args.errorMessage,
|
|
877
|
+
});
|
|
878
|
+
}
|