@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.
- package/package.json +2 -2
- package/src/__tests__/full-stack.integration.test.ts +3 -2
- package/src/__tests__/schema-cli.integration.test.ts +29 -0
- package/src/__tests__/transition-guard.integration.test.ts +2 -2
- package/src/api/__tests__/batch.integration.test.ts +3 -2
- package/src/api/__tests__/dispatcher-live.integration.test.ts +3 -2
- package/src/api/__tests__/jwt.test.ts +27 -0
- package/src/api/__tests__/pat-scope.test.ts +36 -0
- package/src/api/__tests__/request-id-middleware.test.ts +51 -0
- package/src/api/auth-middleware.ts +65 -1
- package/src/api/auth-routes.ts +11 -0
- package/src/api/index.ts +3 -1
- package/src/api/jwt.ts +5 -5
- package/src/api/pat-scope.ts +14 -0
- package/src/api/request-context.ts +3 -0
- package/src/api/request-id-middleware.ts +2 -0
- package/src/api/routes.ts +22 -0
- package/src/api/server.ts +29 -1
- package/src/bun-db/__tests__/batch-methods.test.ts +3 -2
- package/src/bun-db/__tests__/query-guards.test.ts +3 -2
- package/src/bun-db/__tests__/write-brand.test.ts +48 -0
- package/src/bun-db/query.ts +40 -9
- package/src/db/__tests__/assert-exists-in.integration.test.ts +2 -2
- package/src/db/__tests__/eagerload.integration.test.ts +2 -2
- package/src/db/__tests__/event-store-executor.integration.test.ts +138 -1
- package/src/db/__tests__/implicit-projection-equivalence.integration.test.ts +3 -1
- package/src/db/__tests__/multi-row-insert.integration.test.ts +5 -4
- package/src/db/__tests__/schema-migration.integration.test.ts +4 -3
- package/src/db/__tests__/source-shadow-create.integration.test.ts +3 -2
- package/src/db/__tests__/tenant-db-where-merge.integration.test.ts +3 -2
- package/src/db/__tests__/tenant-db.integration.test.ts +7 -6
- package/src/db/apply-entity-event.ts +19 -8
- package/src/db/event-store-executor.ts +91 -8
- package/src/db/queries/shadow-swap.ts +1 -1
- package/src/db/query.ts +1 -0
- package/src/db/table-builder.ts +23 -1
- package/src/db/tenant-db.ts +6 -0
- package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +6 -5
- package/src/engine/__tests__/boot-validator.test.ts +210 -0
- package/src/engine/__tests__/build-config-feature-schema.test.ts +21 -0
- package/src/engine/__tests__/define-feature-entity-mapping.test.ts +6 -0
- package/src/engine/__tests__/extend-entity-projection.test.ts +123 -0
- package/src/engine/__tests__/projection-helpers.test.ts +2 -2
- package/src/engine/__tests__/required-surface-keys.test.ts +134 -1
- package/src/engine/__tests__/soft-delete-cleanup.test.ts +49 -13
- package/src/engine/boot-validator/entity-handler.ts +45 -0
- package/src/engine/boot-validator/gdpr-storage.ts +2 -1
- package/src/engine/boot-validator/index.ts +14 -1
- package/src/engine/boot-validator/screens-nav.ts +90 -6
- package/src/engine/build-app-schema.ts +15 -7
- package/src/engine/define-feature.ts +17 -0
- package/src/engine/define-handler.ts +16 -2
- package/src/engine/entity-handlers.ts +32 -13
- package/src/engine/extensions/user-data.ts +6 -0
- package/src/engine/index.ts +6 -1
- package/src/engine/projection-helpers.ts +8 -5
- package/src/engine/registry.ts +47 -2
- package/src/engine/schema-builder.ts +3 -1
- package/src/engine/soft-delete-cleanup.ts +41 -4
- package/src/engine/steps/unsafe-projection-delete.ts +5 -1
- package/src/engine/tier-resolver-extension.ts +11 -0
- package/src/engine/types/feature.ts +29 -21
- package/src/engine/types/fields.ts +12 -0
- package/src/engine/types/handlers.ts +13 -0
- package/src/engine/types/index.ts +2 -0
- package/src/engine/types/projection.ts +33 -5
- package/src/event-store/index.ts +8 -0
- package/src/event-store/rebuild-dead-letter.ts +111 -0
- package/src/files/__tests__/storage-tracking.integration.test.ts +8 -0
- package/src/files/file-routes.ts +1 -1
- package/src/pipeline/__tests__/dispatcher.test.ts +43 -0
- package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +2 -2
- package/src/pipeline/__tests__/projection-rebuild.integration.test.ts +24 -0
- package/src/pipeline/__tests__/rebuild-poison-quarantine.integration.test.ts +274 -0
- package/src/pipeline/dispatcher.ts +4 -10
- package/src/pipeline/msp-rebuild.ts +36 -3
- package/src/pipeline/projection-rebuild.ts +55 -3
- package/src/pipeline/projections-runner.ts +1 -1
- package/src/schema-cli.ts +24 -15
- package/src/secrets/__tests__/contains-secret.test.ts +34 -0
- package/src/secrets/types.ts +8 -1
- package/src/testing/db-cleanup.ts +4 -1
- package/src/testing/index.ts +1 -0
- package/src/testing/seed.ts +50 -0
- package/src/time/__tests__/boot-tz-warning.test.ts +24 -2
- package/src/time/__tests__/geo-tz.test.ts +9 -3
- package/src/time/__tests__/iana.test.ts +9 -0
- package/src/time/boot-tz-warning.ts +5 -1
- package/src/time/iana.ts +17 -15
- package/src/time/tz-context.ts +6 -1
- package/src/utils/__tests__/serialization.test.ts +6 -0
- 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 {
|
|
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
|
|
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
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
expect(
|
|
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 !==
|
|
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(
|
|
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 {
|
|
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
|
|
342
|
-
//
|
|
343
|
-
//
|
|
344
|
-
//
|
|
345
|
-
|
|
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 —
|