@cosmicdrift/kumiko-bundled-features 0.163.2 → 0.164.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
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.164.0",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -117,11 +117,11 @@
|
|
|
117
117
|
"./step-dispatcher": "./src/step-dispatcher/index.ts"
|
|
118
118
|
},
|
|
119
119
|
"dependencies": {
|
|
120
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
121
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
122
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
123
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
124
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
120
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.164.0",
|
|
121
|
+
"@cosmicdrift/kumiko-framework": "0.164.0",
|
|
122
|
+
"@cosmicdrift/kumiko-headless": "0.164.0",
|
|
123
|
+
"@cosmicdrift/kumiko-renderer": "0.164.0",
|
|
124
|
+
"@cosmicdrift/kumiko-renderer-web": "0.164.0",
|
|
125
125
|
"@mollie/api-client": "^4.5.0",
|
|
126
126
|
"imapflow": "^1.3.3",
|
|
127
127
|
"mailparser": "^3.9.8",
|
|
@@ -749,6 +749,70 @@ describe("runForgetCleanup :: per-User-Sub-Tx-Isolation (advisor-pinned Architek
|
|
|
749
749
|
).delete = originalUserDelete;
|
|
750
750
|
}
|
|
751
751
|
});
|
|
752
|
+
|
|
753
|
+
test("Hook meldet {status:'incomplete'} → kein Rollback, User trotzdem Deleted, im result.incomplete gesammelt", async () => {
|
|
754
|
+
await seedUser(ALICE_ID, {
|
|
755
|
+
status: USER_STATUS.DeletionRequested,
|
|
756
|
+
gracePeriodEnd: instantFromOffsetMs(-60 * 1000),
|
|
757
|
+
});
|
|
758
|
+
await seedMembership(ALICE_ID, TENANT_A);
|
|
759
|
+
|
|
760
|
+
const usages = stack.registry.getExtensionUsages("userData");
|
|
761
|
+
const userUsage = usages.find((u) => u.entityName === "user");
|
|
762
|
+
if (!userUsage?.options) throw new Error("user usage not found");
|
|
763
|
+
const originalUserDelete = (
|
|
764
|
+
userUsage.options as {
|
|
765
|
+
delete: (
|
|
766
|
+
ctx: { userId: string; tenantId: string; db: unknown },
|
|
767
|
+
strategy: string,
|
|
768
|
+
) => Promise<undefined | { status: "ok" } | { status: "incomplete"; reason: string }>;
|
|
769
|
+
}
|
|
770
|
+
).delete;
|
|
771
|
+
(
|
|
772
|
+
userUsage.options as {
|
|
773
|
+
delete: (
|
|
774
|
+
ctx: { userId: string; tenantId: string; db: unknown },
|
|
775
|
+
strategy: string,
|
|
776
|
+
) => Promise<undefined | { status: "ok" } | { status: "incomplete"; reason: string }>;
|
|
777
|
+
}
|
|
778
|
+
).delete = async (ctx: { userId: string; tenantId: string; db: unknown }, strategy: string) => {
|
|
779
|
+
if (ctx.userId === ALICE_ID) {
|
|
780
|
+
return { status: "incomplete", reason: "synthetic partial cleanup for alice" };
|
|
781
|
+
}
|
|
782
|
+
return originalUserDelete(ctx, strategy);
|
|
783
|
+
};
|
|
784
|
+
|
|
785
|
+
try {
|
|
786
|
+
const result = await runForgetCleanup({
|
|
787
|
+
db: stack.db,
|
|
788
|
+
registry: stack.registry,
|
|
789
|
+
now: NOW(),
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
// No hard failure: user flipped, no errors entry.
|
|
793
|
+
expect(result.processedUserIds).toContain(ALICE_ID);
|
|
794
|
+
expect(result.errors.some((e) => e.userId === ALICE_ID)).toBe(false);
|
|
795
|
+
|
|
796
|
+
// But collected in the incomplete set with reason + tenant + entity.
|
|
797
|
+
const aliceIncomplete = result.incomplete.find((i) => i.userId === ALICE_ID);
|
|
798
|
+
expect(aliceIncomplete?.reason).toBe("synthetic partial cleanup for alice");
|
|
799
|
+
expect(aliceIncomplete?.tenantId).toBe(TENANT_A);
|
|
800
|
+
expect(aliceIncomplete?.entityName).toBe("user");
|
|
801
|
+
|
|
802
|
+
// Status flip proceeded despite the incomplete report.
|
|
803
|
+
const aliceRow = await fetchUser(ALICE_ID);
|
|
804
|
+
expect(aliceRow?.status).toBe(USER_STATUS.Deleted);
|
|
805
|
+
} finally {
|
|
806
|
+
(
|
|
807
|
+
userUsage.options as {
|
|
808
|
+
delete: (
|
|
809
|
+
ctx: { userId: string; tenantId: string; db: unknown },
|
|
810
|
+
strategy: string,
|
|
811
|
+
) => Promise<undefined | { status: "ok" } | { status: "incomplete"; reason: string }>;
|
|
812
|
+
}
|
|
813
|
+
).delete = originalUserDelete;
|
|
814
|
+
}
|
|
815
|
+
});
|
|
752
816
|
});
|
|
753
817
|
|
|
754
818
|
// eraseKey never fails in the in-memory adapter, so the rollback test
|
|
@@ -133,6 +133,17 @@ export interface ForgetCleanupError {
|
|
|
133
133
|
readonly message: string;
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
+
/** A hook reporting `{status:"incomplete", reason}` — no throw, no
|
|
137
|
+
* rollback, the user still gets flipped to Deleted (see Header
|
|
138
|
+
* "incomplete is not a hard failure"). Collected for operator
|
|
139
|
+
* visibility, NOT in `errors` (those stand for hook throws + rollback). */
|
|
140
|
+
export interface ForgetCleanupIncomplete {
|
|
141
|
+
readonly userId: string;
|
|
142
|
+
readonly tenantId: TenantId;
|
|
143
|
+
readonly entityName: string;
|
|
144
|
+
readonly reason: string;
|
|
145
|
+
}
|
|
146
|
+
|
|
136
147
|
export interface RunForgetCleanupResult {
|
|
137
148
|
/** User die in diesem Lauf von DeletionRequested → Deleted geflippt wurden. */
|
|
138
149
|
readonly processedUserIds: readonly string[];
|
|
@@ -140,6 +151,8 @@ export interface RunForgetCleanupResult {
|
|
|
140
151
|
readonly hookCallsAttempted: number;
|
|
141
152
|
/** Hook-Errors fuer Operator-Visibility. Lauf bricht nicht ab — siehe Header. */
|
|
142
153
|
readonly errors: readonly ForgetCleanupError[];
|
|
154
|
+
/** Hooks that reported `{status:"incomplete"}` — partial success, user still Deleted. */
|
|
155
|
+
readonly incomplete: readonly ForgetCleanupIncomplete[];
|
|
143
156
|
}
|
|
144
157
|
|
|
145
158
|
interface HookEntry {
|
|
@@ -170,7 +183,7 @@ export async function runForgetCleanup(
|
|
|
170
183
|
);
|
|
171
184
|
|
|
172
185
|
if (dueUsers.length === 0) {
|
|
173
|
-
return { processedUserIds: [], hookCallsAttempted: 0, errors: [] };
|
|
186
|
+
return { processedUserIds: [], hookCallsAttempted: 0, errors: [], incomplete: [] };
|
|
174
187
|
}
|
|
175
188
|
|
|
176
189
|
// Step 2: Sammle alle EXT_USER_DATA-Usages einmalig — Liste der
|
|
@@ -192,6 +205,7 @@ export async function runForgetCleanup(
|
|
|
192
205
|
.sort((a, b) => a.order - b.order);
|
|
193
206
|
|
|
194
207
|
const errors: ForgetCleanupError[] = [];
|
|
208
|
+
const incomplete: ForgetCleanupIncomplete[] = [];
|
|
195
209
|
const processedUserIds: string[] = [];
|
|
196
210
|
let hookCallsAttempted = 0;
|
|
197
211
|
|
|
@@ -208,6 +222,7 @@ export async function runForgetCleanup(
|
|
|
208
222
|
});
|
|
209
223
|
hookCallsAttempted += userResult.hookCallsAttempted;
|
|
210
224
|
errors.push(...userResult.errors);
|
|
225
|
+
incomplete.push(...userResult.incomplete);
|
|
211
226
|
if (userResult.success) {
|
|
212
227
|
processedUserIds.push(user.id);
|
|
213
228
|
|
|
@@ -241,13 +256,14 @@ export async function runForgetCleanup(
|
|
|
241
256
|
}
|
|
242
257
|
}
|
|
243
258
|
|
|
244
|
-
return { processedUserIds, hookCallsAttempted, errors };
|
|
259
|
+
return { processedUserIds, hookCallsAttempted, errors, incomplete };
|
|
245
260
|
}
|
|
246
261
|
|
|
247
262
|
interface ProcessUserResult {
|
|
248
263
|
readonly success: boolean;
|
|
249
264
|
readonly hookCallsAttempted: number;
|
|
250
265
|
readonly errors: readonly ForgetCleanupError[];
|
|
266
|
+
readonly incomplete: readonly ForgetCleanupIncomplete[];
|
|
251
267
|
/** Atom 5b: userEmail VOR Tx gecacht (user-Hook anonymisiert in Tx).
|
|
252
268
|
* null wenn user-Row beim Pre-Tx-Lookup nicht (mehr) existiert oder
|
|
253
269
|
* email leer ist. */
|
|
@@ -269,6 +285,7 @@ async function processUser(args: {
|
|
|
269
285
|
}): Promise<ProcessUserResult> {
|
|
270
286
|
const { db, registry, userId, hookEntries, buildStorageProvider, appTenantModel, kms } = args;
|
|
271
287
|
const errors: ForgetCleanupError[] = [];
|
|
288
|
+
const incomplete: ForgetCleanupIncomplete[] = [];
|
|
272
289
|
let hookCallsAttempted = 0;
|
|
273
290
|
|
|
274
291
|
// Atom 5b — userEmail VOR der Tx cachen. user-Hook (user-data-rights-
|
|
@@ -333,7 +350,7 @@ async function processUser(args: {
|
|
|
333
350
|
const strategy = policyToStrategy(policy.policy?.strategy ?? null);
|
|
334
351
|
|
|
335
352
|
hookCallsAttempted++;
|
|
336
|
-
await entry.deleteHook(
|
|
353
|
+
const hookResult = await entry.deleteHook(
|
|
337
354
|
{
|
|
338
355
|
db: tx,
|
|
339
356
|
registry,
|
|
@@ -345,6 +362,21 @@ async function processUser(args: {
|
|
|
345
362
|
},
|
|
346
363
|
strategy,
|
|
347
364
|
);
|
|
365
|
+
// "incomplete" is not a hard failure (see Header): no throw,
|
|
366
|
+
// no rollback, eraseKey + status flip proceed unchanged.
|
|
367
|
+
// Only collected + logged for operator visibility.
|
|
368
|
+
if (hookResult?.status === "incomplete") {
|
|
369
|
+
incomplete.push({
|
|
370
|
+
userId,
|
|
371
|
+
tenantId,
|
|
372
|
+
entityName: entry.entityName,
|
|
373
|
+
reason: hookResult.reason,
|
|
374
|
+
});
|
|
375
|
+
// biome-ignore lint/suspicious/noConsole: operator-visibility for partial hook completion
|
|
376
|
+
console.warn(
|
|
377
|
+
`[user-data-rights:run-forget-cleanup] hook incomplete userId=${userId} tenantId=${tenantId} entityName=${entry.entityName} reason=${hookResult.reason}`,
|
|
378
|
+
);
|
|
379
|
+
}
|
|
348
380
|
}
|
|
349
381
|
}
|
|
350
382
|
|
|
@@ -399,6 +431,10 @@ async function processUser(args: {
|
|
|
399
431
|
success: txSucceeded,
|
|
400
432
|
hookCallsAttempted,
|
|
401
433
|
errors,
|
|
434
|
+
// Only surfaced on a committed sub-tx — a later throw in the same
|
|
435
|
+
// user's loop rolls this back too, so a rolled-back "incomplete"
|
|
436
|
+
// report would misreport a non-deleted user as partially cleaned up.
|
|
437
|
+
incomplete: txSucceeded ? incomplete : [],
|
|
402
438
|
userEmailBeforeDelete,
|
|
403
439
|
userLocaleBeforeDelete,
|
|
404
440
|
tenantIdsBeforeDelete,
|