@cosmicdrift/kumiko-framework 0.290.0 → 0.292.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 +4 -4
- package/src/__tests__/pii-personal-migration-report-codemod.test.ts +160 -0
- package/src/api/__tests__/server-error-logging.test.ts +168 -17
- package/src/api/request-context.ts +3 -0
- package/src/api/request-id-middleware.ts +2 -1
- package/src/api/routes.ts +35 -3
- package/src/changes.json +63 -0
- package/src/crypto/__tests__/event-pii.test.ts +110 -9
- package/src/crypto/__tests__/subject-resolver.test.ts +23 -2
- package/src/crypto/subject-resolver.ts +25 -8
- package/src/db/queries/shadow-swap.ts +35 -0
- package/src/engine/__tests__/boot-validator-pii-retention.test.ts +122 -0
- package/src/engine/__tests__/boot-validator-projection-list.test.ts +93 -0
- package/src/engine/__tests__/boot-validator.test.ts +226 -0
- package/src/engine/__tests__/build-app-schema.test.ts +18 -0
- package/src/engine/__tests__/engine.test.ts +87 -0
- package/src/engine/__tests__/form-money-currency-types.test.ts +90 -0
- package/src/engine/boot-validator/entity-handler.ts +44 -0
- package/src/engine/boot-validator/index.ts +10 -4
- package/src/engine/boot-validator/pii-retention.ts +8 -0
- package/src/engine/boot-validator/projection-list-screens.ts +52 -2
- package/src/engine/boot-validator/screens.ts +50 -0
- package/src/engine/create-app.ts +54 -0
- package/src/engine/extension-names.ts +10 -0
- package/src/engine/feature-config-events-jobs.ts +19 -0
- package/src/engine/index.ts +3 -0
- package/src/engine/screen-helpers.ts +1 -0
- package/src/engine/system-user.ts +3 -5
- package/src/event-store/__tests__/backfill-pii.integration.test.ts +30 -6
- package/src/files/provider-resolver.ts +9 -2
- package/src/i18n/required-surface-keys.ts +1 -0
- package/src/jobs/__tests__/job-last-success.integration.test.ts +135 -0
- package/src/jobs/index.ts +7 -1
- package/src/jobs/job-runner.ts +94 -4
- package/src/logging/utils.ts +14 -1
- package/src/observability/index.ts +1 -0
- package/src/observability/standard-metrics.ts +20 -0
- package/src/pipeline/__tests__/blind-index-rebuild-guard.integration.test.ts +96 -0
- package/src/pipeline/projection-rebuild.ts +7 -0
- package/src/schema-cli.ts +21 -0
- package/src/scripts/codemod/pii-personal-migration.ts +242 -2
- package/src/ui-types/list-row-meta.ts +4 -1
|
@@ -2506,6 +2506,116 @@ describe("boot-validator", () => {
|
|
|
2506
2506
|
);
|
|
2507
2507
|
});
|
|
2508
2508
|
|
|
2509
|
+
// fw#2839: an actionForm has no entity, so a money field there can't
|
|
2510
|
+
// inherit entity.defaultCurrency — undeclared, the renderer seeds a bare
|
|
2511
|
+
// `0` and the handler's zod schema rejects the submit.
|
|
2512
|
+
describe("money field currency source (fw#2839)", () => {
|
|
2513
|
+
const moneyForm = (currency?: unknown) =>
|
|
2514
|
+
makeFeature({
|
|
2515
|
+
fields: { amount: { type: "money", ...(currency !== undefined && { currency }) } },
|
|
2516
|
+
sections: [{ title: "Payment", fields: ["amount"] }],
|
|
2517
|
+
});
|
|
2518
|
+
|
|
2519
|
+
test("money field ohne currency → Throw, nennt Screen und Feld", () => {
|
|
2520
|
+
expect(() => validateBoot([moneyForm()])).toThrow(
|
|
2521
|
+
/Screen "approve-invoice" \(actionForm\) money field "amount" must declare where its currency comes from/,
|
|
2522
|
+
);
|
|
2523
|
+
});
|
|
2524
|
+
|
|
2525
|
+
test("Fehlermeldung nennt beide gültigen Formen wörtlich", () => {
|
|
2526
|
+
expect(() => validateBoot([moneyForm()])).toThrow(
|
|
2527
|
+
/currency: \{ kind: "literal", code: "EUR" \}[\s\S]*currency: \{ kind: "tenant" \}/,
|
|
2528
|
+
);
|
|
2529
|
+
});
|
|
2530
|
+
|
|
2531
|
+
test("currency: { kind: 'literal', code } → kein Throw", () => {
|
|
2532
|
+
expect(() => validateBoot([moneyForm({ kind: "literal", code: "EUR" })])).not.toThrow();
|
|
2533
|
+
});
|
|
2534
|
+
|
|
2535
|
+
test("currency: { kind: 'tenant' } → kein Throw", () => {
|
|
2536
|
+
expect(() => validateBoot([moneyForm({ kind: "tenant" })])).not.toThrow();
|
|
2537
|
+
});
|
|
2538
|
+
|
|
2539
|
+
test("literal mit leerem code → Throw", () => {
|
|
2540
|
+
expect(() => validateBoot([moneyForm({ kind: "literal", code: " " })])).toThrow(
|
|
2541
|
+
/empty or non-string `code`/,
|
|
2542
|
+
);
|
|
2543
|
+
});
|
|
2544
|
+
|
|
2545
|
+
test("unbekannter kind → Throw", () => {
|
|
2546
|
+
expect(() => validateBoot([moneyForm({ kind: "entityDefault" })])).toThrow(
|
|
2547
|
+
/unknown currency kind "entityDefault"/,
|
|
2548
|
+
);
|
|
2549
|
+
});
|
|
2550
|
+
|
|
2551
|
+
test("non-money Felder bleiben unberührt", () => {
|
|
2552
|
+
expect(() => validateBoot([makeFeature()])).not.toThrow();
|
|
2553
|
+
});
|
|
2554
|
+
|
|
2555
|
+
// The check sits in validateFormFieldsMap, the walk actionForm,
|
|
2556
|
+
// secretMint and secretMint's confirm step all share.
|
|
2557
|
+
function mintFeature(
|
|
2558
|
+
fields: Record<string, unknown>,
|
|
2559
|
+
confirmFields?: Record<string, unknown>,
|
|
2560
|
+
) {
|
|
2561
|
+
return defineFeature("shop", (r) => {
|
|
2562
|
+
r.writeHandler({
|
|
2563
|
+
name: "token:mint",
|
|
2564
|
+
schema: { _type: "stub" } as never,
|
|
2565
|
+
handler: async () => ({ isSuccess: true, data: {} }) as never,
|
|
2566
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
2567
|
+
});
|
|
2568
|
+
r.screen({
|
|
2569
|
+
id: "mint-token",
|
|
2570
|
+
type: "secretMint",
|
|
2571
|
+
handler: "shop:write:token:mint",
|
|
2572
|
+
fields: fields as never,
|
|
2573
|
+
layout: { sections: [{ title: "Mint", fields: Object.keys(fields) }] as never },
|
|
2574
|
+
reveal: { fields: [{ field: "token", label: "Token" }] },
|
|
2575
|
+
...(confirmFields !== undefined && {
|
|
2576
|
+
confirm: {
|
|
2577
|
+
handler: "shop:write:token:mint",
|
|
2578
|
+
fields: confirmFields as never,
|
|
2579
|
+
layout: {
|
|
2580
|
+
sections: [{ title: "Confirm", fields: Object.keys(confirmFields) }] as never,
|
|
2581
|
+
},
|
|
2582
|
+
},
|
|
2583
|
+
}),
|
|
2584
|
+
});
|
|
2585
|
+
});
|
|
2586
|
+
}
|
|
2587
|
+
|
|
2588
|
+
test("secretMint money field ohne currency → Throw", () => {
|
|
2589
|
+
expect(() => validateBoot([mintFeature({ fee: { type: "money" } })])).toThrow(
|
|
2590
|
+
/Screen "mint-token" \(secretMint\) money field "fee" must declare where its currency comes from/,
|
|
2591
|
+
);
|
|
2592
|
+
});
|
|
2593
|
+
|
|
2594
|
+
test("secretMint confirm-Step money field ohne currency → Throw", () => {
|
|
2595
|
+
expect(() =>
|
|
2596
|
+
validateBoot([
|
|
2597
|
+
mintFeature(
|
|
2598
|
+
{ fee: { type: "money", currency: { kind: "literal", code: "EUR" } } },
|
|
2599
|
+
{ topUp: { type: "money" } },
|
|
2600
|
+
),
|
|
2601
|
+
]),
|
|
2602
|
+
).toThrow(
|
|
2603
|
+
/Screen "mint-token" \(secretMint confirm\) money field "topUp" must declare where its currency comes from/,
|
|
2604
|
+
);
|
|
2605
|
+
});
|
|
2606
|
+
|
|
2607
|
+
test("secretMint mit deklarierten Quellen in beiden Steps → kein Throw", () => {
|
|
2608
|
+
expect(() =>
|
|
2609
|
+
validateBoot([
|
|
2610
|
+
mintFeature(
|
|
2611
|
+
{ fee: { type: "money", currency: { kind: "literal", code: "EUR" } } },
|
|
2612
|
+
{ topUp: { type: "money", currency: { kind: "tenant" } } },
|
|
2613
|
+
),
|
|
2614
|
+
]),
|
|
2615
|
+
).not.toThrow();
|
|
2616
|
+
});
|
|
2617
|
+
});
|
|
2618
|
+
|
|
2509
2619
|
test("layout.sections leer → Throw", () => {
|
|
2510
2620
|
expect(() => validateBoot([makeFeature({ sections: [] })])).toThrow(
|
|
2511
2621
|
/has an empty sections list/,
|
|
@@ -3846,6 +3956,122 @@ describe("boot-validator", () => {
|
|
|
3846
3956
|
);
|
|
3847
3957
|
});
|
|
3848
3958
|
|
|
3959
|
+
test("reference optionsQuery naming a registered handler → kein Throw (fw#2780)", () => {
|
|
3960
|
+
const features = [
|
|
3961
|
+
defineFeature("shop", (r) => {
|
|
3962
|
+
r.entity(
|
|
3963
|
+
"customer",
|
|
3964
|
+
createEntity({
|
|
3965
|
+
fields: { name: createTextField({ personal: false, reason: "test_fixture" }) },
|
|
3966
|
+
}),
|
|
3967
|
+
);
|
|
3968
|
+
stubListHandler(r, "customer");
|
|
3969
|
+
r.queryHandler({
|
|
3970
|
+
name: "customer:options",
|
|
3971
|
+
schema: z.object({}),
|
|
3972
|
+
handler: async () => ({ rows: [] }) as never,
|
|
3973
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
3974
|
+
});
|
|
3975
|
+
r.entity(
|
|
3976
|
+
"order",
|
|
3977
|
+
createEntity({
|
|
3978
|
+
fields: {
|
|
3979
|
+
customerId: {
|
|
3980
|
+
type: "reference",
|
|
3981
|
+
entity: "customer",
|
|
3982
|
+
optionsQuery: "shop:query:customer:options",
|
|
3983
|
+
},
|
|
3984
|
+
},
|
|
3985
|
+
}),
|
|
3986
|
+
);
|
|
3987
|
+
}),
|
|
3988
|
+
];
|
|
3989
|
+
expect(() => validateBoot(features)).not.toThrow();
|
|
3990
|
+
});
|
|
3991
|
+
|
|
3992
|
+
test("reference optionsQuery auf unregistrierten Handler → Throw (fw#2780)", () => {
|
|
3993
|
+
const features = [
|
|
3994
|
+
defineFeature("shop", (r) => {
|
|
3995
|
+
r.entity(
|
|
3996
|
+
"customer",
|
|
3997
|
+
createEntity({
|
|
3998
|
+
fields: { name: createTextField({ personal: false, reason: "test_fixture" }) },
|
|
3999
|
+
}),
|
|
4000
|
+
);
|
|
4001
|
+
stubListHandler(r, "customer");
|
|
4002
|
+
r.entity(
|
|
4003
|
+
"order",
|
|
4004
|
+
createEntity({
|
|
4005
|
+
fields: {
|
|
4006
|
+
customerId: {
|
|
4007
|
+
type: "reference",
|
|
4008
|
+
entity: "customer",
|
|
4009
|
+
optionsQuery: "shop:query:customer:typo",
|
|
4010
|
+
},
|
|
4011
|
+
},
|
|
4012
|
+
}),
|
|
4013
|
+
);
|
|
4014
|
+
}),
|
|
4015
|
+
];
|
|
4016
|
+
expect(() => validateBoot(features)).toThrow(
|
|
4017
|
+
/Reference field "customerId" on entity "order" declares optionsQuery "shop:query:customer:typo" which is not a registered query-handler/,
|
|
4018
|
+
);
|
|
4019
|
+
});
|
|
4020
|
+
|
|
4021
|
+
test("leerer optionsQuery → Throw (fw#2780)", () => {
|
|
4022
|
+
const features = [
|
|
4023
|
+
defineFeature("shop", (r) => {
|
|
4024
|
+
r.entity(
|
|
4025
|
+
"customer",
|
|
4026
|
+
createEntity({
|
|
4027
|
+
fields: { name: createTextField({ personal: false, reason: "test_fixture" }) },
|
|
4028
|
+
}),
|
|
4029
|
+
);
|
|
4030
|
+
stubListHandler(r, "customer");
|
|
4031
|
+
r.entity(
|
|
4032
|
+
"order",
|
|
4033
|
+
createEntity({
|
|
4034
|
+
fields: {
|
|
4035
|
+
customerId: { type: "reference", entity: "customer", optionsQuery: "" },
|
|
4036
|
+
},
|
|
4037
|
+
}),
|
|
4038
|
+
);
|
|
4039
|
+
}),
|
|
4040
|
+
];
|
|
4041
|
+
expect(() => validateBoot(features)).toThrow(/has an empty optionsQuery/);
|
|
4042
|
+
});
|
|
4043
|
+
|
|
4044
|
+
test("reference sub-field optionsQuery auf unregistrierten Handler → Throw mit parent.child-Pfad (fw#2780)", () => {
|
|
4045
|
+
const features = [
|
|
4046
|
+
defineFeature("shop", (r) => {
|
|
4047
|
+
r.entity(
|
|
4048
|
+
"product",
|
|
4049
|
+
createEntity({
|
|
4050
|
+
fields: { name: createTextField({ personal: false, reason: "test_fixture" }) },
|
|
4051
|
+
}),
|
|
4052
|
+
);
|
|
4053
|
+
stubListHandler(r, "product");
|
|
4054
|
+
r.entity(
|
|
4055
|
+
"invoice",
|
|
4056
|
+
createEntity({
|
|
4057
|
+
fields: {
|
|
4058
|
+
lines: createEmbeddedListField({
|
|
4059
|
+
productId: {
|
|
4060
|
+
type: "reference",
|
|
4061
|
+
entity: "product",
|
|
4062
|
+
optionsQuery: "shop:query:product:typo",
|
|
4063
|
+
},
|
|
4064
|
+
}),
|
|
4065
|
+
},
|
|
4066
|
+
}),
|
|
4067
|
+
);
|
|
4068
|
+
}),
|
|
4069
|
+
];
|
|
4070
|
+
expect(() => validateBoot(features)).toThrow(
|
|
4071
|
+
/Reference field "lines\.productId" on entity "invoice" declares optionsQuery "shop:query:product:typo"/,
|
|
4072
|
+
);
|
|
4073
|
+
});
|
|
4074
|
+
|
|
3849
4075
|
test("reference sub-field labelField referencing an unknown field → Throw with the parent.child field path", () => {
|
|
3850
4076
|
const features = [
|
|
3851
4077
|
defineFeature("shop", (r) => {
|
|
@@ -644,6 +644,24 @@ describe("buildAppSchema", () => {
|
|
|
644
644
|
expect(JSON.parse(JSON.stringify(projected))).toEqual(projected);
|
|
645
645
|
});
|
|
646
646
|
|
|
647
|
+
test("money field's `currency: { kind: 'literal', code }` declaration survives the projection (fw#2839)", () => {
|
|
648
|
+
const entity = {
|
|
649
|
+
defaultCurrency: "USD",
|
|
650
|
+
fields: { price: { type: "money", currency: { kind: "literal", code: "USD" } } },
|
|
651
|
+
} as unknown as EntityDefinition;
|
|
652
|
+
|
|
653
|
+
const f = defineFeature("ent", (r) => {
|
|
654
|
+
r.entity("thing", entity);
|
|
655
|
+
});
|
|
656
|
+
const app = buildAppSchema(createRegistry([f]));
|
|
657
|
+
const projected = app.features[0]!.entities["thing"] as unknown as {
|
|
658
|
+
fields: Record<string, Record<string, unknown>>;
|
|
659
|
+
};
|
|
660
|
+
|
|
661
|
+
expect(projected.fields["price"]?.["currency"]).toEqual({ kind: "literal", code: "USD" });
|
|
662
|
+
expect(JSON.parse(JSON.stringify(projected))).toEqual(projected);
|
|
663
|
+
});
|
|
664
|
+
|
|
647
665
|
test("text format, timestamp locatedBy, file accept/maxSize, image variants and decimal scale survive the projection", () => {
|
|
648
666
|
// Regression: all of these are read by the edit view-model (password
|
|
649
667
|
// masking, wall-clock input, upload constraints, preview variant, derived
|
|
@@ -909,6 +909,45 @@ describe("createRegistry", () => {
|
|
|
909
909
|
]);
|
|
910
910
|
});
|
|
911
911
|
|
|
912
|
+
test("optionsQuery leaves searchable/sortable on the labelField column (fw#2780)", () => {
|
|
913
|
+
const feature = defineFeature("crm", (r) => {
|
|
914
|
+
r.entity(
|
|
915
|
+
"customer",
|
|
916
|
+
createEntity({
|
|
917
|
+
table: "Customers",
|
|
918
|
+
fields: { name: createTextField({ personal: false, reason: "test_fixture" }) },
|
|
919
|
+
}),
|
|
920
|
+
);
|
|
921
|
+
r.queryHandler("customer:options", z.object({}), async () => [], {
|
|
922
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
923
|
+
});
|
|
924
|
+
r.entity(
|
|
925
|
+
"order",
|
|
926
|
+
createEntity({
|
|
927
|
+
table: "Orders",
|
|
928
|
+
fields: {
|
|
929
|
+
customerId: {
|
|
930
|
+
type: "reference",
|
|
931
|
+
entity: "customer",
|
|
932
|
+
labelField: "name",
|
|
933
|
+
optionsQuery: "crm:query:customer:options",
|
|
934
|
+
searchable: true,
|
|
935
|
+
sortable: true,
|
|
936
|
+
},
|
|
937
|
+
},
|
|
938
|
+
}),
|
|
939
|
+
);
|
|
940
|
+
});
|
|
941
|
+
|
|
942
|
+
const registry = createRegistry([feature]);
|
|
943
|
+
expect(registry.getSortableReferences("order")).toEqual([
|
|
944
|
+
{ fieldName: "customerId", targetEntityName: "customer", labelField: "name" },
|
|
945
|
+
]);
|
|
946
|
+
expect(registry.getSearchableReferences("order")).toEqual([
|
|
947
|
+
{ fieldName: "customerId", targetEntityName: "customer", labelField: "name" },
|
|
948
|
+
]);
|
|
949
|
+
});
|
|
950
|
+
|
|
912
951
|
test("throws at boot when a sortable reference field has no explicit labelField (fw#2741)", () => {
|
|
913
952
|
const feature = defineFeature("crm", (r) => {
|
|
914
953
|
r.entity(
|
|
@@ -1204,6 +1243,54 @@ describe("createApp", () => {
|
|
|
1204
1243
|
);
|
|
1205
1244
|
});
|
|
1206
1245
|
|
|
1246
|
+
// fw#2839: a literal currency source is only meaningful for a code the app
|
|
1247
|
+
// knows — a typo would otherwise render and submit an unformattable amount.
|
|
1248
|
+
function literalCurrencyFeature(code: string) {
|
|
1249
|
+
return defineFeature("test", (r) => {
|
|
1250
|
+
r.entity(
|
|
1251
|
+
"invoice",
|
|
1252
|
+
createEntity({
|
|
1253
|
+
table: "Invoices",
|
|
1254
|
+
defaultCurrency: "EUR",
|
|
1255
|
+
fields: { total: { type: "money", currency: { kind: "literal", code } } },
|
|
1256
|
+
} as never),
|
|
1257
|
+
);
|
|
1258
|
+
});
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
test("rejects currency: { kind: 'literal' } with a code outside the currencies list", () => {
|
|
1262
|
+
expect(() =>
|
|
1263
|
+
createApp({ roles: ["Admin"], features: [literalCurrencyFeature("EURO")] }),
|
|
1264
|
+
).toThrow(/code: "EURO" \} which is not in the currencies list/);
|
|
1265
|
+
});
|
|
1266
|
+
|
|
1267
|
+
test("accepts currency: { kind: 'literal' } with a code from the currencies list", () => {
|
|
1268
|
+
expect(() =>
|
|
1269
|
+
createApp({ roles: ["Admin"], features: [literalCurrencyFeature("EUR")] }),
|
|
1270
|
+
).not.toThrow();
|
|
1271
|
+
});
|
|
1272
|
+
|
|
1273
|
+
test("rejects an unknown literal code on an actionForm screen field too", () => {
|
|
1274
|
+
const feature = defineFeature("test", (r) => {
|
|
1275
|
+
r.writeHandler({
|
|
1276
|
+
name: "invoice:pay",
|
|
1277
|
+
schema: { _type: "stub" } as never,
|
|
1278
|
+
handler: async () => ({ isSuccess: true, data: {} }) as never,
|
|
1279
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
1280
|
+
});
|
|
1281
|
+
r.screen({
|
|
1282
|
+
id: "invoice-pay",
|
|
1283
|
+
type: "actionForm",
|
|
1284
|
+
handler: "test:write:invoice:pay",
|
|
1285
|
+
fields: { amount: { type: "money", currency: { kind: "literal", code: "EURO" } } } as never,
|
|
1286
|
+
layout: { sections: [{ title: "Pay", fields: ["amount"] }] } as never,
|
|
1287
|
+
});
|
|
1288
|
+
});
|
|
1289
|
+
expect(() => createApp({ roles: ["Admin"], features: [feature] })).toThrow(
|
|
1290
|
+
/Screen "invoice-pay" in feature "test", money field "amount"/,
|
|
1291
|
+
);
|
|
1292
|
+
});
|
|
1293
|
+
|
|
1207
1294
|
// hasMoneyField used to only look at top-level fields, so an entity whose
|
|
1208
1295
|
// only money lives inside an embedded-list sub-schema (e.g. invoice lines
|
|
1209
1296
|
// with no top-level money field) slipped past this check — its cells and
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// fw#2839: the type half of the fail-closed currency-source gate. This PR
|
|
2
|
+
// ships the break in one release instead of the #2810 warn-then-throw ladder,
|
|
3
|
+
// on the premise that a consumer sees it from the COMPILER at bump time, not
|
|
4
|
+
// only from a boot error in production. These `@ts-expect-error`s are that
|
|
5
|
+
// premise: each turns into a test failure the moment the narrowing stops
|
|
6
|
+
// catching an undeclared money field in one of the three forms an author
|
|
7
|
+
// writes a screen in.
|
|
8
|
+
|
|
9
|
+
import { describe, expect, test } from "bun:test";
|
|
10
|
+
import type {
|
|
11
|
+
ActionFormScreenDefinition,
|
|
12
|
+
ScreenDefinition,
|
|
13
|
+
SecretMintScreenDefinition,
|
|
14
|
+
} from "@cosmicdrift/kumiko-types/screen";
|
|
15
|
+
|
|
16
|
+
const layout = { sections: [{ title: "Payment", fields: ["amount"] }] };
|
|
17
|
+
|
|
18
|
+
describe("money field on an entity-less form screen is a compile error without `currency`", () => {
|
|
19
|
+
test("annotated as the ScreenDefinition union", () => {
|
|
20
|
+
const screen: ScreenDefinition = {
|
|
21
|
+
id: "pay",
|
|
22
|
+
type: "actionForm",
|
|
23
|
+
handler: "billing:write:pay",
|
|
24
|
+
// @ts-expect-error — money needs a declared currency source on an
|
|
25
|
+
// entity-less form; if this compiles, FormFieldDefinition stopped
|
|
26
|
+
// narrowing and the break would only surface at boot.
|
|
27
|
+
fields: { amount: { type: "money", required: true } },
|
|
28
|
+
layout,
|
|
29
|
+
};
|
|
30
|
+
expect(screen.type).toBe("actionForm");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("annotated as ActionFormScreenDefinition", () => {
|
|
34
|
+
const screen: ActionFormScreenDefinition = {
|
|
35
|
+
id: "pay",
|
|
36
|
+
type: "actionForm",
|
|
37
|
+
handler: "billing:write:pay",
|
|
38
|
+
// @ts-expect-error — see above.
|
|
39
|
+
fields: { amount: { type: "money", required: true } },
|
|
40
|
+
layout,
|
|
41
|
+
};
|
|
42
|
+
expect(screen.id).toBe("pay");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("contextually typed through an r.screen(...)-shaped parameter", () => {
|
|
46
|
+
const register = (def: ScreenDefinition): string => def.id;
|
|
47
|
+
expect(
|
|
48
|
+
register({
|
|
49
|
+
id: "pay",
|
|
50
|
+
type: "actionForm",
|
|
51
|
+
handler: "billing:write:pay",
|
|
52
|
+
// @ts-expect-error — see above.
|
|
53
|
+
fields: { amount: { type: "money", required: true } },
|
|
54
|
+
layout,
|
|
55
|
+
}),
|
|
56
|
+
).toBe("pay");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("secretMint's own fields are narrowed the same way", () => {
|
|
60
|
+
const screen: SecretMintScreenDefinition = {
|
|
61
|
+
id: "mint",
|
|
62
|
+
type: "secretMint",
|
|
63
|
+
handler: "billing:write:mint",
|
|
64
|
+
// @ts-expect-error — see above.
|
|
65
|
+
fields: { amount: { type: "money", required: true } },
|
|
66
|
+
layout,
|
|
67
|
+
reveal: { fields: [{ field: "token", label: "Token" }] },
|
|
68
|
+
};
|
|
69
|
+
expect(screen.type).toBe("secretMint");
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("declared currency sources compile", () => {
|
|
74
|
+
test("literal and tenant are both accepted", () => {
|
|
75
|
+
const screen: ScreenDefinition = {
|
|
76
|
+
id: "pay",
|
|
77
|
+
type: "actionForm",
|
|
78
|
+
handler: "billing:write:pay",
|
|
79
|
+
fields: {
|
|
80
|
+
amount: { type: "money", required: true, currency: { kind: "literal", code: "EUR" } },
|
|
81
|
+
fee: { type: "money", currency: { kind: "tenant" } },
|
|
82
|
+
},
|
|
83
|
+
layout: { sections: [{ title: "Payment", fields: ["amount", "fee"] }] },
|
|
84
|
+
};
|
|
85
|
+
expect(Object.keys(screen.type === "actionForm" ? screen.fields : {})).toEqual([
|
|
86
|
+
"amount",
|
|
87
|
+
"fee",
|
|
88
|
+
]);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -7,6 +7,7 @@ import type {
|
|
|
7
7
|
EntityDefinition,
|
|
8
8
|
FeatureDefinition,
|
|
9
9
|
MultiSelectFieldDef,
|
|
10
|
+
QueryHandlerDef,
|
|
10
11
|
} from "../types";
|
|
11
12
|
|
|
12
13
|
export const FILE_FIELD_TYPES = new Set(["file", "image", "files", "images"]);
|
|
@@ -429,13 +430,16 @@ function isValidEmbeddedDecimalScale(scale: number): boolean {
|
|
|
429
430
|
// 3) Query handler `<feature>:query:<entity>:list` is registered — the
|
|
430
431
|
// renderer fires it on Combobox open, so a missing handler crashes the
|
|
431
432
|
// Combobox at runtime.
|
|
433
|
+
// 4) optionsQuery (if set) names a registered query handler.
|
|
432
434
|
function validateReferenceTarget(
|
|
433
435
|
entityName: string,
|
|
434
436
|
fieldPath: string,
|
|
435
437
|
refString: string,
|
|
436
438
|
labelField: string | undefined,
|
|
439
|
+
optionsQuery: string | undefined,
|
|
437
440
|
feature: FeatureDefinition,
|
|
438
441
|
featureMap: ReadonlyMap<string, FeatureDefinition>,
|
|
442
|
+
queryHandlers: ReadonlyMap<string, QueryHandlerDef>,
|
|
439
443
|
): void {
|
|
440
444
|
const target = parseRefTarget(refString, feature.name);
|
|
441
445
|
const targetFeature = featureMap.get(target.featureName);
|
|
@@ -489,6 +493,40 @@ function validateReferenceTarget(
|
|
|
489
493
|
`different label/entity.`,
|
|
490
494
|
);
|
|
491
495
|
}
|
|
496
|
+
// The check above still applies when optionsQuery is set: optionsQuery only
|
|
497
|
+
// replaces the picker's option list, while list cells keep resolving the
|
|
498
|
+
// UUID through `<targetFeature>:query:<targetEntity>:list`
|
|
499
|
+
// (use-reference-lookup).
|
|
500
|
+
validateReferenceOptionsQuery(entityName, fieldPath, optionsQuery, feature, queryHandlers);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// fw#2780: the picker may source its options from a query handler instead of
|
|
504
|
+
// the referenced entity's table. A typo'd QN would only surface as a 404 on
|
|
505
|
+
// first Combobox open, so it is pinned at boot — same treatment as
|
|
506
|
+
// `DashboardFilterDefinition.optionsQuery` (boot-validator/query-refs.ts).
|
|
507
|
+
function validateReferenceOptionsQuery(
|
|
508
|
+
entityName: string,
|
|
509
|
+
fieldPath: string,
|
|
510
|
+
optionsQuery: string | undefined,
|
|
511
|
+
feature: FeatureDefinition,
|
|
512
|
+
queryHandlers: ReadonlyMap<string, QueryHandlerDef>,
|
|
513
|
+
): void {
|
|
514
|
+
// skip: a field without optionsQuery keeps the entity-table option source.
|
|
515
|
+
if (optionsQuery === undefined) return;
|
|
516
|
+
if (optionsQuery.length === 0) {
|
|
517
|
+
throw new Error(
|
|
518
|
+
`[Feature ${feature.name}] Reference field "${fieldPath}" on entity "${entityName}" ` +
|
|
519
|
+
`has an empty optionsQuery. Drop the property or name a query handler.`,
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
if (!queryHandlers.has(optionsQuery)) {
|
|
523
|
+
throw new Error(
|
|
524
|
+
`[Feature ${feature.name}] Reference field "${fieldPath}" on entity "${entityName}" ` +
|
|
525
|
+
`declares optionsQuery "${optionsQuery}" which is not a registered query-handler. ` +
|
|
526
|
+
`Check the QN spelling (expected "<feature>:query:<short>") and that the handler ` +
|
|
527
|
+
`is declared via r.queryHandler(...).`,
|
|
528
|
+
);
|
|
529
|
+
}
|
|
492
530
|
}
|
|
493
531
|
|
|
494
532
|
// Tier 2.7e-3 + Cross-Feature: ReferenceFieldDef validation for top-level
|
|
@@ -498,6 +536,7 @@ function validateReferenceTarget(
|
|
|
498
536
|
export function validateReferenceFields(
|
|
499
537
|
feature: FeatureDefinition,
|
|
500
538
|
featureMap: ReadonlyMap<string, FeatureDefinition>,
|
|
539
|
+
queryHandlers: ReadonlyMap<string, QueryHandlerDef>,
|
|
501
540
|
): void {
|
|
502
541
|
for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
|
|
503
542
|
for (const [fieldName, field] of Object.entries(entity.fields)) {
|
|
@@ -507,8 +546,10 @@ export function validateReferenceFields(
|
|
|
507
546
|
fieldName,
|
|
508
547
|
field.entity,
|
|
509
548
|
field.labelField,
|
|
549
|
+
field.optionsQuery,
|
|
510
550
|
feature,
|
|
511
551
|
featureMap,
|
|
552
|
+
queryHandlers,
|
|
512
553
|
);
|
|
513
554
|
}
|
|
514
555
|
}
|
|
@@ -517,6 +558,7 @@ export function validateReferenceFields(
|
|
|
517
558
|
export function validateEmbeddedFields(
|
|
518
559
|
feature: FeatureDefinition,
|
|
519
560
|
featureMap: ReadonlyMap<string, FeatureDefinition>,
|
|
561
|
+
queryHandlers: ReadonlyMap<string, QueryHandlerDef>,
|
|
520
562
|
): void {
|
|
521
563
|
for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
|
|
522
564
|
for (const [fieldName, field] of Object.entries(entity.fields)) {
|
|
@@ -554,8 +596,10 @@ export function validateEmbeddedFields(
|
|
|
554
596
|
`${fieldName}.${subName}`,
|
|
555
597
|
subField.entity,
|
|
556
598
|
subField.labelField,
|
|
599
|
+
subField.optionsQuery,
|
|
557
600
|
feature,
|
|
558
601
|
featureMap,
|
|
602
|
+
queryHandlers,
|
|
559
603
|
);
|
|
560
604
|
}
|
|
561
605
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { validateEntityFieldEncryptionAvailable } from "../../db/entity-field-encryption";
|
|
2
2
|
import { dedupeFeatures } from "../dedupe-features";
|
|
3
|
+
import { FILE_STORAGE_PROVIDER_ENV } from "../extension-names";
|
|
3
4
|
import { QnTypes, qualifyEntityName } from "../qualified-name";
|
|
4
5
|
import type { FeatureDefinition } from "../types";
|
|
5
6
|
import { validateAccessDeclarations } from "./access-declarations";
|
|
@@ -52,6 +53,7 @@ import { collectClaimKeys, validateOwnershipRules } from "./ownership";
|
|
|
52
53
|
import { validateParentRefs } from "./parent-ref";
|
|
53
54
|
import { validatePiiAndRetention } from "./pii-retention";
|
|
54
55
|
import {
|
|
56
|
+
buildQueryHandlerMap,
|
|
55
57
|
validateProjectionListScreens,
|
|
56
58
|
validateRelatedListSectionQueries,
|
|
57
59
|
} from "./projection-list-screens";
|
|
@@ -187,6 +189,10 @@ export function validateBoot(
|
|
|
187
189
|
}
|
|
188
190
|
}
|
|
189
191
|
|
|
192
|
+
// Registered query-handler QNs, built once for the per-feature reference
|
|
193
|
+
// checks below (a reference field's `optionsQuery` may target any feature).
|
|
194
|
+
const queryHandlerQns = buildQueryHandlerMap(features);
|
|
195
|
+
|
|
190
196
|
let hasEncryptedFields = false;
|
|
191
197
|
let hasFileFields = false;
|
|
192
198
|
|
|
@@ -197,10 +203,10 @@ export function validateBoot(
|
|
|
197
203
|
validatePiiAndRetention(feature);
|
|
198
204
|
validateRecordOwnedSubjects(feature);
|
|
199
205
|
validateApiExposureMatching(feature, allExposedApis, featureMap);
|
|
200
|
-
validateEmbeddedFields(feature, featureMap);
|
|
206
|
+
validateEmbeddedFields(feature, featureMap, queryHandlerQns);
|
|
201
207
|
validateMultiSelectFields(feature);
|
|
202
208
|
validateImageVariants(feature);
|
|
203
|
-
validateReferenceFields(feature, featureMap);
|
|
209
|
+
validateReferenceFields(feature, featureMap, queryHandlerQns);
|
|
204
210
|
validateTransitions(feature);
|
|
205
211
|
validateExtensionUsages(feature, extensionProviders);
|
|
206
212
|
validateExtendSchemaCollisions(feature);
|
|
@@ -273,9 +279,9 @@ export function validateBoot(
|
|
|
273
279
|
validateEntityFieldEncryptionAvailable();
|
|
274
280
|
}
|
|
275
281
|
|
|
276
|
-
if (hasFileFields && !process.env[
|
|
282
|
+
if (hasFileFields && !process.env[FILE_STORAGE_PROVIDER_ENV]) {
|
|
277
283
|
throw new Error(
|
|
278
|
-
|
|
284
|
+
`${FILE_STORAGE_PROVIDER_ENV} environment variable is required (file/image fields in use)`,
|
|
279
285
|
);
|
|
280
286
|
}
|
|
281
287
|
|
|
@@ -197,6 +197,14 @@ export function validatePiiAndRetention(feature: FeatureDefinition): void {
|
|
|
197
197
|
console.warn(
|
|
198
198
|
`[kumiko:boot] [Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" has a user-reference-typical name but no personal annotation — a foreign key into \`user\` carries Art.17 obligations even with no annotated content on the entity. Mark it { personal: "ref" } AND register r.useExtension(EXT_USER_DATA, "${entityName}", …) for Art.17 coverage — this warning is the only boot-time check for that, registering the hook is not enforced. Or { personal: { of: "${fieldName}" } } on the field it owns. If business data, set { personal: false, reason: "..." } to silence.`,
|
|
199
199
|
);
|
|
200
|
+
} else if (!annot.subjectRef && (field.type === "text" || field.type === "longText")) {
|
|
201
|
+
// Deprecation step towards #2810, where a text field without a
|
|
202
|
+
// stance becomes a boot error. Only text/longText: the other field
|
|
203
|
+
// types keep `personal` optional.
|
|
204
|
+
// biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
|
|
205
|
+
console.warn(
|
|
206
|
+
`[kumiko:boot] [Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" (type "${field.type}") declares no personal stance. Text fields will require one — this warning becomes a boot error in a future release (#2810). Declare { personal: "self" | "tenant" | "ref" | { of: "<ownerField>" } | false }; "false" additionally needs { reason: "..." } stating why the value is not personal data.`,
|
|
207
|
+
);
|
|
200
208
|
}
|
|
201
209
|
}
|
|
202
210
|
}
|