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