@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.163.2",
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.163.2",
121
- "@cosmicdrift/kumiko-framework": "0.163.2",
122
- "@cosmicdrift/kumiko-headless": "0.163.2",
123
- "@cosmicdrift/kumiko-renderer": "0.163.2",
124
- "@cosmicdrift/kumiko-renderer-web": "0.163.2",
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,