From 32c1cda666bc8ccd6e083d8d94d96a42e65c3983 Mon Sep 17 00:00:00 2001 From: Kevin Ingersoll <kingersoll@gmail.com> Date: Wed, 8 May 2024 17:26:13 +0100 Subject: [PATCH 1/2] fix(store,world): throw on unexpected config keys (#2797) --- .changeset/silver-toys-roll.md | 6 ++++++ packages/store/ts/config/v2/store.test.ts | 9 +++++++++ packages/store/ts/config/v2/store.ts | 4 ++-- packages/world/ts/config/v2/world.test.ts | 9 +++++++++ packages/world/ts/config/v2/world.ts | 2 +- 5 files changed, 27 insertions(+), 3 deletions(-) create mode 100644 .changeset/silver-toys-roll.md diff --git a/.changeset/silver-toys-roll.md b/.changeset/silver-toys-roll.md new file mode 100644 index 0000000000..190623c482 --- /dev/null +++ b/.changeset/silver-toys-roll.md @@ -0,0 +1,6 @@ +--- +"@latticexyz/store": patch +"@latticexyz/world": patch +--- + +`defineStore` and `defineWorld` will now throw a type error if an unexpected config option is used. diff --git a/packages/store/ts/config/v2/store.test.ts b/packages/store/ts/config/v2/store.test.ts index 458f356d6a..0faf3c999e 100644 --- a/packages/store/ts/config/v2/store.test.ts +++ b/packages/store/ts/config/v2/store.test.ts @@ -516,4 +516,13 @@ describe("defineStore", () => { defineStore(config); }); + + it("should throw if config has unexpected key", () => { + attest(() => + defineStore({ + // @ts-expect-error Invalid config option + invalidOption: "nope", + }), + ).type.errors("`invalidOption` is not a valid Store config option."); + }); }); diff --git a/packages/store/ts/config/v2/store.ts b/packages/store/ts/config/v2/store.ts index 2305f2fba4..0922998f56 100644 --- a/packages/store/ts/config/v2/store.ts +++ b/packages/store/ts/config/v2/store.ts @@ -1,4 +1,4 @@ -import { flatMorph, narrow } from "@arktype/util"; +import { ErrorMessage, flatMorph, narrow } from "@arktype/util"; import { get, hasOwnKey, mergeIfUndefined } from "./generics"; import { UserTypes } from "./output"; import { CONFIG_DEFAULTS } from "./defaults"; @@ -23,7 +23,7 @@ export type validateStore<store> = { ? narrow<store[key]> : key extends keyof StoreInput ? StoreInput[key] - : never; + : ErrorMessage<`\`${key & string}\` is not a valid Store config option.`>; }; export function validateStore(store: unknown): asserts store is StoreInput { diff --git a/packages/world/ts/config/v2/world.test.ts b/packages/world/ts/config/v2/world.test.ts index 94ab0f6842..245e9c9e26 100644 --- a/packages/world/ts/config/v2/world.test.ts +++ b/packages/world/ts/config/v2/world.test.ts @@ -752,4 +752,13 @@ describe("defineWorld", () => { defineWorld(config); }); + + it("should throw if config has unexpected key", () => { + attest(() => + defineWorld({ + // @ts-expect-error Invalid config option + invalidOption: "nope", + }), + ).type.errors("`invalidOption` is not a valid World config option."); + }); }); diff --git a/packages/world/ts/config/v2/world.ts b/packages/world/ts/config/v2/world.ts index 13898d5542..b26b358677 100644 --- a/packages/world/ts/config/v2/world.ts +++ b/packages/world/ts/config/v2/world.ts @@ -33,7 +33,7 @@ export type validateWorld<world> = { ErrorMessage<`Namespaces config will be enabled soon.`> : key extends keyof WorldInput ? conform<world[key], WorldInput[key]> - : world[key]; + : ErrorMessage<`\`${key & string}\` is not a valid World config option.`>; }; export function validateWorld(world: unknown): asserts world is WorldInput { From 3dbf3bf3a3295ad63264044e315dec075de528fd Mon Sep 17 00:00:00 2001 From: Kevin Ingersoll <kingersoll@gmail.com> Date: Wed, 8 May 2024 17:45:16 +0100 Subject: [PATCH 2/2] fix(world): config uses readonly arrays (#2805) --- .changeset/seven-windows-tickle.md | 5 +++++ packages/world/ts/config/defaults.ts | 8 ++++---- packages/world/ts/config/types.ts | 10 +++++----- packages/world/ts/config/v2/compat.ts | 12 ++++++------ packages/world/ts/config/v2/defaults.ts | 11 +++++++++-- packages/world/ts/config/v2/input.ts | 18 ++++++++++++++---- packages/world/ts/config/v2/output.ts | 6 +++--- packages/world/ts/config/v2/world.ts | 6 ++++-- packages/world/ts/config/worldConfig.ts | 11 +++++++---- 9 files changed, 57 insertions(+), 30 deletions(-) create mode 100644 .changeset/seven-windows-tickle.md diff --git a/.changeset/seven-windows-tickle.md b/.changeset/seven-windows-tickle.md new file mode 100644 index 0000000000..4ca8c2e6f6 --- /dev/null +++ b/.changeset/seven-windows-tickle.md @@ -0,0 +1,5 @@ +--- +"@latticexyz/world": patch +--- + +Updated World config types to use readonly arrays. diff --git a/packages/world/ts/config/defaults.ts b/packages/world/ts/config/defaults.ts index a6f33553e0..d00fc7e626 100644 --- a/packages/world/ts/config/defaults.ts +++ b/packages/world/ts/config/defaults.ts @@ -1,7 +1,7 @@ export const SYSTEM_DEFAULTS = { registerFunctionSelector: true, openAccess: true, - accessList: [] as string[], + accessList: [], } as const; export type SYSTEM_DEFAULTS = typeof SYSTEM_DEFAULTS; @@ -9,14 +9,14 @@ export type SYSTEM_DEFAULTS = typeof SYSTEM_DEFAULTS; export const WORLD_DEFAULTS = { worldContractName: undefined, worldInterfaceName: "IWorld", - systems: {} as Record<string, never>, - excludeSystems: [] as string[], + systems: {}, + excludeSystems: [], postDeployScript: "PostDeploy", deploysDirectory: "./deploys", worldsFile: "./worlds.json", worldgenDirectory: "world", worldImportPath: "@latticexyz/world/src/", - modules: [] as [], + modules: [], } as const; export type WORLD_DEFAULTS = typeof WORLD_DEFAULTS; diff --git a/packages/world/ts/config/types.ts b/packages/world/ts/config/types.ts index 70825876f8..d07ee198f4 100644 --- a/packages/world/ts/config/types.ts +++ b/packages/world/ts/config/types.ts @@ -25,7 +25,7 @@ export type SystemUserConfig = { /** If openAccess is false, only the addresses or systems in `access` can call the system */ openAccess: false; /** An array of addresses or system names that can access the system */ - accessList: string[]; + accessList: readonly string[]; } ); @@ -38,7 +38,7 @@ export interface ExpandSystemConfig<T extends SystemUserConfig, SystemName exten openAccess: SYSTEM_DEFAULTS["openAccess"]; } > { - accessList: T extends { accessList: string[] } ? T["accessList"] : SYSTEM_DEFAULTS["accessList"]; + accessList: T extends { accessList: readonly string[] } ? T["accessList"] : SYSTEM_DEFAULTS["accessList"]; } export type SystemsUserConfig = Record<string, SystemUserConfig>; @@ -53,7 +53,7 @@ export type ModuleConfig = { /** Should this module be installed as a root module? */ root?: boolean; /** Arguments to be passed to the module's install method */ - args?: (ValueWithType | DynamicResolution)[]; + args?: readonly (ValueWithType | DynamicResolution)[]; }; // zod doesn't preserve doc comments @@ -71,7 +71,7 @@ export interface WorldUserConfig { */ systems?: SystemsUserConfig; /** Systems to exclude from automatic deployment */ - excludeSystems?: string[]; + excludeSystems?: readonly string[]; /** * Script to execute after the deployment is complete (Default "PostDeploy"). * Script must be placed in the forge scripts directory (see foundry.toml) and have a ".s.sol" extension. @@ -86,7 +86,7 @@ export interface WorldUserConfig { /** Path for world package imports. Default is "@latticexyz/world/src/" */ worldImportPath?: string; /** Modules to in the World */ - modules?: ModuleConfig[]; + modules?: readonly ModuleConfig[]; } export type WorldConfig = z.output<typeof zWorldConfig>; diff --git a/packages/world/ts/config/v2/compat.ts b/packages/world/ts/config/v2/compat.ts index 95a7850a90..04d7a82b9b 100644 --- a/packages/world/ts/config/v2/compat.ts +++ b/packages/world/ts/config/v2/compat.ts @@ -1,15 +1,15 @@ -import { conform, mutable } from "@arktype/util"; +import { conform } from "@arktype/util"; import { Module, World, Systems } from "./output"; import { Store } from "@latticexyz/store"; import { storeToV1 } from "@latticexyz/store/config/v2"; -type modulesToV1<modules extends readonly Module[]> = mutable<{ - [key in keyof modules]: Required<modules[key]>; -}>; +type modulesToV1<modules extends readonly Module[]> = { + [key in keyof modules]: modules[key]; +}; function modulesToV1<modules extends readonly Module[]>(modules: modules): modulesToV1<modules> { return modules.map((module) => ({ - name: module.name, + ...module, root: module.root ?? false, args: module.args ?? [], })) as never; @@ -29,7 +29,7 @@ function systemsToV1<systems extends Systems>(systems: systems): systemsToV1<sys export type worldToV1<world> = world extends World ? Omit<storeToV1<world>, "v2"> & { systems: systemsToV1<world["systems"]>; - excludeSystems: mutable<world["excludeSystems"]>; + excludeSystems: world["excludeSystems"]; modules: modulesToV1<world["modules"]>; worldContractName: world["deploy"]["customWorldContract"]; postDeployScript: world["deploy"]["postDeployScript"]; diff --git a/packages/world/ts/config/v2/defaults.ts b/packages/world/ts/config/v2/defaults.ts index f8982f24d8..b070a1cbc6 100644 --- a/packages/world/ts/config/v2/defaults.ts +++ b/packages/world/ts/config/v2/defaults.ts @@ -1,11 +1,18 @@ export const SYSTEM_DEFAULTS = { registerFunctionSelectors: true, openAccess: true, - accessList: [] as string[], + accessList: [], } as const; export type SYSTEM_DEFAULTS = typeof SYSTEM_DEFAULTS; +export const MODULE_DEFAULTS = { + root: false, + args: [], +} as const; + +export type MODULE_DEFAULTS = typeof MODULE_DEFAULTS; + export const CODEGEN_DEFAULTS = { worldInterfaceName: "IWorld", worldgenDirectory: "world", @@ -27,7 +34,7 @@ export type DEPLOY_DEFAULTS = typeof DEPLOY_DEFAULTS; export const CONFIG_DEFAULTS = { systems: {}, tables: {}, - excludeSystems: [] as string[], + excludeSystems: [], modules: [], codegen: CODEGEN_DEFAULTS, deploy: DEPLOY_DEFAULTS, diff --git a/packages/world/ts/config/v2/input.ts b/packages/world/ts/config/v2/input.ts index e1ea7961b3..43f2f33770 100644 --- a/packages/world/ts/config/v2/input.ts +++ b/packages/world/ts/config/v2/input.ts @@ -1,6 +1,6 @@ import { evaluate } from "@arktype/util"; import { StoreInput, StoreWithShorthandsInput } from "@latticexyz/store/config/v2"; -import { Module } from "./output"; +import { DynamicResolution, ValueWithType } from "./dynamicResolution"; export type SystemInput = { /** The full resource selector consists of namespace and name */ @@ -16,11 +16,21 @@ export type SystemInput = { /** If openAccess is true, any address can call the system */ openAccess?: boolean; /** An array of addresses or system names that can access the system */ - accessList?: string[]; + accessList?: readonly string[]; }; export type SystemsInput = { [key: string]: SystemInput }; +export type ModuleInput = { + /** The name of the module */ + readonly name: string; + /** Should this module be installed as a root module? */ + readonly root?: boolean; + /** Arguments to be passed to the module's install method */ + // TODO: make more strongly typed by taking in tables input + readonly args?: readonly (ValueWithType | DynamicResolution)[]; +}; + export type DeployInput = { /** * Script to execute after the deployment is complete (Default "PostDeploy"). @@ -56,9 +66,9 @@ export type WorldInput = evaluate< */ systems?: SystemsInput; /** System names to exclude from automatic deployment */ - excludeSystems?: string[]; + excludeSystems?: readonly string[]; /** Modules to in the World */ - modules?: Module[]; + modules?: readonly ModuleInput[]; /** Deploy config */ deploy?: DeployInput; /** Codegen config */ diff --git a/packages/world/ts/config/v2/output.ts b/packages/world/ts/config/v2/output.ts index 99456b6af2..95c374767a 100644 --- a/packages/world/ts/config/v2/output.ts +++ b/packages/world/ts/config/v2/output.ts @@ -5,9 +5,9 @@ export type Module = { /** The name of the module */ readonly name: string; /** Should this module be installed as a root module? */ - readonly root?: boolean; + readonly root: boolean; /** Arguments to be passed to the module's install method */ - readonly args?: (ValueWithType | DynamicResolution)[]; + readonly args: readonly (ValueWithType | DynamicResolution)[]; }; export type System = { @@ -24,7 +24,7 @@ export type System = { /** If openAccess is true, any address can call the system */ readonly openAccess: boolean; /** An array of addresses or system names that can access the system */ - readonly accessList: string[]; + readonly accessList: readonly string[]; }; export type Systems = { readonly [key: string]: System }; diff --git a/packages/world/ts/config/v2/world.ts b/packages/world/ts/config/v2/world.ts index b26b358677..df5882a4de 100644 --- a/packages/world/ts/config/v2/world.ts +++ b/packages/world/ts/config/v2/world.ts @@ -14,7 +14,7 @@ import { isObject, } from "@latticexyz/store/config/v2"; import { SystemsInput, WorldInput } from "./input"; -import { CONFIG_DEFAULTS } from "./defaults"; +import { CONFIG_DEFAULTS, MODULE_DEFAULTS } from "./defaults"; import { Tables } from "@latticexyz/store/internal"; import { resolveSystems } from "./systems"; import { resolveNamespacedTables } from "./namespaces"; @@ -91,6 +91,8 @@ export function resolveWorld<const world extends WorldInput>(world: world): reso const resolvedStore = resolveStore(world); + const modules = (world.modules ?? CONFIG_DEFAULTS.modules).map((mod) => mergeIfUndefined(mod, MODULE_DEFAULTS)); + return mergeIfUndefined( { ...resolvedStore, @@ -99,7 +101,7 @@ export function resolveWorld<const world extends WorldInput>(world: world): reso deploy: resolveDeploy(world.deploy), systems: resolveSystems(world.systems ?? CONFIG_DEFAULTS.systems), excludeSystems: get(world, "excludeSystems"), - modules: world.modules, + modules, }, CONFIG_DEFAULTS, ) as never; diff --git a/packages/world/ts/config/worldConfig.ts b/packages/world/ts/config/worldConfig.ts index e846eff478..ee82848abd 100644 --- a/packages/world/ts/config/worldConfig.ts +++ b/packages/world/ts/config/worldConfig.ts @@ -4,7 +4,7 @@ import { SYSTEM_DEFAULTS, WORLD_DEFAULTS } from "./defaults"; const zSystemName = zObjectName; const zModuleName = zObjectName; -const zSystemAccessList = z.array(zSystemName.or(zEthereumAddress)).default(SYSTEM_DEFAULTS.accessList); +const zSystemAccessList = z.array(zSystemName.or(zEthereumAddress)).readonly().default(SYSTEM_DEFAULTS.accessList); // The system config is a combination of a name config and access config const zSystemConfig = z.intersection( @@ -32,7 +32,10 @@ const zDynamicResolution = z.object({ type: z.nativeEnum(DynamicResolutionType), const zModuleConfig = z.object({ name: zModuleName, root: z.boolean().default(false), - args: z.array(z.union([zValueWithType, zDynamicResolution])).default([]), + args: z + .array(z.union([zValueWithType, zDynamicResolution])) + .readonly() + .default([]), }); // The parsed world config is the result of parsing the user config @@ -40,13 +43,13 @@ export const zWorldConfig = z.object({ worldContractName: z.string().optional(), worldInterfaceName: z.string().default(WORLD_DEFAULTS.worldInterfaceName), systems: z.record(zSystemName, zSystemConfig).default(WORLD_DEFAULTS.systems), - excludeSystems: z.array(zSystemName).default(WORLD_DEFAULTS.excludeSystems), + excludeSystems: z.array(zSystemName).readonly().default(WORLD_DEFAULTS.excludeSystems), postDeployScript: z.string().default(WORLD_DEFAULTS.postDeployScript), deploysDirectory: z.string().default(WORLD_DEFAULTS.deploysDirectory), worldsFile: z.string().default(WORLD_DEFAULTS.worldsFile), worldgenDirectory: z.string().default(WORLD_DEFAULTS.worldgenDirectory), worldImportPath: z.string().default(WORLD_DEFAULTS.worldImportPath), - modules: z.array(zModuleConfig).default(WORLD_DEFAULTS.modules), + modules: z.array(zModuleConfig).readonly().default(WORLD_DEFAULTS.modules), }); // Catchall preserves other plugins' options