@cosmicdrift/kumiko-bundled-features 0.24.1 → 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.
Files changed (40) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/env-schemas.test.ts +53 -11
  3. package/src/custom-fields/__tests__/audit-integration.integration.test.ts +28 -1
  4. package/src/custom-fields/__tests__/custom-fields.integration.test.ts +139 -0
  5. package/src/custom-fields/__tests__/drift.test.ts +43 -0
  6. package/src/custom-fields/__tests__/field-definition-row.test.ts +62 -0
  7. package/src/custom-fields/__tests__/retention.integration.test.ts +76 -0
  8. package/src/custom-fields/__tests__/wire-for-entity.test.ts +29 -0
  9. package/src/custom-fields/constants.ts +8 -7
  10. package/src/custom-fields/db/queries/projection.ts +13 -5
  11. package/src/custom-fields/db/queries/retention.ts +20 -6
  12. package/src/custom-fields/executor.ts +10 -0
  13. package/src/custom-fields/feature.ts +32 -39
  14. package/src/custom-fields/handlers/clear-custom-field.write.ts +5 -1
  15. package/src/custom-fields/handlers/define-system-field.write.ts +5 -22
  16. package/src/custom-fields/handlers/define-tenant-field.write.ts +13 -23
  17. package/src/custom-fields/handlers/delete-system-field.write.ts +3 -9
  18. package/src/custom-fields/handlers/delete-tenant-field.write.ts +3 -9
  19. package/src/custom-fields/lib/field-access.ts +4 -0
  20. package/src/custom-fields/lib/field-definition-row.ts +33 -0
  21. package/src/custom-fields/run-retention.ts +6 -5
  22. package/src/custom-fields/web/__tests__/custom-fields-form-section.test.tsx +45 -10
  23. package/src/custom-fields/web/client-plugin.tsx +2 -0
  24. package/src/custom-fields/web/custom-fields-form-section.tsx +19 -9
  25. package/src/custom-fields/web/i18n.ts +30 -0
  26. package/src/custom-fields/wire-for-entity.ts +1 -1
  27. package/src/custom-fields/wire-user-data-rights.ts +9 -0
  28. package/src/feature-toggles/handlers/set.write.ts +13 -8
  29. package/src/secrets/feature.ts +4 -11
  30. package/src/subscription-stripe/feature.ts +2 -2
  31. package/src/template-resolver/handlers/list.query.ts +12 -10
  32. package/src/tenant/__tests__/seed-testing.integration.test.ts +26 -0
  33. package/src/tenant/seeding.ts +3 -3
  34. package/src/user-data-rights/__tests__/file-binary-forget-cleanup.integration.test.ts +26 -74
  35. package/src/user-data-rights/__tests__/file-binary-forget-failure.integration.test.ts +211 -0
  36. package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +272 -0
  37. package/src/user-data-rights/__tests__/forget-test-helpers.ts +86 -0
  38. package/src/user-data-rights/db/queries/export-jobs.ts +1 -0
  39. package/src/user-data-rights/run-forget-cleanup.ts +77 -36
  40. package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +21 -6
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.24.1",
3
+ "version": "0.25.0",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -1,10 +1,6 @@
1
1
  import { describe, expect, it } from "bun:test";
2
2
  import { randomBytes } from "node:crypto";
3
- import {
4
- composeEnvSchema,
5
- type KumikoBootError,
6
- parseEnv,
7
- } from "@cosmicdrift/kumiko-framework/env";
3
+ import { composeEnvSchema, KumikoBootError, parseEnv } from "@cosmicdrift/kumiko-framework/env";
8
4
  import { authEmailPasswordEnvSchema, createAuthEmailPasswordFeature } from "../auth-email-password";
9
5
  import { createSecretsFeature, secretsEnvSchema } from "../secrets";
10
6
  import {
@@ -18,6 +14,13 @@ import {
18
14
 
19
15
  const validKek = randomBytes(32).toString("base64");
20
16
 
17
+ function asBootError(err: unknown): KumikoBootError {
18
+ if (!(err instanceof KumikoBootError)) {
19
+ throw err instanceof Error ? err : new Error(String(err));
20
+ }
21
+ return err;
22
+ }
23
+
21
24
  describe("secretsEnvSchema", () => {
22
25
  it("accepts a base64-32 KEK and defaults CURRENT_VERSION to '1'", () => {
23
26
  const env = parseEnv(secretsEnvSchema, {
@@ -32,7 +35,7 @@ describe("secretsEnvSchema", () => {
32
35
  parseEnv(secretsEnvSchema, { KUMIKO_SECRETS_MASTER_KEY_V1: "dGVzdA==" });
33
36
  throw new Error("should have thrown");
34
37
  } catch (err) {
35
- const boot = err as KumikoBootError;
38
+ const boot = asBootError(err);
36
39
  const v1 = boot.errors.find((e) => e.name === "KUMIKO_SECRETS_MASTER_KEY_V1");
37
40
  expect(v1?.kind).toBe("invalid");
38
41
  expect(v1?.message).toContain("32 bytes");
@@ -47,13 +50,36 @@ describe("secretsEnvSchema", () => {
47
50
  });
48
51
  throw new Error("should have thrown");
49
52
  } catch (err) {
50
- const cur = (err as KumikoBootError).errors.find(
53
+ const cur = asBootError(err).errors.find(
54
+ (e) => e.name === "KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION",
55
+ );
56
+ expect(cur?.kind).toBe("invalid");
57
+ }
58
+ });
59
+
60
+ it("rejects CURRENT_VERSION '0' (V0 never exists, selector starts at V1)", () => {
61
+ try {
62
+ parseEnv(secretsEnvSchema, {
63
+ KUMIKO_SECRETS_MASTER_KEY_V1: validKek,
64
+ KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: "0",
65
+ });
66
+ throw new Error("should have thrown");
67
+ } catch (err) {
68
+ const cur = asBootError(err).errors.find(
51
69
  (e) => e.name === "KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION",
52
70
  );
53
71
  expect(cur?.kind).toBe("invalid");
54
72
  }
55
73
  });
56
74
 
75
+ it("accepts CURRENT_VERSION '2' (positive version selector)", () => {
76
+ const env = parseEnv(secretsEnvSchema, {
77
+ KUMIKO_SECRETS_MASTER_KEY_V1: validKek,
78
+ KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: "2",
79
+ });
80
+ expect(env.KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION).toBe("2");
81
+ });
82
+
57
83
  it("attaches the schema via r.envSchema() on createSecretsFeature()", () => {
58
84
  const f = createSecretsFeature();
59
85
  expect(f.envSchema).toBe(secretsEnvSchema);
@@ -74,7 +100,7 @@ describe("authEmailPasswordEnvSchema", () => {
74
100
  parseEnv(authEmailPasswordEnvSchema, { JWT_SECRET: "short" });
75
101
  throw new Error("should have thrown");
76
102
  } catch (err) {
77
- const jwt = (err as KumikoBootError).errors.find((e) => e.name === "JWT_SECRET");
103
+ const jwt = asBootError(err).errors.find((e) => e.name === "JWT_SECRET");
78
104
  expect(jwt?.kind).toBe("invalid");
79
105
  }
80
106
  });
@@ -103,11 +129,27 @@ describe("subscriptionStripeEnvSchema", () => {
103
129
  });
104
130
  throw new Error("should have thrown");
105
131
  } catch (err) {
106
- const boot = err as KumikoBootError;
132
+ const boot = asBootError(err);
107
133
  expect(boot.errors.length).toBe(2);
108
134
  }
109
135
  });
110
136
 
137
+ it("rejects a publishable key and a non-whsec webhook secret", () => {
138
+ try {
139
+ parseEnv(subscriptionStripeEnvSchema, {
140
+ STRIPE_WEBHOOK_SECRET: "wrong_abc",
141
+ STRIPE_API_KEY: "pk_live_xyz",
142
+ });
143
+ throw new Error("should have thrown");
144
+ } catch (err) {
145
+ const boot = asBootError(err);
146
+ const api = boot.errors.find((e) => e.name === "STRIPE_API_KEY");
147
+ const hook = boot.errors.find((e) => e.name === "STRIPE_WEBHOOK_SECRET");
148
+ expect(api?.kind).toBe("invalid");
149
+ expect(hook?.kind).toBe("invalid");
150
+ }
151
+ });
152
+
111
153
  it("attaches the schema via r.envSchema() on the factory", () => {
112
154
  const f = createSubscriptionStripeFeature({
113
155
  webhookSecret: "whsec_x",
@@ -129,7 +171,7 @@ describe("subscriptionMollieEnvSchema", () => {
129
171
  parseEnv(subscriptionMollieEnvSchema, { MOLLIE_API_KEY: "no-prefix" });
130
172
  throw new Error("should have thrown");
131
173
  } catch (err) {
132
- const k = (err as KumikoBootError).errors.find((e) => e.name === "MOLLIE_API_KEY");
174
+ const k = asBootError(err).errors.find((e) => e.name === "MOLLIE_API_KEY");
133
175
  expect(k?.kind).toBe("invalid");
134
176
  }
135
177
  });
@@ -200,7 +242,7 @@ describe("compose across all Phase-2 features", () => {
200
242
  parseEnv(composed.schema, {}, { sources: composed.sources });
201
243
  throw new Error("should have thrown");
202
244
  } catch (err) {
203
- const out = (err as KumikoBootError).format();
245
+ const out = asBootError(err).format();
204
246
  expect(out).toContain("✗ JWT_SECRET (auth-email-password, required, missing)");
205
247
  expect(out).toContain("✗ KUMIKO_SECRETS_MASTER_KEY_V1 (secrets, required, missing)");
206
248
  expect(out).toContain("✗ STRIPE_API_KEY (subscription-stripe, required, missing)");
@@ -265,13 +265,40 @@ describe("T1.5a: custom-fields events are visible in the audit log", () => {
265
265
  adminWithAudit,
266
266
  );
267
267
 
268
+ // Tenant-2 defines its OWN field. Without this, an audit query that
269
+ // returned zero rows for ANY reason (e.g. a broken filter) would still
270
+ // pass the "doesn't see leakyField" assertion — a false-positive that
271
+ // reads "isolated" but actually means "blind". Asserting tenant-2 sees its
272
+ // own event proves the query genuinely returns tenant-2's data.
273
+ const otherTenantDefiner = createTestUser({
274
+ id: 11,
275
+ roles: ["TenantAdmin"],
276
+ tenantId: otherTenantAdmin.tenantId,
277
+ });
278
+ await stack.http.writeOk(
279
+ "custom-fields:write:define-tenant-field",
280
+ {
281
+ entityName: "property",
282
+ fieldKey: "ownField",
283
+ serializedField: { type: "text" },
284
+ required: false,
285
+ searchable: false,
286
+ displayOrder: 0,
287
+ },
288
+ otherTenantDefiner,
289
+ );
290
+
268
291
  const res = await stack.http.queryOk<AuditResponse>(
269
292
  AuditQueries.list,
270
293
  { aggregateType: "field-definition" },
271
294
  otherTenantAdmin,
272
295
  );
273
296
 
274
- const leak = res.rows.find((r) => (r.payload["fieldKey"] as string) === "leakyField");
297
+ // Tenant-2 sees its own event ...
298
+ const own = res.rows.find((r) => r.payload["fieldKey"] === "ownField");
299
+ expect(own).toBeDefined();
300
+ // ... but never tenant-1's.
301
+ const leak = res.rows.find((r) => r.payload["fieldKey"] === "leakyField");
275
302
  expect(leak).toBeUndefined();
276
303
  });
277
304
  });
@@ -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
+ });
@@ -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
  });
@@ -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
- // Event-Type-Names (qualified at registration via r.defineEvent — final
32
- // names are `custom-fields:event:field-definition-created` etc.).
33
- // Short-names MUST be in kebab-case (no dots): qualifyEntityName runs toKebab
34
- // which collapses dots to dashes, so a dotted short-name diverges from the
35
- // registry key when handlers hand-build the qualified string.
36
- export const FIELD_DEFINITION_CREATED_EVENT = "field-definition-created";
37
- export const FIELD_DEFINITION_UPDATED_EVENT = "field-definition-updated";
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,15 +1,23 @@
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
- // postgres-js infers boolean params as boolean[] candidatesroute via text::jsonb.
10
- if (typeof value === "boolean") {
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
 
@@ -21,7 +29,7 @@ export async function setCustomFieldValue(
21
29
  fieldKey: string,
22
30
  value: unknown,
23
31
  aggregateId: string,
24
- tenantId: string,
32
+ tenantId: TenantId,
25
33
  ): Promise<void> {
26
34
  const tbl = quoteTable(tableName);
27
35
  const escapedKey = fieldKey.replace(/'/g, "''");
@@ -37,7 +45,7 @@ export async function clearCustomFieldKey(
37
45
  tableName: string,
38
46
  fieldKey: string,
39
47
  aggregateId: string,
40
- tenantId: string,
48
+ tenantId: TenantId,
41
49
  ): Promise<void> {
42
50
  const tbl = quoteTable(tableName);
43
51
  await asRawClient(db).unsafe(
@@ -54,7 +62,7 @@ export async function removeCustomFieldKeyForTenant(
54
62
  db: DbRunner,
55
63
  tableName: string,
56
64
  fieldKey: string,
57
- tenantId: string,
65
+ tenantId: TenantId,
58
66
  ): Promise<void> {
59
67
  const tbl = quoteTable(tableName);
60
68
  await asRawClient(db).unsafe(