diff --git a/.changeset/seven-melons-kneel.md b/.changeset/seven-melons-kneel.md new file mode 100644 index 0000000000..3366d99e76 --- /dev/null +++ b/.changeset/seven-melons-kneel.md @@ -0,0 +1,34 @@ +--- +"@latticexyz/cli": minor +"@latticexyz/store": minor +"@latticexyz/world": minor +--- + +MUD projects can now use multiple namespaces via a new top-level `namespaces` config option. + +```ts +import { defineWorld } from "@latticexyz/world"; + +export default defineWorld({ + namespaces: { + app: { + tables: { ... }, + systems: { ... }, + }, + }, +}); +``` + +Once you use the top-level `namespaces` config option, your project will be in "multiple namespaces mode", which expects a source directory structure similar to the config structure: a top-level `namespaces` directory with nested namespace directories that correspond to each namespace label in the config. + +``` +mud-project/ +├─ mud.config.ts +└─ src/ + └─ namespaces/ + └─ app/ + ├─ TasksSystem.sol + └─ codegen/ + ├─ tables/ + └─ Tasks.sol +``` diff --git a/examples/multiple-namespaces/mud.config.ts b/examples/multiple-namespaces/mud.config.ts index a5cc87cc9d..7d256438b6 100644 --- a/examples/multiple-namespaces/mud.config.ts +++ b/examples/multiple-namespaces/mud.config.ts @@ -1,23 +1,31 @@ import { defineWorld } from "@latticexyz/world"; export default defineWorld({ - namespace: "game", - codegen: { namespaceDirectories: true }, - tables: { - Health: { - schema: { - player: "address", - value: "uint32", + namespaces: { + game: { + tables: { + Health: { + schema: { + player: "address", + value: "uint32", + }, + key: ["player"], + }, + Position: { + schema: { + player: "address", + x: "int32", + y: "int32", + }, + key: ["player"], + }, }, - key: ["player"], - }, - Position: { - schema: { - player: "address", - x: "int32", - y: "int32", + systems: { + HiddenSystem: { + openAccess: false, + accessList: ["MoveSystem"], + }, }, - key: ["player"], }, }, }); diff --git a/examples/multiple-namespaces/src/codegen/index.sol b/examples/multiple-namespaces/src/codegen/index.sol deleted file mode 100644 index 465ad5a907..0000000000 --- a/examples/multiple-namespaces/src/codegen/index.sol +++ /dev/null @@ -1,7 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity >=0.8.24; - -/* Autogenerated file. Do not edit manually. */ - -import { Health } from "./game/tables/Health.sol"; -import { Position, PositionData } from "./game/tables/Position.sol"; diff --git a/examples/multiple-namespaces/src/codegen/world/IHiddenSystem.sol b/examples/multiple-namespaces/src/codegen/world/IHiddenSystem.sol new file mode 100644 index 0000000000..2e626445c0 --- /dev/null +++ b/examples/multiple-namespaces/src/codegen/world/IHiddenSystem.sol @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.24; + +/* Autogenerated file. Do not edit manually. */ + +/** + * @title IHiddenSystem + * @author MUD (https://mud.dev) by Lattice (https://lattice.xyz) + * @dev This interface is automatically generated from the corresponding system contract. Do not edit manually. + */ +interface IHiddenSystem { + function game__hide() external; +} diff --git a/examples/multiple-namespaces/src/codegen/world/IWorld.sol b/examples/multiple-namespaces/src/codegen/world/IWorld.sol index f42e816eff..7364caf07a 100644 --- a/examples/multiple-namespaces/src/codegen/world/IWorld.sol +++ b/examples/multiple-namespaces/src/codegen/world/IWorld.sol @@ -5,6 +5,7 @@ pragma solidity >=0.8.24; import { IBaseWorld } from "@latticexyz/world/src/codegen/interfaces/IBaseWorld.sol"; import { IHealSystem } from "./IHealSystem.sol"; +import { IHiddenSystem } from "./IHiddenSystem.sol"; import { IMoveSystem } from "./IMoveSystem.sol"; /** @@ -14,4 +15,4 @@ import { IMoveSystem } from "./IMoveSystem.sol"; * that are dynamically registered in the World during deployment. * @dev This is an autogenerated file; do not edit manually. */ -interface IWorld is IBaseWorld, IHealSystem, IMoveSystem {} +interface IWorld is IBaseWorld, IHealSystem, IHiddenSystem, IMoveSystem {} diff --git a/examples/multiple-namespaces/src/systems/HealSystem.sol b/examples/multiple-namespaces/src/namespaces/game/HealSystem.sol similarity index 80% rename from examples/multiple-namespaces/src/systems/HealSystem.sol rename to examples/multiple-namespaces/src/namespaces/game/HealSystem.sol index 76fd429295..f09d1193f3 100644 --- a/examples/multiple-namespaces/src/systems/HealSystem.sol +++ b/examples/multiple-namespaces/src/namespaces/game/HealSystem.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity >=0.8.24; import { System } from "@latticexyz/world/src/System.sol"; -import { Health } from "../codegen/game/tables/Health.sol"; +import { Health } from "./codegen/tables/Health.sol"; contract HealSystem is System { function heal(address player) public { diff --git a/examples/multiple-namespaces/src/namespaces/game/HiddenSystem.sol b/examples/multiple-namespaces/src/namespaces/game/HiddenSystem.sol new file mode 100644 index 0000000000..2ee3fc4e8d --- /dev/null +++ b/examples/multiple-namespaces/src/namespaces/game/HiddenSystem.sol @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.24; +import { System } from "@latticexyz/world/src/System.sol"; + +contract HiddenSystem is System { + function hide() public { + // Do nothing for now + } +} diff --git a/examples/multiple-namespaces/src/systems/MoveSystem.sol b/examples/multiple-namespaces/src/namespaces/game/MoveSystem.sol similarity index 79% rename from examples/multiple-namespaces/src/systems/MoveSystem.sol rename to examples/multiple-namespaces/src/namespaces/game/MoveSystem.sol index 8362719053..8c37126dfe 100644 --- a/examples/multiple-namespaces/src/systems/MoveSystem.sol +++ b/examples/multiple-namespaces/src/namespaces/game/MoveSystem.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity >=0.8.24; import { System } from "@latticexyz/world/src/System.sol"; -import { Position } from "../codegen/game/tables/Position.sol"; +import { Position } from "./codegen/tables/Position.sol"; contract MoveSystem is System { function move(address player, int32 x, int32 y) public { diff --git a/examples/multiple-namespaces/src/namespaces/game/codegen/index.sol b/examples/multiple-namespaces/src/namespaces/game/codegen/index.sol new file mode 100644 index 0000000000..bb28a6fbce --- /dev/null +++ b/examples/multiple-namespaces/src/namespaces/game/codegen/index.sol @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.24; + +/* Autogenerated file. Do not edit manually. */ + +import { Health } from "./tables/Health.sol"; +import { Position, PositionData } from "./tables/Position.sol"; diff --git a/examples/multiple-namespaces/src/codegen/game/tables/Health.sol b/examples/multiple-namespaces/src/namespaces/game/codegen/tables/Health.sol similarity index 100% rename from examples/multiple-namespaces/src/codegen/game/tables/Health.sol rename to examples/multiple-namespaces/src/namespaces/game/codegen/tables/Health.sol diff --git a/examples/multiple-namespaces/src/codegen/game/tables/Position.sol b/examples/multiple-namespaces/src/namespaces/game/codegen/tables/Position.sol similarity index 100% rename from examples/multiple-namespaces/src/codegen/game/tables/Position.sol rename to examples/multiple-namespaces/src/namespaces/game/codegen/tables/Position.sol diff --git a/examples/multiple-namespaces/test/HealthTest.t.sol b/examples/multiple-namespaces/test/HealthTest.t.sol index ca7cfd1681..9b8667d17c 100644 --- a/examples/multiple-namespaces/test/HealthTest.t.sol +++ b/examples/multiple-namespaces/test/HealthTest.t.sol @@ -5,7 +5,7 @@ import "forge-std/Test.sol"; import { MudTest } from "@latticexyz/world/test/MudTest.t.sol"; import { IWorld } from "../src/codegen/world/IWorld.sol"; -import { Health } from "../src/codegen/game/tables/Health.sol"; +import { Health } from "../src/namespaces/game/codegen/tables/Health.sol"; contract HealthTest is MudTest { function testHealth(address player) public { diff --git a/examples/multiple-namespaces/test/HiddenSystemTest.t.sol b/examples/multiple-namespaces/test/HiddenSystemTest.t.sol new file mode 100644 index 0000000000..667633163e --- /dev/null +++ b/examples/multiple-namespaces/test/HiddenSystemTest.t.sol @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.24; + +import "forge-std/Test.sol"; +import { MudTest } from "@latticexyz/world/test/MudTest.t.sol"; + +import { IWorld } from "../src/codegen/world/IWorld.sol"; +import { Position, PositionData } from "../src/namespaces/game/codegen/tables/Position.sol"; + +contract HiddenSystemTest is MudTest { + function testHide() public { + vm.expectRevert(); + IWorld(worldAddress).game__hide(); + } +} diff --git a/examples/multiple-namespaces/test/PositionTest.t.sol b/examples/multiple-namespaces/test/PositionTest.t.sol index 35609441a6..e5837bb6ca 100644 --- a/examples/multiple-namespaces/test/PositionTest.t.sol +++ b/examples/multiple-namespaces/test/PositionTest.t.sol @@ -5,7 +5,7 @@ import "forge-std/Test.sol"; import { MudTest } from "@latticexyz/world/test/MudTest.t.sol"; import { IWorld } from "../src/codegen/world/IWorld.sol"; -import { Position, PositionData } from "../src/codegen/game/tables/Position.sol"; +import { Position, PositionData } from "../src/namespaces/game/codegen/tables/Position.sol"; contract PositionTest is MudTest { function testPosition(address player, int32 x, int32 y) public { diff --git a/packages/cli/src/deploy/deploy.ts b/packages/cli/src/deploy/deploy.ts index b14378be88..ee148df050 100644 --- a/packages/cli/src/deploy/deploy.ts +++ b/packages/cli/src/deploy/deploy.ts @@ -63,17 +63,17 @@ export async function deploy({ ...libraries.map((library) => ({ bytecode: library.prepareDeploy(deployerAddress, libraries).bytecode, deployedBytecodeSize: library.deployedBytecodeSize, - label: `${library.path}:${library.name} library`, + debugLabel: `${library.path}:${library.name} library`, })), ...systems.map((system) => ({ bytecode: system.prepareDeploy(deployerAddress, libraries).bytecode, deployedBytecodeSize: system.deployedBytecodeSize, - label: `${resourceToLabel(system)} system`, + debugLabel: `${resourceToLabel(system)} system`, })), ...modules.map((mod) => ({ bytecode: mod.prepareDeploy(deployerAddress, libraries).bytecode, deployedBytecodeSize: mod.deployedBytecodeSize, - label: `${mod.name} module`, + debugLabel: `${mod.name} module`, })), ], }); diff --git a/packages/cli/src/deploy/ensureContract.ts b/packages/cli/src/deploy/ensureContract.ts index b9722d9a9e..e2e8259d5c 100644 --- a/packages/cli/src/deploy/ensureContract.ts +++ b/packages/cli/src/deploy/ensureContract.ts @@ -9,7 +9,7 @@ import { wait } from "@latticexyz/common/utils"; export type Contract = { bytecode: Hex; deployedBytecodeSize: number; - label?: string; + debugLabel?: string; }; export async function ensureContract({ @@ -17,34 +17,35 @@ export async function ensureContract({ deployerAddress, bytecode, deployedBytecodeSize, - label = "contract", + debugLabel = "contract", }: { readonly client: Client; readonly deployerAddress: Hex; } & Contract): Promise { if (bytecode.includes("__$")) { - throw new Error(`Found unlinked public library in ${label} bytecode`); + throw new Error(`Found unlinked public library in ${debugLabel} bytecode`); } const address = getCreate2Address({ from: deployerAddress, salt, bytecode }); const contractCode = await getBytecode(client, { address, blockTag: "pending" }); if (contractCode) { - debug("found", label, "at", address); + debug("found", debugLabel, "at", address); return []; } if (deployedBytecodeSize > contractSizeLimit) { console.warn( - `\nBytecode for ${label} (${deployedBytecodeSize} bytes) is over the contract size limit (${contractSizeLimit} bytes). Run \`forge build --sizes\` for more info.\n`, + `\nBytecode for ${debugLabel} (${deployedBytecodeSize} bytes) is over the contract size limit (${contractSizeLimit} bytes). Run \`forge build --sizes\` for more info.\n`, ); } else if (deployedBytecodeSize > contractSizeLimit * 0.95) { console.warn( - `\nBytecode for ${label} (${deployedBytecodeSize} bytes) is almost over the contract size limit (${contractSizeLimit} bytes). Run \`forge build --sizes\` for more info.\n`, + // eslint-disable-next-line max-len + `\nBytecode for ${debugLabel} (${deployedBytecodeSize} bytes) is almost over the contract size limit (${contractSizeLimit} bytes). Run \`forge build --sizes\` for more info.\n`, ); } - debug("deploying", label, "at", address); + debug("deploying", debugLabel, "at", address); return [ await pRetry( () => @@ -57,7 +58,7 @@ export async function ensureContract({ retries: 3, onFailedAttempt: async (error) => { const delay = error.attemptNumber * 500; - debug(`failed to deploy ${label}, retrying in ${delay}ms...`); + debug(`failed to deploy ${debugLabel}, retrying in ${delay}ms...`); await wait(delay); }, }, diff --git a/packages/cli/src/deploy/ensureModules.ts b/packages/cli/src/deploy/ensureModules.ts index a51c60cf61..2fa5ef310a 100644 --- a/packages/cli/src/deploy/ensureModules.ts +++ b/packages/cli/src/deploy/ensureModules.ts @@ -27,7 +27,7 @@ export async function ensureModules({ contracts: modules.map((mod) => ({ bytecode: mod.prepareDeploy(deployerAddress, libraries).bytecode, deployedBytecodeSize: mod.deployedBytecodeSize, - label: `${mod.name} module`, + debugLabel: `${mod.name} module`, })), }); diff --git a/packages/cli/src/deploy/ensureSystems.ts b/packages/cli/src/deploy/ensureSystems.ts index 4d18dc1892..a9360702cf 100644 --- a/packages/cli/src/deploy/ensureSystems.ts +++ b/packages/cli/src/deploy/ensureSystems.ts @@ -69,7 +69,7 @@ export async function ensureSystems({ contracts: missingSystems.map((system) => ({ bytecode: system.prepareDeploy(deployerAddress, libraries).bytecode, deployedBytecodeSize: system.deployedBytecodeSize, - label: `${resourceToLabel(system)} system`, + debugLabel: `${resourceToLabel(system)} system`, })), }); diff --git a/packages/cli/src/deploy/getWorldContracts.ts b/packages/cli/src/deploy/getWorldContracts.ts index cb39d5400e..c20a01fad4 100644 --- a/packages/cli/src/deploy/getWorldContracts.ts +++ b/packages/cli/src/deploy/getWorldContracts.ts @@ -48,31 +48,31 @@ export function getWorldContracts(deployerAddress: Hex) { AccessManagementSystem: { bytecode: accessManagementSystemBytecode, deployedBytecodeSize: accessManagementSystemDeployedBytecodeSize, - label: "access management system", + debugLabel: "access management system", address: accessManagementSystem, }, BalanceTransferSystem: { bytecode: balanceTransferSystemBytecode, deployedBytecodeSize: balanceTransferSystemDeployedBytecodeSize, - label: "balance transfer system", + debugLabel: "balance transfer system", address: balanceTransferSystem, }, BatchCallSystem: { bytecode: batchCallSystemBytecode, deployedBytecodeSize: batchCallSystemDeployedBytecodeSize, - label: "batch call system", + debugLabel: "batch call system", address: batchCallSystem, }, RegistrationSystem: { bytecode: registrationBytecode, deployedBytecodeSize: registrationDeployedBytecodeSize, - label: "core registration system", + debugLabel: "core registration system", address: registration, }, InitModule: { bytecode: initModuleBytecode, deployedBytecodeSize: initModuleDeployedBytecodeSize, - label: "core module", + debugLabel: "core module", address: initModule, }, }; diff --git a/packages/cli/src/deploy/getWorldFactoryContracts.ts b/packages/cli/src/deploy/getWorldFactoryContracts.ts index 7934655a3e..f98acbc380 100644 --- a/packages/cli/src/deploy/getWorldFactoryContracts.ts +++ b/packages/cli/src/deploy/getWorldFactoryContracts.ts @@ -20,7 +20,7 @@ export function getWorldFactoryContracts(deployerAddress: Hex) { WorldFactory: { bytecode: worldFactoryBytecode, deployedBytecodeSize: worldFactoryDeployedBytecodeSize, - label: "world factory", + debugLabel: "world factory", address: worldFactory, }, }; diff --git a/packages/cli/src/deploy/getWorldProxyFactoryContracts.ts b/packages/cli/src/deploy/getWorldProxyFactoryContracts.ts index af292d2141..5662df94a5 100644 --- a/packages/cli/src/deploy/getWorldProxyFactoryContracts.ts +++ b/packages/cli/src/deploy/getWorldProxyFactoryContracts.ts @@ -20,7 +20,7 @@ export function getWorldProxyFactoryContracts(deployerAddress: Hex) { WorldProxyFactory: { bytecode: worldProxyFactoryBytecode, deployedBytecodeSize: worldProxyFactoryDeployedBytecodeSize, - label: "world proxy factory", + debugLabel: "world proxy factory", address: worldProxyFactory, }, }; diff --git a/packages/store/mud.config.ts b/packages/store/mud.config.ts index f9e958c98f..f42f338267 100644 --- a/packages/store/mud.config.ts +++ b/packages/store/mud.config.ts @@ -1,10 +1,10 @@ import { defineStore } from "./ts/config/v2/store"; export default defineStore({ + namespace: "store", codegen: { storeImportPath: "./src", }, - namespace: "store", userTypes: { ResourceId: { filePath: "./src/ResourceId.sol", type: "bytes32" }, FieldLayout: { filePath: "./src/FieldLayout.sol", type: "bytes32" }, diff --git a/packages/store/ts/codegen/tablegen.ts b/packages/store/ts/codegen/tablegen.ts index a31cb3e899..d69ed131f5 100644 --- a/packages/store/ts/codegen/tablegen.ts +++ b/packages/store/ts/codegen/tablegen.ts @@ -29,9 +29,7 @@ export async function tablegen({ rootDir, config }: TablegenOptions) { await Promise.all( Object.values(config.namespaces).map(async (namespace) => { - // TODO: get this value from config once multiple namespaces are supported - const multipleNamespaces = false; - const sourceDir = multipleNamespaces + const sourceDir = config.multipleNamespaces ? path.join(config.sourceDirectory, "namespaces", namespace.label) : config.sourceDirectory; const codegenDir = path.join(sourceDir, config.codegen.outputDirectory); @@ -60,9 +58,11 @@ export async function tablegen({ rootDir, config }: TablegenOptions) { }), ); - const codegenIndexPath = path.join(rootDir, codegenDir, config.codegen.indexFilename); - const source = renderTableIndex(codegenIndexPath, tableOptions); - await formatAndWriteSolidity(source, codegenIndexPath, "Generated table index"); + if (config.codegen.indexFilename !== false && tableOptions.length > 0) { + const codegenIndexPath = path.join(rootDir, codegenDir, config.codegen.indexFilename); + const source = renderTableIndex(codegenIndexPath, tableOptions); + await formatAndWriteSolidity(source, codegenIndexPath, "Generated table index"); + } }), ); } diff --git a/packages/store/ts/config/v2/defaults.ts b/packages/store/ts/config/v2/defaults.ts index eb5e0cb957..13bf194559 100644 --- a/packages/store/ts/config/v2/defaults.ts +++ b/packages/store/ts/config/v2/defaults.ts @@ -4,8 +4,6 @@ export const CODEGEN_DEFAULTS = { storeImportPath: "@latticexyz/store/src", userTypesFilename: "common.sol", outputDirectory: "codegen", - // TODO: default to true if using top-level `namespaces` key (once its migrated to store) - namespaceDirectories: false, indexFilename: "index.sol", } as const satisfies CodegenInput; diff --git a/packages/store/ts/config/v2/flattenNamespacedTables.ts b/packages/store/ts/config/v2/flattenNamespacedTables.ts new file mode 100644 index 0000000000..390bcc7da9 --- /dev/null +++ b/packages/store/ts/config/v2/flattenNamespacedTables.ts @@ -0,0 +1,41 @@ +import { show } from "@arktype/util"; +import { Namespaces } from "./output"; + +type flattenNamespacedTableKeys = config extends { + readonly namespaces: infer namespaces; +} + ? { + [namespaceLabel in keyof namespaces]: namespaces[namespaceLabel] extends { readonly tables: infer tables } + ? namespaceLabel extends "" + ? keyof tables + : `${namespaceLabel & string}__${keyof tables & string}` + : never; + }[keyof namespaces] + : never; + +/** + * @internal Only kept for backwards compatibility + */ +export type flattenNamespacedTables = config extends { readonly namespaces: Namespaces } + ? { + readonly [key in flattenNamespacedTableKeys]: key extends `${infer namespaceLabel}__${infer tableLabel}` + ? config["namespaces"][namespaceLabel]["tables"][tableLabel] + : config["namespaces"][""]["tables"][key]; + } + : never; + +/** + * @internal Only kept for backwards compatibility + */ +export function flattenNamespacedTables( + config: config, +): show> { + return Object.fromEntries( + Object.entries(config.namespaces).flatMap(([namespaceLabel, namespace]) => + Object.entries(namespace.tables).map(([tableLabel, table]) => [ + namespaceLabel === "" ? tableLabel : `${namespaceLabel}__${tableLabel}`, + table, + ]), + ), + ) as never; +} diff --git a/packages/store/ts/config/v2/index.ts b/packages/store/ts/config/v2/index.ts index 17dfc8a467..bdc85ce3b2 100644 --- a/packages/store/ts/config/v2/index.ts +++ b/packages/store/ts/config/v2/index.ts @@ -11,5 +11,6 @@ export * from "./defaults"; export * from "./codegen"; export * from "./enums"; export * from "./userTypes"; +export * from "./flattenNamespacedTables"; export * from "./namespace"; -export * from "./namespacedTables"; +export * from "./namespaces"; diff --git a/packages/store/ts/config/v2/input.ts b/packages/store/ts/config/v2/input.ts index b074e5a8c1..4e7564f41f 100644 --- a/packages/store/ts/config/v2/input.ts +++ b/packages/store/ts/config/v2/input.ts @@ -47,7 +47,7 @@ export type TableInput = { export type TableShorthandInput = SchemaInput | string; export type TablesInput = { - // remove label and namespace from table input as these are set contextually + // remove label and namespace as these are set contextually // and allow defining a table using shorthand readonly [label: string]: Omit | TableShorthandInput; }; @@ -68,7 +68,12 @@ export type NamespaceInput = { readonly tables?: TablesInput; }; +export type NamespacesInput = { + readonly [label: string]: Omit; +}; + export type StoreInput = Omit & { + readonly namespaces?: NamespacesInput; /** * Directory of Solidity source relative to the MUD config. * This is used to resolve other paths in the config, like codegen and user types. diff --git a/packages/store/ts/config/v2/namespacedTables.ts b/packages/store/ts/config/v2/namespacedTables.ts deleted file mode 100644 index 8cddb3a779..0000000000 --- a/packages/store/ts/config/v2/namespacedTables.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { flatMorph, show } from "@arktype/util"; -import { Tables } from "./output"; - -/** - * @internal Only kept for backwards compatibility - */ -export type resolveNamespacedTables = { - readonly [label in keyof tables as namespace extends "" - ? label - : `${namespace & string}__${label & string}`]: tables[label]; -}; - -/** - * @internal Only kept for backwards compatibility - */ -export function resolveNamespacedTables( - tables: tables, - namespace: namespace, -): show> { - return flatMorph(tables as Tables, (label, table) => [ - namespace === "" ? label : `${namespace}__${label}`, - table, - ]) as never; -} diff --git a/packages/store/ts/config/v2/namespaces.test.ts b/packages/store/ts/config/v2/namespaces.test.ts new file mode 100644 index 0000000000..572bdb9b02 --- /dev/null +++ b/packages/store/ts/config/v2/namespaces.test.ts @@ -0,0 +1,14 @@ +import { describe, it } from "vitest"; +import { attest } from "@arktype/attest"; +import { defineNamespaces } from "./namespaces"; + +describe("defineNamespaces", () => { + it("should throw on duplicates", () => { + attest(() => + defineNamespaces({ + First: { namespace: "app" }, + Second: { namespace: "app" }, + }), + ).throws("Found namespaces defined more than once in config: app"); + }); +}); diff --git a/packages/store/ts/config/v2/namespaces.ts b/packages/store/ts/config/v2/namespaces.ts new file mode 100644 index 0000000000..cb8e4c3d19 --- /dev/null +++ b/packages/store/ts/config/v2/namespaces.ts @@ -0,0 +1,58 @@ +import { show, flatMorph } from "@arktype/util"; +import { isObject, mergeIfUndefined } from "./generics"; +import { NamespacesInput } from "./input"; +import { AbiTypeScope, Scope } from "./scope"; +import { validateNamespace, resolveNamespace } from "./namespace"; +import { groupBy } from "@latticexyz/common/utils"; + +export type validateNamespaces = { + [label in keyof namespaces]: validateNamespace; +}; + +export function validateNamespaces( + namespaces: unknown, + scope: scope, +): asserts namespaces is NamespacesInput { + if (!isObject(namespaces)) { + throw new Error(`Expected namespaces, received ${JSON.stringify(namespaces)}`); + } + for (const namespace of Object.values(namespaces)) { + validateNamespace(namespace, scope); + } +} + +export type resolveNamespaces = { + readonly [label in keyof namespaces]: resolveNamespace, scope>; +}; + +export function resolveNamespaces( + input: input, + scope: scope, +): show> { + if (!isObject(input)) { + throw new Error(`Expected namespaces config, received ${JSON.stringify(input)}`); + } + + const namespaces = flatMorph(input as NamespacesInput, (label, namespace) => [ + label, + resolveNamespace(mergeIfUndefined(namespace, { label }), scope), + ]); + + // This should probably be in `validate`, but `namespace` gets set during the resolve step above, so it's easier to validate here. + const duplicates = Array.from(groupBy(Object.values(namespaces), (namespace) => namespace.namespace).entries()) + .filter(([, entries]) => entries.length > 1) + .map(([namespace]) => namespace); + if (duplicates.length > 0) { + throw new Error(`Found namespaces defined more than once in config: ${duplicates.join(", ")}`); + } + + return namespaces as never; +} + +export function defineNamespaces( + input: validateNamespaces, + scope: scope = AbiTypeScope as never, +): show> { + validateNamespaces(input, scope); + return resolveNamespaces(input, scope) as never; +} diff --git a/packages/store/ts/config/v2/output.ts b/packages/store/ts/config/v2/output.ts index f882e7a470..a2d65c9cf2 100644 --- a/packages/store/ts/config/v2/output.ts +++ b/packages/store/ts/config/v2/output.ts @@ -62,15 +62,13 @@ export type Codegen = { */ readonly outputDirectory: string; /** - * Whether or not to organize codegen output (table libraries, etc.) into directories by namespace. + * Tables index filename. * - * For example, a `Counter` table in the `app` namespace will have codegen at `codegen/app/tables/Counter.sol`. + * Defaults to `"index.sol"` when in single-namespace mode, and `false` for multi-namespace mode. * - * Defaults to `true` when using top-level `namespaces` key, `false` otherwise. + * @deprecated We recommend importing directly from table libraries rather than from the index for better compile times and deterministic deploys. */ - // TODO: move `namespaces` key handling into store so we can conditionally turn this on/off - readonly namespaceDirectories: boolean; - readonly indexFilename: string; + readonly indexFilename: string | false; }; export type Namespace = { @@ -90,7 +88,28 @@ export type Namespaces = { readonly [label: string]: Namespace; }; -export type Store = Omit & { +export type Store = { + /** + * @internal + * Whether this project is using multiple namespaces or not, dictated by using `namespaces` config key. + * + * If using multiple namespaces, systems must be organized in `namespaces/{namespaceLabel}` directories. + * Similarly, table libraries will be generated into these namespace directories. + */ + readonly multipleNamespaces: boolean; + /** + * When in single-namespace mode, this is set to the config's base `namespace`. + * When in multi-namespace mode, this is `null`. + */ + readonly namespace: string | null; + readonly namespaces: Namespaces; + /** + * Flattened set of tables, where each key is `{namespaceLabel}__{tableLabel}`. + * For namespace labels using an empty string, no double-underscore is used, so the key is `{tableLabel}`. + * This is kept for backwards compatibility. + * It's recommended that you use `config.namespaces[namespaceLabel].tables[tableLabel]` instead. + */ + readonly tables: Namespace["tables"]; /** * Directory of Solidity source relative to the MUD config. * This is used to resolve other paths in the config, like codegen and user types. @@ -102,5 +121,4 @@ export type Store = Omit & { readonly enums: Enums; readonly enumValues: EnumValues; readonly codegen: Codegen; - readonly namespaces: Namespaces; }; diff --git a/packages/store/ts/config/v2/store.test.ts b/packages/store/ts/config/v2/store.test.ts index 3c8ec66ad5..c8a9e2197d 100644 --- a/packages/store/ts/config/v2/store.test.ts +++ b/packages/store/ts/config/v2/store.test.ts @@ -2,12 +2,12 @@ import { describe, it } from "vitest"; import { defineStore } from "./store"; import { attest } from "@arktype/attest"; import { resourceToHex } from "@latticexyz/common"; -import { CODEGEN_DEFAULTS, TABLE_CODEGEN_DEFAULTS, TABLE_DEPLOY_DEFAULTS } from "./defaults"; import { Store } from "./output"; import { satisfy } from "@arktype/util"; +import { Hex } from "viem"; describe("defineStore", () => { - it("should return the full config given a full config with one key", () => { + it("should return a fully resolved config for a single namespace", () => { const config = defineStore({ tables: { Example: { @@ -17,178 +17,234 @@ describe("defineStore", () => { }, }); - const expectedBaseNamespace = { + const expectedConfig = { + multipleNamespaces: false, namespace: "" as string, + namespaces: { + "": { + label: "", + namespace: "" as string, + tables: { + Example: { + label: "Example", + type: "table", + namespace: "" as string, + name: "Example" as string, + tableId: "0x746200000000000000000000000000004578616d706c65000000000000000000" as Hex, + schema: { + id: { type: "address", internalType: "address" }, + name: { type: "string", internalType: "string" }, + age: { type: "uint256", internalType: "uint256" }, + }, + key: ["age"], + codegen: { + outputDirectory: "tables" as string, + tableIdArgument: false, + storeArgument: false, + dataStruct: true as boolean, + }, + deploy: { disabled: false }, + }, + }, + }, + }, tables: { Example: { label: "Example", type: "table", namespace: "" as string, name: "Example" as string, - tableId: resourceToHex({ type: "table", namespace: "", name: "Example" }), + tableId: "0x746200000000000000000000000000004578616d706c65000000000000000000" as Hex, schema: { - id: { - type: "address", - internalType: "address", - }, - name: { - type: "string", - internalType: "string", - }, - age: { - type: "uint256", - internalType: "uint256", - }, + id: { type: "address", internalType: "address" }, + name: { type: "string", internalType: "string" }, + age: { type: "uint256", internalType: "uint256" }, }, key: ["age"], - codegen: { ...TABLE_CODEGEN_DEFAULTS, dataStruct: true as boolean }, - deploy: TABLE_DEPLOY_DEFAULTS, - }, - }, - } as const; - - const expectedConfig = { - ...expectedBaseNamespace, - namespaces: { - "": { - label: "", - ...expectedBaseNamespace, + codegen: { + outputDirectory: "tables" as string, + tableIdArgument: false, + storeArgument: false, + dataStruct: true as boolean, + }, + deploy: { disabled: false }, }, }, sourceDirectory: "src", userTypes: {}, enums: {}, enumValues: {}, - codegen: CODEGEN_DEFAULTS, + codegen: { + storeImportPath: "@latticexyz/store/src", + userTypesFilename: "common.sol", + outputDirectory: "codegen", + indexFilename: "index.sol", + }, } as const; attest(config).equals(expectedConfig); + attest(expectedConfig); }); - it("should return the full config given a full config with one key and user types", () => { + it("should return a fully resolved config for multiple namespaces", () => { const config = defineStore({ - tables: { - Example: { - schema: { id: "dynamic", name: "string", age: "static" }, - key: ["age"], + namespaces: { + root: { + namespace: "", + tables: { + Example: { + schema: { id: "address", name: "string", age: "uint256" }, + key: ["age"], + }, + }, }, }, - userTypes: { - static: { type: "address", filePath: "path/to/file" }, - dynamic: { type: "string", filePath: "path/to/file" }, - }, }); - const expectedBaseNamespace = { - namespace: "" as string, + const expectedConfig = { + multipleNamespaces: true, + namespace: null, + namespaces: { + root: { + label: "root", + namespace: "" as string, + tables: { + Example: { + label: "Example", + type: "table", + namespace: "" as string, + name: "Example" as string, + tableId: "0x746200000000000000000000000000004578616d706c65000000000000000000" as Hex, + schema: { + id: { type: "address", internalType: "address" }, + name: { type: "string", internalType: "string" }, + age: { type: "uint256", internalType: "uint256" }, + }, + key: ["age"], + codegen: { + outputDirectory: "tables" as string, + tableIdArgument: false, + storeArgument: false, + dataStruct: true as boolean, + }, + deploy: { disabled: false }, + }, + }, + }, + }, tables: { - Example: { + root__Example: { label: "Example", type: "table", namespace: "" as string, name: "Example" as string, - tableId: resourceToHex({ type: "table", namespace: "", name: "Example" }), + tableId: "0x746200000000000000000000000000004578616d706c65000000000000000000" as Hex, schema: { - id: { - type: "string", - internalType: "dynamic", - }, - name: { - type: "string", - internalType: "string", - }, - age: { - type: "address", - internalType: "static", - }, + id: { type: "address", internalType: "address" }, + name: { type: "string", internalType: "string" }, + age: { type: "uint256", internalType: "uint256" }, }, key: ["age"], - codegen: { ...TABLE_CODEGEN_DEFAULTS, dataStruct: true as boolean }, - deploy: TABLE_DEPLOY_DEFAULTS, - }, - }, - } as const; - - const expectedConfig = { - ...expectedBaseNamespace, - namespaces: { - "": { - label: "", - ...expectedBaseNamespace, + codegen: { + outputDirectory: "tables" as string, + tableIdArgument: false, + storeArgument: false, + dataStruct: true as boolean, + }, + deploy: { disabled: false }, }, }, sourceDirectory: "src", - userTypes: { - static: { type: "address", filePath: "path/to/file" }, - dynamic: { type: "string", filePath: "path/to/file" }, - }, + userTypes: {}, enums: {}, enumValues: {}, - codegen: CODEGEN_DEFAULTS, + codegen: { + storeImportPath: "@latticexyz/store/src", + userTypesFilename: "common.sol", + outputDirectory: "codegen", + indexFilename: "index.sol", + }, } as const; attest(config).equals(expectedConfig); + attest(expectedConfig); }); - it("should return the full config given a full config with two key", () => { + it("should only allow for single namespace or multiple namespaces, not both", () => { + attest(() => + defineStore({ + // @ts-expect-error Cannot use `namespaces` with `namespace` or `tables` keys. + namespaces: {}, + namespace: "app", + }), + ) + .throws("Cannot use `namespaces` with `namespace` or `tables` keys.") + .type.errors("Cannot use `namespaces` with `namespace` or `tables` keys."); + + attest(() => + defineStore({ + // @ts-expect-error Cannot use `namespaces` with `namespace` or `tables` keys. + namespaces: {}, + tables: {}, + }), + ) + .throws("Cannot use `namespaces` with `namespace` or `tables` keys.") + .type.errors("Cannot use `namespaces` with `namespace` or `tables` keys."); + }); + + // TODO: move to table tests + it("should resolve schema with user types", () => { const config = defineStore({ + userTypes: { + static: { type: "address", filePath: "path/to/file" }, + dynamic: { type: "string", filePath: "path/to/file" }, + }, tables: { Example: { - schema: { id: "address", name: "string", age: "uint256" }, - key: ["age", "id"], + schema: { id: "dynamic", name: "string", age: "static" }, + key: ["age"], }, }, }); - const expectedBaseNamespace = { - namespace: "" as string, + const expectedSchema = { + id: { + type: "string", + internalType: "dynamic", + }, + name: { + type: "string", + internalType: "string", + }, + age: { + type: "address", + internalType: "static", + }, + } as const; + + attest(config.tables.Example.schema).equals(expectedSchema); + attest(expectedSchema); + }); + + // TODO: move to table tests + it("should resolve a table with a composite key", () => { + const config = defineStore({ tables: { Example: { - label: "Example", - type: "table", - namespace: "" as string, - name: "Example" as string, - tableId: resourceToHex({ type: "table", namespace: "", name: "Example" }), - schema: { - id: { - type: "address", - internalType: "address", - }, - name: { - type: "string", - internalType: "string", - }, - age: { - type: "uint256", - internalType: "uint256", - }, - }, + schema: { id: "address", name: "string", age: "uint256" }, key: ["age", "id"], - codegen: { ...TABLE_CODEGEN_DEFAULTS, dataStruct: false as boolean }, - deploy: TABLE_DEPLOY_DEFAULTS, }, }, - } as const; + }); - const expectedConfig = { - ...expectedBaseNamespace, - namespaces: { - "": { - label: "", - ...expectedBaseNamespace, - }, - }, - sourceDirectory: "src", - userTypes: {}, - enums: {}, - enumValues: {}, - codegen: CODEGEN_DEFAULTS, - } as const; + const expectedKey = ["age", "id"] as const; - attest(config).equals(expectedConfig); + attest(config.tables.Example.key).equals(expectedKey); + attest(expectedKey); }); - it("should resolve two tables in the config with different schemas", () => { + // TODO: move to table tests + it("should resolve two tables with different schemas", () => { const config = defineStore({ tables: { First: { @@ -202,80 +258,50 @@ describe("defineStore", () => { }, }); - const expectedBaseNamespace = { - namespace: "" as string, - tables: { - First: { - label: "First", - type: "table", - namespace: "" as string, - name: "First" as string, - tableId: resourceToHex({ type: "table", namespace: "", name: "First" }), - schema: { - firstKey: { - type: "address", - internalType: "address", - }, - firstName: { - type: "string", - internalType: "string", - }, - firstAge: { - type: "uint256", - internalType: "uint256", - }, - }, - key: ["firstKey", "firstAge"], - codegen: { ...TABLE_CODEGEN_DEFAULTS, dataStruct: false as boolean }, - deploy: TABLE_DEPLOY_DEFAULTS, - }, - Second: { - label: "Second", - type: "table", - namespace: "" as string, - name: "Second" as string, - tableId: resourceToHex({ type: "table", namespace: "", name: "Second" }), - schema: { - secondKey: { - type: "address", - internalType: "address", - }, - secondName: { - type: "string", - internalType: "string", - }, - secondAge: { - type: "uint256", - internalType: "uint256", - }, - }, - key: ["secondKey", "secondAge"], - codegen: { ...TABLE_CODEGEN_DEFAULTS, dataStruct: false as boolean }, - deploy: TABLE_DEPLOY_DEFAULTS, - }, + const expectedFirstSchema = { + firstKey: { + type: "address", + internalType: "address", + }, + firstName: { + type: "string", + internalType: "string", + }, + firstAge: { + type: "uint256", + internalType: "uint256", }, } as const; - const expectedConfig = { - ...expectedBaseNamespace, - namespaces: { - "": { - label: "", - ...expectedBaseNamespace, - }, + const expectedSecondSchema = { + secondKey: { + type: "address", + internalType: "address", + }, + secondName: { + type: "string", + internalType: "string", + }, + secondAge: { + type: "uint256", + internalType: "uint256", }, - sourceDirectory: "src", - userTypes: {}, - enums: {}, - enumValues: {}, - codegen: CODEGEN_DEFAULTS, } as const; - attest(config).equals(expectedConfig); + attest(config.tables.First.schema).equals(expectedFirstSchema); + attest(expectedFirstSchema); + + attest(config.tables.Second.schema).equals(expectedSecondSchema); + attest(expectedSecondSchema); }); - it("should resolve two tables in the config with different schemas and user types", () => { + // TODO: move to table tests + it("should resolve two tables with different schemas and user types", () => { const config = defineStore({ + userTypes: { + Static: { type: "address", filePath: "path/to/file" }, + Dynamic: { type: "string", filePath: "path/to/file" }, + }, tables: { First: { schema: { firstKey: "Static", firstName: "Dynamic", firstAge: "uint256" }, @@ -286,85 +312,43 @@ describe("defineStore", () => { key: ["secondKey", "secondAge"], }, }, - userTypes: { - Static: { type: "address", filePath: "path/to/file" }, - Dynamic: { type: "string", filePath: "path/to/file" }, - }, }); - const expectedBaseNamespace = { - namespace: "" as string, - tables: { - First: { - label: "First", - type: "table", - namespace: "" as string, - name: "First" as string, - tableId: resourceToHex({ type: "table", namespace: "", name: "First" }), - schema: { - firstKey: { - type: "address", - internalType: "Static", - }, - firstName: { - type: "string", - internalType: "Dynamic", - }, - firstAge: { - type: "uint256", - internalType: "uint256", - }, - }, - key: ["firstKey", "firstAge"], - codegen: { ...TABLE_CODEGEN_DEFAULTS, dataStruct: false as boolean }, - deploy: TABLE_DEPLOY_DEFAULTS, - }, - Second: { - label: "Second", - type: "table", - namespace: "" as string, - name: "Second" as string, - tableId: resourceToHex({ type: "table", namespace: "", name: "Second" }), - schema: { - secondKey: { - type: "address", - internalType: "Static", - }, - secondName: { - type: "string", - internalType: "Dynamic", - }, - secondAge: { - type: "uint256", - internalType: "uint256", - }, - }, - key: ["secondKey", "secondAge"], - codegen: { ...TABLE_CODEGEN_DEFAULTS, dataStruct: false as boolean }, - deploy: TABLE_DEPLOY_DEFAULTS, - }, + const expectedFirstSchema = { + firstKey: { + type: "address", + internalType: "Static", + }, + firstName: { + type: "string", + internalType: "Dynamic", + }, + firstAge: { + type: "uint256", + internalType: "uint256", }, } as const; - const expectedConfig = { - ...expectedBaseNamespace, - namespaces: { - "": { - label: "", - ...expectedBaseNamespace, - }, + const expectedSecondSchema = { + secondKey: { + type: "address", + internalType: "Static", }, - sourceDirectory: "src", - userTypes: { - Static: { type: "address", filePath: "path/to/file" }, - Dynamic: { type: "string", filePath: "path/to/file" }, + secondName: { + type: "string", + internalType: "Dynamic", + }, + secondAge: { + type: "uint256", + internalType: "uint256", }, - enums: {}, - enumValues: {}, - codegen: CODEGEN_DEFAULTS, } as const; - attest(config).equals(expectedConfig); + attest(config.tables.First.schema).equals(expectedFirstSchema); + attest(expectedFirstSchema); + + attest(config.tables.Second.schema).equals(expectedSecondSchema); + attest(expectedSecondSchema); }); it("should throw if referring to fields of different tables", () => { @@ -422,14 +406,9 @@ describe("defineStore", () => { .type.errors(`Type '"name"' is not assignable to type '"id" | "age"'`); }); - it("should return the full config given a full config with enums and user types", () => { + // TODO: move to table tests + it("should resolve schema with enums and user types", () => { const config = defineStore({ - tables: { - Example: { - schema: { id: "dynamic", name: "ValidNames", age: "static" }, - key: ["name"], - }, - }, userTypes: { static: { type: "address", filePath: "path/to/file" }, dynamic: { type: "string", filePath: "path/to/file" }, @@ -437,77 +416,39 @@ describe("defineStore", () => { enums: { ValidNames: ["first", "second"], }, - }); - - const expectedBaseNamespace = { - namespace: "" as string, tables: { Example: { - label: "Example", - type: "table", - namespace: "" as string, - name: "Example" as string, - tableId: resourceToHex({ type: "table", namespace: "", name: "Example" }), - schema: { - id: { - type: "string", - internalType: "dynamic", - }, - name: { - type: "uint8", - internalType: "ValidNames", - }, - age: { - type: "address", - internalType: "static", - }, - }, + schema: { id: "dynamic", name: "ValidNames", age: "static" }, key: ["name"], - codegen: { ...TABLE_CODEGEN_DEFAULTS, dataStruct: true as boolean }, - deploy: TABLE_DEPLOY_DEFAULTS, }, }, - } as const; + }); - const expectedConfig = { - ...expectedBaseNamespace, - namespaces: { - "": { - label: "", - ...expectedBaseNamespace, - }, + const expectedSchema = { + id: { + type: "string", + internalType: "dynamic", }, - sourceDirectory: "src", - userTypes: { - static: { type: "address", filePath: "path/to/file" }, - dynamic: { type: "string", filePath: "path/to/file" }, - }, - enums: { - ValidNames: ["first", "second"], + name: { + type: "uint8", + internalType: "ValidNames", }, - enumValues: { - ValidNames: { - first: 0, - second: 1, - }, + age: { + type: "address", + internalType: "static", }, - codegen: CODEGEN_DEFAULTS, } as const; - attest(config).equals(expectedConfig); + attest(config.tables.Example.schema).equals(expectedSchema); + attest(expectedSchema); }); - it("should use the root namespace as default namespace", () => { + it("should accept an empty input", () => { const config = defineStore({}); - attest(config.namespace).equals(""); - }); - - it("should use pipe through non-default namespace", () => { - const config = defineStore({ namespace: "custom" }); - attest(config.namespace).equals("custom"); + attest>(); }); - it("should satisfy the output type", () => { + it("should satisfy the output type when using single namespace", () => { const config = defineStore({ tables: { Name: { schema: { id: "address" }, key: ["id"] } }, userTypes: { CustomType: { type: "address", filePath: "path/to/file" } }, @@ -516,12 +457,25 @@ describe("defineStore", () => { attest>(); }); - it("should accept an empty input", () => { - const config = defineStore({}); + it("should satisfy the output type when using multiple namespaces", () => { + const config = defineStore({ + namespaces: {}, + }); + attest>(); }); - it("should use the global namespace instead for tables", () => { + it("should use the root namespace as default namespace", () => { + const config = defineStore({}); + attest(config.namespace).equals(""); + }); + + it("should use pipe through non-default namespace", () => { + const config = defineStore({ namespace: "custom" }); + attest(config.namespace).equals("custom"); + }); + + it("should use the base namespace for tables", () => { const config = defineStore({ namespace: "namespace", tables: { @@ -548,36 +502,68 @@ describe("defineStore", () => { ); }); - it("should throw if label is overridden in the store context", () => { + it("should throw if table label/namespace is overridden in namespace context", () => { attest(() => defineStore({ namespace: "CustomNS", tables: { Example: { + // @ts-expect-error "Overrides of `label` and `namespace` are not allowed for tables in this context" + label: "NotAllowed", schema: { id: "address" }, key: ["id"], - // @ts-expect-error "Overrides of `label` and `namespace` are not allowed for tables in a store config" - label: "NotAllowed", }, }, }), - ).throwsAndHasTypeError("Overrides of `label` and `namespace` are not allowed for tables in a store config"); - }); + ).throwsAndHasTypeError("Overrides of `label` and `namespace` are not allowed for tables in this context"); - it("should throw if namespace is overridden in the store context", () => { attest(() => defineStore({ namespace: "CustomNS", tables: { Example: { + // @ts-expect-error "Overrides of `label` and `namespace` are not allowed for tables in this context" + namespace: "NotAllowed", schema: { id: "address" }, key: ["id"], - // @ts-expect-error "Overrides of `label` and `namespace` are not allowed for tables in a store config" - namespace: "NotAllowed", }, }, }), - ).throwsAndHasTypeError("Overrides of `label` and `namespace` are not allowed for tables in a store config"); + ).throwsAndHasTypeError("Overrides of `label` and `namespace` are not allowed for tables in this context"); + + attest(() => + defineStore({ + namespaces: { + CustomNS: { + tables: { + Example: { + // @ts-expect-error "Overrides of `label` and `namespace` are not allowed for tables in this context" + label: "NotAllowed", + schema: { id: "address" }, + key: ["id"], + }, + }, + }, + }, + }), + ).throwsAndHasTypeError("Overrides of `label` and `namespace` are not allowed for tables in this context"); + + attest(() => + defineStore({ + namespaces: { + CustomNS: { + tables: { + Example: { + // @ts-expect-error "Overrides of `label` and `namespace` are not allowed for tables in this context" + namespace: "NotAllowed", + schema: { id: "address" }, + key: ["id"], + }, + }, + }, + }, + }), + ).throwsAndHasTypeError("Overrides of `label` and `namespace` are not allowed for tables in this context"); }); it("should allow const enum as input", () => { @@ -619,154 +605,30 @@ describe("defineStore", () => { ).type.errors("`invalidOption` is not a valid Store config option."); }); - it("should namespace output directories for tables", () => { - const config = defineStore({ - namespace: "app", - codegen: { - namespaceDirectories: true, - }, - tables: { - NamespaceDir: { - schema: { name: "string" }, - key: [], - }, - NotNamespaceDir: { - schema: { name: "string" }, - key: [], - codegen: { - outputDirectory: "tables", - }, - }, - }, - }); - - const expectedTables = { - NamespaceDir: { - label: "NamespaceDir", - type: "table", - namespace: "app" as string, - name: "NamespaceDir" as string, - tableId: resourceToHex({ type: "table", namespace: "app", name: "NamespaceDir" }), - schema: { - name: { - type: "string", - internalType: "string", - }, - }, - key: [], - codegen: { - ...TABLE_CODEGEN_DEFAULTS, - dataStruct: false as boolean, - outputDirectory: "app/tables" as string, - }, - deploy: TABLE_DEPLOY_DEFAULTS, - }, - NotNamespaceDir: { - label: "NotNamespaceDir", - type: "table", - namespace: "app" as string, - name: "NotNamespaceDir" as string, - tableId: resourceToHex({ type: "table", namespace: "app", name: "NotNamespaceDir" }), - schema: { - name: { - type: "string", - internalType: "string", - }, - }, - key: [], - codegen: { - ...TABLE_CODEGEN_DEFAULTS, - dataStruct: false as boolean, - outputDirectory: "tables", - }, - deploy: TABLE_DEPLOY_DEFAULTS, - }, - } as const; - - const expectedConfig = { - namespace: "app" as string, - tables: { - app__NamespaceDir: expectedTables.NamespaceDir, - app__NotNamespaceDir: expectedTables.NotNamespaceDir, - }, - namespaces: { - app: { - label: "app", - namespace: "app" as string, - tables: expectedTables, - }, - }, - sourceDirectory: "src", - userTypes: {}, - enums: {}, - enumValues: {}, - codegen: { - ...CODEGEN_DEFAULTS, - namespaceDirectories: true, - }, - } as const; - - // Running attest on the whole object is hard to parse when it fails, so test the inner objects first - attest(config.codegen).equals(expectedConfig.codegen); - attest(config.tables.app__NamespaceDir.codegen).equals( - expectedConfig.tables.app__NamespaceDir.codegen, - ); - attest( - config.tables.app__NotNamespaceDir.codegen, - ).equals(expectedConfig.tables.app__NotNamespaceDir.codegen); - - attest(config.tables.app__NamespaceDir).equals(config.namespaces.app.tables.NamespaceDir); - attest(config.tables.app__NotNamespaceDir).equals(config.namespaces.app.tables.NotNamespaceDir); - - attest(config).equals(expectedConfig); - }); - describe("shorthands", () => { + // TODO: move to table tests it("should accept a shorthand store config as input and expand it", () => { const config = defineStore({ tables: { Name: "address" } }); - const expectedBaseNamespace = { - namespace: "" as string, - tables: { - Name: { - label: "Name", - type: "table", - namespace: "" as string, - name: "Name" as string, - tableId: resourceToHex({ type: "table", namespace: "", name: "Name" }), - schema: { - id: { - type: "bytes32", - internalType: "bytes32", - }, - value: { - type: "address", - internalType: "address", - }, - }, - key: ["id"], - codegen: { ...TABLE_CODEGEN_DEFAULTS, dataStruct: false as boolean }, - deploy: TABLE_DEPLOY_DEFAULTS, + const expectedTable = { + schema: { + id: { + type: "bytes32", + internalType: "bytes32", }, - }, - } as const; - - const expectedConfig = { - ...expectedBaseNamespace, - namespaces: { - "": { - label: "", - ...expectedBaseNamespace, + value: { + type: "address", + internalType: "address", }, }, - sourceDirectory: "src", - userTypes: {}, - enums: {}, - enumValues: {}, - codegen: CODEGEN_DEFAULTS, + key: ["id"], } as const; - attest(config).equals(expectedConfig); + attest(config.tables.Name.schema).equals(expectedTable.schema); + attest(expectedTable.schema); + + attest(config.tables.Name.key).equals(expectedTable.key); + attest(expectedTable.key); }); it("should satisfy the output type", () => { @@ -789,49 +651,25 @@ describe("defineStore", () => { userTypes: { CustomType: { type: "address", filePath: "path/to/file" } }, }); - const expectedBaseNamespace = { - namespace: "" as string, - tables: { - Name: { - label: "Name", - type: "table", - namespace: "" as string, - name: "Name" as string, - tableId: resourceToHex({ type: "table", namespace: "", name: "Name" }), - schema: { - id: { - type: "bytes32", - internalType: "bytes32", - }, - value: { - type: "address", - internalType: "CustomType", - }, - }, - key: ["id"], - codegen: { ...TABLE_CODEGEN_DEFAULTS, dataStruct: false as boolean }, - deploy: TABLE_DEPLOY_DEFAULTS, + const expectedTable = { + schema: { + id: { + type: "bytes32", + internalType: "bytes32", }, - }, - } as const; - - const expectedConfig = { - ...expectedBaseNamespace, - namespaces: { - "": { - label: "", - ...expectedBaseNamespace, + value: { + type: "address", + internalType: "CustomType", }, }, - sourceDirectory: "src", - userTypes: { CustomType: { type: "address", filePath: "path/to/file" } }, - enums: {}, - enumValues: {}, - codegen: CODEGEN_DEFAULTS, + key: ["id"], } as const; - attest(config).equals(expectedConfig); - attest(expectedConfig); + attest(config.tables.Name.schema).equals(expectedTable.schema); + attest(expectedTable.schema); + + attest(config.tables.Name.key).equals(expectedTable.key); + attest(expectedTable.key); }); it("given a schema with a key field with static ABI type, it should use `id` as single key", () => { @@ -839,52 +677,29 @@ describe("defineStore", () => { tables: { Example: { id: "address", name: "string", age: "uint256" } }, }); - const expectedBaseNamespace = { - namespace: "" as string, - tables: { - Example: { - label: "Example", - type: "table", - namespace: "" as string, - name: "Example" as string, - tableId: resourceToHex({ type: "table", namespace: "", name: "Example" }), - schema: { - id: { - type: "address", - internalType: "address", - }, - name: { - type: "string", - internalType: "string", - }, - age: { - type: "uint256", - internalType: "uint256", - }, - }, - key: ["id"], - codegen: { ...TABLE_CODEGEN_DEFAULTS, dataStruct: true as boolean }, - deploy: TABLE_DEPLOY_DEFAULTS, + const expectedTable = { + schema: { + id: { + type: "address", + internalType: "address", }, - }, - } as const; - - const expectedConfig = { - ...expectedBaseNamespace, - namespaces: { - "": { - label: "", - ...expectedBaseNamespace, + name: { + type: "string", + internalType: "string", + }, + age: { + type: "uint256", + internalType: "uint256", }, }, - sourceDirectory: "src", - userTypes: {}, - enums: {}, - enumValues: {}, - codegen: CODEGEN_DEFAULTS, + key: ["id"], } as const; - attest(config).equals(expectedConfig); + attest(config.tables.Example.schema).equals(expectedTable.schema); + attest(expectedTable.schema); + + attest(config.tables.Example.key).equals(expectedTable.key); + attest(expectedTable.key); }); it("given a schema with a key field with static custom type, it should use `id` as single key", () => { @@ -892,73 +707,29 @@ describe("defineStore", () => { tables: { Example: { id: "address", name: "string", age: "uint256" } }, }); - const expectedBaseNamespace = { - namespace: "" as string, - tables: { - Example: { - label: "Example", - type: "table", - namespace: "" as string, - name: "Example" as string, - tableId: resourceToHex({ type: "table", namespace: "", name: "Example" }), - schema: { - id: { - type: "address", - internalType: "address", - }, - name: { - type: "string", - internalType: "string", - }, - age: { - type: "uint256", - internalType: "uint256", - }, - }, - key: ["id"], - codegen: { ...TABLE_CODEGEN_DEFAULTS, dataStruct: true as boolean }, - deploy: TABLE_DEPLOY_DEFAULTS, + const expectedTable = { + schema: { + id: { + type: "address", + internalType: "address", }, - }, - } as const; - - const expectedConfig = { - ...expectedBaseNamespace, - namespaces: { - "": { - label: "", - ...expectedBaseNamespace, + name: { + type: "string", + internalType: "string", + }, + age: { + type: "uint256", + internalType: "uint256", }, }, - sourceDirectory: "src", - userTypes: {}, - enums: {}, - enumValues: {}, - codegen: CODEGEN_DEFAULTS, + key: ["id"], } as const; - attest(config).equals(expectedConfig); - }); - - it("should pass through full table config inputs", () => { - const config = defineStore({ - tables: { - Example: { - schema: { id: "address", name: "string", age: "uint256" }, - key: ["age", "id"], - }, - }, - }); - const expected = defineStore({ - tables: { - Example: { - schema: { id: "address", name: "string", age: "uint256" }, - key: ["age", "id"], - }, - }, - }); + attest(config.tables.Example.schema).equals(expectedTable.schema); + attest(expectedTable.schema); - attest(config).equals(expected); + attest(config.tables.Example.key).equals(expectedTable.key); + attest(expectedTable.key); }); it("should throw if the shorthand doesn't include a key field", () => { diff --git a/packages/store/ts/config/v2/store.ts b/packages/store/ts/config/v2/store.ts index 43708c52e8..6189462ff3 100644 --- a/packages/store/ts/config/v2/store.ts +++ b/packages/store/ts/config/v2/store.ts @@ -1,16 +1,14 @@ -import { ErrorMessage, show, flatMorph, narrow } from "@arktype/util"; -import { get, hasOwnKey, mergeIfUndefined } from "./generics"; +import { ErrorMessage, show, narrow } from "@arktype/util"; +import { get, hasOwnKey } from "./generics"; import { UserTypes } from "./output"; import { CONFIG_DEFAULTS } from "./defaults"; -import { StoreInput, TableInput } from "./input"; -import { resolveTables, validateTables } from "./tables"; +import { StoreInput } from "./input"; +import { validateTables } from "./tables"; import { scopeWithUserTypes, validateUserTypes } from "./userTypes"; import { mapEnums, resolveEnums, scopeWithEnums } from "./enums"; import { resolveCodegen } from "./codegen"; -import { resolveNamespacedTables } from "./namespacedTables"; -import { resolveTable } from "./table"; -import { resolveNamespace } from "./namespace"; -import { expandTableShorthand } from "./tableShorthand"; +import { resolveNamespaces, validateNamespaces } from "./namespaces"; +import { flattenNamespacedTables } from "./flattenNamespacedTables"; export type extendedScope = scopeWithEnums, scopeWithUserTypes>>; @@ -19,20 +17,31 @@ export function extendedScope(input: input): extendedScope { } export type validateStore = { - [key in keyof input]: key extends "tables" - ? validateTables> - : key extends "userTypes" - ? UserTypes - : key extends "enums" - ? narrow - : key extends keyof StoreInput - ? StoreInput[key] - : ErrorMessage<`\`${key & string}\` is not a valid Store config option.`>; + [key in keyof input]: key extends "namespaces" + ? input extends { namespace?: unknown; tables?: unknown } + ? ErrorMessage<"Cannot use `namespaces` with `namespace` or `tables` keys."> + : validateNamespaces> + : key extends "tables" + ? validateTables> + : key extends "userTypes" + ? UserTypes + : key extends "enums" + ? narrow + : key extends keyof StoreInput + ? StoreInput[key] + : ErrorMessage<`\`${key & string}\` is not a valid Store config option.`>; }; export function validateStore(input: unknown): asserts input is StoreInput { const scope = extendedScope(input); + if (hasOwnKey(input, "namespaces")) { + if (hasOwnKey(input, "namespace") || hasOwnKey(input, "tables")) { + throw new Error("Cannot use `namespaces` with `namespace` or `tables` keys."); + } + validateNamespaces(input.namespaces, scope); + } + if (hasOwnKey(input, "namespace") && typeof input.namespace === "string" && input.namespace.length > 14) { throw new Error(`\`namespace\` must fit into a \`bytes14\`, but "${input.namespace}" is too long.`); } @@ -46,82 +55,64 @@ export function validateStore(input: unknown): asserts input is StoreInput { } } -export type resolveStore< - input, - namespace = "namespace" extends keyof input - ? input["namespace"] extends string - ? input["namespace"] - : CONFIG_DEFAULTS["namespace"] - : CONFIG_DEFAULTS["namespace"], -> = { - readonly namespace: string; +type resolveNamespaceMode = "namespaces" extends keyof input + ? { + readonly multipleNamespaces: true; + readonly namespace: null; + readonly namespaces: resolveNamespaces>; + } + : { + readonly multipleNamespaces: false; + readonly namespace: string; + readonly namespaces: resolveNamespaces< + { + readonly [label in "namespace" extends keyof input + ? input["namespace"] extends string + ? input["namespace"] + : CONFIG_DEFAULTS["namespace"] + : CONFIG_DEFAULTS["namespace"]]: input; + }, + extendedScope + >; + }; + +export type resolveStore = resolveNamespaceMode & { + readonly tables: flattenNamespacedTables>; readonly sourceDirectory: "sourceDirectory" extends keyof input ? input["sourceDirectory"] : CONFIG_DEFAULTS["sourceDirectory"]; - readonly tables: "tables" extends keyof input - ? resolveNamespacedTables< - { - readonly [label in keyof input["tables"]]: resolveTable< - mergeIfUndefined, { label: label; namespace: namespace }>, - extendedScope - >; - }, - namespace - > - : {}; readonly userTypes: "userTypes" extends keyof input ? input["userTypes"] : {}; readonly enums: "enums" extends keyof input ? show> : {}; readonly enumValues: "enums" extends keyof input ? show> : {}; readonly codegen: "codegen" extends keyof input ? resolveCodegen : resolveCodegen<{}>; - readonly namespaces: { - readonly [label in namespace & string]: resolveNamespace< - { - readonly label: label; - readonly tables: "tables" extends keyof input ? input["tables"] : undefined; - }, - extendedScope - >; - }; }; export function resolveStore(input: input): resolveStore { const scope = extendedScope(input); - const namespace = input.namespace ?? CONFIG_DEFAULTS["namespace"]; - const codegen = resolveCodegen(input.codegen); - - const tablesInput = flatMorph(input.tables ?? {}, (label, shorthand) => { - const table = expandTableShorthand(shorthand, scope) as TableInput; - return [ - label, - { - ...table, - label, - namespace, - codegen: { - ...table.codegen, - outputDirectory: - table.codegen?.outputDirectory ?? - (codegen.namespaceDirectories && namespace !== "" ? `${namespace}/tables` : "tables"), - }, - }, - ]; - }); - const namespaces = { - [namespace]: resolveNamespace({ label: namespace, tables: tablesInput }, scope), - }; + const baseNamespace = input.namespace ?? CONFIG_DEFAULTS["namespace"]; + const namespaces = input.namespaces + ? ({ + multipleNamespaces: true, + namespace: null, + namespaces: resolveNamespaces(input.namespaces, scope), + } as const) + : ({ + multipleNamespaces: false, + namespace: baseNamespace, + namespaces: resolveNamespaces({ [baseNamespace]: input }, scope), + } as const); - const tables = resolveTables(tablesInput, scope); + const tables = flattenNamespacedTables(namespaces as never); return { - namespace, - tables: resolveNamespacedTables(tables, namespace), - namespaces, + ...namespaces, + tables, sourceDirectory: input.sourceDirectory ?? CONFIG_DEFAULTS["sourceDirectory"], userTypes: input.userTypes ?? {}, enums: resolveEnums(input.enums ?? {}), enumValues: mapEnums(input.enums ?? {}), - codegen, + codegen: resolveCodegen(input.codegen), } as never; } diff --git a/packages/store/ts/config/v2/table.ts b/packages/store/ts/config/v2/table.ts index e711d254a0..534fc4f799 100644 --- a/packages/store/ts/config/v2/table.ts +++ b/packages/store/ts/config/v2/table.ts @@ -61,7 +61,7 @@ export type validateTable< ? validateSchema, scope> : key extends "label" | "namespace" ? options["inStoreContext"] extends true - ? ErrorMessage<"Overrides of `label` and `namespace` are not allowed for tables in a store config"> + ? ErrorMessage<"Overrides of `label` and `namespace` are not allowed for tables in this context"> : key extends keyof input ? narrow : never @@ -104,7 +104,7 @@ export function validateTable( } if (options.inStoreContext && (hasOwnKey(input, "label") || hasOwnKey(input, "namespace"))) { - throw new Error("Overrides of `label` and `namespace` are not allowed for tables in a store config."); + throw new Error("Overrides of `label` and `namespace` are not allowed for tables in this context."); } } diff --git a/packages/world/mud.config.ts b/packages/world/mud.config.ts index 6c0607e052..5d2e54d914 100644 --- a/packages/world/mud.config.ts +++ b/packages/world/mud.config.ts @@ -1,12 +1,15 @@ import { defineWorld } from "./ts/config/v2/world"; -export default defineWorld({ +/** + * @internal + */ +export const configInput = { + namespace: "world", // NOTE: this namespace is only used for tables, the core system is deployed in the root namespace. codegen: { worldImportPath: "./src", worldgenDirectory: "interfaces", worldInterfaceName: "IBaseWorld", }, - namespace: "world", // NOTE: this namespace is only used for tables, the core system is deployed in the root namespace. userTypes: { ResourceId: { filePath: "@latticexyz/store/src/ResourceId.sol", type: "bytes32" }, }, @@ -117,4 +120,6 @@ export default defineWorld({ // (see: https://github.com/latticexyz/mud/issues/631) "StoreRegistrationSystem", ], -}); +} as const; + +export default defineWorld(configInput); diff --git a/packages/world/ts/config/v2/input.ts b/packages/world/ts/config/v2/input.ts index f728550dfb..a8f4cbd339 100644 --- a/packages/world/ts/config/v2/input.ts +++ b/packages/world/ts/config/v2/input.ts @@ -1,5 +1,4 @@ -import { show } from "@arktype/util"; -import { StoreInput } from "@latticexyz/store/config/v2"; +import { StoreInput, NamespaceInput as StoreNamespaceInput } from "@latticexyz/store/config/v2"; import { DynamicResolution, ValueWithType } from "./dynamicResolution"; import { Codegen } from "./output"; @@ -37,6 +36,14 @@ export type SystemsInput = { readonly [label: string]: Omit; }; +export type NamespaceInput = StoreNamespaceInput & { + readonly systems?: SystemsInput; +}; + +export type NamespacesInput = { + readonly [label: string]: Omit; +}; + type ModuleInputArtifactPath = | { /** @@ -88,30 +95,22 @@ export type DeployInput = { export type CodegenInput = Partial; -export type WorldInput = show< - StoreInput & { - readonly namespaces?: NamespacesInput; - /** - * Contracts named *System will be deployed by default - * as public systems at `namespace/ContractName`, unless overridden - * - * The key is the system name (capitalized). - * The value is a SystemConfig object. - */ - readonly systems?: SystemsInput; - /** System names to exclude from codegen and deployment */ - readonly excludeSystems?: readonly string[]; - /** Modules to install in the World */ - readonly modules?: readonly ModuleInput[]; - /** Deploy config */ - readonly deploy?: DeployInput; - /** Codegen config */ - readonly codegen?: CodegenInput; - } ->; - -export type NamespacesInput = { - readonly [label: string]: NamespaceInput; +export type WorldInput = Omit & { + readonly namespaces?: NamespacesInput; + /** + * Contracts named *System will be deployed by default + * as public systems at `namespace/ContractName`, unless overridden + * + * The key is the system name (capitalized). + * The value is a SystemConfig object. + */ + readonly systems?: SystemsInput; + /** System names to exclude from codegen and deployment */ + readonly excludeSystems?: readonly string[]; + /** Modules to install in the World */ + readonly modules?: readonly ModuleInput[]; + /** Deploy config */ + readonly deploy?: DeployInput; + /** Codegen config */ + readonly codegen?: CodegenInput; }; - -export type NamespaceInput = Pick; diff --git a/packages/world/ts/config/v2/namespace.ts b/packages/world/ts/config/v2/namespace.ts new file mode 100644 index 0000000000..3075379681 --- /dev/null +++ b/packages/world/ts/config/v2/namespace.ts @@ -0,0 +1,43 @@ +import { NamespaceInput, SystemsInput } from "./input"; +import { + hasOwnKey, + validateNamespace as validateStoreNamespace, + resolveNamespace as resolveStoreNamespace, + Scope, + AbiTypeScope, +} from "@latticexyz/store/config/v2"; +import { resolveSystems, validateSystems } from "./systems"; + +export type validateNamespace = { + [key in keyof input]: key extends "systems" ? validateSystems : validateStoreNamespace[key]; +}; + +export function validateNamespace( + input: unknown, + scope: scope, +): asserts input is NamespaceInput { + if (hasOwnKey(input, "systems")) { + validateSystems(input.systems); + } + validateStoreNamespace(input, scope); +} + +export type resolveNamespace = input extends NamespaceInput + ? resolveStoreNamespace & { + readonly systems: input["systems"] extends SystemsInput + ? resolveSystems["namespace"]> + : {}; + } + : never; + +export function resolveNamespace( + input: input, + scope: scope = AbiTypeScope as never, +): resolveNamespace { + const namespace = resolveStoreNamespace(input, scope); + const systems = resolveSystems(input.systems ?? {}, namespace.namespace); + return { + ...namespace, + systems, + } as never; +} diff --git a/packages/world/ts/config/v2/namespaces.ts b/packages/world/ts/config/v2/namespaces.ts index ced34101c3..b6a2379fab 100644 --- a/packages/world/ts/config/v2/namespaces.ts +++ b/packages/world/ts/config/v2/namespaces.ts @@ -1,42 +1,11 @@ -import { - Scope, - AbiTypeScope, - validateTables, - isObject, - hasOwnKey, - resolveTable, - mergeIfUndefined, - extendedScope, - getPath, - expandTableShorthand, -} from "@latticexyz/store/config/v2"; -import { NamespaceInput, NamespacesInput } from "./input"; -import { ErrorMessage, conform } from "@arktype/util"; +import { show, flatMorph } from "@arktype/util"; +import { NamespacesInput } from "./input"; +import { validateNamespace, resolveNamespace } from "./namespace"; +import { groupBy } from "@latticexyz/common/utils"; +import { AbiTypeScope, Scope, isObject, mergeIfUndefined } from "@latticexyz/store/config/v2"; -export type namespacedTableKeys = world extends { namespaces: infer namespaces } - ? { - [k in keyof namespaces]: namespaces[k] extends { tables: infer tables } - ? `${k & string}__${keyof tables & string}` - : never; - }[keyof namespaces] - : never; - -export type validateNamespace = { - readonly [key in keyof namespace]: key extends "tables" - ? validateTables - : key extends keyof NamespaceInput - ? conform - : ErrorMessage<`\`${key & string}\` is not a valid namespace config option.`>; -}; - -export function validateNamespace( - namespace: unknown, - scope: scope, -): asserts namespace is NamespaceInput { - if (hasOwnKey(namespace, "tables")) { - validateTables(namespace.tables, scope); - } -} +// Copied from store/ts/config/v2/namespaces.ts but using world namespace validate/resolve methods +// TODO: figure out how to dedupe these? export type validateNamespaces = { [label in keyof namespaces]: validateNamespace; @@ -54,16 +23,38 @@ export function validateNamespaces( } } -export type resolveNamespacedTables = "namespaces" extends keyof world - ? { - readonly [key in namespacedTableKeys]: key extends `${infer namespace}__${infer table}` - ? resolveTable< - mergeIfUndefined< - expandTableShorthand>, - { name: table; namespace: namespace } - >, - extendedScope - > - : never; - } - : {}; +export type resolveNamespaces = { + readonly [label in keyof namespaces]: resolveNamespace, scope>; +}; + +export function resolveNamespaces( + input: input, + scope: scope, +): show> { + if (!isObject(input)) { + throw new Error(`Expected namespaces config, received ${JSON.stringify(input)}`); + } + + const namespaces = flatMorph(input as NamespacesInput, (label, namespace) => [ + label, + resolveNamespace(mergeIfUndefined(namespace, { label }), scope), + ]); + + // This should probably be in `validate`, but `namespace` gets set during the resolve step above, so it's easier to validate here. + const duplicates = Array.from(groupBy(Object.values(namespaces), (namespace) => namespace.namespace).entries()) + .filter(([, entries]) => entries.length > 1) + .map(([namespace]) => namespace); + if (duplicates.length > 0) { + throw new Error(`Found namespaces defined more than once in config: ${duplicates.join(", ")}`); + } + + return namespaces as never; +} + +export function defineNamespaces( + input: validateNamespaces, + scope: scope = AbiTypeScope as never, +): show> { + validateNamespaces(input, scope); + return resolveNamespaces(input, scope) as never; +} diff --git a/packages/world/ts/config/v2/output.ts b/packages/world/ts/config/v2/output.ts index 120ba132c5..593413a74c 100644 --- a/packages/world/ts/config/v2/output.ts +++ b/packages/world/ts/config/v2/output.ts @@ -1,4 +1,5 @@ import { Store } from "@latticexyz/store"; +import { Namespace as StoreNamespace } from "@latticexyz/store/config/v2"; import { DynamicResolution, ValueWithType } from "./dynamicResolution"; import { Hex } from "viem"; @@ -60,6 +61,13 @@ export type Systems = { readonly [label: string]: System; }; +// TODO: should we make Namespace an interface that we can extend here instead of overriding? +export type Namespace = StoreNamespace & { readonly systems: Systems }; + +export type Namespaces = { + readonly [label: string]: Namespace; +}; + export type Deploy = { /** The name of a custom World contract to deploy. If no name is provided, a default MUD World is deployed */ readonly customWorldContract: string | undefined; @@ -90,7 +98,8 @@ export type Codegen = { readonly worldImportPath: string; }; -export type World = Store & { +export type World = Omit & { + readonly namespaces: Namespaces; readonly systems: Systems; /** Systems to exclude from automatic deployment */ readonly excludeSystems: readonly string[]; diff --git a/packages/world/ts/config/v2/systems.ts b/packages/world/ts/config/v2/systems.ts index 31d4a58934..b259d2dade 100644 --- a/packages/world/ts/config/v2/systems.ts +++ b/packages/world/ts/config/v2/systems.ts @@ -13,8 +13,8 @@ export type validateSystems = { export function validateSystems(input: unknown): asserts input is SystemsInput { if (isObject(input)) { - for (const table of Object.values(input)) { - validateSystem(table, { inNamespace: true }); + for (const system of Object.values(input)) { + validateSystem(system, { inNamespace: true }); } return; } diff --git a/packages/world/ts/config/v2/world.test.ts b/packages/world/ts/config/v2/world.test.ts index 273c92a887..58022db37a 100644 --- a/packages/world/ts/config/v2/world.test.ts +++ b/packages/world/ts/config/v2/world.test.ts @@ -2,26 +2,13 @@ import { describe, it } from "vitest"; import { defineWorld } from "./world"; import { attest } from "@arktype/attest"; import { resourceToHex } from "@latticexyz/common"; -import { - CONFIG_DEFAULTS as STORE_CONFIG_DEFAULTS, - TABLE_CODEGEN_DEFAULTS, - CODEGEN_DEFAULTS as STORE_CODEGEN_DEFAULTS, - TABLE_DEPLOY_DEFAULTS, -} from "@latticexyz/store/config/v2"; -import { - CODEGEN_DEFAULTS as WORLD_CODEGEN_DEFAULTS, - DEPLOY_DEFAULTS, - CONFIG_DEFAULTS as WORLD_CONFIG_DEFAULTS, -} from "./defaults"; import { World } from "./output"; - -const CONFIG_DEFAULTS = { ...STORE_CONFIG_DEFAULTS, ...WORLD_CONFIG_DEFAULTS }; -const CODEGEN_DEFAULTS = { ...STORE_CODEGEN_DEFAULTS, ...WORLD_CODEGEN_DEFAULTS }; +import { satisfy } from "@arktype/util"; +import { Hex } from "viem"; describe("defineWorld", () => { - it.skip("should resolve namespaced tables", () => { + it("should resolve namespaced tables", () => { const config = defineWorld({ - // @ts-expect-error TODO: remove once namespaces support ships namespaces: { ExampleNS: { tables: { @@ -38,47 +25,47 @@ describe("defineWorld", () => { }, }); - const expected = { - ...CONFIG_DEFAULTS, - namespace: "" as string, - tables: { - ExampleNS__ExampleTable: { - label: "ExampleTable", - type: "table", - namespace: "ExampleNS" as string, - name: "ExampleTable" as string, - tableId: resourceToHex({ type: "table", namespace: "ExampleNS", name: "ExampleTable" }), - schema: { - id: { - type: "address", - internalType: "address", - }, - value: { - type: "uint256", - internalType: "uint256", - }, - dynamic: { - type: "string", - internalType: "string", - }, - }, - key: ["id"], - codegen: { ...TABLE_CODEGEN_DEFAULTS, dataStruct: true as boolean }, - deploy: TABLE_DEPLOY_DEFAULTS, + const expectedTable = { + label: "ExampleTable", + namespace: "ExampleNS" as string, + name: "ExampleTable" as string, + schema: { + id: { + type: "address", + internalType: "address", + }, + value: { + type: "uint256", + internalType: "uint256", + }, + dynamic: { + type: "string", + internalType: "string", }, }, - userTypes: {}, - enums: {}, - enumValues: {}, - codegen: CODEGEN_DEFAULTS, + key: ["id"], } as const; - attest(config).equals(expected); + attest(config.tables.ExampleNS__ExampleTable.label).equals(expectedTable.label); + attest(expectedTable.label); + + attest(config.tables.ExampleNS__ExampleTable.namespace).equals( + expectedTable.namespace, + ); + attest(expectedTable.namespace); + + attest(config.tables.ExampleNS__ExampleTable.name).equals(expectedTable.name); + attest(expectedTable.name); + + attest(config.tables.ExampleNS__ExampleTable.schema).equals(expectedTable.schema); + attest(expectedTable.schema); + + attest(config.tables.ExampleNS__ExampleTable.key).equals(expectedTable.key); + attest(expectedTable.key); }); - it.skip("should resolve namespaced table config with user types and enums", () => { + it("should extend the output World type", () => { const config = defineWorld({ - // @ts-expect-error TODO: remove once namespaces support ships namespaces: { ExampleNS: { tables: { @@ -102,89 +89,139 @@ describe("defineWorld", () => { }, }); - const expected = { - ...CONFIG_DEFAULTS, - codegen: CODEGEN_DEFAULTS, - tables: { - ExampleNS__ExampleTable: { - label: "ExampleTable", - type: "table", - namespace: "ExampleNS" as string, - name: "ExampleTable" as string, - tableId: resourceToHex({ type: "table", namespace: "ExampleNS", name: "ExampleTable" }), - schema: { - id: { - type: "address", - internalType: "Static", - }, - value: { - type: "uint8", - internalType: "MyEnum", - }, - dynamic: { - type: "string", - internalType: "Dynamic", - }, - }, - key: ["id"], - codegen: { ...TABLE_CODEGEN_DEFAULTS, dataStruct: true as boolean }, - deploy: TABLE_DEPLOY_DEFAULTS, - }, - }, - userTypes: { - Static: { type: "address", filePath: "path/to/file" }, - Dynamic: { type: "string", filePath: "path/to/file" }, - }, - enums: { - MyEnum: ["First", "Second"], - }, - enumValues: { - MyEnum: { - First: 0, - Second: 1, - }, - }, - namespace: "" as string, - } as const; + attest>(); + }); - attest(config).equals(expected); + it("should only allow for single namespace or multiple namespaces, not both", () => { + attest(() => + defineWorld({ + // @ts-expect-error Cannot use `namespaces` with `namespace`, `tables`, or `systems` keys. + namespaces: {}, + namespace: "app", + }), + ) + .throws("Cannot use `namespaces` with `namespace`, `tables`, or `systems` keys.") + .type.errors("Cannot use `namespaces` with `namespace`, `tables`, or `systems` keys."); + + attest(() => + defineWorld({ + // @ts-expect-error Cannot use `namespaces` with `namespace`, `tables`, or `systems` keys. + namespaces: {}, + tables: {}, + }), + ) + .throws("Cannot use `namespaces` with `namespace`, `tables`, or `systems` keys.") + .type.errors("Cannot use `namespaces` with `namespace`, `tables`, or `systems` keys."); + + attest(() => + defineWorld({ + // @ts-expect-error Cannot use `namespaces` with `namespace`, `tables`, or `systems` keys. + namespaces: {}, + systems: {}, + }), + ) + .throws("Cannot use `namespaces` with `namespace`, `tables`, or `systems` keys.") + .type.errors("Cannot use `namespaces` with `namespace`, `tables`, or `systems` keys."); }); - it.skip("should extend the output World type", () => { + it("should return a fully resolved config for a single namespace", () => { const config = defineWorld({ - // @ts-expect-error TODO: remove once namespaces support ships + tables: { + Example: { + schema: { id: "address", name: "string", age: "uint256" }, + key: ["age"], + }, + }, + }); + + const expectedConfig = { + multipleNamespaces: false, + namespace: "" as string, namespaces: { - ExampleNS: { + "": { + label: "", + namespace: "" as string, tables: { - ExampleTable: { + Example: { + label: "Example", + type: "table", + namespace: "" as string, + name: "Example" as string, + tableId: "0x746200000000000000000000000000004578616d706c65000000000000000000" as Hex, schema: { - id: "Static", - value: "MyEnum", - dynamic: "Dynamic", + id: { type: "address", internalType: "address" }, + name: { type: "string", internalType: "string" }, + age: { type: "uint256", internalType: "uint256" }, }, - key: ["id"], + key: ["age"], + codegen: { + outputDirectory: "tables" as string, + tableIdArgument: false, + storeArgument: false, + dataStruct: true as boolean, + }, + deploy: { disabled: false }, }, }, + systems: {}, }, }, - userTypes: { - Static: { type: "address", filePath: "path/to/file" }, - Dynamic: { type: "string", filePath: "path/to/file" }, + tables: { + Example: { + label: "Example", + type: "table", + namespace: "" as string, + name: "Example" as string, + tableId: "0x746200000000000000000000000000004578616d706c65000000000000000000" as Hex, + schema: { + id: { type: "address", internalType: "address" }, + name: { type: "string", internalType: "string" }, + age: { type: "uint256", internalType: "uint256" }, + }, + key: ["age"], + codegen: { + outputDirectory: "tables" as string, + tableIdArgument: false, + storeArgument: false, + dataStruct: true as boolean, + }, + deploy: { disabled: false }, + }, }, - enums: { - MyEnum: ["First", "Second"], + sourceDirectory: "src", + userTypes: {}, + enums: {}, + enumValues: {}, + codegen: { + storeImportPath: "@latticexyz/store/src", + userTypesFilename: "common.sol", + outputDirectory: "codegen", + indexFilename: "index.sol", + worldInterfaceName: "IWorld", + worldgenDirectory: "world", + worldImportPath: "@latticexyz/world/src", }, - }); + systems: {}, + excludeSystems: [], + deploy: { + customWorldContract: undefined, + postDeployScript: "PostDeploy", + deploysDirectory: "./deploys", + worldsFile: "./worlds.json", + upgradeableWorldImplementation: false, + }, + modules: [], + } as const; - attest(); + attest(config).equals(expectedConfig); + attest(expectedConfig); }); - it.skip("should not use the global namespace for namespaced tables", () => { + it("should return a fully resolved config for multiple namespaces", () => { const config = defineWorld({ - namespace: "namespace", - // @ts-expect-error TODO: remove once namespaces support ships namespaces: { - AnotherOne: { + root: { + namespace: "", tables: { Example: { schema: { id: "address", name: "string", age: "uint256" }, @@ -195,544 +232,97 @@ describe("defineWorld", () => { }, }); - attest(config.namespace).equals("namespace"); - // @ts-expect-error TODO: fix once namespaces support ships - attest(config.tables.AnotherOne__Example.namespace).equals("AnotherOne"); - // @ts-expect-error TODO: fix once namespaces support ships - attest(config.tables.AnotherOne__Example.tableId).equals( - resourceToHex({ type: "table", name: "Example", namespace: "AnotherOne" }), - ); - }); - - describe("should have the same output as `defineWorld` for store config inputs", () => { - it("should return the full config given a full config with one key", () => { - const config = defineWorld({ - tables: { - Example: { - schema: { id: "address", name: "string", age: "uint256" }, - key: ["age"], - }, - }, - }); - - const expectedBaseNamespace = { - namespace: "" as string, - tables: { - Example: { - label: "Example", - type: "table", - namespace: "" as string, - name: "Example" as string, - tableId: resourceToHex({ type: "table", namespace: "", name: "Example" }), - schema: { - id: { - type: "address", - internalType: "address", - }, - name: { - type: "string", - internalType: "string", - }, - age: { - type: "uint256", - internalType: "uint256", - }, - }, - key: ["age"], - codegen: { ...TABLE_CODEGEN_DEFAULTS, dataStruct: true as boolean }, - deploy: TABLE_DEPLOY_DEFAULTS, - }, - }, - } as const; - - const expectedConfig = { - ...CONFIG_DEFAULTS, - ...expectedBaseNamespace, - namespaces: { - "": { - label: "", - ...expectedBaseNamespace, - }, - }, - userTypes: {}, - enums: {}, - enumValues: {}, - codegen: CODEGEN_DEFAULTS, - } as const; - - attest(config).equals(expectedConfig); - }); - - it("should return the full config given a full config with one key and user types", () => { - const config = defineWorld({ - tables: { - Example: { - schema: { id: "dynamic", name: "string", age: "static" }, - key: ["age"], - }, - }, - userTypes: { - static: { type: "address", filePath: "path/to/file" }, - dynamic: { type: "string", filePath: "path/to/file" }, - }, - }); - - const expectedBaseNamespace = { - namespace: "" as string, - tables: { - Example: { - label: "Example", - type: "table", - namespace: "" as string, - name: "Example" as string, - tableId: resourceToHex({ type: "table", namespace: "", name: "Example" }), - schema: { - id: { - type: "string", - internalType: "dynamic", - }, - name: { - type: "string", - internalType: "string", - }, - age: { - type: "address", - internalType: "static", - }, - }, - key: ["age"], - codegen: { ...TABLE_CODEGEN_DEFAULTS, dataStruct: true as boolean }, - deploy: TABLE_DEPLOY_DEFAULTS, - }, - }, - } as const; - - const expectedConfig = { - ...CONFIG_DEFAULTS, - ...expectedBaseNamespace, - namespaces: { - "": { - label: "", - ...expectedBaseNamespace, - }, - }, - userTypes: { - static: { type: "address", filePath: "path/to/file" }, - dynamic: { type: "string", filePath: "path/to/file" }, - }, - enums: {}, - enumValues: {}, - codegen: CODEGEN_DEFAULTS, - } as const; - - attest(config).equals(expectedConfig); - }); - - it("should return the full config given a full config with two key", () => { - const config = defineWorld({ - tables: { - Example: { - schema: { id: "address", name: "string", age: "uint256" }, - key: ["age", "id"], - }, - }, - }); - - const expectedBaseNamespace = { - namespace: "" as string, - tables: { - Example: { - label: "Example", - type: "table", - namespace: "" as string, - name: "Example" as string, - tableId: resourceToHex({ type: "table", namespace: "", name: "Example" }), - schema: { - id: { - type: "address", - internalType: "address", - }, - name: { - type: "string", - internalType: "string", - }, - age: { - type: "uint256", - internalType: "uint256", - }, - }, - key: ["age", "id"], - codegen: { ...TABLE_CODEGEN_DEFAULTS, dataStruct: false as boolean }, - deploy: TABLE_DEPLOY_DEFAULTS, - }, - }, - } as const; - - const expectedConfig = { - ...CONFIG_DEFAULTS, - ...expectedBaseNamespace, - namespaces: { - "": { - label: "", - ...expectedBaseNamespace, - }, - }, - userTypes: {}, - enums: {}, - enumValues: {}, - codegen: CODEGEN_DEFAULTS, - deploy: DEPLOY_DEFAULTS, - } as const; - - attest(config).equals(expectedConfig); - }); - - it("should resolve two tables in the config with different schemas", () => { - const config = defineWorld({ - tables: { - First: { - schema: { firstKey: "address", firstName: "string", firstAge: "uint256" }, - key: ["firstKey", "firstAge"], - }, - Second: { - schema: { secondKey: "address", secondName: "string", secondAge: "uint256" }, - key: ["secondKey", "secondAge"], - }, - }, - }); - - const expectedBaseNamespace = { - namespace: "" as string, - tables: { - First: { - label: "First", - type: "table", - namespace: "" as string, - name: "First" as string, - tableId: resourceToHex({ type: "table", namespace: "", name: "First" }), - schema: { - firstKey: { - type: "address", - internalType: "address", - }, - firstName: { - type: "string", - internalType: "string", - }, - firstAge: { - type: "uint256", - internalType: "uint256", - }, - }, - key: ["firstKey", "firstAge"], - codegen: { ...TABLE_CODEGEN_DEFAULTS, dataStruct: false as boolean }, - deploy: TABLE_DEPLOY_DEFAULTS, - }, - Second: { - label: "Second", - type: "table", - namespace: "" as string, - name: "Second" as string, - tableId: resourceToHex({ type: "table", namespace: "", name: "Second" }), - schema: { - secondKey: { - type: "address", - internalType: "address", - }, - secondName: { - type: "string", - internalType: "string", - }, - secondAge: { - type: "uint256", - internalType: "uint256", - }, - }, - key: ["secondKey", "secondAge"], - codegen: { ...TABLE_CODEGEN_DEFAULTS, dataStruct: false as boolean }, - deploy: TABLE_DEPLOY_DEFAULTS, - }, - }, - } as const; - - const expectedConfig = { - ...CONFIG_DEFAULTS, - ...expectedBaseNamespace, - namespaces: { - "": { - label: "", - ...expectedBaseNamespace, - }, - }, - userTypes: {}, - enums: {}, - enumValues: {}, - codegen: CODEGEN_DEFAULTS, - } as const; - - attest(config).equals(expectedConfig); - }); - - it("should resolve two tables in the config with different schemas and user types", () => { - const config = defineWorld({ - tables: { - First: { - schema: { firstKey: "Static", firstName: "Dynamic", firstAge: "uint256" }, - key: ["firstKey", "firstAge"], - }, - Second: { - schema: { secondKey: "Static", secondName: "Dynamic", secondAge: "uint256" }, - key: ["secondKey", "secondAge"], - }, - }, - userTypes: { - Static: { type: "address", filePath: "path/to/file" }, - Dynamic: { type: "string", filePath: "path/to/file" }, - }, - }); - - const expectedBaseNamespace = { - namespace: "" as string, - tables: { - First: { - label: "First", - type: "table", - namespace: "" as string, - name: "First" as string, - tableId: resourceToHex({ type: "table", namespace: "", name: "First" }), - schema: { - firstKey: { - type: "address", - internalType: "Static", - }, - firstName: { - type: "string", - internalType: "Dynamic", - }, - firstAge: { - type: "uint256", - internalType: "uint256", - }, - }, - key: ["firstKey", "firstAge"], - codegen: { ...TABLE_CODEGEN_DEFAULTS, dataStruct: false as boolean }, - deploy: TABLE_DEPLOY_DEFAULTS, - }, - Second: { - label: "Second", - type: "table", - namespace: "" as string, - name: "Second" as string, - tableId: resourceToHex({ type: "table", namespace: "", name: "Second" }), - schema: { - secondKey: { - type: "address", - internalType: "Static", - }, - secondName: { - type: "string", - internalType: "Dynamic", - }, - secondAge: { - type: "uint256", - internalType: "uint256", - }, - }, - key: ["secondKey", "secondAge"], - codegen: { ...TABLE_CODEGEN_DEFAULTS, dataStruct: false as boolean }, - deploy: TABLE_DEPLOY_DEFAULTS, - }, - }, - } as const; - - const expectedConfig = { - ...CONFIG_DEFAULTS, - ...expectedBaseNamespace, - namespaces: { - "": { - label: "", - ...expectedBaseNamespace, - }, - }, - userTypes: { - Static: { type: "address", filePath: "path/to/file" }, - Dynamic: { type: "string", filePath: "path/to/file" }, - }, - enums: {}, - enumValues: {}, - codegen: CODEGEN_DEFAULTS, - } as const; - - attest(config).equals(expectedConfig); - }); - - it("should throw if referring to fields of different tables", () => { - attest(() => - defineWorld({ - tables: { - First: { - schema: { firstKey: "address", firstName: "string", firstAge: "uint256" }, - key: ["firstKey", "firstAge"], - }, - Second: { - schema: { secondKey: "address", secondName: "string", secondAge: "uint256" }, - // @ts-expect-error Type '"firstKey"' is not assignable to type '"secondKey" | "secondAge"' - key: ["firstKey", "secondAge"], - }, - }, - }), - ) - .throws('Invalid key. Expected `("secondKey" | "secondAge")[]`, received `["firstKey", "secondAge"]`') - .type.errors(`Type '"firstKey"' is not assignable to type '"secondKey" | "secondAge"'`); - }); - - it("should throw an error if the provided key is not a static field", () => { - attest(() => - defineWorld({ - tables: { - Example: { - schema: { id: "address", name: "string", age: "uint256" }, - // @ts-expect-error Type '"name"' is not assignable to type '"id" | "age"'. - key: ["name"], - }, - }, - }), - ) - .throws('Invalid key. Expected `("id" | "age")[]`, received `["name"]`') - .type.errors(`Type '"name"' is not assignable to type '"id" | "age"'`); - }); - - it("should throw an error if the provided key is not a static field with user types", () => { - attest(() => - defineWorld({ + const expectedConfig = { + multipleNamespaces: true, + namespace: null, + namespaces: { + root: { + label: "root", + namespace: "" as string, tables: { Example: { - schema: { id: "address", name: "Dynamic", age: "uint256" }, - // @ts-expect-error Type '"name"' is not assignable to type '"id" | "age"'. - key: ["name"], - }, - }, - userTypes: { - Dynamic: { type: "string", filePath: "path/to/file" }, - }, - }), - ) - .throws('Invalid key. Expected `("id" | "age")[]`, received `["name"]`') - .type.errors(`Type '"name"' is not assignable to type '"id" | "age"'`); - }); - - it("should return the full config given a full config with enums and user types", () => { - const config = defineWorld({ - tables: { - Example: { - schema: { id: "dynamic", name: "ValidNames", age: "static" }, - key: ["name"], - }, - }, - userTypes: { - static: { type: "address", filePath: "path/to/file" }, - dynamic: { type: "string", filePath: "path/to/file" }, - }, - enums: { - ValidNames: ["first", "second"], - }, - }); - - const expectedBaseNamespace = { - namespace: "" as string, - tables: { - Example: { - label: "Example", - type: "table", - namespace: "" as string, - name: "Example" as string, - tableId: resourceToHex({ type: "table", namespace: "", name: "Example" }), - schema: { - id: { - type: "string", - internalType: "dynamic", - }, - name: { - type: "uint8", - internalType: "ValidNames", + label: "Example", + type: "table", + namespace: "" as string, + name: "Example" as string, + tableId: "0x746200000000000000000000000000004578616d706c65000000000000000000" as Hex, + schema: { + id: { type: "address", internalType: "address" }, + name: { type: "string", internalType: "string" }, + age: { type: "uint256", internalType: "uint256" }, }, - age: { - type: "address", - internalType: "static", + key: ["age"], + codegen: { + outputDirectory: "tables" as string, + tableIdArgument: false, + storeArgument: false, + dataStruct: true as boolean, }, + deploy: { disabled: false }, }, - key: ["name"], - codegen: { ...TABLE_CODEGEN_DEFAULTS, dataStruct: true as boolean }, - deploy: TABLE_DEPLOY_DEFAULTS, }, + systems: {}, }, - } as const; - - const expectedConfig = { - ...CONFIG_DEFAULTS, - ...expectedBaseNamespace, - namespaces: { - "": { - label: "", - ...expectedBaseNamespace, + }, + tables: { + root__Example: { + label: "Example", + type: "table", + namespace: "" as string, + name: "Example" as string, + tableId: "0x746200000000000000000000000000004578616d706c65000000000000000000" as Hex, + schema: { + id: { type: "address", internalType: "address" }, + name: { type: "string", internalType: "string" }, + age: { type: "uint256", internalType: "uint256" }, }, - }, - codegen: CODEGEN_DEFAULTS, - userTypes: { - static: { type: "address", filePath: "path/to/file" }, - dynamic: { type: "string", filePath: "path/to/file" }, - }, - enums: { - ValidNames: ["first", "second"], - }, - enumValues: { - ValidNames: { - first: 0, - second: 1, + key: ["age"], + codegen: { + outputDirectory: "tables" as string, + tableIdArgument: false, + storeArgument: false, + dataStruct: true as boolean, }, + deploy: { disabled: false }, }, - } as const; - - attest(config).equals(expectedConfig); - attest(expectedConfig).equals(expectedConfig); - }); - - it("should use the root namespace as default namespace", () => { - const config = defineWorld({}); - attest(config.namespace).equals(""); - }); - - it("should use pipe through non-default namespaces", () => { - const config = defineWorld({ namespace: "custom" }); - attest(config.namespace).equals("custom"); - }); - - it("should extend the output World type", () => { - const config = defineWorld({ - tables: { Name: { schema: { key: "CustomType" }, key: ["key"] } }, - userTypes: { CustomType: { type: "address", filePath: "path/to/file" } }, - }); + }, + sourceDirectory: "src", + userTypes: {}, + enums: {}, + enumValues: {}, + codegen: { + storeImportPath: "@latticexyz/store/src", + userTypesFilename: "common.sol", + outputDirectory: "codegen", + indexFilename: "index.sol", + worldInterfaceName: "IWorld", + worldgenDirectory: "world", + worldImportPath: "@latticexyz/world/src", + }, + systems: {}, + excludeSystems: [], + deploy: { + customWorldContract: undefined, + postDeployScript: "PostDeploy", + deploysDirectory: "./deploys", + worldsFile: "./worlds.json", + upgradeableWorldImplementation: false, + }, + modules: [], + } as const; - attest(); - }); + attest(config).equals(expectedConfig); + attest(expectedConfig); + }); - it("should use the global namespace instead for tables", () => { - const config = defineWorld({ - namespace: "namespace", - tables: { - Example: { - schema: { id: "address", name: "string", age: "uint256" }, - key: ["age"], - }, - }, - }); + it("should use the root namespace as default namespace", () => { + const config = defineWorld({}); + attest(config.namespace).equals(""); + }); - attest(config.namespace).equals("namespace"); - attest(config.tables.namespace__Example.namespace).equals("namespace"); - attest(config.tables.namespace__Example.tableId).equals( - resourceToHex({ type: "table", name: "Example", namespace: "namespace" }), - ); - }); + it("should use pipe through non-default namespaces", () => { + const config = defineWorld({ namespace: "custom" }); + attest(config.namespace).equals("custom"); }); it("should use the custom name and namespace as table index", () => { @@ -749,45 +339,42 @@ describe("defineWorld", () => { attest<"CustomNS__Example", keyof typeof config.tables>(); }); - it("should throw if namespace is overridden in top level tables", () => { + it("should throw if table label/namespace is overridden in namespace context", () => { attest(() => defineWorld({ namespace: "CustomNS", tables: { Example: { - // @ts-expect-error "Overrides of `label` and `namespace` are not allowed for tables in a store config" - namespace: "NotAllowed", + // @ts-expect-error "Overrides of `label` and `namespace` are not allowed for tables in this context" + label: "NotAllowed", schema: { id: "address" }, key: ["id"], }, }, }), - ).throwsAndHasTypeError("Overrides of `label` and `namespace` are not allowed for tables in a store config"); - }); + ).throwsAndHasTypeError("Overrides of `label` and `namespace` are not allowed for tables in this context"); - it("should throw if label is overridden in top level tables", () => { attest(() => defineWorld({ + namespace: "CustomNS", tables: { Example: { - // @ts-expect-error "Overrides of `label` and `namespace` are not allowed for tables in a store config" - label: "NotAllowed", + // @ts-expect-error "Overrides of `label` and `namespace` are not allowed for tables in this context" + namespace: "NotAllowed", schema: { id: "address" }, key: ["id"], }, }, }), - ).throwsAndHasTypeError("Overrides of `label` and `namespace` are not allowed for tables in a store config"); - }); + ).throwsAndHasTypeError("Overrides of `label` and `namespace` are not allowed for tables in this context"); - it.skip("should throw if label is overridden in namespaced tables", () => { attest(() => defineWorld({ - // @ts-expect-error TODO: remove once namespaces support ships namespaces: { - MyNamespace: { + CustomNS: { tables: { Example: { + // @ts-expect-error "Overrides of `label` and `namespace` are not allowed for tables in this context" label: "NotAllowed", schema: { id: "address" }, key: ["id"], @@ -796,17 +383,15 @@ describe("defineWorld", () => { }, }, }), - ).throwsAndHasTypeError("Overrides of `label` and `namespace` are not allowed for tables in a store config"); - }); + ).throwsAndHasTypeError("Overrides of `label` and `namespace` are not allowed for tables in this context"); - it.skip("should throw if namespace is overridden in namespaced tables", () => { attest(() => defineWorld({ - // @ts-expect-error TODO: remove once namespaces support ships namespaces: { - MyNamespace: { + CustomNS: { tables: { Example: { + // @ts-expect-error "Overrides of `label` and `namespace` are not allowed for tables in this context" namespace: "NotAllowed", schema: { id: "address" }, key: ["id"], @@ -815,16 +400,7 @@ describe("defineWorld", () => { }, }, }), - ).throwsAndHasTypeError("Overrides of `label` and `namespace` are not allowed for tables in a store config"); - }); - - it("should throw if namespaces are defined (TODO: remove once namespaces support ships)", () => { - attest(() => - defineWorld({ - // @ts-expect-error TODO: remove once namespaces support ships - namespaces: {}, - }), - ).type.errors("Namespaces config will be enabled soon."); + ).throwsAndHasTypeError("Overrides of `label` and `namespace` are not allowed for tables in this context"); }); it("should expand systems config", () => { @@ -883,426 +459,4 @@ describe("defineWorld", () => { }), ).type.errors("`invalidOption` is not a valid World config option."); }); - - describe("shorthands", () => { - it.skip("should resolve namespaced shorthand table config with user types and enums", () => { - const config = defineWorld({ - // @ts-expect-error TODO - namespaces: { - ExampleNS: { - tables: { - ExampleTable: "Static", - }, - }, - }, - userTypes: { - Static: { type: "address", filePath: "path/to/file" }, - Dynamic: { type: "string", filePath: "path/to/file" }, - }, - enums: { - MyEnum: ["First", "Second"], - }, - }); - - const expected = { - ...CONFIG_DEFAULTS, - codegen: CODEGEN_DEFAULTS, - tables: { - ExampleNS__ExampleTable: { - label: "ExampleTable", - type: "table", - namespace: "ExampleNS", - name: "ExampleTable" as string, - tableId: resourceToHex({ type: "table", namespace: "ExampleNS", name: "ExampleTable" }), - schema: { - id: { - type: "bytes32", - internalType: "bytes32", - }, - value: { - type: "address", - internalType: "Static", - }, - }, - key: ["id"], - codegen: { ...TABLE_CODEGEN_DEFAULTS, dataStruct: false as boolean }, - deploy: TABLE_DEPLOY_DEFAULTS, - }, - }, - userTypes: { - Static: { type: "address", filePath: "path/to/file" as string }, - Dynamic: { type: "string", filePath: "path/to/file" as string }, - }, - enums: { - MyEnum: ["First", "Second"], - }, - enumValues: { - MyEnum: { - First: 0, - Second: 1, - }, - }, - namespace: "" as string, - } as const; - - attest(config).equals(expected); - }); - - it.skip("should resolve namespaced shorthand schema table config with user types and enums", () => { - const config = defineWorld({ - // @ts-expect-error TODO - namespaces: { - ExampleNS: { - tables: { - ExampleTable: { - id: "Static", - value: "MyEnum", - dynamic: "Dynamic", - }, - }, - }, - }, - userTypes: { - Static: { type: "address", filePath: "path/to/file" }, - Dynamic: { type: "string", filePath: "path/to/file" }, - }, - enums: { - MyEnum: ["First", "Second"], - }, - }); - - const expected = { - ...CONFIG_DEFAULTS, - codegen: CODEGEN_DEFAULTS, - tables: { - ExampleNS__ExampleTable: { - label: "ExampleTable", - type: "table", - namespace: "ExampleNS", - name: "ExampleTable" as string, - tableId: resourceToHex({ type: "table", namespace: "ExampleNS", name: "ExampleTable" }), - schema: { - id: { - type: "address", - internalType: "Static", - }, - value: { - type: "uint8", - internalType: "MyEnum", - }, - dynamic: { - type: "string", - internalType: "Dynamic", - }, - }, - key: ["id"], - codegen: { ...TABLE_CODEGEN_DEFAULTS, dataStruct: true as boolean }, - deploy: TABLE_DEPLOY_DEFAULTS, - }, - }, - userTypes: { - Static: { type: "address", filePath: "path/to/file" as string }, - Dynamic: { type: "string", filePath: "path/to/file" as string }, - }, - enums: { - MyEnum: ["First", "Second"], - }, - enumValues: { - MyEnum: { - First: 0, - Second: 1, - }, - }, - namespace: "" as string, - } as const; - - attest(config).equals(expected); - }); - - it("should accept a shorthand store config as input and expand it", () => { - const config = defineWorld({ tables: { Name: "address" } }); - - const expectedBaseNamespace = { - namespace: "" as string, - tables: { - Name: { - label: "Name", - type: "table", - namespace: "" as string, - name: "Name" as string, - tableId: resourceToHex({ type: "table", namespace: "", name: "Name" }), - schema: { - id: { - type: "bytes32", - internalType: "bytes32", - }, - value: { - type: "address", - internalType: "address", - }, - }, - key: ["id"], - codegen: { ...TABLE_CODEGEN_DEFAULTS, dataStruct: false as boolean }, - deploy: TABLE_DEPLOY_DEFAULTS, - }, - }, - } as const; - - const expectedConfig = { - ...CONFIG_DEFAULTS, - ...expectedBaseNamespace, - namespaces: { - "": { - label: "", - ...expectedBaseNamespace, - }, - }, - userTypes: {}, - enums: {}, - enumValues: {}, - codegen: CODEGEN_DEFAULTS, - } as const; - - attest(config).equals(expectedConfig); - attest(expectedConfig); - }); - - it("should accept a user type as input and expand it", () => { - const config = defineWorld({ - tables: { Name: "CustomType" }, - userTypes: { CustomType: { type: "address", filePath: "path/to/file" } }, - }); - - const expectedBaseNamespace = { - namespace: "" as string, - tables: { - Name: { - label: "Name", - type: "table", - namespace: "" as string, - name: "Name" as string, - tableId: resourceToHex({ type: "table", namespace: "", name: "Name" }), - schema: { - id: { - type: "bytes32", - internalType: "bytes32", - }, - value: { - type: "address", - internalType: "CustomType", - }, - }, - key: ["id"], - codegen: { ...TABLE_CODEGEN_DEFAULTS, dataStruct: false as boolean }, - deploy: TABLE_DEPLOY_DEFAULTS, - }, - }, - } as const; - - const expectedConfig = { - ...CONFIG_DEFAULTS, - ...expectedBaseNamespace, - namespaces: { - "": { - label: "", - ...expectedBaseNamespace, - }, - }, - userTypes: { CustomType: { type: "address", filePath: "path/to/file" } }, - enums: {}, - enumValues: {}, - codegen: CODEGEN_DEFAULTS, - } as const; - - attest(config).equals(expectedConfig); - attest(expectedConfig); - }); - - it("given a schema with a key field with static ABI type, it should use `id` as single key", () => { - const config = defineWorld({ - tables: { Example: { id: "address", name: "string", age: "uint256" } }, - }); - - const expectedBaseNamespace = { - namespace: "" as string, - tables: { - Example: { - label: "Example", - type: "table", - namespace: "" as string, - name: "Example" as string, - tableId: resourceToHex({ type: "table", namespace: "", name: "Example" }), - schema: { - id: { - type: "address", - internalType: "address", - }, - name: { - type: "string", - internalType: "string", - }, - age: { - type: "uint256", - internalType: "uint256", - }, - }, - key: ["id"], - codegen: { ...TABLE_CODEGEN_DEFAULTS, dataStruct: true as boolean }, - deploy: TABLE_DEPLOY_DEFAULTS, - }, - }, - } as const; - - const expectedConfig = { - ...CONFIG_DEFAULTS, - ...expectedBaseNamespace, - namespaces: { - "": { - label: "", - ...expectedBaseNamespace, - }, - }, - userTypes: {}, - enums: {}, - enumValues: {}, - codegen: CODEGEN_DEFAULTS, - } as const; - - attest(config).equals(expectedConfig); - }); - - it("given a schema with a key field with static custom type, it should use `id` as single key", () => { - const config = defineWorld({ - tables: { Example: { id: "address", name: "string", age: "uint256" } }, - }); - - const expectedBaseNamespace = { - namespace: "" as string, - tables: { - Example: { - label: "Example", - type: "table", - namespace: "" as string, - name: "Example" as string, - tableId: resourceToHex({ type: "table", namespace: "", name: "Example" }), - schema: { - id: { - type: "address", - internalType: "address", - }, - name: { - type: "string", - internalType: "string", - }, - age: { - type: "uint256", - internalType: "uint256", - }, - }, - key: ["id"], - codegen: { ...TABLE_CODEGEN_DEFAULTS, dataStruct: true as boolean }, - deploy: TABLE_DEPLOY_DEFAULTS, - }, - }, - } as const; - - const expectedConfig = { - ...CONFIG_DEFAULTS, - ...expectedBaseNamespace, - namespaces: { - "": { - label: "", - ...expectedBaseNamespace, - }, - }, - userTypes: {}, - enums: {}, - enumValues: {}, - codegen: CODEGEN_DEFAULTS, - } as const; - - attest(config).equals(expectedConfig); - }); - - it("throw an error if the shorthand doesn't include a key field", () => { - attest(() => - defineWorld({ - tables: { - // @ts-expect-error Invalid schema. Expected an `id` field with a static ABI type or an explicit `key` option. - Example: { - name: "string", - age: "uint256", - }, - }, - }), - ).throwsAndHasTypeError( - "Invalid schema. Expected an `id` field with a static ABI type or an explicit `key` option.", - ); - }); - - it("throw an error if the shorthand config includes a non-static key field", () => { - attest(() => - // @ts-expect-error Invalid schema. Expected an `id` field with a static ABI type or an explicit `key` option. - defineWorld({ tables: { Example: { id: "string", name: "string", age: "uint256" } } }), - ).throwsAndHasTypeError( - "Invalid schema. Expected an `id` field with a static ABI type or an explicit `key` option.", - ); - }); - - it("throw an error if the shorthand config includes a non-static user type as key field", () => { - attest(() => - defineWorld({ - // @ts-expect-error Invalid schema. Expected an `id` field with a static ABI type or an explicit `key` option. - tables: { Example: { id: "dynamic", name: "string", age: "uint256" } }, - userTypes: { - dynamic: { type: "string", filePath: "path/to/file" }, - static: { type: "address", filePath: "path/to/file" }, - }, - }), - ).throwsAndHasTypeError( - "Invalid schema. Expected an `id` field with a static ABI type or an explicit `key` option.", - ); - }); - - it("should allow a const config as input", () => { - const config = { - tables: { - Example: { - schema: { id: "address", name: "string", age: "uint256" }, - key: ["age"], - }, - }, - } as const; - - defineWorld(config); - }); - - it.skip("should throw with an invalid namespace config option", () => { - attest(() => - defineWorld({ - // @ts-expect-error TODO - namespaces: { - ExampleNS: { - tables: { - ExampleTable: "number", - }, - }, - }, - }), - ).type.errors(`Type '"number"' is not assignable to type 'AbiType'.`); - }); - - it.skip("should throw with a non-existent namespace config option", () => { - attest(() => - defineWorld({ - // @ts-expect-error TODO - namespaces: { - ExampleNS: { - invalidProperty: true, - }, - }, - }), - ).type.errors("`invalidProperty` is not a valid namespace config option."); - }); - }); }); diff --git a/packages/world/ts/config/v2/world.ts b/packages/world/ts/config/v2/world.ts index bdb9c4420b..fb89a7f6e2 100644 --- a/packages/world/ts/config/v2/world.ts +++ b/packages/world/ts/config/v2/world.ts @@ -1,112 +1,127 @@ -import { ErrorMessage, conform, narrow, type withJsDoc } from "@arktype/util"; +import { ErrorMessage, conform, type withJsDoc } from "@arktype/util"; import { - UserTypes, extendedScope, get, - resolveTable, - validateTable, mergeIfUndefined, - validateTables, resolveStore, - Store, hasOwnKey, validateStore, - expandTableShorthand, + flattenNamespacedTables, + CONFIG_DEFAULTS as STORE_CONFIG_DEFAULTS, } from "@latticexyz/store/config/v2"; import { SystemsInput, WorldInput } from "./input"; import { CONFIG_DEFAULTS, MODULE_DEFAULTS } from "./defaults"; -import { Tables } from "@latticexyz/store/internal"; import { resolveSystems, validateSystems } from "./systems"; -import { resolveNamespacedTables, validateNamespaces } from "./namespaces"; +import { resolveNamespaces, validateNamespaces } from "./namespaces"; import { resolveCodegen } from "./codegen"; import { resolveDeploy } from "./deploy"; import type { World } from "./output.js"; +import { StoreInput } from "@latticexyz/store/config/v2"; -export type validateWorld = { - readonly [key in keyof world]: key extends "tables" - ? validateTables> - : key extends "userTypes" - ? UserTypes - : key extends "enums" - ? narrow - : key extends "systems" - ? validateSystems - : key extends "namespaces" - ? // ? validateNamespaces> - ErrorMessage<`Namespaces config will be enabled soon.`> - : key extends keyof WorldInput - ? conform - : ErrorMessage<`\`${key & string}\` is not a valid World config option.`>; +export type validateWorld = { + readonly [key in keyof input]: key extends "namespaces" + ? input extends { namespace?: unknown; tables?: unknown; systems?: unknown } + ? ErrorMessage<"Cannot use `namespaces` with `namespace`, `tables`, or `systems` keys."> + : validateNamespaces> + : key extends "systems" + ? validateSystems + : key extends "codegen" + ? conform + : key extends keyof StoreInput + ? validateStore[key] + : key extends keyof WorldInput + ? conform + : ErrorMessage<`\`${key & string}\` is not a valid World config option.`>; }; -export function validateWorld(world: unknown): asserts world is WorldInput { - const scope = extendedScope(world); - validateStore(world); +export function validateWorld(input: unknown): asserts input is WorldInput { + const scope = extendedScope(input); - if (hasOwnKey(world, "systems")) { - validateSystems(world.systems); + if (hasOwnKey(input, "namespaces")) { + if (hasOwnKey(input, "namespace") || hasOwnKey(input, "tables") || hasOwnKey(input, "systems")) { + throw new Error("Cannot use `namespaces` with `namespace`, `tables`, or `systems` keys."); + } + validateNamespaces(input.namespaces, scope); } - if (hasOwnKey(world, "namespaces")) { - validateNamespaces(world.namespaces, scope); + if (hasOwnKey(input, "systems")) { + validateSystems(input.systems); } + + validateStore(input); } -export type resolveWorld["namespace"]> = withJsDoc< - resolveStore & - mergeIfUndefined< - { readonly tables: resolveNamespacedTables } & { - [key in Exclude]: key extends "systems" - ? resolveSystems - : key extends "deploy" - ? resolveDeploy - : key extends "codegen" - ? resolveCodegen - : world[key]; - }, - CONFIG_DEFAULTS - >, - World ->; +type resolveNamespaceMode = "namespaces" extends keyof input + ? { + readonly multipleNamespaces: true; + readonly namespace: null; + readonly namespaces: resolveNamespaces>; + } + : { + readonly multipleNamespaces: false; + readonly namespace: string; + readonly namespaces: resolveNamespaces< + { + // TODO: improve this so we don't have to duplicate store behavior + readonly [label in "namespace" extends keyof input + ? input["namespace"] extends string + ? input["namespace"] + : STORE_CONFIG_DEFAULTS["namespace"] + : STORE_CONFIG_DEFAULTS["namespace"]]: input; + }, + extendedScope + >; + }; + +export type resolveWorld = resolveNamespaceMode & + Omit, "multipleNamespaces" | "namespace" | "namespaces" | "tables"> & { + readonly tables: flattenNamespacedTables>; + // TODO: flatten systems from namespaces + readonly systems: "systems" extends keyof input + ? input["systems"] extends SystemsInput + ? resolveNamespaceMode["namespace"] extends string + ? resolveSystems["namespace"]> + : {} + : {} + : {}; + readonly excludeSystems: "excludeSystems" extends keyof input + ? input["excludeSystems"] + : CONFIG_DEFAULTS["excludeSystems"]; + readonly modules: "modules" extends keyof input ? input["modules"] : CONFIG_DEFAULTS["modules"]; + readonly codegen: "codegen" extends keyof input ? resolveCodegen : resolveCodegen<{}>; + readonly deploy: "deploy" extends keyof input ? resolveDeploy : resolveDeploy<{}>; + }; -export function resolveWorld(world: world): resolveWorld { - const scope = extendedScope(world); - const resolvedStore = resolveStore(world); - const namespaces = world.namespaces ?? {}; +export function resolveWorld(input: input): resolveWorld { + const scope = extendedScope(input); + const store = resolveStore(input); - const resolvedNamespacedTables = Object.fromEntries( - Object.entries(namespaces) - .map(([namespaceKey, namespace]) => - Object.entries(namespace.tables ?? {}).map(([tableKey, table]) => { - validateTable(table, scope); - return [ - `${namespaceKey}__${tableKey}`, - resolveTable( - mergeIfUndefined(expandTableShorthand(table, scope), { namespace: namespaceKey, label: tableKey }), - scope, - ), - ]; - }), - ) - .flat(), - ) as Tables; + const namespaces = input.namespaces + ? resolveNamespaces(input.namespaces, scope) + : resolveNamespaces({ [store.namespace!]: input }, scope); - const modules = (world.modules ?? CONFIG_DEFAULTS.modules).map((mod) => mergeIfUndefined(mod, MODULE_DEFAULTS)); + const tables = flattenNamespacedTables({ namespaces }); + const modules = (input.modules ?? CONFIG_DEFAULTS.modules).map((mod) => mergeIfUndefined(mod, MODULE_DEFAULTS)); return mergeIfUndefined( { - ...resolvedStore, - tables: { ...resolvedStore.tables, ...resolvedNamespacedTables }, - codegen: mergeIfUndefined(resolvedStore.codegen, resolveCodegen(world.codegen)), - deploy: resolveDeploy(world.deploy), - systems: resolveSystems(world.systems ?? CONFIG_DEFAULTS.systems, resolvedStore.namespace), - excludeSystems: get(world, "excludeSystems"), + ...store, + namespaces, + tables, + // TODO: flatten systems from namespaces + systems: + !store.multipleNamespaces && input.systems + ? resolveSystems(input.systems, store.namespace) + : CONFIG_DEFAULTS.systems, + excludeSystems: get(input, "excludeSystems"), + codegen: mergeIfUndefined(store.codegen, resolveCodegen(input.codegen)), + deploy: resolveDeploy(input.deploy), modules, }, CONFIG_DEFAULTS, ) as never; } -export function defineWorld(input: validateWorld): resolveWorld { +export function defineWorld(input: validateWorld): withJsDoc, World> { validateWorld(input); return resolveWorld(input) as never; } diff --git a/packages/world/ts/node/getSystemContracts.ts b/packages/world/ts/node/getSystemContracts.ts index 8b9e12c497..76aff24a4d 100644 --- a/packages/world/ts/node/getSystemContracts.ts +++ b/packages/world/ts/node/getSystemContracts.ts @@ -17,8 +17,6 @@ export async function getSystemContracts({ rootDir, config, }: GetSystemContractsOptions): Promise { - // TODO: get this value from config once multiple namespaces are supported - const multipleNamespaces = false; const solidityFiles = await findSolidityFiles({ cwd: rootDir, pattern: path.join(config.sourceDirectory, "**"), @@ -35,7 +33,8 @@ export async function getSystemContracts({ ) .map((file) => { const namespaceLabel = (() => { - if (!multipleNamespaces) return config.namespace; + // TODO: remove `config.namespace` null check once this narrows properly + if (!config.multipleNamespaces && config.namespace != null) return config.namespace; const relativePath = path.relative(path.join(rootDir, config.sourceDirectory), file.filename); const [namespacesDir, namespaceDir] = relativePath.split(path.sep); diff --git a/packages/world/ts/node/resolveSystems.ts b/packages/world/ts/node/resolveSystems.ts index 853590fd48..eada4b459f 100644 --- a/packages/world/ts/node/resolveSystems.ts +++ b/packages/world/ts/node/resolveSystems.ts @@ -1,8 +1,8 @@ import { isHex } from "viem"; import { getSystemContracts } from "./getSystemContracts"; import { System, World } from "../config/v2"; -import { resolveSystem } from "../config/v2/system"; -import { resolveNamespace } from "@latticexyz/store/config/v2"; +import { resolveNamespace } from "../config/v2/namespace"; +import { resourceToLabel } from "@latticexyz/common"; export type ResolvedSystem = System & { readonly sourcePath: string; @@ -16,23 +16,34 @@ export async function resolveSystems({ config: World; }): Promise { const systemContracts = await getSystemContracts({ rootDir, config }); - const contractNames = systemContracts.map((contract) => contract.systemLabel); // validate every system in config refers to an existing system contract - const missingSystems = Object.keys(config.systems).filter((systemLabel) => !contractNames.includes(systemLabel)); + const configSystems = Object.values(config.namespaces).flatMap((namespace) => + Object.values(namespace.systems).map((system) => ({ + ...system, + // TODO: remove this once config outputs namespace labels of resources + namespaceLabel: namespace.label, + })), + ); + const missingSystems = configSystems.filter( + (system) => !systemContracts.some((s) => s.namespaceLabel === system.namespace && s.systemLabel === system.label), + ); if (missingSystems.length > 0) { - throw new Error(`Found systems in config with no corresponding system contract: ${missingSystems.join(", ")}`); + throw new Error( + `Found systems in config with no corresponding system contract: ${missingSystems.map(resourceToLabel).join(", ")}`, + ); } const systems = systemContracts .map((contract): ResolvedSystem => { const systemConfig = - config.systems[contract.systemLabel] ?? - // TODO: move this to `resolveNamespace({ systems: ... })` - resolveSystem({ - label: contract.systemLabel, - namespace: resolveNamespace({ label: contract.namespaceLabel }).namespace, - }); + config.namespaces[contract.namespaceLabel].systems[contract.systemLabel] ?? + resolveNamespace({ + label: contract.namespaceLabel, + systems: { + [contract.systemLabel]: {}, + }, + }).systems[contract.systemLabel]; return { ...systemConfig, sourcePath: contract.sourcePath, diff --git a/packages/world/ts/scripts/build.ts b/packages/world/ts/scripts/build.ts index 75e5630967..76ce1e5e6a 100644 --- a/packages/world/ts/scripts/build.ts +++ b/packages/world/ts/scripts/build.ts @@ -1,8 +1,9 @@ import path from "node:path"; -import { loadConfig, resolveConfigPath } from "@latticexyz/config/node"; +import { resolveConfigPath } from "@latticexyz/config/node"; import { tablegen } from "@latticexyz/store/codegen"; -import { World } from "../config/v2"; +import { defineWorld } from "../config/v2"; import { worldgen } from "../node"; +import config, { configInput } from "../../mud.config"; /** * To avoid circular dependencies, we run a very similar `build` step as `cli` package here. @@ -13,16 +14,12 @@ import { worldgen } from "../node"; const configPath = await resolveConfigPath(); const rootDir = path.dirname(configPath); -const config = (await loadConfig(configPath)) as World; await Promise.all([ tablegen({ rootDir, config }), worldgen({ rootDir, - config: { - ...config, - // override the namespace to be the root namespace for generating the core system interface - namespace: "", - }, + // use root namespace to generate the core system interfaces + config: defineWorld({ ...configInput, namespace: "" }), }), ]);