@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.
- package/package.json +8 -6
- package/src/auth-email-password/__tests__/auth-claims.integration.test.ts +3 -2
- package/src/auth-email-password/__tests__/email-verification.integration.test.ts +5 -9
- package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +3 -2
- package/src/auth-email-password/__tests__/multi-roles.integration.test.ts +3 -2
- package/src/auth-email-password/__tests__/password-reset.integration.test.ts +4 -3
- package/src/auth-email-password/handlers/change-password.write.ts +11 -11
- package/src/auth-email-password/handlers/confirm-token-flow.ts +33 -104
- package/src/auth-email-password/web/__tests__/invite-accept-screen.test.tsx +44 -0
- package/src/auth-email-password/web/__tests__/user-menu.test.tsx +14 -1
- package/src/auth-email-password/web/auth-gate.tsx +3 -4
- package/src/auth-email-password/web/invite-accept-screen.tsx +7 -3
- package/src/auth-email-password/web/session.tsx +5 -1
- package/src/auth-email-password/web/user-menu.tsx +1 -1
- package/src/config/__tests__/deserialize-value.test.ts +3 -6
- package/src/custom-fields/__tests__/custom-fields.integration.test.ts +65 -0
- package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +4 -3
- package/src/custom-fields/constants.ts +1 -0
- package/src/custom-fields/handlers/delete-system-field.write.ts +2 -1
- package/src/custom-fields/handlers/delete-tenant-field.write.ts +2 -1
- package/src/custom-fields/wire-for-entity.ts +104 -77
- package/src/data-retention/__tests__/parse-override.test.ts +11 -6
- package/src/data-retention/resolve-for-tenant.ts +16 -4
- package/src/data-retention/run-retention-cleanup.ts +72 -10
- package/src/delivery/attempt-log.ts +22 -10
- package/src/feature-toggles/__tests__/feature-toggles.integration.test.ts +4 -4
- package/src/folders/__tests__/folders.integration.test.ts +26 -0
- package/src/folders/feature.ts +4 -2
- package/src/folders/handlers/delete-folder.write.ts +40 -0
- package/src/folders/web/__tests__/folder-manager.test.tsx +24 -0
- package/src/folders/web/folder-manager.tsx +18 -13
- package/src/folders/web/i18n.ts +0 -2
- package/src/folders-user-data/__tests__/hooks.integration.test.ts +69 -0
- package/src/folders-user-data/hooks.ts +31 -8
- package/src/folders-user-data/index.ts +4 -4
- package/src/jobs/feature.ts +23 -21
- package/src/jobs/handlers/projection-rebuild.job.ts +14 -4
- package/src/ledger/__tests__/ledger.integration.test.ts +44 -0
- package/src/ledger/__tests__/reports.test.ts +20 -0
- package/src/ledger/entity.ts +1 -1
- package/src/ledger/handlers/confirm-schedule-period.write.ts +29 -13
- package/src/ledger/handlers/reverse-transaction.write.ts +44 -5
- package/src/ledger/recurring.ts +18 -9
- package/src/ledger/schemas.ts +4 -1
- package/src/ledger/web/index.ts +3 -2
- package/src/managed-pages/__tests__/branding-coerce.test.ts +2 -6
- package/src/personal-access-tokens/__tests__/pat.integration.test.ts +205 -0
- package/src/personal-access-tokens/__tests__/scopes.test.ts +42 -0
- package/src/personal-access-tokens/constants.ts +33 -0
- package/src/personal-access-tokens/feature.ts +78 -0
- package/src/personal-access-tokens/handlers/available-scopes.query.ts +21 -0
- package/src/personal-access-tokens/handlers/create.write.ts +41 -0
- package/src/personal-access-tokens/handlers/list.query.ts +47 -0
- package/src/personal-access-tokens/handlers/revoke.write.ts +37 -0
- package/src/personal-access-tokens/hash.ts +17 -0
- package/src/personal-access-tokens/index.ts +45 -0
- package/src/personal-access-tokens/resolver.ts +58 -0
- package/src/personal-access-tokens/roles.ts +29 -0
- package/src/personal-access-tokens/schema/api-token.ts +56 -0
- package/src/personal-access-tokens/scopes.ts +43 -0
- package/src/personal-access-tokens/web/client-plugin.tsx +29 -0
- package/src/personal-access-tokens/web/i18n.ts +69 -0
- package/src/personal-access-tokens/web/index.ts +7 -0
- package/src/personal-access-tokens/web/pat-tokens-screen.tsx +251 -0
- package/src/renderer-simple/__tests__/template-resolver.integration.test.ts +2 -2
- package/src/sessions/__tests__/cleanup.integration.test.ts +3 -3
- package/src/sessions/__tests__/rebuild-survival.integration.test.ts +5 -9
- package/src/sessions/__tests__/sessions.integration.test.ts +14 -9
- package/src/sessions/schema/user-session.ts +8 -2
- package/src/sessions/session-callbacks.ts +9 -3
- package/src/subscription-stripe/__tests__/runtime.test.ts +14 -24
- package/src/tags/__tests__/tags.integration.test.ts +15 -0
- package/src/tags/handlers/assign-tag.write.ts +13 -3
- package/src/tags/schemas.ts +10 -4
- package/src/template-resolver/__tests__/template-resolver.integration.test.ts +2 -2
- package/src/template-resolver/testing.ts +4 -3
- package/src/tier-engine/feature.ts +22 -10
- package/src/tier-engine/handlers/set-tenant-tier.write.ts +11 -1
- package/src/user/__tests__/stream-tenant-backfill.integration.test.ts +186 -0
- package/src/user/db/queries/stream-tenant-backfill.ts +112 -0
- package/src/user/index.ts +4 -0
- package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +4 -4
- package/src/user-data-rights/__tests__/audit-log.integration.test.ts +4 -4
- package/src/user-data-rights/__tests__/cross-data-matrix.integration.test.ts +4 -4
- package/src/user-data-rights/__tests__/download.integration.test.ts +7 -7
- package/src/user-data-rights/__tests__/export-job-idempotency.integration.test.ts +5 -10
- package/src/user-data-rights/__tests__/export-job-list-cross-tenant.integration.test.ts +84 -0
- package/src/user-data-rights/__tests__/file-retention.integration.test.ts +3 -3
- package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +3 -2
- package/src/user-data-rights/__tests__/forget-test-helpers.ts +3 -2
- package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +3 -3
- package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +10 -6
- package/src/user-data-rights/__tests__/request-cancel-deletion.integration.test.ts +4 -4
- package/src/user-data-rights/__tests__/request-deletion-callback.integration.test.ts +4 -4
- package/src/user-data-rights/__tests__/request-export.integration.test.ts +4 -9
- package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +7 -3
- package/src/user-data-rights/__tests__/run-export-jobs-cron-context.integration.test.ts +8 -4
- package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +12 -17
- package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +9 -5
- package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +3 -3
- package/src/user-data-rights/handlers/download-attempt-list.query.ts +1 -1
- package/src/user-data-rights/handlers/download-by-job.query.ts +4 -6
- package/src/user-data-rights/handlers/export-job-detail.query.ts +1 -0
- package/src/user-data-rights/handlers/export-job-list.query.ts +1 -0
- package/src/user-data-rights/lib/update-user-lifecycle.ts +8 -24
- package/src/user-data-rights/run-forget-cleanup.ts +8 -1
- package/src/user-data-rights/web/__tests__/public-deletion-gate.test.tsx +7 -1
- package/src/user-data-rights/web/i18n.ts +0 -10
- package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +3 -2
- package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +102 -54
- package/src/user-data-rights-defaults/hooks/user.userdata-hook.ts +53 -15
- package/src/user-profile/__tests__/profile-screen.test.tsx +9 -0
- package/src/user-profile/handlers/change-email.write.ts +11 -8
- package/src/user-profile/web/profile-screen.tsx +10 -8
- package/src/auth-email-password/stream-tenant.ts +0 -31
- 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
|
|
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
|
|
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
|
|
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:
|
|
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:
|
|
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 {
|
|
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,
|
|
40
|
-
// hook für API-flatten,
|
|
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.
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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 →
|
|
10
|
-
// - softDelete →
|
|
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 {
|
|
28
|
-
import
|
|
29
|
-
|
|
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
|
|
122
|
-
hardDeleted +=
|
|
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
|
|
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": {
|