@cosmicdrift/kumiko-bundled-features 0.105.2 → 0.109.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.
Files changed (116) hide show
  1. package/package.json +8 -6
  2. package/src/auth-email-password/__tests__/auth-claims.integration.test.ts +3 -2
  3. package/src/auth-email-password/__tests__/email-verification.integration.test.ts +5 -9
  4. package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +3 -2
  5. package/src/auth-email-password/__tests__/multi-roles.integration.test.ts +3 -2
  6. package/src/auth-email-password/__tests__/password-reset.integration.test.ts +4 -3
  7. package/src/auth-email-password/handlers/change-password.write.ts +11 -11
  8. package/src/auth-email-password/handlers/confirm-token-flow.ts +33 -104
  9. package/src/auth-email-password/web/__tests__/invite-accept-screen.test.tsx +44 -0
  10. package/src/auth-email-password/web/__tests__/user-menu.test.tsx +14 -1
  11. package/src/auth-email-password/web/auth-gate.tsx +3 -4
  12. package/src/auth-email-password/web/invite-accept-screen.tsx +7 -3
  13. package/src/auth-email-password/web/session.tsx +5 -1
  14. package/src/auth-email-password/web/user-menu.tsx +1 -1
  15. package/src/config/__tests__/deserialize-value.test.ts +3 -6
  16. package/src/custom-fields/__tests__/custom-fields.integration.test.ts +65 -0
  17. package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +4 -3
  18. package/src/custom-fields/constants.ts +1 -0
  19. package/src/custom-fields/handlers/delete-system-field.write.ts +2 -1
  20. package/src/custom-fields/handlers/delete-tenant-field.write.ts +2 -1
  21. package/src/custom-fields/wire-for-entity.ts +104 -77
  22. package/src/data-retention/__tests__/parse-override.test.ts +11 -6
  23. package/src/data-retention/resolve-for-tenant.ts +16 -4
  24. package/src/data-retention/run-retention-cleanup.ts +72 -10
  25. package/src/delivery/attempt-log.ts +22 -10
  26. package/src/feature-toggles/__tests__/feature-toggles.integration.test.ts +4 -4
  27. package/src/folders/__tests__/folders.integration.test.ts +26 -0
  28. package/src/folders/feature.ts +4 -2
  29. package/src/folders/handlers/delete-folder.write.ts +40 -0
  30. package/src/folders/web/__tests__/folder-manager.test.tsx +24 -0
  31. package/src/folders/web/folder-manager.tsx +18 -13
  32. package/src/folders/web/i18n.ts +0 -2
  33. package/src/folders-user-data/__tests__/hooks.integration.test.ts +69 -0
  34. package/src/folders-user-data/hooks.ts +31 -8
  35. package/src/folders-user-data/index.ts +4 -4
  36. package/src/jobs/feature.ts +23 -21
  37. package/src/jobs/handlers/projection-rebuild.job.ts +14 -4
  38. package/src/ledger/__tests__/ledger.integration.test.ts +44 -0
  39. package/src/ledger/__tests__/reports.test.ts +20 -0
  40. package/src/ledger/entity.ts +1 -1
  41. package/src/ledger/handlers/confirm-schedule-period.write.ts +29 -13
  42. package/src/ledger/handlers/reverse-transaction.write.ts +44 -5
  43. package/src/ledger/recurring.ts +18 -9
  44. package/src/ledger/schemas.ts +4 -1
  45. package/src/ledger/web/index.ts +3 -2
  46. package/src/managed-pages/__tests__/branding-coerce.test.ts +2 -6
  47. package/src/personal-access-tokens/__tests__/pat.integration.test.ts +205 -0
  48. package/src/personal-access-tokens/__tests__/scopes.test.ts +42 -0
  49. package/src/personal-access-tokens/constants.ts +33 -0
  50. package/src/personal-access-tokens/feature.ts +78 -0
  51. package/src/personal-access-tokens/handlers/available-scopes.query.ts +21 -0
  52. package/src/personal-access-tokens/handlers/create.write.ts +41 -0
  53. package/src/personal-access-tokens/handlers/list.query.ts +47 -0
  54. package/src/personal-access-tokens/handlers/revoke.write.ts +37 -0
  55. package/src/personal-access-tokens/hash.ts +17 -0
  56. package/src/personal-access-tokens/index.ts +45 -0
  57. package/src/personal-access-tokens/resolver.ts +58 -0
  58. package/src/personal-access-tokens/roles.ts +29 -0
  59. package/src/personal-access-tokens/schema/api-token.ts +56 -0
  60. package/src/personal-access-tokens/scopes.ts +43 -0
  61. package/src/personal-access-tokens/web/client-plugin.tsx +29 -0
  62. package/src/personal-access-tokens/web/i18n.ts +69 -0
  63. package/src/personal-access-tokens/web/index.ts +7 -0
  64. package/src/personal-access-tokens/web/pat-tokens-screen.tsx +251 -0
  65. package/src/renderer-simple/__tests__/template-resolver.integration.test.ts +2 -2
  66. package/src/sessions/__tests__/cleanup.integration.test.ts +3 -3
  67. package/src/sessions/__tests__/rebuild-survival.integration.test.ts +5 -9
  68. package/src/sessions/__tests__/sessions.integration.test.ts +14 -9
  69. package/src/sessions/schema/user-session.ts +8 -2
  70. package/src/sessions/session-callbacks.ts +9 -3
  71. package/src/subscription-stripe/__tests__/runtime.test.ts +14 -24
  72. package/src/tags/__tests__/tags.integration.test.ts +15 -0
  73. package/src/tags/handlers/assign-tag.write.ts +13 -3
  74. package/src/tags/schemas.ts +10 -4
  75. package/src/template-resolver/__tests__/template-resolver.integration.test.ts +2 -2
  76. package/src/template-resolver/testing.ts +4 -3
  77. package/src/tier-engine/feature.ts +22 -10
  78. package/src/tier-engine/handlers/set-tenant-tier.write.ts +11 -1
  79. package/src/user/__tests__/stream-tenant-backfill.integration.test.ts +186 -0
  80. package/src/user/db/queries/stream-tenant-backfill.ts +112 -0
  81. package/src/user/index.ts +4 -0
  82. package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +4 -4
  83. package/src/user-data-rights/__tests__/audit-log.integration.test.ts +4 -4
  84. package/src/user-data-rights/__tests__/cross-data-matrix.integration.test.ts +4 -4
  85. package/src/user-data-rights/__tests__/download.integration.test.ts +7 -7
  86. package/src/user-data-rights/__tests__/export-job-idempotency.integration.test.ts +5 -10
  87. package/src/user-data-rights/__tests__/export-job-list-cross-tenant.integration.test.ts +84 -0
  88. package/src/user-data-rights/__tests__/file-retention.integration.test.ts +3 -3
  89. package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +3 -2
  90. package/src/user-data-rights/__tests__/forget-test-helpers.ts +3 -2
  91. package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +3 -3
  92. package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +10 -6
  93. package/src/user-data-rights/__tests__/request-cancel-deletion.integration.test.ts +4 -4
  94. package/src/user-data-rights/__tests__/request-deletion-callback.integration.test.ts +4 -4
  95. package/src/user-data-rights/__tests__/request-export.integration.test.ts +4 -9
  96. package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +7 -3
  97. package/src/user-data-rights/__tests__/run-export-jobs-cron-context.integration.test.ts +8 -4
  98. package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +12 -17
  99. package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +9 -5
  100. package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +3 -3
  101. package/src/user-data-rights/handlers/download-attempt-list.query.ts +1 -1
  102. package/src/user-data-rights/handlers/download-by-job.query.ts +4 -6
  103. package/src/user-data-rights/handlers/export-job-detail.query.ts +1 -0
  104. package/src/user-data-rights/handlers/export-job-list.query.ts +1 -0
  105. package/src/user-data-rights/lib/update-user-lifecycle.ts +8 -24
  106. package/src/user-data-rights/run-forget-cleanup.ts +8 -1
  107. package/src/user-data-rights/web/__tests__/public-deletion-gate.test.tsx +7 -1
  108. package/src/user-data-rights/web/i18n.ts +0 -10
  109. package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +3 -2
  110. package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +102 -54
  111. package/src/user-data-rights-defaults/hooks/user.userdata-hook.ts +53 -15
  112. package/src/user-profile/__tests__/profile-screen.test.tsx +9 -0
  113. package/src/user-profile/handlers/change-email.write.ts +11 -8
  114. package/src/user-profile/web/profile-screen.tsx +10 -8
  115. package/src/auth-email-password/stream-tenant.ts +0 -31
  116. package/src/user-data-rights-defaults/db/queries/user-hook.ts +0 -17
@@ -22,6 +22,10 @@ import {
22
22
  SYSTEM_TENANT_ID,
23
23
  } from "@cosmicdrift/kumiko-framework/engine";
24
24
  import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
25
+ import {
26
+ createProjectionStateTable,
27
+ rebuildProjection,
28
+ } from "@cosmicdrift/kumiko-framework/pipeline";
25
29
  import {
26
30
  createTestUser,
27
31
  setupTestStack,
@@ -84,6 +88,7 @@ beforeAll(async () => {
84
88
  await unsafeCreateEntityTable(stack.db, fieldDefinitionEntity);
85
89
  await unsafeCreateEntityTable(stack.db, propertyEntity);
86
90
  await createEventsTable(stack.db);
91
+ await createProjectionStateTable(stack.db);
87
92
  });
88
93
 
89
94
  afterAll(async () => {
@@ -919,3 +924,63 @@ describe("custom-fields integration — update-tenant-field (Bug-Bash D2)", () =
919
924
  expect(causeMessage).toContain("update-tenant-field");
920
925
  });
921
926
  });
927
+
928
+ describe("custom-fields integration — official host-entity rebuild (#759)", () => {
929
+ test("rebuildProjection replays customField.set/cleared + fieldDefinition.deleted", async () => {
930
+ await defineField("property", "color", "text");
931
+ await defineField("property", "doomed", "text");
932
+ const propId = "dddddddd-dddd-4000-8000-00000000000d";
933
+ await createProperty(propId, "RebuildOfficial");
934
+ await setCustomField("property", propId, "color", "blue");
935
+ await setCustomField("property", propId, "doomed", "bye");
936
+ // fieldDef-delete cascade removes the key — the rebuild must preserve the
937
+ // removal (fieldDefinition.deleted rides the extra "field-definition" source).
938
+ await stack.http.writeOk(
939
+ "custom-fields:write:delete-tenant-field",
940
+ { entityName: "property", fieldKey: "doomed" },
941
+ admin,
942
+ );
943
+ await stack.eventDispatcher?.runOnce();
944
+
945
+ let row = (await listProperties()).rows.find((r) => r["id"] === propId);
946
+ expect(row?.["color"]).toBe("blue");
947
+ expect(row?.["doomed"]).toBeUndefined();
948
+
949
+ // The routine schema-migration path: full official rebuild of the host
950
+ // entity's implicit projection. Pre-#759 this reset every custom_fields
951
+ // jsonb to its default '{}' with no recovery path.
952
+ const result = await rebuildProjection("property-test:projection:property-entity", {
953
+ db: stack.db,
954
+ registry: stack.registry,
955
+ });
956
+ expect(result.eventsProcessed).toBeGreaterThan(0);
957
+
958
+ row = (await listProperties()).rows.find((r) => r["id"] === propId);
959
+ expect(row?.["name"]).toBe("RebuildOfficial");
960
+ expect(row?.["color"]).toBe("blue");
961
+ expect(row?.["doomed"]).toBeUndefined();
962
+ });
963
+
964
+ test("clear-custom-field removal survives the official rebuild", async () => {
965
+ await defineField("property", "flag", "text");
966
+ await defineField("property", "keep", "text");
967
+ const propId = "eeeeeeee-eeee-4000-8000-00000000000e";
968
+ await createProperty(propId, "ClearSurvives");
969
+ await setCustomField("property", propId, "flag", "on");
970
+ await setCustomField("property", propId, "keep", "yes");
971
+ await clearCustomField("property", propId, "flag");
972
+ await stack.eventDispatcher?.runOnce();
973
+
974
+ await rebuildProjection("property-test:projection:property-entity", {
975
+ db: stack.db,
976
+ registry: stack.registry,
977
+ });
978
+
979
+ const row = (await listProperties()).rows.find((r) => r["id"] === propId);
980
+ // Kontrollfeld beweist dass der Rebuild die set-Events real replayt hat —
981
+ // ein leeres {} wuerde "flag undefined" trivial erfuellen.
982
+ expect(row?.["keep"]).toBe("yes");
983
+ expect(row?.["flag"]).toBeUndefined();
984
+ expect(row?.["name"]).toBe("ClearSurvives");
985
+ });
986
+ });
@@ -22,7 +22,7 @@
22
22
  // (strip runs, host nulls the owner).
23
23
 
24
24
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
25
- import { asRawClient, insertOne } from "@cosmicdrift/kumiko-framework/bun-db";
25
+ import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
26
26
  import {
27
27
  buildEntityTable,
28
28
  createEventStoreExecutor,
@@ -46,6 +46,7 @@ import {
46
46
  TestUsers,
47
47
  unsafeCreateEntityTable,
48
48
  } from "@cosmicdrift/kumiko-framework/stack";
49
+ import { seedRow } from "@cosmicdrift/kumiko-framework/testing";
49
50
  import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
50
51
  import { z } from "zod";
51
52
  import { createComplianceProfilesFeature } from "../../compliance-profiles";
@@ -241,7 +242,7 @@ async function readRow(id: string): Promise<Record<string, unknown> | undefined>
241
242
  // the export runner iterates their data. Status active = NOT picked up by
242
243
  // runForgetCleanup.
243
244
  async function seedActiveUserWithMembership(): Promise<void> {
244
- await insertOne(stack.db, userTable, {
245
+ await seedRow(stack.db, userTable, {
245
246
  id: admin.id,
246
247
  tenantId: TENANT,
247
248
  email: `admin@example.com`,
@@ -258,7 +259,7 @@ async function seedActiveUserWithMembership(): Promise<void> {
258
259
  // Seed the acting admin as DeletionRequested + grace expired so
259
260
  // runForgetCleanup picks them up, with a membership in TENANT.
260
261
  async function seedForgetUserWithMembership(): Promise<void> {
261
- await insertOne(stack.db, userTable, {
262
+ await seedRow(stack.db, userTable, {
262
263
  id: admin.id,
263
264
  tenantId: TENANT,
264
265
  email: `admin@example.com`,
@@ -31,6 +31,7 @@ export const CUSTOM_FIELDS_FORM_EXTENSION_NAME = "CustomFieldsFormSection";
31
31
  // Entity-CRUD auto-events for the `field-definition` entity. registry.ts emits
32
32
  // these as `${entityName}.created`/`.updated` (dot form) — they do NOT run
33
33
  // through r.defineEvent/toKebab, so the dot MUST stay.
34
+ export const FIELD_DEFINITION_AGGREGATE_TYPE = "field-definition";
34
35
  export const FIELD_DEFINITION_CREATED_EVENT = "field-definition.created";
35
36
  export const FIELD_DEFINITION_UPDATED_EVENT = "field-definition.updated";
36
37
  // Qualified at registration via r.defineEvent — final name is
@@ -1,5 +1,6 @@
1
1
  import { SYSTEM_TENANT_ID, type WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import { fieldDefinitionAggregateId } from "../aggregate-id";
3
+ import { FIELD_DEFINITION_AGGREGATE_TYPE } from "../constants";
3
4
  import { fieldDefinitionExecutor } from "../executor";
4
5
  import { customFieldsFeature } from "../feature";
5
6
  import { type DeleteFieldPayload, deleteFieldPayloadSchema } from "../schemas";
@@ -30,7 +31,7 @@ export const deleteSystemFieldHandler: WriteHandlerDef = {
30
31
  if (result.isSuccess) {
31
32
  await ctx.unsafeAppendEvent({
32
33
  aggregateId,
33
- aggregateType: "field-definition",
34
+ aggregateType: FIELD_DEFINITION_AGGREGATE_TYPE,
34
35
  type: customFieldsFeature.exports.fieldDefinitionDeletedEvent.name,
35
36
  payload: {
36
37
  entityName: payload.entityName,
@@ -1,5 +1,6 @@
1
1
  import { isSystemTenant, type WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import { fieldDefinitionAggregateId } from "../aggregate-id";
3
+ import { FIELD_DEFINITION_AGGREGATE_TYPE } from "../constants";
3
4
  import { fieldDefinitionExecutor } from "../executor";
4
5
  import { customFieldsFeature } from "../feature";
5
6
  import { type DeleteFieldPayload, deleteFieldPayloadSchema } from "../schemas";
@@ -38,7 +39,7 @@ export const deleteTenantFieldHandler: WriteHandlerDef = {
38
39
  if (result.isSuccess) {
39
40
  await ctx.unsafeAppendEvent({
40
41
  aggregateId,
41
- aggregateType: "field-definition",
42
+ aggregateType: FIELD_DEFINITION_AGGREGATE_TYPE,
42
43
  type: customFieldsFeature.exports.fieldDefinitionDeletedEvent.name,
43
44
  payload: { entityName: payload.entityName, fieldKey: payload.fieldKey, tenantId },
44
45
  });
@@ -1,4 +1,4 @@
1
- import { extractTableName } from "@cosmicdrift/kumiko-framework/db";
1
+ import { type DbRunner, extractTableName } from "@cosmicdrift/kumiko-framework/db";
2
2
  import {
3
3
  createJsonbField,
4
4
  type FeatureRegistrar,
@@ -6,7 +6,8 @@ import {
6
6
  type JsonbFieldDef,
7
7
  type TenantId,
8
8
  } from "@cosmicdrift/kumiko-framework/engine";
9
- import { CUSTOM_FIELDS_EXTENSION } from "./constants";
9
+ import type { StoredEvent } from "@cosmicdrift/kumiko-framework/event-store";
10
+ import { CUSTOM_FIELDS_EXTENSION, FIELD_DEFINITION_AGGREGATE_TYPE } from "./constants";
10
11
  import {
11
12
  clearCustomFieldKey,
12
13
  removeCustomFieldKeyForTenant,
@@ -36,8 +37,9 @@ export function customFieldsField(): JsonbFieldDef {
36
37
 
37
38
  // Vollständige integration der custom-fields-Bundle für eine spezifische
38
39
  // host-entity. Eine einzige Aufruf-Stelle pro consumer registriert ALLE
39
- // wiring-Aspekte: extension-tracking, MSP für value-projection, postQuery-
40
- // hook für API-flatten, search-payload-extension für indexable customFields.
40
+ // wiring-Aspekte: extension-tracking, MSP für value-projection, rebuild-
41
+ // replay via extendEntityProjection, postQuery-hook für API-flatten,
42
+ // search-payload-extension für indexable customFields.
41
43
  //
42
44
  // Consumer-side:
43
45
  //
@@ -61,15 +63,20 @@ export function customFieldsField(): JsonbFieldDef {
61
63
  // emittiert haben. Updated entityTable.customFields jsonb über
62
64
  // jsonb_set / jsonb-key-removal.
63
65
  //
64
- // 3. r.entityHook("postQuery", entity, flatten-fn) bei JEDEM Read auf
66
+ // 3. r.extendEntityProjection hängt dieselben apply-Handler in die
67
+ // implicit projection der host-entity, damit rebuildProjection die
68
+ // customField-Events mit-replayt. Ohne das fällt customFields bei
69
+ // jedem Entity-Rebuild (Schema-Migration!) auf `{}` zurück (#759).
70
+ //
71
+ // 4. r.entityHook("postQuery", entity, flatten-fn) — bei JEDEM Read auf
65
72
  // diese entity wird `row.customFields` jsonb auf root-level expanded
66
73
  // damit die API-response wie Stammfelder aussieht.
67
74
  //
68
- // 4. r.searchPayloadExtension(entity, contributor) — searchable
75
+ // 5. r.searchPayloadExtension(entity, contributor) — searchable
69
76
  // customFields-keys werden flach ins Meilisearch-Index-Doc beigetragen
70
77
  // (F3-wiring).
71
78
  //
72
- // 5. fieldDefinition.deleted-Event-Handler im selben MSP — bei delete
79
+ // 6. fieldDefinition.deleted-Event-Handler im selben MSP — bei delete
73
80
  // einer fieldDefinition werden orphan values aus allen entity-rows
74
81
  // entfernt (key-removal pro fieldKey).
75
82
  export function wireCustomFieldsFor<TReg extends FeatureRegistrar<string>>(
@@ -87,80 +94,100 @@ export function wireCustomFieldsFor<TReg extends FeatureRegistrar<string>>(
87
94
  const clearedEventType = customFieldsFeature.exports.clearedEvent.name;
88
95
  const fieldDefDeletedType = customFieldsFeature.exports.fieldDefinitionDeletedEvent.name;
89
96
 
90
- r.multiStreamProjection({
91
- name: `custom-fields-${entityName}-projection`,
92
- apply: {
93
- [setEventType]: async (event, tx) => {
94
- // skip: MSP feuert für ALLE aggregate-types die customField.set
95
- // emittieren wir wollen nur die unserer wired host-entity.
96
- // Andere consumers haben eigene MSPs für ihre Entities.
97
- if (event.aggregateType !== entityName) return;
98
- const payload = event.payload as CustomFieldSetPayload; // @cast-boundary engine-payload
97
+ // Geteilte apply-Bodies: die MSP liefert sie live (async cursor), die
98
+ // entity-projection-extension replayt sie in rebuildProjection — gleiche
99
+ // SQL-Helper, beide idempotent. 2-Param-Signatur ist die gemeinsame
100
+ // Teilmenge von MultiStreamApplyFn und SingleStreamApplyFn.
101
+ const applyCustomFieldSet = async (event: StoredEvent, tx: DbRunner): Promise<void> => {
102
+ // skip: feuert für ALLE aggregate-types die customField.set emittieren
103
+ // wir wollen nur die unserer wired host-entity. Andere consumers haben
104
+ // eigene Wirings für ihre Entities.
105
+ if (event.aggregateType !== entityName) return;
106
+ const payload = event.payload as CustomFieldSetPayload; // @cast-boundary engine-payload
99
107
 
100
- // skip: sensitive fields self-project in the write handler (see
101
- // set-custom-field) and persist a value-less event so PII never enters
102
- // the log. Such events arrive here with value === undefined — skipping
103
- // is correct both live (the handler already wrote the row) and on replay
104
- // (the value is intentionally gone, the accepted rebuild-loss).
105
- if (payload.value === undefined) return;
108
+ // skip: sensitive fields self-project in the write handler (see
109
+ // set-custom-field) and persist a value-less event so PII never enters
110
+ // the log. Such events arrive here with value === undefined — skipping
111
+ // is correct both live (the handler already wrote the row) and on replay
112
+ // (the value is intentionally gone, the accepted rebuild-loss).
113
+ if (payload.value === undefined) return;
106
114
 
107
- // jsonb_set: setze key auf value. Wenn key noch nicht existiert →
108
- // wird angelegt (create_missing=true ist default). value muss als
109
- // jsonb-literal kommen.
110
- const tableName = extractTableName(entityTable, "custom-fields/wire-for-entity");
111
- await setCustomFieldValue(
112
- tx,
113
- tableName,
114
- payload.fieldKey,
115
- payload.value,
116
- event.aggregateId,
117
- event.tenantId,
118
- );
119
- },
120
- [clearedEventType]: async (event, tx) => {
121
- // skip: MSP feuert für alle aggregate-types — nur unsere host-entity
122
- // verarbeiten.
123
- if (event.aggregateType !== entityName) return;
124
- const payload = event.payload as CustomFieldClearedPayload; // @cast-boundary engine-payload
115
+ // jsonb_set: setze key auf value. Wenn key noch nicht existiert →
116
+ // wird angelegt (create_missing=true ist default). value muss als
117
+ // jsonb-literal kommen.
118
+ const tableName = extractTableName(entityTable, "custom-fields/wire-for-entity");
119
+ await setCustomFieldValue(
120
+ tx,
121
+ tableName,
122
+ payload.fieldKey,
123
+ payload.value,
124
+ event.aggregateId,
125
+ event.tenantId,
126
+ );
127
+ };
125
128
 
126
- // jsonb minus operator (`-`) entfernt key aus jsonb-object.
127
- const tableName = extractTableName(entityTable, "custom-fields/wire-for-entity");
128
- await clearCustomFieldKey(
129
- tx,
130
- tableName,
131
- payload.fieldKey,
132
- event.aggregateId,
133
- event.tenantId,
134
- );
135
- },
136
- [fieldDefDeletedType]: async (event, tx) => {
137
- // fieldDefinition.deleted fires nur einmal pro fieldDef-delete
138
- // (NICHT per-entity). Wir entfernen den key aus ALLEN rows der host-
139
- // entity falls die deleted-fieldDef für diese entity galt.
140
- const payload = event.payload as {
141
- entityName: string;
142
- fieldKey: string;
143
- tenantId?: TenantId;
144
- }; // @cast-boundary engine-payload
145
- // skip: fieldDefinition.deleted feuert für ALLE fieldDefs cross-entity;
146
- // nur wenn die deleted-fieldDef diese host-entity betraf, cleanen wir
147
- // ihre Rows.
148
- if (payload.entityName !== entityName) return;
129
+ const applyCustomFieldCleared = async (event: StoredEvent, tx: DbRunner): Promise<void> => {
130
+ // skip: feuert für alle aggregate-types — nur unsere host-entity
131
+ // verarbeiten.
132
+ if (event.aggregateType !== entityName) return;
133
+ const payload = event.payload as CustomFieldClearedPayload; // @cast-boundary engine-payload
134
+
135
+ // jsonb minus operator (`-`) entfernt key aus jsonb-object.
136
+ const tableName = extractTableName(entityTable, "custom-fields/wire-for-entity");
137
+ await clearCustomFieldKey(tx, tableName, payload.fieldKey, event.aggregateId, event.tenantId);
138
+ };
139
+
140
+ const applyFieldDefinitionDeleted = async (event: StoredEvent, tx: DbRunner): Promise<void> => {
141
+ // fieldDefinition.deleted fires nur einmal pro fieldDef-delete
142
+ // (NICHT per-entity). Wir entfernen den key aus ALLEN rows der host-
143
+ // entity falls die deleted-fieldDef für diese entity galt.
144
+ const payload = event.payload as {
145
+ entityName: string;
146
+ fieldKey: string;
147
+ tenantId?: TenantId;
148
+ }; // @cast-boundary engine-payload
149
+ // skip: fieldDefinition.deleted feuert für ALLE fieldDefs cross-entity;
150
+ // nur wenn die deleted-fieldDef diese host-entity betraf, cleanen wir
151
+ // ihre Rows.
152
+ if (payload.entityName !== entityName) return;
153
+
154
+ const tableName = extractTableName(entityTable, "custom-fields/wire-for-entity");
155
+ // Scope cleanup to the deleted definition's owning tenant. System-scope
156
+ // definitions apply to every tenant → cascade across all rows; tenant-
157
+ // scope deletions must only touch that tenant's rows, else deleting one
158
+ // tenant's field strips the same kebab key from every tenant (data loss).
159
+ // Fallback to the event's stream tenantId for events appended before the
160
+ // payload carried tenantId.
161
+ const defTenantId = payload.tenantId ?? event.tenantId;
162
+ if (isSystemTenant(defTenantId)) {
163
+ await removeCustomFieldKeyFromAllTenants(tx, tableName, payload.fieldKey);
164
+ } else {
165
+ await removeCustomFieldKeyForTenant(tx, tableName, payload.fieldKey, defTenantId);
166
+ }
167
+ };
149
168
 
150
- const tableName = extractTableName(entityTable, "custom-fields/wire-for-entity");
151
- // Scope cleanup to the deleted definition's owning tenant. System-scope
152
- // definitions apply to every tenant → cascade across all rows; tenant-
153
- // scope deletions must only touch that tenant's rows, else deleting one
154
- // tenant's field strips the same kebab key from every tenant (data loss).
155
- // Fallback to the event's stream tenantId for events appended before the
156
- // payload carried tenantId.
157
- const defTenantId = payload.tenantId ?? event.tenantId;
158
- if (isSystemTenant(defTenantId)) {
159
- await removeCustomFieldKeyFromAllTenants(tx, tableName, payload.fieldKey);
160
- } else {
161
- await removeCustomFieldKeyForTenant(tx, tableName, payload.fieldKey, defTenantId);
162
- }
163
- },
169
+ r.multiStreamProjection({
170
+ name: `custom-fields-${entityName}-projection`,
171
+ apply: {
172
+ [setEventType]: applyCustomFieldSet,
173
+ [clearedEventType]: applyCustomFieldCleared,
174
+ [fieldDefDeletedType]: applyFieldDefinitionDeleted,
175
+ },
176
+ });
177
+
178
+ // Rebuild-wiring (#759): ohne das replayt rebuildProjection nur die
179
+ // entity-lifecycle-Events und resettet jede customFields-jsonb auf ihr
180
+ // Default `{}` — die Werte existieren ausschließlich als customField.set/
181
+ // .cleared-Events. set/cleared reiten auf dem HOST-entity-Stream
182
+ // (aggregateType = entityName, von source schon abgedeckt);
183
+ // fieldDefinition.deleted lebt auf dem "field-definition"-Stream und
184
+ // kommt deshalb als extra source dazu.
185
+ r.extendEntityProjection(entityName, {
186
+ sources: [FIELD_DEFINITION_AGGREGATE_TYPE],
187
+ apply: {
188
+ [setEventType]: applyCustomFieldSet,
189
+ [clearedEventType]: applyCustomFieldCleared,
190
+ [fieldDefDeletedType]: applyFieldDefinitionDeleted,
164
191
  },
165
192
  });
166
193
 
@@ -1,6 +1,14 @@
1
- import { describe, expect, spyOn, test } from "bun:test";
1
+ import { afterEach, describe, expect, spyOn, test } from "bun:test";
2
2
  import { parseRetentionOverrideOrNull } from "../_internal/parse-override";
3
3
 
4
+ // A mid-test assertion throw skips the trailing `warn.mockRestore()` in that
5
+ // test, leaving the spy live for every test after it (551/1) — this backstop
6
+ // restores unconditionally regardless of how the test exited.
7
+ afterEach(() => {
8
+ const warn = console.warn as unknown as { mockRestore?: () => void };
9
+ warn.mockRestore?.();
10
+ });
11
+
4
12
  // parseRetentionOverrideOrNull is the read boundary for a tenant's stored
5
13
  // data-retention override (DSGVO-relevant policy in a config column). It must
6
14
  // never let a corrupt or schema-violating value reach the retention decision:
@@ -31,19 +39,17 @@ describe("parseRetentionOverrideOrNull", () => {
31
39
  });
32
40
 
33
41
  test("corrupt JSON returns null without throwing", () => {
34
- const warn = spyOn(console, "warn").mockImplementation(() => {});
42
+ spyOn(console, "warn").mockImplementation(() => {});
35
43
  expect(() => parse("{not json")).not.toThrow();
36
44
  expect(parse("{not json")).toBeNull();
37
- warn.mockRestore();
38
45
  });
39
46
 
40
47
  test("JSON that parses but violates the schema is dropped to null — never leaked through", () => {
41
- const warn = spyOn(console, "warn").mockImplementation(() => {});
48
+ spyOn(console, "warn").mockImplementation(() => {});
42
49
  expect(parse('{"strategy":"delete"}')).toBeNull(); // enum drift
43
50
  expect(parse('{"keepFor":"30days"}')).toBeNull(); // keepFor format drift
44
51
  expect(parse('{"keepFor":42}')).toBeNull(); // wrong type
45
52
  expect(parse('{"unknownKey":1}')).toBeNull(); // strict() rejects extra keys
46
- warn.mockRestore();
47
53
  });
48
54
 
49
55
  test("each dropped value surfaces exactly one operator warning", () => {
@@ -51,6 +57,5 @@ describe("parseRetentionOverrideOrNull", () => {
51
57
  parse("{not json");
52
58
  parse('{"strategy":"delete"}');
53
59
  expect(warn).toHaveBeenCalledTimes(2);
54
- warn.mockRestore();
55
60
  });
56
61
  });
@@ -26,15 +26,27 @@ export interface ResolveForTenantArgs {
26
26
  * Tenant-Override (Layer 3) zurueck.
27
27
  */
28
28
  readonly tenantPreset?: RetentionPresetKey | null;
29
+ /**
30
+ * Pre-fetched override row for this (tenantId, entityName) pair — lets a
31
+ * bulk caller (the cleanup cron, N entities × M tenants) read every
32
+ * override for a tenant ONCE and pass each row in, instead of one
33
+ * `fetchOne` per entity. undefined (the default) still runs the
34
+ * single-lookup fetchOne for the "policy-for" query handler's use case.
35
+ * `null` explicitly means "pre-fetched, no override row exists".
36
+ */
37
+ readonly preloadedOverride?: { config: string | null } | null;
29
38
  }
30
39
 
31
40
  export async function resolveRetentionPolicyForTenant(
32
41
  args: ResolveForTenantArgs,
33
42
  ): Promise<EffectiveRetentionPolicy> {
34
- const overrideRow = (await fetchOne(args.db, tenantRetentionOverrideTable, {
35
- tenantId: args.tenantId,
36
- entityName: args.entityName,
37
- })) as { config: string | null } | null; // @cast-boundary db-runner
43
+ const overrideRow =
44
+ args.preloadedOverride !== undefined
45
+ ? args.preloadedOverride
46
+ : ((await fetchOne(args.db, tenantRetentionOverrideTable, {
47
+ tenantId: args.tenantId,
48
+ entityName: args.entityName,
49
+ })) as { config: string | null } | null); // @cast-boundary db-runner
38
50
 
39
51
  const tenantOverride = parseRetentionOverrideOrNull(
40
52
  overrideRow?.config ?? null,
@@ -6,9 +6,15 @@
6
6
  // Strategy auf Rows an deren reference-Timestamp aelter als der keepFor-Cutoff
7
7
  // ist:
8
8
  //
9
- // - hardDelete → deleteManyBatched (selbst-begrenzt, kein Full-Table-Scan)
10
- // - softDelete → isDeleted=true/deletedAt=now, nur auf noch-nicht-geloeschte
9
+ // - hardDelete → executor.forget pro Row (Event → rebuild-safe hard purge)
10
+ // - softDelete → executor.delete pro Row (Event → rebuild-safe soft-delete),
11
+ // nur auf noch-nicht-geloeschte (isDeleted:false-Filter)
11
12
  // - anonymize → DEFERRED (siehe unten)
13
+ //
14
+ // Über den Executor statt Batch-deleteMany/updateMany: ein eventloser Batch-Write
15
+ // auf die (erased) Projektions-Tabelle wird beim Projection-Rebuild
16
+ // gewischt/resurrektiert — das #648-Loch. Kosten: N Events pro Cleanup statt ein
17
+ // Batch-Statement (per-Row-Ceiling, Batch-Event-Variante als Follow-up).
12
18
  // - blockDelete → ignoriert (Aufbewahrungs-Pflicht; user-forget loest anonymize)
13
19
  //
14
20
  // **Schaerfer als soft-delete-cleanup:** dieser Cron hardDeleted LIVE Rows
@@ -24,12 +30,23 @@
24
30
  // zeitgesteuertes anonymize; der user-forget-Flow (run-forget-cleanup) deckt
25
31
  // anonymize keyed auf userId ab. Follow-up fuer den Marker.
26
32
 
27
- import { deleteManyBatched, updateMany } from "@cosmicdrift/kumiko-framework/bun-db";
28
- import type { DbRunner, WhereObject } from "@cosmicdrift/kumiko-framework/db";
29
- import type { Registry, TenantId } from "@cosmicdrift/kumiko-framework/engine";
33
+ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
34
+ import {
35
+ createEventStoreExecutor,
36
+ createTenantDb,
37
+ type DbRunner,
38
+ type WhereObject,
39
+ } from "@cosmicdrift/kumiko-framework/db";
40
+ import {
41
+ createSystemUser,
42
+ type EntityId,
43
+ type Registry,
44
+ type TenantId,
45
+ } from "@cosmicdrift/kumiko-framework/engine";
30
46
  import { computeCutoff, type Instant } from "./keep-for";
31
47
  import type { RetentionPresetKey } from "./presets";
32
48
  import { resolveRetentionPolicyForTenant } from "./resolve-for-tenant";
49
+ import { tenantRetentionOverrideTable } from "./schema/tenant-retention-override";
33
50
 
34
51
  const DEFAULT_BATCH_LIMIT = 1000;
35
52
  const DEFAULT_REFERENCE_FIELD = "createdAt";
@@ -71,6 +88,24 @@ export interface RunRetentionCleanupResult {
71
88
  readonly skipped: readonly RetentionCleanupSkip[];
72
89
  }
73
90
 
91
+ // Select one batchLimit-sized page of matching rows and run the executor op per
92
+ // row (event → rebuild-safe). Returns the count of successful ops.
93
+ async function purgeMatchingRows(
94
+ db: DbRunner,
95
+ table: Parameters<typeof selectMany>[1],
96
+ where: WhereObject,
97
+ batchLimit: number,
98
+ op: (id: EntityId) => Promise<{ readonly isSuccess: boolean }>,
99
+ ): Promise<number> {
100
+ const rows = await selectMany<{ id: EntityId }>(db, table, where, { limit: batchLimit });
101
+ let count = 0;
102
+ for (const row of rows) {
103
+ const res = await op(row.id);
104
+ if (res.isSuccess) count++;
105
+ }
106
+ return count;
107
+ }
108
+
74
109
  export async function runRetentionCleanup(
75
110
  args: RunRetentionCleanupArgs,
76
111
  ): Promise<RunRetentionCleanupResult> {
@@ -82,6 +117,27 @@ export async function runRetentionCleanup(
82
117
  const anonymizeDeferred: string[] = [];
83
118
  const skipped: RetentionCleanupSkip[] = [];
84
119
 
120
+ // Retention writes go through the executor (events) so a projection rebuild
121
+ // replays the cleanup — a batch deleteMany/updateMany on the (erased)
122
+ // projection table is eventless and gets wiped/resurrected on rebuild (the
123
+ // #648 hole this closes). The cron acts as the system actor; the WHERE already
124
+ // tenant-scopes, so a system-mode TenantDb (no extra filter) is correct.
125
+ // ponytail: per-row events, one batchLimit-sized page per entity per run —
126
+ // the daily cron converges (hardDelete removes rows, softDelete's isDeleted
127
+ // filter shrinks the set). A single batched forget/delete event is a follow-up.
128
+ const systemUser = createSystemUser(tenantId);
129
+ const tdb = createTenantDb(db, tenantId, "system");
130
+
131
+ // Pre-load every override row for this tenant ONCE — N entities × M
132
+ // tenants would otherwise mean one fetchOne per entity per tenant, even
133
+ // though the whole set for a tenant is a single small, indexed read.
134
+ const overrideRows = await selectMany<{ entityName: string; config: string | null }>(
135
+ db,
136
+ tenantRetentionOverrideTable,
137
+ { tenantId },
138
+ );
139
+ const overrideByEntity = new Map(overrideRows.map((r) => [r.entityName, { config: r.config }]));
140
+
85
141
  for (const proj of registry.getAllProjections().values()) {
86
142
  // Nur implicit-Entity-Projektionen mit Tabelle — wie soft-delete-cleanup.
87
143
  // Custom-Projektionen + unmanaged-Tables (z.B. sessions) sind kein Target.
@@ -94,9 +150,12 @@ export async function runRetentionCleanup(
94
150
  tenantId,
95
151
  entityName,
96
152
  tenantPreset,
153
+ preloadedOverride: overrideByEntity.get(entityName) ?? null,
97
154
  });
98
155
  const policy = resolved.policy;
99
156
  if (!policy) continue;
157
+ const entity = registry.getEntity(entityName);
158
+ if (!entity) continue;
100
159
 
101
160
  const table = proj.table as Record<string, unknown>; // @cast-boundary column-presence probe
102
161
  const declaredReference = policy.reference ?? DEFAULT_REFERENCE_FIELD;
@@ -118,8 +177,10 @@ export async function runRetentionCleanup(
118
177
 
119
178
  switch (policy.strategy) {
120
179
  case "hardDelete": {
121
- const res = await deleteManyBatched(db, proj.table, where, { limit: batchLimit });
122
- hardDeleted += res.deleted;
180
+ const executor = createEventStoreExecutor(proj.table, entity, { entityName });
181
+ hardDeleted += await purgeMatchingRows(db, proj.table, where, batchLimit, (id) =>
182
+ executor.forget({ id }, systemUser, tdb),
183
+ );
123
184
  break;
124
185
  }
125
186
  case "softDelete": {
@@ -127,13 +188,14 @@ export async function runRetentionCleanup(
127
188
  skipped.push({ entityName, reason: "missing_softdelete_columns" });
128
189
  break;
129
190
  }
130
- const updated = await updateMany(
191
+ const executor = createEventStoreExecutor(proj.table, entity, { entityName });
192
+ softDeleted += await purgeMatchingRows(
131
193
  db,
132
194
  proj.table,
133
- { isDeleted: true, deletedAt: now },
134
195
  { ...where, isDeleted: false },
196
+ batchLimit,
197
+ (id) => executor.delete({ id }, systemUser, tdb),
135
198
  );
136
- softDeleted += updated.length;
137
199
  break;
138
200
  }
139
201
  case "anonymize": {