@cosmicdrift/kumiko-bundled-features 0.121.1 → 0.122.1

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.121.1",
3
+ "version": "0.122.1",
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>",
@@ -96,11 +96,11 @@
96
96
  "./step-dispatcher": "./src/step-dispatcher/index.ts"
97
97
  },
98
98
  "dependencies": {
99
- "@cosmicdrift/kumiko-dispatcher-live": "0.121.1",
100
- "@cosmicdrift/kumiko-framework": "0.121.1",
101
- "@cosmicdrift/kumiko-headless": "0.121.1",
102
- "@cosmicdrift/kumiko-renderer": "0.121.1",
103
- "@cosmicdrift/kumiko-renderer-web": "0.121.1",
99
+ "@cosmicdrift/kumiko-dispatcher-live": "0.122.1",
100
+ "@cosmicdrift/kumiko-framework": "0.122.1",
101
+ "@cosmicdrift/kumiko-headless": "0.122.1",
102
+ "@cosmicdrift/kumiko-renderer": "0.122.1",
103
+ "@cosmicdrift/kumiko-renderer-web": "0.122.1",
104
104
  "@mollie/api-client": "^4.5.0",
105
105
  "@node-rs/argon2": "^2.0.2",
106
106
  "@types/nodemailer": "^8.0.0",
@@ -283,8 +283,11 @@ describe("#494 :: read_users-Rebuild bewahrt Lifecycle-State", () => {
283
283
  expect(backfilled).toBeGreaterThanOrEqual(1);
284
284
 
285
285
  // Proof the loop didn't abort at the corrupt row: rebuild the projection —
286
- // goodRow got its user.updated event (survives), badRow never did (wiped
287
- // back to Active, exactly the data-loss this backfill exists to prevent).
286
+ // goodRow got its user.updated event (survives). badRow's Stream ist
287
+ // archiviert und replayt seit fw#832 gar nicht mehr: die Row ist nach dem
288
+ // Rebuild WEG (nicht bloss auf Active zurueckgewischt). Genau deshalb ist
289
+ // der failed-Report oben der Operator-Alarm — Heilung: restoreStream +
290
+ // Backfill-Re-Run, DANN rebuild.
288
291
  await rebuildProjection(USER_PROJECTION, { db: stack.db, registry });
289
292
  const rows = (await selectMany(stack.db, userTable, {})) as Array<{
290
293
  id: string;
@@ -293,6 +296,6 @@ describe("#494 :: read_users-Rebuild bewahrt Lifecycle-State", () => {
293
296
  const good = rows.find((r) => r.id === goodRow.id);
294
297
  const bad = rows.find((r) => r.id === badRow.id);
295
298
  expect(good?.status).toBe(USER_STATUS.Restricted);
296
- expect(bad?.status).toBe(USER_STATUS.Active);
299
+ expect(bad).toBeUndefined();
297
300
  });
298
301
  });
@@ -574,6 +574,67 @@ describe("runExportJobs :: Atom 4a download-tokens", () => {
574
574
  expect(tokenRows).toHaveLength(1);
575
575
  });
576
576
 
577
+ test("Re-Run (fw#832): Job zurueck auf pending → Token in place rotiert, kein 2. created-Event", async () => {
578
+ const jobId = await seedPendingJob();
579
+ const first = await runExportJobs({
580
+ db: stack.db,
581
+ registry: stack.registry,
582
+ buildStorageProvider: buildProvider,
583
+ now: NOW(),
584
+ });
585
+ const firstPlain = first.tokenByJobId.get(jobId);
586
+ expect(firstPlain).toBeDefined();
587
+
588
+ const [tokenBefore] = (await selectMany(stack.db, exportDownloadTokensTable, {
589
+ jobId,
590
+ })) as Array<{ id: string }>;
591
+
592
+ // Operator-Recovery (Email-Fail, plain verloren): NUR den Job auf
593
+ // pending flippen — die Token-Row bleibt liegen. Vor fw#832 created
594
+ // der Worker dann ein ZWEITES Aggregat fuers gleiche jobId und der
595
+ // Projection-Rebuild kollidierte dauerhaft am one_per_job-Index.
596
+ await updateRows(
597
+ stack.db,
598
+ exportJobsTable,
599
+ { status: EXPORT_JOB_STATUS.Pending },
600
+ { id: jobId },
601
+ );
602
+
603
+ const second = await runExportJobs({
604
+ db: stack.db,
605
+ registry: stack.registry,
606
+ buildStorageProvider: buildProvider,
607
+ now: NOW(),
608
+ });
609
+ expect(second.completedJobIds).toContain(jobId);
610
+ const secondPlain = second.tokenByJobId.get(jobId);
611
+ expect(secondPlain).toBeDefined();
612
+ expect(secondPlain).not.toBe(firstPlain);
613
+
614
+ // Genau EINE Row, GLEICHES Aggregat, frischer Hash, Audit-Reset.
615
+ const tokenRows = (await selectMany(stack.db, exportDownloadTokensTable, {
616
+ jobId,
617
+ })) as Array<{
618
+ id: string;
619
+ tokenHash: string;
620
+ useCount: number | null;
621
+ lastUsedAt: unknown;
622
+ }>;
623
+ expect(tokenRows).toHaveLength(1);
624
+ expect(tokenRows[0]?.id).toBe(tokenBefore?.id as string);
625
+ expect(tokenRows[0]?.tokenHash).toBe(await hashDownloadToken(secondPlain as string));
626
+ expect(tokenRows[0]?.useCount).toBe(0);
627
+ expect(tokenRows[0]?.lastUsedAt).toBeNull();
628
+
629
+ // Rebuild-Invariante: genau EIN created-Event — der one_per_job-
630
+ // Index kann beim Replay nie kollidieren.
631
+ const events = (await asRawClient(stack.db).unsafe(
632
+ `SELECT type FROM kumiko_events WHERE aggregate_type = 'export-download-token'`,
633
+ )) as unknown as Array<{ type: string }>;
634
+ expect(events.filter((e) => e.type === "export-download-token.created")).toHaveLength(1);
635
+ expect(events.some((e) => e.type === "export-download-token.updated")).toBe(true);
636
+ });
637
+
577
638
  test("failed-Job: kein Token wird generiert", async () => {
578
639
  // Bewusst keinen Job — leerer Pending-Pass. Aber wir koennen den
579
640
  // failure-Pfad pinnen via runUserExport-Fehler. Einfacher: nur
@@ -105,9 +105,10 @@ export const tokenCrud = createEventStoreExecutor(
105
105
  *
106
106
  * **Plain-Token-Recovery bei Email-Fail:** plain ist ephemeral. Wenn
107
107
  * der Callback throwt, ist der plain verloren. Operator-Eingriff
108
- * noetig (Token in DB nullen + Job auf pending next Worker-Run
109
- * generiert neuen Token). Atom 5b (Re-issue-handler) wird das
110
- * automatisieren.
108
+ * noetig: Job auf pending flippen NUR das. Die Token-Row NICHT
109
+ * loeschen (eventloser Read-Side-Delete Rebuild-Kollision, fw#832);
110
+ * der naechste Worker-Run rotiert das Token in place auf demselben
111
+ * Aggregat. Atom 5b (Re-issue-handler) wird das automatisieren.
111
112
  */
112
113
  export type SendExportReadyEmailFn = (args: {
113
114
  readonly userId: string;
@@ -427,22 +428,53 @@ async function processJob(args: {
427
428
  // Plain-Token bleibt im Worker-Memory + wird via RunExportJobsResult
428
429
  // an Atom 5 (Notification) weitergegeben. NUR der hash landet in DB.
429
430
  // ES via tokenCrud.create — kein direct-INSERT.
431
+ //
432
+ // **Re-Run (fw#832):** Flippt ein Operator einen done/failed-Job
433
+ // zurueck auf pending (Plain-Token verloren, Email-Fail), existiert
434
+ // fuers jobId schon eine Token-Row. Ein zweites create wuerde ein
435
+ // zweites `created`-Event auf frischem Aggregat emittieren — der
436
+ // Projection-Rebuild kollidiert dann dauerhaft am one_per_job-Index.
437
+ // Stattdessen: Token-Rotation in place auf demselben Aggregat
438
+ // (update mit frischem Hash + Reset der Audit-Felder).
430
439
  const expiresAt = addDurationSpec(now, ttl);
431
440
  const { plain: tokenPlain, hash: tokenHash } = await generateDownloadToken();
432
- const tokenCreateResult = await tokenCrud.create(
433
- {
434
- jobId: job.id,
435
- tokenHash,
436
- issuedAt: now,
437
- expiresAt,
438
- // Atom 4a.fix: useCount explizit 0 statt default null. 4b's
439
- // Verify-Pfad incrementiert via `useCount + 1` ohne COALESCE-
440
- // Defensiv-Code.
441
- useCount: 0,
442
- },
443
- executor,
444
- tdb,
441
+ const existingToken = await fetchOne<{ id: string; version: number }>(
442
+ db,
443
+ exportDownloadTokensTable,
444
+ { jobId: job.id },
445
445
  );
446
+ const tokenCreateResult = existingToken
447
+ ? await tokenCrud.update(
448
+ {
449
+ id: existingToken.id,
450
+ version: existingToken.version,
451
+ changes: {
452
+ tokenHash,
453
+ issuedAt: now,
454
+ expiresAt,
455
+ useCount: 0,
456
+ lastUsedAt: null,
457
+ lastUsedFromIp: null,
458
+ lastUsedUserAgent: null,
459
+ },
460
+ },
461
+ executor,
462
+ tdb,
463
+ )
464
+ : await tokenCrud.create(
465
+ {
466
+ jobId: job.id,
467
+ tokenHash,
468
+ issuedAt: now,
469
+ expiresAt,
470
+ // Atom 4a.fix: useCount explizit 0 statt default null. 4b's
471
+ // Verify-Pfad incrementiert via `useCount + 1` ohne COALESCE-
472
+ // Defensiv-Code.
473
+ useCount: 0,
474
+ },
475
+ executor,
476
+ tdb,
477
+ );
446
478
  if (!tokenCreateResult.isSuccess) {
447
479
  // Token-Creation failed VOR done-flip → Job bleibt running.
448
480
  // Catch-Pfad flippt auf failed mit klarer Diagnose. Storage-
@@ -93,9 +93,11 @@ export const exportDownloadTokenEntity = createEntity({
93
93
  }),
94
94
  },
95
95
 
96
- // 1 Token pro Job. UNIQUE auf jobId — garantiert dass Worker-
97
- // Idempotency (Atom 3b's "2× run done-Job → no-op") auch fuer
98
- // Token-Generierung gilt: zweiter Versuch faellt auf Constraint.
96
+ // 1 Token pro Job. UNIQUE auf jobId — Backstop fuer Worker-Idempotency
97
+ // (Atom 3b's "2× run done-Job → no-op"). Der Worker created NIE ein
98
+ // zweites Aggregat fuers gleiche jobId: Re-Runs rotieren das Token
99
+ // in place via update (fw#832) — sonst kollidiert der Projection-
100
+ // Rebuild an genau diesem Index.
99
101
  indexes: [
100
102
  {
101
103
  unique: true,