@cosmicdrift/kumiko-framework 0.105.1 → 0.108.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 (92) hide show
  1. package/package.json +2 -2
  2. package/src/__tests__/full-stack.integration.test.ts +3 -2
  3. package/src/__tests__/schema-cli.integration.test.ts +29 -0
  4. package/src/__tests__/transition-guard.integration.test.ts +2 -2
  5. package/src/api/__tests__/batch.integration.test.ts +3 -2
  6. package/src/api/__tests__/dispatcher-live.integration.test.ts +3 -2
  7. package/src/api/__tests__/jwt.test.ts +27 -0
  8. package/src/api/__tests__/pat-scope.test.ts +36 -0
  9. package/src/api/__tests__/request-id-middleware.test.ts +51 -0
  10. package/src/api/auth-middleware.ts +65 -1
  11. package/src/api/auth-routes.ts +11 -0
  12. package/src/api/index.ts +3 -1
  13. package/src/api/jwt.ts +5 -5
  14. package/src/api/pat-scope.ts +14 -0
  15. package/src/api/request-context.ts +3 -0
  16. package/src/api/request-id-middleware.ts +2 -0
  17. package/src/api/routes.ts +22 -0
  18. package/src/api/server.ts +29 -1
  19. package/src/bun-db/__tests__/batch-methods.test.ts +3 -2
  20. package/src/bun-db/__tests__/query-guards.test.ts +3 -2
  21. package/src/bun-db/__tests__/write-brand.test.ts +48 -0
  22. package/src/bun-db/query.ts +40 -9
  23. package/src/db/__tests__/assert-exists-in.integration.test.ts +2 -2
  24. package/src/db/__tests__/eagerload.integration.test.ts +2 -2
  25. package/src/db/__tests__/event-store-executor.integration.test.ts +138 -1
  26. package/src/db/__tests__/implicit-projection-equivalence.integration.test.ts +3 -1
  27. package/src/db/__tests__/multi-row-insert.integration.test.ts +5 -4
  28. package/src/db/__tests__/schema-migration.integration.test.ts +4 -3
  29. package/src/db/__tests__/source-shadow-create.integration.test.ts +3 -2
  30. package/src/db/__tests__/tenant-db-where-merge.integration.test.ts +3 -2
  31. package/src/db/__tests__/tenant-db.integration.test.ts +7 -6
  32. package/src/db/apply-entity-event.ts +19 -8
  33. package/src/db/event-store-executor.ts +91 -8
  34. package/src/db/queries/shadow-swap.ts +1 -1
  35. package/src/db/query.ts +1 -0
  36. package/src/db/table-builder.ts +23 -1
  37. package/src/db/tenant-db.ts +6 -0
  38. package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +6 -5
  39. package/src/engine/__tests__/boot-validator.test.ts +210 -0
  40. package/src/engine/__tests__/build-config-feature-schema.test.ts +21 -0
  41. package/src/engine/__tests__/define-feature-entity-mapping.test.ts +6 -0
  42. package/src/engine/__tests__/extend-entity-projection.test.ts +123 -0
  43. package/src/engine/__tests__/projection-helpers.test.ts +2 -2
  44. package/src/engine/__tests__/required-surface-keys.test.ts +134 -1
  45. package/src/engine/__tests__/soft-delete-cleanup.test.ts +49 -13
  46. package/src/engine/boot-validator/entity-handler.ts +45 -0
  47. package/src/engine/boot-validator/gdpr-storage.ts +2 -1
  48. package/src/engine/boot-validator/index.ts +14 -1
  49. package/src/engine/boot-validator/screens-nav.ts +90 -6
  50. package/src/engine/build-app-schema.ts +15 -7
  51. package/src/engine/define-feature.ts +17 -0
  52. package/src/engine/define-handler.ts +16 -2
  53. package/src/engine/entity-handlers.ts +32 -13
  54. package/src/engine/extensions/user-data.ts +6 -0
  55. package/src/engine/index.ts +6 -1
  56. package/src/engine/projection-helpers.ts +8 -5
  57. package/src/engine/registry.ts +47 -2
  58. package/src/engine/schema-builder.ts +3 -1
  59. package/src/engine/soft-delete-cleanup.ts +41 -4
  60. package/src/engine/steps/unsafe-projection-delete.ts +5 -1
  61. package/src/engine/tier-resolver-extension.ts +11 -0
  62. package/src/engine/types/feature.ts +29 -21
  63. package/src/engine/types/fields.ts +12 -0
  64. package/src/engine/types/handlers.ts +13 -0
  65. package/src/engine/types/index.ts +2 -0
  66. package/src/engine/types/projection.ts +33 -5
  67. package/src/event-store/index.ts +8 -0
  68. package/src/event-store/rebuild-dead-letter.ts +111 -0
  69. package/src/files/__tests__/storage-tracking.integration.test.ts +8 -0
  70. package/src/files/file-routes.ts +1 -1
  71. package/src/pipeline/__tests__/dispatcher.test.ts +43 -0
  72. package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +2 -2
  73. package/src/pipeline/__tests__/projection-rebuild.integration.test.ts +24 -0
  74. package/src/pipeline/__tests__/rebuild-poison-quarantine.integration.test.ts +274 -0
  75. package/src/pipeline/dispatcher.ts +4 -10
  76. package/src/pipeline/msp-rebuild.ts +36 -3
  77. package/src/pipeline/projection-rebuild.ts +55 -3
  78. package/src/pipeline/projections-runner.ts +1 -1
  79. package/src/schema-cli.ts +24 -15
  80. package/src/secrets/__tests__/contains-secret.test.ts +34 -0
  81. package/src/secrets/types.ts +8 -1
  82. package/src/testing/db-cleanup.ts +4 -1
  83. package/src/testing/index.ts +1 -0
  84. package/src/testing/seed.ts +50 -0
  85. package/src/time/__tests__/boot-tz-warning.test.ts +24 -2
  86. package/src/time/__tests__/geo-tz.test.ts +9 -3
  87. package/src/time/__tests__/iana.test.ts +9 -0
  88. package/src/time/boot-tz-warning.ts +5 -1
  89. package/src/time/iana.ts +17 -15
  90. package/src/time/tz-context.ts +6 -1
  91. package/src/utils/__tests__/serialization.test.ts +6 -0
  92. package/src/utils/serialization.ts +10 -3
@@ -0,0 +1,123 @@
1
+ // r.extendEntityProjection — registrar/registry contracts (#759). The
2
+ // runtime rebuild-replay behaviour is proven in
3
+ // custom-fields/__tests__/custom-fields.integration.test.ts.
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import { z } from "zod";
7
+ import { defineFeature } from "../define-feature";
8
+ import { createEntity, createTextField } from "../factories";
9
+ import { createRegistry } from "../registry";
10
+
11
+ function exampleEntity(name = "unit") {
12
+ return createEntity({
13
+ table: name,
14
+ fields: { name: createTextField() },
15
+ });
16
+ }
17
+
18
+ const noopApply = async (): Promise<void> => {};
19
+
20
+ describe("r.extendEntityProjection — registration", () => {
21
+ test("merges apply keys + extraSources into the implicit projection", () => {
22
+ const feature = defineFeature("test", (r) => {
23
+ r.entity("unit", exampleEntity());
24
+ const setEvent = r.defineEvent("custom-field-set", z.object({ fieldKey: z.string() }));
25
+ r.extendEntityProjection("unit", {
26
+ sources: ["field-definition"],
27
+ apply: { [setEvent.name]: noopApply },
28
+ });
29
+ });
30
+ const registry = createRegistry([feature]);
31
+ const projection = registry.getAllProjections().get("test:projection:unit-entity");
32
+ expect(projection).toBeDefined();
33
+ expect(projection?.apply["test:event:custom-field-set"]).toBe(noopApply);
34
+ expect(projection?.apply["unit.created"]).toBeDefined();
35
+ expect(projection?.extraSources).toEqual(["field-definition"]);
36
+ expect(projection?.source).toBe("unit");
37
+ });
38
+
39
+ test("extension source equal to the entity name is not duplicated into extraSources", () => {
40
+ const feature = defineFeature("test", (r) => {
41
+ r.entity("unit", exampleEntity());
42
+ const setEvent = r.defineEvent("custom-field-set", z.object({ fieldKey: z.string() }));
43
+ r.extendEntityProjection("unit", {
44
+ sources: ["unit"],
45
+ apply: { [setEvent.name]: noopApply },
46
+ });
47
+ });
48
+ const registry = createRegistry([feature]);
49
+ const projection = registry.getAllProjections().get("test:projection:unit-entity");
50
+ expect(projection?.extraSources).toBeUndefined();
51
+ });
52
+
53
+ test("auto-verb of a registered extraSources entity is a valid apply-key", () => {
54
+ const feature = defineFeature("test", (r) => {
55
+ r.entity("unit", exampleEntity());
56
+ r.entity("field-definition", exampleEntity("field_definitions"));
57
+ r.extendEntityProjection("unit", {
58
+ sources: ["field-definition"],
59
+ apply: { "field-definition.deleted": noopApply },
60
+ });
61
+ });
62
+ const registry = createRegistry([feature]);
63
+ const projection = registry.getAllProjections().get("test:projection:unit-entity");
64
+ expect(projection?.apply["field-definition.deleted"]).toBe(noopApply);
65
+ });
66
+
67
+ test("registration order is free: extension before r.entity in the same feature", () => {
68
+ const feature = defineFeature("test", (r) => {
69
+ const setEvent = r.defineEvent("custom-field-set", z.object({ fieldKey: z.string() }));
70
+ r.extendEntityProjection("unit", { apply: { [setEvent.name]: noopApply } });
71
+ r.entity("unit", exampleEntity());
72
+ });
73
+ const registry = createRegistry([feature]);
74
+ const projection = registry.getAllProjections().get("test:projection:unit-entity");
75
+ expect(projection?.apply["test:event:custom-field-set"]).toBe(noopApply);
76
+ });
77
+
78
+ test("empty apply throws at registration time", () => {
79
+ expect(() =>
80
+ defineFeature("test", (r) => {
81
+ r.entity("unit", exampleEntity());
82
+ r.extendEntityProjection("unit", { apply: {} });
83
+ }),
84
+ ).toThrow(/no apply handlers/);
85
+ });
86
+ });
87
+
88
+ describe("r.extendEntityProjection — registry-build validation", () => {
89
+ test("unknown entity fails at registry build", () => {
90
+ const feature = defineFeature("test", (r) => {
91
+ r.entity("unit", exampleEntity());
92
+ const setEvent = r.defineEvent("custom-field-set", z.object({ fieldKey: z.string() }));
93
+ r.extendEntityProjection("typo-entity", { apply: { [setEvent.name]: noopApply } });
94
+ });
95
+ expect(() => createRegistry([feature])).toThrow(/no r\.entity/);
96
+ });
97
+
98
+ test("apply-key collision with a lifecycle apply fails at registry build", () => {
99
+ const feature = defineFeature("test", (r) => {
100
+ r.entity("unit", exampleEntity());
101
+ r.extendEntityProjection("unit", { apply: { "unit.created": noopApply } });
102
+ });
103
+ expect(() => createRegistry([feature])).toThrow(/collides/);
104
+ });
105
+
106
+ test("apply-key collision between two extensions fails at registry build", () => {
107
+ const feature = defineFeature("test", (r) => {
108
+ r.entity("unit", exampleEntity());
109
+ const setEvent = r.defineEvent("custom-field-set", z.object({ fieldKey: z.string() }));
110
+ r.extendEntityProjection("unit", { apply: { [setEvent.name]: noopApply } });
111
+ r.extendEntityProjection("unit", { apply: { [setEvent.name]: noopApply } });
112
+ });
113
+ expect(() => createRegistry([feature])).toThrow(/collides/);
114
+ });
115
+
116
+ test("unknown apply-key (neither auto-verb nor domain event) fails at registry build", () => {
117
+ const feature = defineFeature("test", (r) => {
118
+ r.entity("unit", exampleEntity());
119
+ r.extendEntityProjection("unit", { apply: { "customField.set": noopApply } });
120
+ });
121
+ expect(() => createRegistry([feature])).toThrow(/no such event/);
122
+ });
123
+ });
@@ -50,7 +50,7 @@ describe("setFields", () => {
50
50
  test("returns an apply fn that UPDATEs the passed fields WHERE id = aggregateId", async () => {
51
51
  const apply = setFields(fakeTable, { status: "sent" });
52
52
  const { fakeTx, unsafe } = makeFakeTx();
53
- await apply(makeFakeEvent(), fakeTx);
53
+ await apply(makeFakeEvent(), fakeTx, fakeTable);
54
54
  expect(unsafe).toHaveBeenCalledTimes(1);
55
55
  const [sqlText, params] = unsafe.mock.calls[0]!;
56
56
  expect(sqlText).toMatch(/UPDATE "fake_table" SET "status" = \$1.*WHERE "id" = \$2/);
@@ -62,7 +62,7 @@ describe("setFields", () => {
62
62
  status: (event.payload as { newStatus: string }).newStatus,
63
63
  }));
64
64
  const { fakeTx, unsafe } = makeFakeTx();
65
- await apply(makeFakeEvent({ payload: { newStatus: "cancelled" } }), fakeTx);
65
+ await apply(makeFakeEvent({ payload: { newStatus: "cancelled" } }), fakeTx, fakeTable);
66
66
  expect(unsafe).toHaveBeenCalledTimes(1);
67
67
  const [, params] = unsafe.mock.calls[0]!;
68
68
  expect(params).toEqual(["cancelled", "agg-42"]);
@@ -1,11 +1,18 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import {
3
3
  ACTION_FORM_ENTITY,
4
+ CONFIG_EDIT_ENTITY,
4
5
  fieldLabelKey,
6
+ requiredKeysFromNav,
5
7
  requiredKeysFromScreen,
8
+ requiredKeysFromWorkspace,
6
9
  screenTitleKey,
7
10
  } from "../../i18n/required-surface-keys";
8
- import type { EntityListScreenDefinition } from "../types";
11
+ import type {
12
+ ConfigEditScreenDefinition,
13
+ EntityEditScreenDefinition,
14
+ EntityListScreenDefinition,
15
+ } from "../types";
9
16
 
10
17
  describe("requiredKeysFromScreen", () => {
11
18
  test("entityList emits screen title + column field labels", () => {
@@ -35,4 +42,130 @@ describe("requiredKeysFromScreen", () => {
35
42
  });
36
43
  expect(keys).toContain(fieldLabelKey("publicstatus", ACTION_FORM_ENTITY, "title"));
37
44
  });
45
+
46
+ test("entityEdit emits submitLabel + section titles + field labels (override honored)", () => {
47
+ const screen: EntityEditScreenDefinition = {
48
+ id: "component-edit",
49
+ type: "entityEdit",
50
+ entity: "component",
51
+ submitLabel: "publicstatus:actions.saveComponent",
52
+ fieldLabels: { name: "publicstatus:override.name" },
53
+ layout: {
54
+ sections: [{ title: "publicstatus:section.basics", fields: ["name", "status"] }],
55
+ },
56
+ };
57
+ const keys = requiredKeysFromScreen("publicstatus", screen);
58
+ expect(keys).toContain("publicstatus:actions.saveComponent");
59
+ expect(keys).toContain("publicstatus:section.basics");
60
+ // override wins over the default entity:field convention
61
+ expect(keys).toContain("publicstatus:override.name");
62
+ expect(keys).not.toContain(fieldLabelKey("publicstatus", "component", "name"));
63
+ expect(keys).toContain(fieldLabelKey("publicstatus", "component", "status"));
64
+ });
65
+
66
+ test("entityEdit extension section pushes only the section title (no field labels)", () => {
67
+ const screen: EntityEditScreenDefinition = {
68
+ id: "component-edit",
69
+ type: "entityEdit",
70
+ entity: "component",
71
+ layout: {
72
+ sections: [
73
+ {
74
+ kind: "extension",
75
+ title: "publicstatus:section.customFields",
76
+ component: { react: {} },
77
+ },
78
+ ],
79
+ },
80
+ };
81
+ const keys = requiredKeysFromScreen("publicstatus", screen);
82
+ expect(keys).toEqual([screenTitleKey("component-edit"), "publicstatus:section.customFields"]);
83
+ });
84
+
85
+ test("configEdit uses the CONFIG_EDIT_ENTITY namespace, honors fieldLabels override", () => {
86
+ const screen: ConfigEditScreenDefinition = {
87
+ id: "settings-retention",
88
+ type: "configEdit",
89
+ scope: "tenant",
90
+ configKeys: { days: "publicstatus:config:retentionDays" },
91
+ fieldLabels: { days: "publicstatus:override.retentionDays" },
92
+ fields: { days: { type: "number" } },
93
+ layout: { sections: [{ fields: ["days"] }] },
94
+ };
95
+ const keys = requiredKeysFromScreen("publicstatus", screen);
96
+ expect(keys).toContain("publicstatus:override.retentionDays");
97
+ expect(keys).not.toContain(fieldLabelKey("publicstatus", CONFIG_EDIT_ENTITY, "days"));
98
+ });
99
+
100
+ test("configEdit without a fieldLabels override falls back to the CONFIG_EDIT_ENTITY convention", () => {
101
+ const screen: ConfigEditScreenDefinition = {
102
+ id: "settings-retention",
103
+ type: "configEdit",
104
+ scope: "tenant",
105
+ configKeys: { days: "publicstatus:config:retentionDays" },
106
+ fields: { days: { type: "number" } },
107
+ layout: { sections: [{ fields: ["days"] }] },
108
+ };
109
+ const keys = requiredKeysFromScreen("publicstatus", screen);
110
+ expect(keys).toContain(fieldLabelKey("publicstatus", CONFIG_EDIT_ENTITY, "days"));
111
+ });
112
+
113
+ test("custom screen emits only the screen title — no field surface to validate", () => {
114
+ const keys = requiredKeysFromScreen("publicstatus", {
115
+ id: "dashboard",
116
+ type: "custom",
117
+ renderer: { react: {} },
118
+ });
119
+ expect(keys).toEqual([screenTitleKey("dashboard")]);
120
+ });
121
+
122
+ test("entityList rowActions/toolbarActions emit label + confirm + confirmLabel", () => {
123
+ const screen: EntityListScreenDefinition = {
124
+ id: "component-list",
125
+ type: "entityList",
126
+ entity: "component",
127
+ columns: ["name"],
128
+ rowActions: [
129
+ {
130
+ id: "delete",
131
+ label: "publicstatus:actions.delete",
132
+ handler: "publicstatus:write:component:delete",
133
+ confirm: "publicstatus:confirm.deleteComponent",
134
+ confirmLabel: "publicstatus:confirm.deleteComponentButton",
135
+ style: "danger",
136
+ },
137
+ ],
138
+ toolbarActions: [
139
+ {
140
+ kind: "writeHandler",
141
+ id: "sync-all",
142
+ label: "publicstatus:actions.syncAll",
143
+ handler: "publicstatus:write:component:syncAll",
144
+ confirm: "publicstatus:confirm.syncAll",
145
+ confirmLabel: "publicstatus:confirm.syncAllButton",
146
+ },
147
+ ],
148
+ };
149
+ const keys = requiredKeysFromScreen("publicstatus", screen);
150
+ expect(keys).toContain("publicstatus:actions.delete");
151
+ expect(keys).toContain("publicstatus:confirm.deleteComponent");
152
+ expect(keys).toContain("publicstatus:confirm.deleteComponentButton");
153
+ expect(keys).toContain("publicstatus:actions.syncAll");
154
+ expect(keys).toContain("publicstatus:confirm.syncAll");
155
+ expect(keys).toContain("publicstatus:confirm.syncAllButton");
156
+ });
157
+ });
158
+
159
+ describe("requiredKeysFromNav / requiredKeysFromWorkspace", () => {
160
+ test("nav label is a required key", () => {
161
+ expect(requiredKeysFromNav({ id: "catalog", label: "shop:nav.catalog" })).toEqual([
162
+ "shop:nav.catalog",
163
+ ]);
164
+ });
165
+
166
+ test("workspace label is a required key", () => {
167
+ expect(
168
+ requiredKeysFromWorkspace({ id: "disposition", label: "bmc:workspace.disposition" }),
169
+ ).toEqual(["bmc:workspace.disposition"]);
170
+ });
38
171
  });
@@ -4,8 +4,10 @@ import { createRegistry } from "../registry";
4
4
  import {
5
5
  DEFAULT_GRACE_DAYS,
6
6
  SOFT_DELETE_CLEANUP_JOB,
7
+ SOFT_DELETE_CLEANUP_SYSTEM_JOB,
7
8
  SOFT_DELETE_GRACE_DAYS_KEY,
8
9
  softDeleteCleanupJob,
10
+ softDeleteCleanupSystemJob,
9
11
  } from "../soft-delete-cleanup";
10
12
  import type { AppContext } from "../types/handlers";
11
13
 
@@ -31,9 +33,17 @@ describe("registry soft-delete auto-wiring", () => {
31
33
  expect(key?.default).toBe(DEFAULT_GRACE_DAYS);
32
34
  });
33
35
 
36
+ test("also injects the system-scope cleanup job (not perTenant)", () => {
37
+ const registry = createRegistry([featureWith(true)]);
38
+ expect(registry.getAllJobs().has(SOFT_DELETE_CLEANUP_SYSTEM_JOB)).toBe(true);
39
+ const job = registry.getJob(SOFT_DELETE_CLEANUP_SYSTEM_JOB);
40
+ expect(job?.perTenant).toBeUndefined();
41
+ });
42
+
34
43
  test("does NOT inject when no entity uses softDelete", () => {
35
44
  const registry = createRegistry([featureWith(false)]);
36
45
  expect(registry.getAllJobs().has(SOFT_DELETE_CLEANUP_JOB)).toBe(false);
46
+ expect(registry.getAllJobs().has(SOFT_DELETE_CLEANUP_SYSTEM_JOB)).toBe(false);
37
47
  expect(registry.getAllConfigKeys().has(SOFT_DELETE_GRACE_DAYS_KEY)).toBe(false);
38
48
  });
39
49
  });
@@ -87,22 +97,21 @@ function makeCtx(opts: { graceDays?: number; calls: DeleteCall[] }): AppContext
87
97
  }
88
98
 
89
99
  describe("softDeleteCleanupJob handler", () => {
90
- test("hard-deletes only softDelete implicit projections, tenant-scoped where the table has tenantId", async () => {
100
+ test("hard-deletes only tenant-scoped softDelete implicit projections; system-global entities are skipped", async () => {
101
+ // Regression (565/1): sysThing (no tenantId column) must NOT be touched
102
+ // here — this handler is perTenant-fanned-out, so sweeping a system-
103
+ // global entity with THIS tenant's grace value would purge it using
104
+ // whichever tenant has the shortest grace period, for every tenant.
105
+ // softDeleteCleanupSystemJob (below) owns sysThing instead.
91
106
  const calls: DeleteCall[] = [];
92
107
  await softDeleteCleanupJob({}, makeCtx({ calls }));
93
108
 
94
- // thing + sysThing deleted; auditEntry (softDelete:false) + custom (explicit) skipped
95
- expect(calls).toHaveLength(2);
96
-
97
- const tenantScoped = calls.filter((c) => c.where["tenantId"] === "t1");
98
- const systemScoped = calls.filter((c) => c.where["tenantId"] === undefined);
99
- expect(tenantScoped).toHaveLength(1);
100
- expect(systemScoped).toHaveLength(1);
101
-
102
- for (const c of calls) {
103
- expect(c.where["isDeleted"]).toBe(true);
104
- expect(c.where["deletedAt"]).toBeDefined();
105
- }
109
+ // thing deleted; sysThing (no tenantId), auditEntry (softDelete:false)
110
+ // and custom (explicit) all skipped.
111
+ expect(calls).toHaveLength(1);
112
+ expect(calls[0]?.where["tenantId"]).toBe("t1");
113
+ expect(calls[0]?.where["isDeleted"]).toBe(true);
114
+ expect(calls[0]?.where["deletedAt"]).toBeDefined();
106
115
  });
107
116
 
108
117
  test("cutoff defaults to DEFAULT_GRACE_DAYS when no config resolver", async () => {
@@ -127,3 +136,30 @@ describe("softDeleteCleanupJob handler", () => {
127
136
  );
128
137
  });
129
138
  });
139
+
140
+ describe("softDeleteCleanupSystemJob handler", () => {
141
+ test("hard-deletes only system-global (no tenantId) softDelete implicit projections", async () => {
142
+ const calls: DeleteCall[] = [];
143
+ await softDeleteCleanupSystemJob({}, makeCtx({ calls }));
144
+
145
+ // sysThing deleted; thing (has tenantId), auditEntry (softDelete:false)
146
+ // and custom (explicit) all skipped.
147
+ expect(calls).toHaveLength(1);
148
+ expect(calls[0]?.where["tenantId"]).toBeUndefined();
149
+ expect(calls[0]?.where["isDeleted"]).toBe(true);
150
+ });
151
+
152
+ test("cutoff is DEFAULT_GRACE_DAYS — no per-tenant config to read", async () => {
153
+ const calls: DeleteCall[] = [];
154
+ await softDeleteCleanupSystemJob({}, makeCtx({ calls }));
155
+ const cutoff = (calls[0]?.where["deletedAt"] as { lt: Temporal.Instant }).lt;
156
+ const expected = Temporal.Now.instant().subtract({ hours: DEFAULT_GRACE_DAYS * 24 });
157
+ expect(Math.abs(cutoff.epochMilliseconds - expected.epochMilliseconds)).toBeLessThan(10_000);
158
+ });
159
+
160
+ test("throws when the job context is missing db/registry", async () => {
161
+ await expect(softDeleteCleanupSystemJob({}, {} as AppContext)).rejects.toThrow(
162
+ /ctx.db \+ ctx.registry/,
163
+ );
164
+ });
165
+ });
@@ -270,6 +270,20 @@ export function validateEntityIndexes(feature: FeatureDefinition): void {
270
270
  `buildEntityTable always creates one automatically. Remove this entry.`,
271
271
  );
272
272
  }
273
+ // AES-GCM's random-IV ciphertext is never equal to itself across two
274
+ // writes of the same plaintext, so a unique index on an encrypted
275
+ // column can never detect a real duplicate.
276
+ if (def.unique) {
277
+ for (const col of def.columns) {
278
+ const field = entity.fields[col];
279
+ if (field && (field.type === "text" || field.type === "longText") && field.encrypted) {
280
+ throw new Error(
281
+ `${where}: column "${col}" is encrypted — a unique index on it can never detect ` +
282
+ `duplicates (non-deterministic ciphertext). Remove \`unique\` or \`encrypted\`.`,
283
+ );
284
+ }
285
+ }
286
+ }
273
287
  }
274
288
  }
275
289
  }
@@ -298,6 +312,15 @@ export function validateEncryptedFields(feature: FeatureDefinition): boolean {
298
312
  `Field "${fieldName}" on entity "${entityName}" cannot be both encrypted and sortable`,
299
313
  );
300
314
  }
315
+ // AES-GCM draws a fresh random IV per call, so the ciphertext is
316
+ // non-deterministic — an equality filter on it never matches, even
317
+ // against the identical plaintext.
318
+ if (field.filterable) {
319
+ throw new Error(
320
+ `Field "${fieldName}" on entity "${entityName}" cannot be both encrypted and filterable ` +
321
+ `— non-deterministic ciphertext never matches an equality filter.`,
322
+ );
323
+ }
301
324
  }
302
325
  }
303
326
  }
@@ -507,3 +530,25 @@ export function validateExtendSchemaCollisions(feature: FeatureDefinition): void
507
530
  }
508
531
  }
509
532
  }
533
+
534
+ // --- derivedFields / fields collision detection ---
535
+ //
536
+ // augmentDerivedFields (entity-handlers.ts) does `{...row}` then
537
+ // `out[fieldName] = def.derive(row, ctx)` — a derivedFields key that matches a
538
+ // stored field name silently overwrites the real, persisted value with the
539
+ // derived one on every read, without a boot or runtime error. Reject it here,
540
+ // analogous to the extendSchema collision check above.
541
+ export function validateDerivedFieldCollisions(feature: FeatureDefinition): void {
542
+ for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
543
+ if (!entity.derivedFields) continue;
544
+ const fieldNames = new Set(Object.keys(entity.fields));
545
+ for (const derivedName of Object.keys(entity.derivedFields)) {
546
+ if (fieldNames.has(derivedName)) {
547
+ throw new Error(
548
+ `Entity "${entityName}": derivedFields key "${derivedName}" conflicts with a stored ` +
549
+ `field of the same name — the derived value would silently overwrite the real one on every read.`,
550
+ );
551
+ }
552
+ }
553
+ }
554
+ }
@@ -1,3 +1,4 @@
1
+ import { EXT_USER_DATA } from "../extension-names";
1
2
  import type { FeatureDefinition } from "../types";
2
3
 
3
4
  // Providers whose bytes do not survive a process restart. Only "inmemory"
@@ -65,7 +66,7 @@ export function validateGdprStoragePersistence(features: readonly FeatureDefinit
65
66
  export function validateGdprHookCompleteness(features: readonly FeatureDefinition[]): void {
66
67
  for (const feature of features) {
67
68
  for (const usage of feature.extensionUsages) {
68
- if (usage.extensionName !== "userData") continue;
69
+ if (usage.extensionName !== EXT_USER_DATA) continue;
69
70
  const hasExport = typeof usage.options?.["export"] === "function";
70
71
  const hasDelete = typeof usage.options?.["delete"] === "function";
71
72
  if (hasExport && !hasDelete) {
@@ -12,6 +12,7 @@ import {
12
12
  warnOnToggleableDependencies,
13
13
  } from "./config-deps";
14
14
  import {
15
+ validateDerivedFieldCollisions,
15
16
  validateEmbeddedFields,
16
17
  validateEncryptedFields,
17
18
  validateEntityIndexes,
@@ -33,11 +34,13 @@ import {
33
34
  collectKnownRoles,
34
35
  collectNavQns,
35
36
  collectScreenQns,
37
+ collectScreensByShortId,
36
38
  collectWorkspaceQns,
37
39
  collectWriteHandlerQns,
38
40
  validateDefaultWorkspaceUniqueness,
39
41
  validateNavCycles,
40
42
  validateNavs,
43
+ validateScreenShortIdCollisions,
41
44
  validateScreens,
42
45
  validateWorkspaces,
43
46
  } from "./screens-nav";
@@ -110,6 +113,8 @@ export function validateBoot(features: readonly FeatureDefinition[]): void {
110
113
  const allNavQns = collectNavQns(features);
111
114
  const allWorkspaceQns = collectWorkspaceQns(features);
112
115
  const allWriteHandlerQns = collectWriteHandlerQns(features);
116
+ const screensByShortId = collectScreensByShortId(features);
117
+ validateScreenShortIdCollisions(screensByShortId);
113
118
 
114
119
  // Cross-feature API exposure-map — jedes Feature deklariert Marker via
115
120
  // r.exposesApi(name). Per-feature validateApiExposureMatching walkt
@@ -144,6 +149,7 @@ export function validateBoot(features: readonly FeatureDefinition[]): void {
144
149
  validateTransitions(feature);
145
150
  validateExtensionUsages(feature, extensionProviders);
146
151
  validateExtendSchemaCollisions(feature);
152
+ validateDerivedFieldCollisions(feature);
147
153
  validateHandlerAccess(feature);
148
154
  validateLocatedTimestamps(feature);
149
155
  validateEntityIndexes(feature);
@@ -154,7 +160,14 @@ export function validateBoot(features: readonly FeatureDefinition[]): void {
154
160
  validateConfigKeyBacking(feature);
155
161
  validateOwnershipRules(feature, allClaimKeys, knownRoles);
156
162
  validateMultiStreamProjections(feature);
157
- validateScreens(feature, featureMap, allWriteHandlerQns, allScreenQns, allConfigKeyQns);
163
+ validateScreens(
164
+ feature,
165
+ featureMap,
166
+ allWriteHandlerQns,
167
+ allScreenQns,
168
+ allConfigKeyQns,
169
+ screensByShortId,
170
+ );
158
171
  validateNavs(feature, allScreenQns, allNavQns, allWorkspaceQns);
159
172
  validateWorkspaces(feature, allNavQns);
160
173
  }
@@ -3,7 +3,13 @@ import { SETTINGS_HUB_AUDIENCE_NAV_QNS } from "../build-config-feature-schema";
3
3
  import { qualifyEntityName } from "../qualified-name";
4
4
  import { getAllowedFilterOps, isFieldFilterable } from "../screen-filter-ops";
5
5
  import type { FeatureDefinition, NavDefinition, WorkspaceDefinition } from "../types";
6
- import type { FieldCondition, RowAction, RowFieldExtractor, ToolbarAction } from "../types/screen";
6
+ import type {
7
+ FieldCondition,
8
+ RowAction,
9
+ RowFieldExtractor,
10
+ ScreenDefinition,
11
+ ToolbarAction,
12
+ } from "../types/screen";
7
13
  import { isExtensionEditSection, normalizeEditField, normalizeListColumn } from "../types/screen";
8
14
 
9
15
  // --- Screen validation ---
@@ -81,12 +87,41 @@ function validateActionFieldRefs(
81
87
  }
82
88
  }
83
89
 
90
+ // Two features registering the same short screen-id is a silent routing
91
+ // footgun: create-app.tsx's runtime router resolves a bare navigate-target
92
+ // id by scanning features[] and taking the first match — the collision
93
+ // never surfaces as an error, the second feature's screen is just
94
+ // unreachable by that id (and always the same one that loses, in whatever
95
+ // order the app composed its features).
96
+ export function validateScreenShortIdCollisions(
97
+ screensByShortId: ReadonlyMap<
98
+ string,
99
+ ReadonlyArray<{ readonly featureName: string; readonly screen: ScreenDefinition }>
100
+ >,
101
+ ): void {
102
+ for (const [shortId, entries] of screensByShortId) {
103
+ const featureNames = new Set(entries.map((e) => e.featureName));
104
+ if (featureNames.size > 1) {
105
+ throw new Error(
106
+ `Screen short-id "${shortId}" is registered by ${featureNames.size} features ` +
107
+ `(${[...featureNames].join(", ")}) — the runtime router resolves a bare navigate-target ` +
108
+ `id by taking the first match, so all but one of these screens would be unreachable by ` +
109
+ `that id. Give each screen a distinct id.`,
110
+ );
111
+ }
112
+ }
113
+ }
114
+
84
115
  export function validateScreens(
85
116
  feature: FeatureDefinition,
86
117
  featureMap: ReadonlyMap<string, FeatureDefinition>,
87
118
  allWriteHandlerQns: ReadonlySet<string>,
88
119
  allScreenQns: ReadonlySet<string>,
89
120
  allConfigKeyQns: ReadonlySet<string>,
121
+ screensByShortId: ReadonlyMap<
122
+ string,
123
+ ReadonlyArray<{ readonly featureName: string; readonly screen: ScreenDefinition }>
124
+ >,
90
125
  ): void {
91
126
  // navigate-Targets (rowAction/toolbarAction) dürfen cross-feature zeigen —
92
127
  // der Runtime-Router (create-app) löst eine bare screenId app-weit über ALLE
@@ -338,11 +373,16 @@ export function validateScreens(
338
373
  }
339
374
  for (const col of screen.columns) {
340
375
  const normalized = normalizeListColumn(col);
341
- // A labeled column whose field is not an entity field is a *virtual*
342
- // presentational column (drawn by a columnRenderer component from the
343
- // row, e.g. tag chips) its `field` is just a column key. Only an
344
- // unlabeled unknown field is an author typo worth failing the boot.
345
- if (!columnFieldNames.has(normalized.field) && normalized.label === undefined) {
376
+ // A virtual presentational column (drawn by a columnRenderer component
377
+ // from the row, e.g. tag chips) needs BOTH a label AND a renderer —
378
+ // renderer is what actually makes it "virtual" (label alone still
379
+ // needs list.ts's virtual-branch to have something to draw; without
380
+ // a renderer the column would render nothing). A column with neither
381
+ // matching a real field NOR a renderer is a typo worth failing the boot.
382
+ if (
383
+ !columnFieldNames.has(normalized.field) &&
384
+ !(normalized.label !== undefined && normalized.renderer !== undefined)
385
+ ) {
346
386
  throw new Error(
347
387
  buildUnknownFieldMessage(
348
388
  feature.name,
@@ -446,6 +486,26 @@ export function validateScreens(
446
486
  `navigate-target "${action.screen}" does not resolve to a registered screen in any feature.`,
447
487
  );
448
488
  }
489
+ // The renderer's default-entityId fallback (row["id"]) only fires
490
+ // for a same-feature entityEdit target — it can't safely guess
491
+ // the id for a screen owned by a different feature. Cross-feature
492
+ // + entityEdit therefore MUST set an explicit entityId, or the
493
+ // edit screen silently opens with no entity context at runtime.
494
+ const target = screensByShortId.get(action.screen)?.[0];
495
+ if (
496
+ target !== undefined &&
497
+ target.featureName !== feature.name &&
498
+ target.screen.type === "entityEdit" &&
499
+ action.entityId === undefined
500
+ ) {
501
+ throw new Error(
502
+ `[Feature ${feature.name}] Screen "${screenId}" (entityList) rowAction "${action.id}" ` +
503
+ `navigates cross-feature to entityEdit screen "${action.screen}" (feature ` +
504
+ `"${target.featureName}") without an explicit entityId field — the renderer's ` +
505
+ `same-feature row["id"] fallback does not apply across features. Set entityId to ` +
506
+ `the row field that names the target entity's id.`,
507
+ );
508
+ }
449
509
  } else {
450
510
  if (!allWriteHandlerQns.has(action.handler)) {
451
511
  throw new Error(
@@ -648,6 +708,30 @@ export function screenShortIdsFrom(allScreenQns: ReadonlySet<string>): Set<strin
648
708
  return set;
649
709
  }
650
710
 
711
+ // Short screen-id → every {featureName, screen} that registers it. The
712
+ // runtime router (create-app.tsx) resolves a bare navigate-target short-id by
713
+ // scanning ALL features and taking the first match — so two features
714
+ // registering the same short-id is a silent routing footgun (whichever
715
+ // feature comes first in the app's features[] array always wins, the other
716
+ // is unreachable by that id) and a prerequisite for the entityId-check below
717
+ // (which target screen it resolves to must be unambiguous).
718
+ export function collectScreensByShortId(
719
+ features: readonly FeatureDefinition[],
720
+ ): Map<string, ReadonlyArray<{ readonly featureName: string; readonly screen: ScreenDefinition }>> {
721
+ const map = new Map<
722
+ string,
723
+ Array<{ readonly featureName: string; readonly screen: ScreenDefinition }>
724
+ >();
725
+ for (const f of features) {
726
+ for (const [screenId, screen] of Object.entries(f.screens)) {
727
+ const entries = map.get(screenId) ?? [];
728
+ entries.push({ featureName: f.name, screen });
729
+ map.set(screenId, entries);
730
+ }
731
+ }
732
+ return map;
733
+ }
734
+
651
735
  // Sammelt alle qualifizierten Write-Handler-QNs (`<feature>:write:<short>`).
652
736
  // Wird vom actionForm-Screen-Validator genutzt um zu prüfen ob der
653
737
  // im Schema deklarierte handler tatsächlich registriert ist —