diff --git a/.vscode/settings.json b/.vscode/settings.json index 6de9fd00..93b727f4 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -20,6 +20,7 @@ "nearley", "ngraph", "ostrio", + "recomputation", "Ruleset", "snackbars", "Spellcasting", diff --git a/app/imports/api/creature/creatureProperties/CreatureProperties.ts b/app/imports/api/creature/creatureProperties/CreatureProperties.ts index c346e8c6..ac4a062d 100644 --- a/app/imports/api/creature/creatureProperties/CreatureProperties.ts +++ b/app/imports/api/creature/creatureProperties/CreatureProperties.ts @@ -6,31 +6,9 @@ import SoftRemovableSchema from '/imports/api/parenting/SoftRemovableSchema'; import propertySchemasIndex from '/imports/api/properties/computedPropertySchemasIndex'; import { storedIconsSchema } from '/imports/api/icons/Icons'; import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS'; +import { InferType, TypedSimpleSchema } from '/imports/api/utility/TypedSimpleSchema'; -// TODO make this a union type of all CreatureProperty types -const CreatureProperties: Mongo.Collection = new Mongo.Collection('creatureProperties'); - -export interface CreatureProperty extends TreeDoc { - _id: string - _migrationError?: string - tags: string[] - type: string - disabled?: boolean - icon?: { - name: string - shape: string - }, - libraryNodeId?: string - slotQuantityFilled?: number - inactive?: boolean - deactivatedByAncestor?: boolean - deactivatedBySelf?: boolean - deactivatedByToggle?: boolean - deactivatingToggleId?: boolean - dirty?: boolean -} - -const CreaturePropertySchema = new SimpleSchema({ +const PreComputeCreaturePropertySchema = new TypedSimpleSchema({ _id: { type: String, regEx: SimpleSchema.RegEx.Id, @@ -75,13 +53,12 @@ const CreaturePropertySchema = new SimpleSchema({ }, }); -const DenormalisedOnlyCreaturePropertySchema = new SimpleSchema({ +const DenormalisedOnlyCreaturePropertySchema = new TypedSimpleSchema({ // Denormalised flag if this property is inactive on the sheet for any reason // Including being disabled, or a descendant of a disabled property inactive: { type: Boolean, optional: true, - index: 1, removeBeforeCompute: true, }, // Denormalised flag if this property was made inactive by an inactive @@ -90,7 +67,6 @@ const DenormalisedOnlyCreaturePropertySchema = new SimpleSchema({ deactivatedByAncestor: { type: Boolean, optional: true, - index: 1, removeBeforeCompute: true, }, // Denormalised flag if this property was made inactive because of its own @@ -98,7 +74,6 @@ const DenormalisedOnlyCreaturePropertySchema = new SimpleSchema({ deactivatedBySelf: { type: Boolean, optional: true, - index: 1, removeBeforeCompute: true, }, // Denormalised flag if this property was made inactive because of a toggle @@ -106,7 +81,6 @@ const DenormalisedOnlyCreaturePropertySchema = new SimpleSchema({ deactivatedByToggle: { type: Boolean, optional: true, - index: 1, removeBeforeCompute: true, }, deactivatingToggleId: { @@ -154,9 +128,23 @@ const DenormalisedOnlyCreaturePropertySchema = new SimpleSchema({ }, }); -CreaturePropertySchema.extend(DenormalisedOnlyCreaturePropertySchema); +const CreaturePropertySchema = PreComputeCreaturePropertySchema.extend(DenormalisedOnlyCreaturePropertySchema); -for (const key in propertySchemasIndex) { +type CreaturePropertyByType = + InferType + & InferType + & InferType + & InferType + & InferType + +type ConvertToUnion = T[keyof T]; +type CreatureProperty = ConvertToUnion<{ [key in keyof typeof propertySchemasIndex]: CreaturePropertyByType }>; +type ActionProperty = CreaturePropertyByType<'action'>; + +const CreatureProperties = new Mongo.Collection('creatureProperties'); + +let key: keyof typeof propertySchemasIndex; +for (key in propertySchemasIndex) { const schema = new SimpleSchema({}); schema.extend(propertySchemasIndex[key]); schema.extend(CreaturePropertySchema); @@ -167,12 +155,13 @@ for (const key in propertySchemasIndex) { if (key === 'any') { // @ts-expect-error don't have types for .attachSchema CreatureProperties.attachSchema(schema); + } else { + // TODO remove all {selector: {type: any}} options + // @ts-expect-error don't have types for .attachSchema + CreatureProperties.attachSchema(schema, { + selector: { type: key } + }); } - // TODO make this an else branch and remove all {selector: {type: any}} options - // @ts-expect-error don't have types for .attachSchema - CreatureProperties.attachSchema(schema, { - selector: { type: key } - }); } export default CreatureProperties; diff --git a/app/imports/api/icons/Icons.js b/app/imports/api/icons/Icons.ts similarity index 80% rename from app/imports/api/icons/Icons.js rename to app/imports/api/icons/Icons.ts index eb2f8fe1..5a85af7a 100644 --- a/app/imports/api/icons/Icons.js +++ b/app/imports/api/icons/Icons.ts @@ -3,10 +3,9 @@ import { ValidatedMethod } from 'meteor/mdg:validated-method'; import { RateLimiterMixin } from 'ddp-rate-limiter-mixin'; import { assertAdmin } from '/imports/api/sharing/sharingPermissions'; import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS'; +import { InferType, TypedSimpleSchema } from '/imports/api/utility/TypedSimpleSchema'; -let Icons = new Mongo.Collection('icons'); - -let iconsSchema = new SimpleSchema({ +const iconsSchema = new TypedSimpleSchema({ name: { type: String, unique: true, @@ -34,6 +33,12 @@ let iconsSchema = new SimpleSchema({ }, }); +type Icon = InferType; + +const Icons = new Mongo.Collection('icons'); +// @ts-expect-error don't have types for .attachSchema +Icons.attachSchema(iconsSchema); + if (Meteor.isServer) { Icons._ensureIndex({ 'name': 'text', @@ -42,7 +47,7 @@ if (Meteor.isServer) { }); } -const storedIconsSchema = new SimpleSchema({ +const storedIconsSchema = new TypedSimpleSchema({ name: { type: String, }, @@ -51,12 +56,15 @@ const storedIconsSchema = new SimpleSchema({ }, }); -Icons.attachSchema(iconsSchema); - // This method does not validate icons against the schema, use wisely; const writeIcons = new ValidatedMethod({ name: 'icons.write', validate: null, + mixins: [RateLimiterMixin], + rateLimit: { + numRequests: 20, + timeInterval: 10000, + }, run(icons) { assertAdmin(this.userId); if (Meteor.isServer) { @@ -88,6 +96,7 @@ const findIcons = new ValidatedMethod({ { // relevant documents have a higher score. fields: { + // @ts-expect-error don't have types for meta text scoring score: { $meta: 'textScore' } }, // `score` property specified in the projection fields above. diff --git a/app/imports/api/properties/Actions.ts b/app/imports/api/properties/Actions.ts index f4d87bdc..39d4c325 100644 --- a/app/imports/api/properties/Actions.ts +++ b/app/imports/api/properties/Actions.ts @@ -7,6 +7,7 @@ import { CreatureProperty } from '/imports/api/creature/creatureProperties/Creat import { InlineCalculation } from '/imports/api/properties/subSchemas/inlineCalculationField'; import { CalculatedField } from '/imports/api/properties/subSchemas/computedField'; import Property from '/imports/api/properties/Properties.type'; +import { TypedSimpleSchema } from '/imports/api/utility/TypedSimpleSchema'; export type CreatureAction = Action & CreatureProperty & { overridden?: boolean @@ -314,7 +315,7 @@ const ComputedOnlyActionSchema = createPropertySchema({ }, }); -const ComputedActionSchema = new SimpleSchema() +const ComputedActionSchema = new TypedSimpleSchema({}) .extend(ActionSchema) .extend(ComputedOnlyActionSchema); diff --git a/app/imports/api/properties/subSchemas/AdjustmentSchema.js b/app/imports/api/properties/subSchemas/AdjustmentSchema.ts similarity index 76% rename from app/imports/api/properties/subSchemas/AdjustmentSchema.js rename to app/imports/api/properties/subSchemas/AdjustmentSchema.ts index 5ca96137..4f43b034 100644 --- a/app/imports/api/properties/subSchemas/AdjustmentSchema.js +++ b/app/imports/api/properties/subSchemas/AdjustmentSchema.ts @@ -1,10 +1,10 @@ -import SimpleSchema from 'simpl-schema'; import { Random } from 'meteor/random'; +import { InferType, TypedSimpleSchema } from '/imports/api/utility/TypedSimpleSchema'; -const AdjustmentSchema = new SimpleSchema({ +const AdjustmentSchema = new TypedSimpleSchema({ _id: { type: String, - regEx: SimpleSchema.RegEx.Id, + max: 17, autoValue() { if (!this.isSet) return Random.id(); } @@ -23,7 +23,7 @@ const AdjustmentSchema = new SimpleSchema({ 'self', // the character who took the action 'each', // rolled once for `each` target 'every', // rolled once and applied to `every` target - ], + ] as const, }, // The stat this rolls applies to, if damage type is set, this is ignored stat: { @@ -32,4 +32,6 @@ const AdjustmentSchema = new SimpleSchema({ }, }); +export type Adjustment = InferType; + export default AdjustmentSchema; diff --git a/app/imports/api/properties/subSchemas/ColorSchema.ts b/app/imports/api/properties/subSchemas/ColorSchema.ts index 34fd807f..70753fa8 100644 --- a/app/imports/api/properties/subSchemas/ColorSchema.ts +++ b/app/imports/api/properties/subSchemas/ColorSchema.ts @@ -1,10 +1,6 @@ -import SimpleSchema from 'simpl-schema'; +import { TypedSimpleSchema } from '/imports/api/utility/TypedSimpleSchema'; -export interface Colored { - color?: string, -} - -const ColorSchema = new SimpleSchema({ +const ColorSchema = new TypedSimpleSchema({ color: { type: String, // match hex colors of the form #A23 or #A23f56 diff --git a/app/imports/api/properties/subSchemas/createPropertySchema.js b/app/imports/api/properties/subSchemas/createPropertySchema.ts similarity index 84% rename from app/imports/api/properties/subSchemas/createPropertySchema.js rename to app/imports/api/properties/subSchemas/createPropertySchema.ts index 2d2d6b11..34a6cde7 100644 --- a/app/imports/api/properties/subSchemas/createPropertySchema.js +++ b/app/imports/api/properties/subSchemas/createPropertySchema.ts @@ -6,12 +6,12 @@ import { fieldToCompute, computedOnlyField, } from '/imports/api/properties/subSchemas/computedField'; -import SimpleSchema from 'simpl-schema'; +import { Definition, TypedSimpleSchema } from '/imports/api/utility/TypedSimpleSchema'; // Search through the schema for keys whose type is 'fieldToCompute' etc. // replace the type with Object and attach extend the schema with // the required fields to make the computation work -export default function createPropertySchema(definition) { +export default function createPropertySchema(definition: Definition) { const computationFields = { inlineCalculationFieldToCompute: [], computedOnlyInlineCalculationField: [], @@ -20,9 +20,9 @@ export default function createPropertySchema(definition) { }; const computedKeys = Object.keys(computationFields); - for (let key in definition) { + for (const key in definition) { const def = definition[key]; - if (computedKeys.includes(def.type)) { + if (typeof def === 'object' && 'type' in def && computedKeys.includes(def.type)) { computationFields[def.type].push(key); applyDefaultCalculationValue(definition, key); def.type = Object; @@ -31,6 +31,7 @@ export default function createPropertySchema(definition) { `computed field: '${key}' of '${def.type}' is expected to be optional` ); } + //@ts-expect-error removeBeforeCompute is an extension of SimpleSchema if (def.removeBeforeCompute) { console.warn( `computed field: '${key}' of '${def.type}' should not be removed before computation` @@ -40,7 +41,7 @@ export default function createPropertySchema(definition) { } // Create a schema with the edited definition - const schema = new SimpleSchema(definition); + const schema = new TypedSimpleSchema(definition); // Extend the schema with all the computation fields computationFields.inlineCalculationFieldToCompute.forEach(key => { @@ -69,7 +70,7 @@ function applyDefaultCalculationValue(definition, key) { // on the fields to compute return; } - let defaultValue = def.defaultValue; + const defaultValue = def.defaultValue; if (!defaultValue) return; let calcField; if (def.type === 'fieldToCompute') { diff --git a/app/imports/api/utility/TypedSimpleSchema.ts b/app/imports/api/utility/TypedSimpleSchema.ts new file mode 100644 index 00000000..0db730c2 --- /dev/null +++ b/app/imports/api/utility/TypedSimpleSchema.ts @@ -0,0 +1,104 @@ +import SimpleSchema, { SimpleSchemaDefinition } from 'simpl-schema'; + +// It DOES NOT support a constructor with multiple schemas. +export type Definition = Exclude; + +// This is a no-op wrapper, effectively implementing a phantom type. +export class TypedSimpleSchema extends SimpleSchema { + constructor(definition: T) { + super(definition); + } + // Extending the schema with another schema &'s their definitions + extend(otherSchema: TypedSimpleSchema): TypedSimpleSchema { + return super.extend(otherSchema); + } +} + +// It cannot be a method due to https://github.com/microsoft/TypeScript/issues/36931. +export function validate(schema: TypedSimpleSchema, value: unknown): asserts value is InferSchema { + schema.validate(value); +} + +// If this type emerges anywhere in calculations, congratulations! +// You've just hit an unimplemented corner case :D +type NotImplementedMarker = { readonly NotImplementedMarker: unique symbol }; + +// Internal calculation markers. +type ArrayMarker = { readonly ArrayMarker: unique symbol }; +type ObjectMarker = { readonly ObjectMarker: unique symbol }; + +export type InferType = ExpandRecursively>>; + +// Infer TypeScript type from SimpleSchema type. +type InferTypeInner = + T extends typeof Array ? ArrayMarker : + T extends typeof Boolean ? boolean : + // eslint-disable-next-line @typescript-eslint/ban-types + T extends typeof Function ? Function : + T extends typeof Number ? number : + T extends typeof Object ? ObjectMarker : + T extends typeof String ? string : + T extends RegExp ? string : + T extends TypedSimpleSchema ? InferSchema : + NotImplementedMarker; + +// Infer TypeScript type from a single field definition. +export type InferField = + Key extends string + ? Def[Key] extends { type: infer Typ } + ? ArrayMarker extends InferTypeInner + ? Array> + : ObjectMarker extends InferTypeInner + ? { [L in keyof Def as L extends `${Key}.${infer SubKey}` ? SubKey extends `${string}.${string}` ? never : SubKey : never]: InferField } + : Def[Key] extends { allowedValues: infer Allowed extends string[] } + ? InferOptional> + : InferOptional> + : NotImplementedMarker + : NotImplementedMarker + +// Infer union from string array (allowedValues should me marked as const for this to work) +type InferEnum = T[number]; + +// Infer optional from optional field +type InferOptional = Def[Key] extends { optional: true } ? U | undefined : U; + +type MakeUndefinedOptional = { [Property in keyof Type as undefined extends Type[Property] ? never : Property]: Type[Property]; } + & { [Property in keyof Type as undefined extends Type[Property] ? Property : never]+?: Type[Property]; }; + +// Infer TypeScript type from a schema definition. +export type InferSchema = InferField< + { '': { type: typeof Object } } + & { [Key in keyof Def as Key extends string ? `.${Key}` : never]: Def[Key] }, '' +>; + +const testSchema = new TypedSimpleSchema({ + name: { + type: String, + optional: true, + }, + age: { + type: Number, + }, + children: { + type: Array, + optional: true, + defaultValue: [], + }, + 'children.$': { + type: String, + }, + type: { + type: String, + allowedValues: ['cat', 'dog'] as const, + optional: true, + } +}); + +// expands object types recursively +type ExpandRecursively = T extends object + ? T extends infer O ? { [K in keyof O]: ExpandRecursively } : never + : T; + +type testType = InferType; + +type subType = ExpandRecursively diff --git a/app/tsconfig.json b/app/tsconfig.json index 4fdac9b5..06622c64 100644 --- a/app/tsconfig.json +++ b/app/tsconfig.json @@ -17,6 +17,7 @@ "allowJs": true, "checkJs": true, "noImplicitAny": false, + "noErrorTruncation": true, "outDir": "build", "paths": { "/*": [