@cosmicdrift/kumiko-bundled-features 0.24.0 → 0.25.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 +1 -1
- package/src/__tests__/env-schemas.test.ts +53 -11
- package/src/auth-email-password/__tests__/auth.integration.test.ts +37 -0
- package/src/auth-email-password/__tests__/email-verification.integration.test.ts +32 -0
- package/src/auth-email-password/__tests__/password-reset.integration.test.ts +31 -0
- package/src/auth-email-password/handlers/change-password.write.ts +12 -2
- package/src/auth-email-password/handlers/confirm-token-flow.ts +17 -2
- package/src/compliance-profiles/__tests__/parse-override.test.ts +53 -0
- package/src/compliance-profiles/_internal/parse-override.ts +8 -7
- package/src/custom-fields/__tests__/audit-integration.integration.test.ts +28 -1
- package/src/custom-fields/__tests__/cross-tenant-set-write.integration.test.ts +178 -0
- package/src/custom-fields/__tests__/custom-fields.integration.test.ts +139 -0
- package/src/custom-fields/__tests__/drift.test.ts +43 -0
- package/src/custom-fields/__tests__/field-access.integration.test.ts +59 -0
- package/src/custom-fields/__tests__/field-definition-row.test.ts +62 -0
- package/src/custom-fields/__tests__/retention.integration.test.ts +76 -0
- package/src/custom-fields/__tests__/value-schema.test.ts +54 -0
- package/src/custom-fields/__tests__/wire-for-entity.test.ts +29 -0
- package/src/custom-fields/constants.ts +8 -7
- package/src/custom-fields/db/queries/projection.ts +19 -7
- package/src/custom-fields/db/queries/retention.ts +20 -6
- package/src/custom-fields/executor.ts +10 -0
- package/src/custom-fields/feature.ts +32 -39
- package/src/custom-fields/handlers/clear-custom-field.write.ts +8 -1
- package/src/custom-fields/handlers/define-system-field.write.ts +5 -22
- package/src/custom-fields/handlers/define-tenant-field.write.ts +13 -23
- package/src/custom-fields/handlers/delete-system-field.write.ts +3 -9
- package/src/custom-fields/handlers/delete-tenant-field.write.ts +3 -9
- package/src/custom-fields/handlers/set-custom-field.write.ts +8 -1
- package/src/custom-fields/lib/field-access.ts +9 -4
- package/src/custom-fields/lib/field-definition-row.ts +33 -0
- package/src/custom-fields/lib/value-schema.ts +14 -2
- package/src/custom-fields/run-retention.ts +6 -5
- package/src/custom-fields/web/__tests__/custom-fields-form-section.test.tsx +45 -10
- package/src/custom-fields/web/client-plugin.tsx +2 -0
- package/src/custom-fields/web/custom-fields-form-section.tsx +19 -9
- package/src/custom-fields/web/i18n.ts +30 -0
- package/src/custom-fields/wire-for-entity.ts +9 -2
- package/src/custom-fields/wire-user-data-rights.ts +9 -0
- package/src/feature-toggles/handlers/set.write.ts +13 -8
- package/src/secrets/feature.ts +4 -11
- package/src/subscription-stripe/feature.ts +2 -2
- package/src/template-resolver/handlers/list.query.ts +12 -10
- package/src/tenant/__tests__/seed-testing.integration.test.ts +26 -0
- package/src/tenant/seeding.ts +3 -3
- package/src/user-data-rights/__tests__/cross-data-matrix.integration.test.ts +11 -11
- package/src/user-data-rights/__tests__/file-binary-forget-cleanup.integration.test.ts +26 -74
- package/src/user-data-rights/__tests__/file-binary-forget-failure.integration.test.ts +211 -0
- package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +272 -0
- package/src/user-data-rights/__tests__/forget-test-helpers.ts +86 -0
- package/src/user-data-rights/db/queries/export-jobs.ts +1 -0
- package/src/user-data-rights/run-forget-cleanup.ts +77 -36
- package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +21 -6
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
createTextField,
|
|
20
20
|
defineEntityListHandler,
|
|
21
21
|
defineFeature,
|
|
22
|
+
SYSTEM_TENANT_ID,
|
|
22
23
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
23
24
|
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
24
25
|
import {
|
|
@@ -102,6 +103,15 @@ beforeEach(async () => {
|
|
|
102
103
|
// (Memory: feedback_role_naming_drift — bundled-features-Convention vs.
|
|
103
104
|
// platform-Convention). Wir bauen einen tenant-admin für die Tests.
|
|
104
105
|
const admin = createTestUser({ roles: ["TenantAdmin"] });
|
|
106
|
+
const systemAdmin = createTestUser({ roles: ["SystemAdmin"] });
|
|
107
|
+
|
|
108
|
+
async function countDefinitions(tenantId: string, fieldKey: string): Promise<number> {
|
|
109
|
+
const rows = await asRawClient(stack.db).unsafe(
|
|
110
|
+
"SELECT count(*)::int AS n FROM read_custom_field_definitions WHERE tenant_id = $1 AND field_key = $2",
|
|
111
|
+
[tenantId, fieldKey],
|
|
112
|
+
);
|
|
113
|
+
return (rows as ReadonlyArray<{ n: number }>)[0]?.n ?? 0;
|
|
114
|
+
}
|
|
105
115
|
|
|
106
116
|
async function defineField(entityName: string, fieldKey: string, type = "text") {
|
|
107
117
|
return stack.http.writeOk(
|
|
@@ -260,6 +270,84 @@ describe("custom-fields integration — Last-Wins on concurrent set", () => {
|
|
|
260
270
|
});
|
|
261
271
|
});
|
|
262
272
|
|
|
273
|
+
describe("custom-fields integration — define/delete handler coverage (B1)", () => {
|
|
274
|
+
// feature.test.ts only covers schema/aggregate-id/registration shape. These
|
|
275
|
+
// drive the handler bodies through the real dispatcher: the deterministic
|
|
276
|
+
// aggregate-id → version_conflict on a duplicate define, the system-tenant
|
|
277
|
+
// guard on define-tenant-field, and the system-scope define→delete roundtrip.
|
|
278
|
+
|
|
279
|
+
test("re-defining the same tenant-field → 409 (deterministic aggregate-id conflict)", async () => {
|
|
280
|
+
await defineField("property", "color", "text");
|
|
281
|
+
const err = await stack.http.writeErr(
|
|
282
|
+
"custom-fields:write:define-tenant-field",
|
|
283
|
+
{
|
|
284
|
+
entityName: "property",
|
|
285
|
+
fieldKey: "color",
|
|
286
|
+
serializedField: { type: "text" },
|
|
287
|
+
required: false,
|
|
288
|
+
searchable: false,
|
|
289
|
+
displayOrder: 0,
|
|
290
|
+
},
|
|
291
|
+
admin,
|
|
292
|
+
);
|
|
293
|
+
expect(err.httpStatus).toBe(409);
|
|
294
|
+
// Only the first define produced a row.
|
|
295
|
+
expect(await countDefinitions(admin.tenantId, "color")).toBe(1);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test("define-tenant-field rejects a caller whose tenant IS the system tenant", async () => {
|
|
299
|
+
// The strict guard (isSystemTenant) blocks system-scope writes through the
|
|
300
|
+
// tenant handler — system definitions must go via define-system-field.
|
|
301
|
+
const systemScopedAdmin = createTestUser({
|
|
302
|
+
roles: ["TenantAdmin"],
|
|
303
|
+
tenantId: SYSTEM_TENANT_ID,
|
|
304
|
+
});
|
|
305
|
+
const err = await stack.http.writeErr(
|
|
306
|
+
"custom-fields:write:define-tenant-field",
|
|
307
|
+
{
|
|
308
|
+
entityName: "property",
|
|
309
|
+
fieldKey: "leaky",
|
|
310
|
+
serializedField: { type: "text" },
|
|
311
|
+
required: false,
|
|
312
|
+
searchable: false,
|
|
313
|
+
displayOrder: 0,
|
|
314
|
+
},
|
|
315
|
+
systemScopedAdmin,
|
|
316
|
+
);
|
|
317
|
+
// The guard throws a plain Error → 500 internal_error. Pin the guard's own
|
|
318
|
+
// message (surfaced as the InternalError cause in test/dev) so this can't
|
|
319
|
+
// be satisfied by some unrelated 5xx that also happens to write no row.
|
|
320
|
+
expect(err.httpStatus).toBe(500);
|
|
321
|
+
expect(err.code).toBe("internal_error");
|
|
322
|
+
const causeMessage = (err.details as { causeMessage?: string } | undefined)?.causeMessage ?? "";
|
|
323
|
+
expect(causeMessage).toContain("define-system-field");
|
|
324
|
+
expect(await countDefinitions(SYSTEM_TENANT_ID, "leaky")).toBe(0);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
test("define-system-field → delete-system-field roundtrip (SystemAdmin, system scope)", async () => {
|
|
328
|
+
const defineRes = await stack.http.writeOk(
|
|
329
|
+
"custom-fields:write:define-system-field",
|
|
330
|
+
{
|
|
331
|
+
entityName: "property",
|
|
332
|
+
fieldKey: "vendorTag",
|
|
333
|
+
serializedField: { type: "text" },
|
|
334
|
+
required: false,
|
|
335
|
+
searchable: false,
|
|
336
|
+
displayOrder: 0,
|
|
337
|
+
},
|
|
338
|
+
systemAdmin,
|
|
339
|
+
);
|
|
340
|
+
expect(defineRes).toBeDefined();
|
|
341
|
+
expect(await countDefinitions(SYSTEM_TENANT_ID, "vendorTag")).toBe(1);
|
|
342
|
+
await stack.http.writeOk(
|
|
343
|
+
"custom-fields:write:delete-system-field",
|
|
344
|
+
{ entityName: "property", fieldKey: "vendorTag" },
|
|
345
|
+
systemAdmin,
|
|
346
|
+
);
|
|
347
|
+
expect(await countDefinitions(SYSTEM_TENANT_ID, "vendorTag")).toBe(0);
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
|
|
263
351
|
describe("custom-fields integration — value validation (Builder-Reuse)", () => {
|
|
264
352
|
async function setErr(entityId: string, fieldKey: string, value: unknown) {
|
|
265
353
|
return stack.http.writeErr(
|
|
@@ -393,6 +481,57 @@ describe("custom-fields integration — value validation (Builder-Reuse)", () =>
|
|
|
393
481
|
expect(await rawCustomFields(id)).toMatchObject({ score: 7 });
|
|
394
482
|
});
|
|
395
483
|
|
|
484
|
+
async function setMissingValueErr(entityId: string, fieldKey: string) {
|
|
485
|
+
// value omitted entirely — JSON drops undefined, so the payload arrives
|
|
486
|
+
// without `value` and the schema-level refine rejects it.
|
|
487
|
+
return stack.http.writeErr(
|
|
488
|
+
"custom-fields:write:set-custom-field",
|
|
489
|
+
{ entityName: "property", entityId, fieldKey },
|
|
490
|
+
admin,
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
test("missing value → 400 validation_error, no event (set requires a value)", async () => {
|
|
495
|
+
// The payload refine (set-custom-field.write.ts) rejects a missing value
|
|
496
|
+
// before the handler runs — otherwise `undefined` would bind as a jsonb
|
|
497
|
+
// NULL against the NOT-NULL custom_fields column. clear-custom-field is the
|
|
498
|
+
// documented way to remove a value.
|
|
499
|
+
const id = "11111111-2222-4000-8000-00000000000e";
|
|
500
|
+
await defineField("property", "label", "text");
|
|
501
|
+
await createProperty(id, "MissingValue");
|
|
502
|
+
|
|
503
|
+
const err = await setMissingValueErr(id, "label");
|
|
504
|
+
expect(err.httpStatus).toBe(400);
|
|
505
|
+
expect(err.code).toBe("validation_error");
|
|
506
|
+
expect(err.details).toMatchObject({ fields: [{ path: "value" }] });
|
|
507
|
+
expect(await countSetEvents(id)).toBe(0);
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
test("default-having field: a missing value is still rejected (default not silently applied)", async () => {
|
|
511
|
+
// Pre-fix bug: `z.number().default(0).safeParse(undefined)` succeeded with
|
|
512
|
+
// data=0, and the handler emitted `payload.value` (= undefined). The refine
|
|
513
|
+
// now rejects the missing value outright — no event, no defaulted-undefined.
|
|
514
|
+
const id = "22222222-3333-4000-8000-00000000000f";
|
|
515
|
+
await stack.http.writeOk(
|
|
516
|
+
"custom-fields:write:define-tenant-field",
|
|
517
|
+
{
|
|
518
|
+
entityName: "property",
|
|
519
|
+
fieldKey: "rank",
|
|
520
|
+
serializedField: { type: "number", default: 0 },
|
|
521
|
+
required: false,
|
|
522
|
+
searchable: false,
|
|
523
|
+
displayOrder: 0,
|
|
524
|
+
},
|
|
525
|
+
admin,
|
|
526
|
+
);
|
|
527
|
+
await createProperty(id, "DefaultMissing");
|
|
528
|
+
|
|
529
|
+
const err = await setMissingValueErr(id, "rank");
|
|
530
|
+
expect(err.httpStatus).toBe(400);
|
|
531
|
+
expect(err.code).toBe("validation_error");
|
|
532
|
+
expect(await countSetEvents(id)).toBe(0);
|
|
533
|
+
});
|
|
534
|
+
|
|
396
535
|
test("embedded field rejects a non-object value → 422, no event", async () => {
|
|
397
536
|
const id = "aaaaaaaa-aaaa-4000-8000-00000000000a";
|
|
398
537
|
// embedded carries a sub-field schema in serializedField — exercises
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { fieldDefinitionAggregateId } from "../aggregate-id";
|
|
3
|
+
|
|
4
|
+
// Drift-Pin-Tests — diese Werte sind Cross-File-Contracts, ein Wechsel muss
|
|
5
|
+
// bewusst geschehen. Wenn diese Tests rot werden: stop, denk nach, revert.
|
|
6
|
+
// aggregate-id.ts verweist namentlich auf diese Datei.
|
|
7
|
+
|
|
8
|
+
describe("custom-fields drift pins", () => {
|
|
9
|
+
test("fieldDefinition aggregate-id namespace is stable across boots", () => {
|
|
10
|
+
// FIELD_DEFINITION_NAMESPACE is in stone — changing it re-keys every
|
|
11
|
+
// existing fieldDefinition-stream and breaks event-replay +
|
|
12
|
+
// definition-history. If this fails: revert the namespace, do not adjust
|
|
13
|
+
// the expected values.
|
|
14
|
+
const sys = fieldDefinitionAggregateId(
|
|
15
|
+
"00000000-0000-0000-0000-000000000001",
|
|
16
|
+
"customer",
|
|
17
|
+
"internalNumber",
|
|
18
|
+
);
|
|
19
|
+
const sysAgain = fieldDefinitionAggregateId(
|
|
20
|
+
"00000000-0000-0000-0000-000000000001",
|
|
21
|
+
"customer",
|
|
22
|
+
"internalNumber",
|
|
23
|
+
);
|
|
24
|
+
const otherTenant = fieldDefinitionAggregateId(
|
|
25
|
+
"11111111-1111-1111-1111-111111111111",
|
|
26
|
+
"customer",
|
|
27
|
+
"internalNumber",
|
|
28
|
+
);
|
|
29
|
+
const otherKey = fieldDefinitionAggregateId(
|
|
30
|
+
"00000000-0000-0000-0000-000000000001",
|
|
31
|
+
"customer",
|
|
32
|
+
"otherKey",
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
expect(sys).toBe(sysAgain); // deterministic: same triple → same id
|
|
36
|
+
expect(sys).not.toBe(otherTenant); // tenantId is part of the key (scope isolation)
|
|
37
|
+
expect(sys).not.toBe(otherKey); // fieldKey is part of the key
|
|
38
|
+
// Pinned actual outputs — the drift-detector for the namespace constant.
|
|
39
|
+
expect(sys).toBe("a6e22096-55ac-54c1-a759-aa42fa94dbe8");
|
|
40
|
+
expect(otherTenant).toBe("5a6cbaf1-159e-53a1-aaed-0e3b836decbe");
|
|
41
|
+
expect(otherKey).toBe("4b683fa3-9560-5747-bee9-46ea237393ac");
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -266,3 +266,62 @@ describe("T1.5b: per-field fieldAccess.write rejects users without required role
|
|
|
266
266
|
expect(err.code).toBe("not_found");
|
|
267
267
|
});
|
|
268
268
|
});
|
|
269
|
+
|
|
270
|
+
// A row whose serialized_field is corrupt (DB corruption / partial write)
|
|
271
|
+
// must NOT silently drop the per-field write gate. Before the fix the access
|
|
272
|
+
// check fell open ({ ok: true }) and the write went through unvalidated; now
|
|
273
|
+
// it fails closed with a distinct reason.
|
|
274
|
+
describe("fail-closed on corrupt serialized_field", () => {
|
|
275
|
+
async function corruptStoredDefinition(fieldKey: string) {
|
|
276
|
+
await asRawClient(stack.db).unsafe(
|
|
277
|
+
`UPDATE read_custom_field_definitions SET serialized_field = '{not valid json' WHERE field_key = $1`,
|
|
278
|
+
[fieldKey],
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
test("set: corrupt definition row → unprocessable field_definition_corrupt (not silent allow)", async () => {
|
|
283
|
+
const propertyId = "77777777-7777-4000-8000-000000000007";
|
|
284
|
+
await defineField("corruptField", { type: "text", fieldAccess: { write: ["TenantAdmin"] } });
|
|
285
|
+
await corruptStoredDefinition("corruptField");
|
|
286
|
+
await stack.http.writeOk(
|
|
287
|
+
"property-t15b:write:property:create",
|
|
288
|
+
{ id: propertyId, name: "Corrupt" },
|
|
289
|
+
tenantAdmin,
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
const err = await stack.http.writeErr(
|
|
293
|
+
"custom-fields:write:set-custom-field",
|
|
294
|
+
{ entityName: "property", entityId: propertyId, fieldKey: "corruptField", value: "X-42" },
|
|
295
|
+
tenantAdmin,
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
expect(err.code).toBe("unprocessable");
|
|
299
|
+
expect(err.details).toMatchObject({
|
|
300
|
+
reason: "field_definition_corrupt",
|
|
301
|
+
fieldKey: "corruptField",
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test("clear: corrupt definition row → unprocessable field_definition_corrupt", async () => {
|
|
306
|
+
const propertyId = "88888888-8888-4000-8000-000000000008";
|
|
307
|
+
await defineField("corruptField", { type: "boolean" });
|
|
308
|
+
await corruptStoredDefinition("corruptField");
|
|
309
|
+
await stack.http.writeOk(
|
|
310
|
+
"property-t15b:write:property:create",
|
|
311
|
+
{ id: propertyId, name: "Corrupt2" },
|
|
312
|
+
tenantAdmin,
|
|
313
|
+
);
|
|
314
|
+
|
|
315
|
+
const err = await stack.http.writeErr(
|
|
316
|
+
"custom-fields:write:clear-custom-field",
|
|
317
|
+
{ entityName: "property", entityId: propertyId, fieldKey: "corruptField" },
|
|
318
|
+
tenantAdmin,
|
|
319
|
+
);
|
|
320
|
+
|
|
321
|
+
expect(err.code).toBe("unprocessable");
|
|
322
|
+
expect(err.details).toMatchObject({
|
|
323
|
+
reason: "field_definition_corrupt",
|
|
324
|
+
fieldKey: "corruptField",
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { buildFieldDefinitionColumns } from "../lib/field-definition-row";
|
|
3
|
+
import { defineFieldPayloadSchema } from "../schemas";
|
|
4
|
+
|
|
5
|
+
function parse(input: unknown) {
|
|
6
|
+
const result = defineFieldPayloadSchema.safeParse(input);
|
|
7
|
+
if (!result.success) {
|
|
8
|
+
throw new Error(`payload invalid: ${result.error.message}`);
|
|
9
|
+
}
|
|
10
|
+
return result.data;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe("buildFieldDefinitionColumns — denormalized columns derive from serializedField", () => {
|
|
14
|
+
test("serializedField.required wins when present (no top-level required)", () => {
|
|
15
|
+
const payload = parse({
|
|
16
|
+
entityName: "customer",
|
|
17
|
+
fieldKey: "internalNumber",
|
|
18
|
+
serializedField: { type: "text", required: true, maxLength: 50 },
|
|
19
|
+
});
|
|
20
|
+
const row = buildFieldDefinitionColumns(payload);
|
|
21
|
+
expect(row.required).toBe(true);
|
|
22
|
+
expect(JSON.parse(row.serializedField).required).toBe(true);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("top-level value is used when serializedField omits the key", () => {
|
|
26
|
+
const payload = parse({
|
|
27
|
+
entityName: "customer",
|
|
28
|
+
fieldKey: "vipFlag",
|
|
29
|
+
serializedField: { type: "boolean" },
|
|
30
|
+
required: true,
|
|
31
|
+
searchable: true,
|
|
32
|
+
displayOrder: 3,
|
|
33
|
+
});
|
|
34
|
+
const row = buildFieldDefinitionColumns(payload);
|
|
35
|
+
expect(row.required).toBe(true);
|
|
36
|
+
expect(row.searchable).toBe(true);
|
|
37
|
+
expect(row.displayOrder).toBe(3);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("serializedField wins over a conflicting top-level value", () => {
|
|
41
|
+
const payload = parse({
|
|
42
|
+
entityName: "customer",
|
|
43
|
+
fieldKey: "code",
|
|
44
|
+
serializedField: { type: "text", required: false },
|
|
45
|
+
required: true,
|
|
46
|
+
});
|
|
47
|
+
const row = buildFieldDefinitionColumns(payload);
|
|
48
|
+
expect(row.required).toBe(false);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("defaults to false/0 when neither source sets the key", () => {
|
|
52
|
+
const payload = parse({
|
|
53
|
+
entityName: "customer",
|
|
54
|
+
fieldKey: "note",
|
|
55
|
+
serializedField: { type: "text" },
|
|
56
|
+
});
|
|
57
|
+
const row = buildFieldDefinitionColumns(payload);
|
|
58
|
+
expect(row.required).toBe(false);
|
|
59
|
+
expect(row.searchable).toBe(false);
|
|
60
|
+
expect(row.displayOrder).toBe(0);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
29
29
|
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
30
30
|
import { z } from "zod";
|
|
31
|
+
import { applyRetentionRemovals, selectHostRowsWithCustomFields } from "../db/queries/retention";
|
|
31
32
|
import { fieldDefinitionEntity } from "../entity";
|
|
32
33
|
import { createCustomFieldsFeature } from "../feature";
|
|
33
34
|
import { runCustomFieldsRetention } from "../run-retention";
|
|
@@ -261,4 +262,79 @@ describe("T1.5d: per-field retention sweep", () => {
|
|
|
261
262
|
expect(cf).not.toHaveProperty("temp");
|
|
262
263
|
expect(cf["keepThis"]).toBe("should-stay");
|
|
263
264
|
});
|
|
265
|
+
|
|
266
|
+
test("mixed strategies on one row: delete drops the key, anonymize nulls it, others stay", async () => {
|
|
267
|
+
const propertyId = "66666666-6666-4000-8000-000000000006";
|
|
268
|
+
await defineField("dropMe", {
|
|
269
|
+
type: "text",
|
|
270
|
+
retention: { keepFor: "30d", strategy: "delete" },
|
|
271
|
+
});
|
|
272
|
+
await defineField("nullMe", {
|
|
273
|
+
type: "text",
|
|
274
|
+
retention: { keepFor: "30d", strategy: "anonymize" },
|
|
275
|
+
});
|
|
276
|
+
await defineField("keepMe", { type: "text" });
|
|
277
|
+
await createProperty(propertyId, "MixedStrategies");
|
|
278
|
+
await setField(propertyId, "dropMe", "secret-a");
|
|
279
|
+
await setField(propertyId, "nullMe", "secret-b");
|
|
280
|
+
await setField(propertyId, "keepMe", "public-c");
|
|
281
|
+
await stack.eventDispatcher?.runOnce();
|
|
282
|
+
|
|
283
|
+
await backdateRow(propertyId, "2026-04-22T10:00:00Z");
|
|
284
|
+
|
|
285
|
+
const report = await runCustomFieldsRetention({
|
|
286
|
+
db: stack.db,
|
|
287
|
+
tenantId: admin.tenantId,
|
|
288
|
+
entityName: "property",
|
|
289
|
+
entityTable: propertyTable,
|
|
290
|
+
now: NOW,
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
expect(report.removalsByFieldKey).toEqual({ dropMe: 1, nullMe: 1 });
|
|
294
|
+
const cf = (await readRow(propertyId))?.["custom_fields"] as Record<string, unknown>;
|
|
295
|
+
expect(cf).not.toHaveProperty("dropMe");
|
|
296
|
+
expect(cf).toHaveProperty("nullMe");
|
|
297
|
+
expect(cf["nullMe"]).toBeNull();
|
|
298
|
+
expect(cf["keepMe"]).toBe("public-c");
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test("atomic removal touches only the targeted keys — a value written after the scan is not clobbered", async () => {
|
|
302
|
+
const propertyId = "77777777-7777-4000-8000-000000000007";
|
|
303
|
+
await defineField("temp", {
|
|
304
|
+
type: "text",
|
|
305
|
+
retention: { keepFor: "30d", strategy: "delete" },
|
|
306
|
+
});
|
|
307
|
+
// No retention policy → never swept; stands in for a concurrent edit.
|
|
308
|
+
await defineField("liveEdit", { type: "text" });
|
|
309
|
+
await createProperty(propertyId, "Concurrent");
|
|
310
|
+
await setField(propertyId, "temp", "expired-value");
|
|
311
|
+
await stack.eventDispatcher?.runOnce();
|
|
312
|
+
await backdateRow(propertyId, "2026-04-22T10:00:00Z");
|
|
313
|
+
|
|
314
|
+
// The sweep scans the row (snapshot has only `temp`)...
|
|
315
|
+
const snapshot = await selectHostRowsWithCustomFields(
|
|
316
|
+
stack.db,
|
|
317
|
+
"read_t15d_properties",
|
|
318
|
+
admin.tenantId,
|
|
319
|
+
);
|
|
320
|
+
expect(snapshot).toHaveLength(1);
|
|
321
|
+
|
|
322
|
+
// ...then a concurrent set-custom-field adds `liveEdit`...
|
|
323
|
+
await setField(propertyId, "liveEdit", "written-mid-sweep");
|
|
324
|
+
await stack.eventDispatcher?.runOnce();
|
|
325
|
+
|
|
326
|
+
// ...then the sweep applies the removal it computed from the (now stale)
|
|
327
|
+
// snapshot. This drives `applyRetentionRemovals` directly because the
|
|
328
|
+
// scan→write window inside `runCustomFieldsRetention` can't be paused
|
|
329
|
+
// mid-flight in-process. It pins the property that actually removes the
|
|
330
|
+
// lost-update class: the write is `custom_fields - {temp}` against the LIVE
|
|
331
|
+
// row, never a read-modify-write of the whole jsonb — so a key absent from
|
|
332
|
+
// the removal lists survives. The pre-fix code rebuilt the whole object
|
|
333
|
+
// from the stale snapshot and would have dropped `liveEdit`.
|
|
334
|
+
await applyRetentionRemovals(stack.db, "read_t15d_properties", ["temp"], [], propertyId);
|
|
335
|
+
|
|
336
|
+
const cf = (await readRow(propertyId))?.["custom_fields"] as Record<string, unknown>;
|
|
337
|
+
expect(cf).not.toHaveProperty("temp");
|
|
338
|
+
expect(cf["liveEdit"]).toBe("written-mid-sweep");
|
|
339
|
+
});
|
|
264
340
|
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { buildCustomFieldValueSchema } from "../lib/value-schema";
|
|
3
|
+
|
|
4
|
+
describe("buildCustomFieldValueSchema — type-shape only", () => {
|
|
5
|
+
test("strips top-level constraint keys (required/maxLength/format)", () => {
|
|
6
|
+
const schema = buildCustomFieldValueSchema({
|
|
7
|
+
type: "text",
|
|
8
|
+
required: true,
|
|
9
|
+
maxLength: 5,
|
|
10
|
+
format: "email",
|
|
11
|
+
});
|
|
12
|
+
expect(schema).not.toBeNull();
|
|
13
|
+
expect(schema?.safeParse("").success).toBe(true);
|
|
14
|
+
expect(schema?.safeParse("not-an-email-and-way-too-long").success).toBe(true);
|
|
15
|
+
expect(schema?.safeParse(42).success).toBe(false);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("embedded validates sub-field TYPE only — sub-field `required` is stripped", () => {
|
|
19
|
+
const schema = buildCustomFieldValueSchema({
|
|
20
|
+
type: "embedded",
|
|
21
|
+
schema: { city: { type: "text", required: true } },
|
|
22
|
+
});
|
|
23
|
+
expect(schema).not.toBeNull();
|
|
24
|
+
// type-valid objects pass, regardless of the sub-field `required` flag
|
|
25
|
+
expect(schema?.safeParse({ city: "Bonn" }).success).toBe(true);
|
|
26
|
+
expect(schema?.safeParse({}).success).toBe(true);
|
|
27
|
+
expect(schema?.safeParse({ city: "" }).success).toBe(true);
|
|
28
|
+
// type-mismatches still rejected: non-object, and wrong sub-field type
|
|
29
|
+
expect(schema?.safeParse("not-an-object").success).toBe(false);
|
|
30
|
+
expect(schema?.safeParse({ city: 123 }).success).toBe(false);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("embedded strip applies to non-text sub-types too (number)", () => {
|
|
34
|
+
const schema = buildCustomFieldValueSchema({
|
|
35
|
+
type: "embedded",
|
|
36
|
+
schema: { age: { type: "number", required: true } },
|
|
37
|
+
});
|
|
38
|
+
expect(schema).not.toBeNull();
|
|
39
|
+
expect(schema?.safeParse({ age: 7 }).success).toBe(true);
|
|
40
|
+
// required stripped → missing key passes (pre-fix the non-optional number
|
|
41
|
+
// sub-field rejected it)
|
|
42
|
+
expect(schema?.safeParse({}).success).toBe(true);
|
|
43
|
+
// type-mismatch still rejected
|
|
44
|
+
expect(schema?.safeParse({ age: "not-a-number" }).success).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("embedded with an unsupported sub-type → null (skip validation)", () => {
|
|
48
|
+
const schema = buildCustomFieldValueSchema({
|
|
49
|
+
type: "embedded",
|
|
50
|
+
schema: { blob: { type: "json" } },
|
|
51
|
+
});
|
|
52
|
+
expect(schema).toBeNull();
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -76,6 +76,35 @@ describe("wireCustomFieldsFor", () => {
|
|
|
76
76
|
});
|
|
77
77
|
});
|
|
78
78
|
|
|
79
|
+
test("postQuery-hook lets base columns win over shadowing custom fieldKeys", async () => {
|
|
80
|
+
const feature = defineFeature("test-property", (r) => {
|
|
81
|
+
r.entity("property", propertyEntity);
|
|
82
|
+
wireCustomFieldsFor(r, "property", propertyTable);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const hook = feature.entityHooks.postQuery["property"]?.[0]?.fn;
|
|
86
|
+
const result = await hook?.(
|
|
87
|
+
{
|
|
88
|
+
entityName: "property",
|
|
89
|
+
rows: [
|
|
90
|
+
{
|
|
91
|
+
id: "p1",
|
|
92
|
+
name: "Hofgarten",
|
|
93
|
+
// a malicious/colliding custom fieldKey must not shadow the real column
|
|
94
|
+
customFields: { id: "spoofed", name: "spoofed", internalNumber: "X-42" },
|
|
95
|
+
},
|
|
96
|
+
],
|
|
97
|
+
},
|
|
98
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
99
|
+
{} as never,
|
|
100
|
+
);
|
|
101
|
+
expect(result?.rows[0]).toMatchObject({
|
|
102
|
+
id: "p1",
|
|
103
|
+
name: "Hofgarten",
|
|
104
|
+
internalNumber: "X-42",
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
79
108
|
test("postQuery-hook handles missing/invalid customFields gracefully", async () => {
|
|
80
109
|
const feature = defineFeature("test-property", (r) => {
|
|
81
110
|
r.entity("property", propertyEntity);
|
|
@@ -28,13 +28,14 @@ export const CustomFieldsQueries = {
|
|
|
28
28
|
// `component: { react: { __component: CUSTOM_FIELDS_FORM_EXTENSION_NAME } }`.
|
|
29
29
|
export const CUSTOM_FIELDS_FORM_EXTENSION_NAME = "CustomFieldsFormSection";
|
|
30
30
|
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
31
|
+
// Entity-CRUD auto-events for the `field-definition` entity. registry.ts emits
|
|
32
|
+
// these as `${entityName}.created`/`.updated` (dot form) — they do NOT run
|
|
33
|
+
// through r.defineEvent/toKebab, so the dot MUST stay.
|
|
34
|
+
export const FIELD_DEFINITION_CREATED_EVENT = "field-definition.created";
|
|
35
|
+
export const FIELD_DEFINITION_UPDATED_EVENT = "field-definition.updated";
|
|
36
|
+
// Qualified at registration via r.defineEvent — final name is
|
|
37
|
+
// `custom-fields:event:field-definition-deleted`. Short-name MUST be kebab
|
|
38
|
+
// (no dots): qualifyEntityName runs toKebab which collapses dots to dashes.
|
|
38
39
|
export const FIELD_DEFINITION_DELETED_EVENT = "field-definition-deleted";
|
|
39
40
|
|
|
40
41
|
// Custom-field-VALUE events. Live auf host-aggregate stream (ES-Option-B).
|
|
@@ -1,31 +1,42 @@
|
|
|
1
1
|
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
2
|
import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
|
|
3
|
+
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
4
|
|
|
4
5
|
function quoteTable(tableName: string): string {
|
|
5
6
|
return `"${tableName.replace(/"/g, '""')}"`;
|
|
6
7
|
}
|
|
7
8
|
|
|
8
9
|
function bindJsonbParam(value: unknown): { sql: string; bound: unknown } {
|
|
9
|
-
//
|
|
10
|
-
|
|
10
|
+
// Scalar JSON primitives can't bind directly to ::jsonb — Postgres rejects a
|
|
11
|
+
// bound boolean/number with "cannot cast type boolean/integer to jsonb" (and
|
|
12
|
+
// Bun.SQL infers boolean[] candidates). Route them through ::text::jsonb with
|
|
13
|
+
// a JSON-encoded literal. Objects/arrays/strings already bind as ::jsonb.
|
|
14
|
+
if (typeof value === "boolean" || typeof value === "number") {
|
|
11
15
|
return { sql: "$1::text::jsonb", bound: JSON.stringify(value) };
|
|
12
16
|
}
|
|
17
|
+
// JSON.stringify throws on bigint; its decimal string is a valid JSON number literal.
|
|
18
|
+
if (typeof value === "bigint") {
|
|
19
|
+
return { sql: "$1::text::jsonb", bound: value.toString() };
|
|
20
|
+
}
|
|
13
21
|
return { sql: "$1::jsonb", bound: value };
|
|
14
22
|
}
|
|
15
23
|
|
|
24
|
+
// Security invariant: aggregateId is a global row UUID, so without the tenant_id
|
|
25
|
+
// filter tenant A could mutate tenant B's row by its UUID (cf. removeCustomFieldKeyForTenant).
|
|
16
26
|
export async function setCustomFieldValue(
|
|
17
27
|
db: DbRunner,
|
|
18
28
|
tableName: string,
|
|
19
29
|
fieldKey: string,
|
|
20
30
|
value: unknown,
|
|
21
31
|
aggregateId: string,
|
|
32
|
+
tenantId: TenantId,
|
|
22
33
|
): Promise<void> {
|
|
23
34
|
const tbl = quoteTable(tableName);
|
|
24
35
|
const escapedKey = fieldKey.replace(/'/g, "''");
|
|
25
36
|
const jsonb = bindJsonbParam(value);
|
|
26
37
|
await asRawClient(db).unsafe(
|
|
27
|
-
`UPDATE ${tbl} SET custom_fields = jsonb_set(custom_fields, '{${escapedKey}}', ${jsonb.sql}, true) WHERE id = $2`,
|
|
28
|
-
[jsonb.bound, aggregateId],
|
|
38
|
+
`UPDATE ${tbl} SET custom_fields = jsonb_set(custom_fields, '{${escapedKey}}', ${jsonb.sql}, true) WHERE id = $2 AND tenant_id = $3`,
|
|
39
|
+
[jsonb.bound, aggregateId, tenantId],
|
|
29
40
|
);
|
|
30
41
|
}
|
|
31
42
|
|
|
@@ -34,11 +45,12 @@ export async function clearCustomFieldKey(
|
|
|
34
45
|
tableName: string,
|
|
35
46
|
fieldKey: string,
|
|
36
47
|
aggregateId: string,
|
|
48
|
+
tenantId: TenantId,
|
|
37
49
|
): Promise<void> {
|
|
38
50
|
const tbl = quoteTable(tableName);
|
|
39
51
|
await asRawClient(db).unsafe(
|
|
40
|
-
`UPDATE ${tbl} SET custom_fields = custom_fields - $1 WHERE id = $2`,
|
|
41
|
-
[fieldKey, aggregateId],
|
|
52
|
+
`UPDATE ${tbl} SET custom_fields = custom_fields - $1 WHERE id = $2 AND tenant_id = $3`,
|
|
53
|
+
[fieldKey, aggregateId, tenantId],
|
|
42
54
|
);
|
|
43
55
|
}
|
|
44
56
|
|
|
@@ -50,7 +62,7 @@ export async function removeCustomFieldKeyForTenant(
|
|
|
50
62
|
db: DbRunner,
|
|
51
63
|
tableName: string,
|
|
52
64
|
fieldKey: string,
|
|
53
|
-
tenantId:
|
|
65
|
+
tenantId: TenantId,
|
|
54
66
|
): Promise<void> {
|
|
55
67
|
const tbl = quoteTable(tableName);
|
|
56
68
|
await asRawClient(db).unsafe(
|
|
@@ -25,15 +25,29 @@ export async function selectHostRowsWithCustomFields(
|
|
|
25
25
|
return Array.isArray(rowsResult) ? rowsResult : [];
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
export async function
|
|
28
|
+
export async function applyRetentionRemovals(
|
|
29
29
|
db: DbRunner,
|
|
30
30
|
tableName: string,
|
|
31
|
-
|
|
31
|
+
deleteKeys: readonly string[],
|
|
32
|
+
anonymizeKeys: readonly string[],
|
|
32
33
|
rowId: string,
|
|
33
34
|
): Promise<void> {
|
|
34
35
|
const quoted = `"${tableName.replace(/"/g, '""')}"`;
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
36
|
+
// Atomic per-row jsonb edit instead of read-modify-write of the whole
|
|
37
|
+
// object: delete-keys are dropped (`- $1::text[]`), anonymize-keys are set
|
|
38
|
+
// to JSON null via a merge patch. Operating on the live row value preserves
|
|
39
|
+
// a concurrent set-custom-field on any *other* key — no lost update.
|
|
40
|
+
await asRawClient(db).unsafe(
|
|
41
|
+
`UPDATE ${quoted} SET custom_fields = CASE
|
|
42
|
+
WHEN jsonb_typeof(custom_fields) = 'object' THEN
|
|
43
|
+
(custom_fields - $1::text[])
|
|
44
|
+
|| COALESCE(
|
|
45
|
+
(SELECT jsonb_object_agg(k, 'null'::jsonb) FROM unnest($2::text[]) AS k),
|
|
46
|
+
'{}'::jsonb
|
|
47
|
+
)
|
|
48
|
+
ELSE custom_fields
|
|
49
|
+
END
|
|
50
|
+
WHERE id = $3`,
|
|
51
|
+
[deleteKeys, anonymizeKeys, rowId],
|
|
52
|
+
);
|
|
39
53
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { createEntityExecutor } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { fieldDefinitionEntity } from "./entity";
|
|
3
|
+
|
|
4
|
+
// Single field-definition executor shared by the four define/delete handlers.
|
|
5
|
+
// createEntityExecutor is side-effect-free; instantiating it once keeps the
|
|
6
|
+
// table+executor pair in one place instead of rebuilding it per handler module.
|
|
7
|
+
export const { executor: fieldDefinitionExecutor } = createEntityExecutor(
|
|
8
|
+
"field-definition",
|
|
9
|
+
fieldDefinitionEntity,
|
|
10
|
+
);
|