From f6b147368db904d6cbe6087dc22623bfaf16d666 Mon Sep 17 00:00:00 2001 From: alvarius Date: Tue, 19 Mar 2024 12:17:40 +0000 Subject: [PATCH] refactor(store, world): separate shorthand config from full config resolvers and cleanup (#2464) --- packages/store/ts/config/v2/README.md | 47 ++ packages/store/ts/config/v2/codegen.ts | 12 + packages/store/ts/config/v2/compat.test.ts | 16 +- packages/store/ts/config/v2/compat.ts | 42 +- packages/store/ts/config/v2/defaults.ts | 1 + packages/store/ts/config/v2/enums.ts | 29 + packages/store/ts/config/v2/generics.ts | 23 + packages/store/ts/config/v2/index.ts | 7 +- packages/store/ts/config/v2/input.ts | 43 ++ packages/store/ts/config/v2/output.ts | 10 +- packages/store/ts/config/v2/schema.test.ts | 8 +- packages/store/ts/config/v2/schema.ts | 70 ++- packages/store/ts/config/v2/scope.test.ts | 4 +- packages/store/ts/config/v2/scope.ts | 16 +- packages/store/ts/config/v2/store.test.ts | 364 +++---------- packages/store/ts/config/v2/store.ts | 211 ++----- .../ts/config/v2/storeWithShorthands.test.ts | 276 ++++++++++ .../store/ts/config/v2/storeWithShorthands.ts | 49 ++ packages/store/ts/config/v2/table.test.ts | 358 +++--------- packages/store/ts/config/v2/table.ts | 265 ++++++--- packages/store/ts/config/v2/tableFull.test.ts | 43 -- packages/store/ts/config/v2/tableFull.ts | 228 -------- .../store/ts/config/v2/tableShorthand.test.ts | 24 +- packages/store/ts/config/v2/tableShorthand.ts | 149 +++-- packages/store/ts/config/v2/tables.ts | 45 ++ packages/store/ts/config/v2/userTypes.ts | 43 ++ packages/world/ts/config/v2/codegen.ts | 12 + packages/world/ts/config/v2/compat.test.ts | 16 +- packages/world/ts/config/v2/compat.ts | 61 +-- packages/world/ts/config/v2/defaults.ts | 3 + packages/world/ts/config/v2/deployment.ts | 12 + packages/world/ts/config/v2/input.ts | 39 +- packages/world/ts/config/v2/namespaces.ts | 57 ++ packages/world/ts/config/v2/output.ts | 38 +- packages/world/ts/config/v2/systems.ts | 12 + packages/world/ts/config/v2/world.test.ts | 514 +++--------------- packages/world/ts/config/v2/world.ts | 296 ++++------ .../ts/config/v2/worldWithShorthands.test.ts | 398 ++++++++++++++ .../world/ts/config/v2/worldWithShorthands.ts | 89 +++ test/mock-game-contracts/mud.config.ts | 4 +- 40 files changed, 1994 insertions(+), 1940 deletions(-) create mode 100644 packages/store/ts/config/v2/README.md create mode 100644 packages/store/ts/config/v2/codegen.ts create mode 100644 packages/store/ts/config/v2/enums.ts create mode 100644 packages/store/ts/config/v2/input.ts create mode 100644 packages/store/ts/config/v2/storeWithShorthands.test.ts create mode 100644 packages/store/ts/config/v2/storeWithShorthands.ts delete mode 100644 packages/store/ts/config/v2/tableFull.test.ts delete mode 100644 packages/store/ts/config/v2/tableFull.ts create mode 100644 packages/store/ts/config/v2/tables.ts create mode 100644 packages/store/ts/config/v2/userTypes.ts create mode 100644 packages/world/ts/config/v2/codegen.ts create mode 100644 packages/world/ts/config/v2/deployment.ts create mode 100644 packages/world/ts/config/v2/namespaces.ts create mode 100644 packages/world/ts/config/v2/systems.ts create mode 100644 packages/world/ts/config/v2/worldWithShorthands.test.ts create mode 100644 packages/world/ts/config/v2/worldWithShorthands.ts diff --git a/packages/store/ts/config/v2/README.md b/packages/store/ts/config/v2/README.md new file mode 100644 index 0000000000..5714091cd9 --- /dev/null +++ b/packages/store/ts/config/v2/README.md @@ -0,0 +1,47 @@ +# Config conventions + +These are the types and functions that should be placed in a file called `x.ts` + +```ts +/** + * validateX returns input if the input is valid and expected type otherwise. + * This makes it such that there are fine grained (and custom) type errors on the input of defineX. + */ +type validateX = { [key in keyof x]: x[key] extends Expected ? x[key] : Expected }; + +/** + * validateX function throws a runtime error if x is not X and has a type assertion + */ +function validateX(x: unknonw): asserts x is X { + // +} + +/** + * resolveX expects a valid input type and maps it to the resolved output type + */ +type resolveX = x extends X ? { [key in keyof x]: Resolved } : never; + +/** + * defineX function validates the input types and calls resolveX to resolve it. + * Note: the runtime validation happens in `resolveX`. + * This is to take advantage of the type assertion in the function body. + */ +function defineX(x: validateX): resolveX { + return resolveX(x); +} + +/** + * resolveX function does not validate the input type, but validates the runtime types. + * (This is to take advantage of the type assertion in the function body). + * This function is used by defineX and other higher level resolution functions. + */ +function resolveX(x: x): resolveX { + validateX(x); + // +} +``` + +There are two files that fall out of this patten: `input.ts` and `output.ts`: + +- `input.ts` includes the flattened input types. They are supposed to be broad and not include all constraints. The stronger constraints are implemented in the `validateX` helpers. +- `output.ts` includes the flattened output types. They are supposed to be broad so downstream consumers can use them as input types for working with the config, and strongly typed config outputs will be assignable to them. diff --git a/packages/store/ts/config/v2/codegen.ts b/packages/store/ts/config/v2/codegen.ts new file mode 100644 index 0000000000..9d783ae435 --- /dev/null +++ b/packages/store/ts/config/v2/codegen.ts @@ -0,0 +1,12 @@ +import { CODEGEN_DEFAULTS } from "./defaults"; +import { isObject, mergeIfUndefined } from "./generics"; + +export type resolveCodegen = codegen extends {} + ? mergeIfUndefined + : typeof CODEGEN_DEFAULTS; + +export function resolveCodegen(codegen: codegen): resolveCodegen { + return ( + isObject(codegen) ? mergeIfUndefined(codegen, CODEGEN_DEFAULTS) : CODEGEN_DEFAULTS + ) as resolveCodegen; +} diff --git a/packages/store/ts/config/v2/compat.test.ts b/packages/store/ts/config/v2/compat.test.ts index b8d8d82ad7..d4437fd483 100644 --- a/packages/store/ts/config/v2/compat.test.ts +++ b/packages/store/ts/config/v2/compat.test.ts @@ -1,15 +1,15 @@ import { describe, it } from "vitest"; -import { resolveStoreConfig } from "./store"; +import { defineStore } from "./store"; import { attest } from "@arktype/attest"; import { StoreConfig as StoreConfigV1 } from "../storeConfig"; import { mudConfig } from "../../register"; -import { configToV1 } from "./compat"; -import { Config } from "./output"; +import { storeToV1 } from "./compat"; +import { Store } from "./output"; describe("configToV1", () => { it("should transform the broad v2 output to the broad v1 output", () => { - attest>(); - attest, StoreConfigV1>(); + attest>(); + attest, StoreConfigV1>(); }); it("should transform a v2 store config output to the v1 config output", () => { @@ -53,7 +53,7 @@ describe("configToV1", () => { }, }) satisfies StoreConfigV1; - const configV2 = resolveStoreConfig({ + const configV2 = defineStore({ enums: { TerrainType: ["None", "Ocean", "Grassland", "Desert"], }, @@ -87,7 +87,7 @@ describe("configToV1", () => { }, }); - attest(configToV1(configV2)).equals(configV1); - attest>(configV1); + attest(storeToV1(configV2)).equals(configV1); + attest>(configV1); }); }); diff --git a/packages/store/ts/config/v2/compat.ts b/packages/store/ts/config/v2/compat.ts index 352ee8f5a2..b563e9ddf5 100644 --- a/packages/store/ts/config/v2/compat.ts +++ b/packages/store/ts/config/v2/compat.ts @@ -1,22 +1,22 @@ import { conform } from "@arktype/util"; -import { Config, Table } from "./output"; +import { Store, Table } from "./output"; import { mapObject } from "@latticexyz/common/utils"; -export type configToV1 = config extends Config +export type storeToV1 = store extends Store ? { - namespace: config["namespace"]; - enums: { [key in keyof config["enums"]]: string[] }; + namespace: store["namespace"]; + enums: { [key in keyof store["enums"]]: string[] }; userTypes: { - [key in keyof config["userTypes"]]: { - internalType: config["userTypes"][key]["type"]; + [key in keyof store["userTypes"]]: { + internalType: store["userTypes"][key]["type"]; filePath: string; }; }; - storeImportPath: config["codegen"]["storeImportPath"]; - userTypesFilename: config["codegen"]["userTypesFilename"]; - codegenDirectory: config["codegen"]["codegenDirectory"]; - codegenIndexFilename: config["codegen"]["codegenIndexFilename"]; - tables: { [key in keyof config["tables"]]: tableToV1 }; + storeImportPath: store["codegen"]["storeImportPath"]; + userTypesFilename: store["codegen"]["userTypesFilename"]; + codegenDirectory: store["codegen"]["codegenDirectory"]; + codegenIndexFilename: store["codegen"]["codegenIndexFilename"]; + tables: { [key in keyof store["tables"]]: tableToV1 }; } : never; @@ -31,13 +31,13 @@ export type tableToV1 = { name: table["name"]; }; -export function configToV1(config: conform): configToV1 { - const resolvedUserTypes = mapObject(config.userTypes, ({ type, filePath }) => ({ +export function storeToV1(store: conform): storeToV1 { + const resolvedUserTypes = mapObject(store.userTypes, ({ type, filePath }) => ({ internalType: type, filePath, })); - const resolvedTables = mapObject(config.tables, (table) => ({ + const resolvedTables = mapObject(store.tables, (table) => ({ directory: table.codegen.directory, dataStruct: table.codegen.dataStruct, tableIdArgument: table.codegen.tableIdArgument, @@ -49,13 +49,13 @@ export function configToV1(config: conform): configToV1< })); return { - namespace: config.namespace, - enums: config.enums, + namespace: store.namespace, + enums: store.enums, userTypes: resolvedUserTypes, - storeImportPath: config.codegen.storeImportPath, - userTypesFilename: config.codegen.userTypesFilename, - codegenDirectory: config.codegen.codegenDirectory, - codegenIndexFilename: config.codegen.codegenIndexFilename, + storeImportPath: store.codegen.storeImportPath, + userTypesFilename: store.codegen.userTypesFilename, + codegenDirectory: store.codegen.codegenDirectory, + codegenIndexFilename: store.codegen.codegenIndexFilename, tables: resolvedTables, - } as unknown as configToV1; + } as unknown as storeToV1; } diff --git a/packages/store/ts/config/v2/defaults.ts b/packages/store/ts/config/v2/defaults.ts index 56705d3949..55ee159bf7 100644 --- a/packages/store/ts/config/v2/defaults.ts +++ b/packages/store/ts/config/v2/defaults.ts @@ -12,6 +12,7 @@ export const TABLE_CODEGEN_DEFAULTS = { } as const; export const TABLE_DEFAULTS = { + namespace: "", type: "table", } as const; diff --git a/packages/store/ts/config/v2/enums.ts b/packages/store/ts/config/v2/enums.ts new file mode 100644 index 0000000000..ae75886324 --- /dev/null +++ b/packages/store/ts/config/v2/enums.ts @@ -0,0 +1,29 @@ +import { Enums } from "./output"; +import { AbiTypeScope, extendScope } from "./scope"; + +function isEnums(enums: unknown): enums is Enums { + return ( + typeof enums === "object" && + enums != null && + Object.values(enums).every((item) => Array.isArray(item) && item.every((element) => typeof element === "string")) + ); +} + +export type scopeWithEnums = Enums extends enums + ? scope + : enums extends Enums + ? extendScope + : scope; + +export function scopeWithEnums( + enums: enums, + scope: scope = AbiTypeScope as scope, +): scopeWithEnums { + if (isEnums(enums)) { + const enumScope = Object.fromEntries(Object.keys(enums).map((key) => [key, "uint8" as const])); + return extendScope(scope, enumScope) as scopeWithEnums; + } + return scope as scopeWithEnums; +} + +export type resolveEnums = { readonly [key in keyof enums]: Readonly }; diff --git a/packages/store/ts/config/v2/generics.ts b/packages/store/ts/config/v2/generics.ts index ebad717821..312525506d 100644 --- a/packages/store/ts/config/v2/generics.ts +++ b/packages/store/ts/config/v2/generics.ts @@ -1,3 +1,5 @@ +import { merge } from "@arktype/util"; + export type get = key extends keyof input ? input[key] : undefined; export function get(input: input, key: key): get { @@ -18,3 +20,24 @@ export function hasOwnKey( export function isObject(input: input): input is input & object { return input != null && typeof input === "object"; } + +export type mergeIfUndefined = merge< + base, + { + [key in keyof merged]: key extends keyof base + ? undefined extends base[key] + ? merged[key] + : base[key] + : merged[key]; + } +>; + +export function mergeIfUndefined( + base: base, + merged: merged, +): mergeIfUndefined { + const allKeys = [...new Set([...Object.keys(base), ...Object.keys(merged)])]; + return Object.fromEntries( + allKeys.map((key) => [key, base[key as keyof base] ?? merged[key as keyof merged]]), + ) as mergeIfUndefined; +} diff --git a/packages/store/ts/config/v2/index.ts b/packages/store/ts/config/v2/index.ts index 35c94ac6ee..083e2d2f23 100644 --- a/packages/store/ts/config/v2/index.ts +++ b/packages/store/ts/config/v2/index.ts @@ -2,9 +2,14 @@ export * from "./generics"; export * from "./scope"; export * from "./schema"; export * from "./tableShorthand"; -export * from "./tableFull"; +export * from "./storeWithShorthands"; export * from "./table"; +export * from "./tables"; export * from "./store"; +export * from "./input"; export * from "./output"; export * from "./defaults"; export * from "./compat"; +export * from "./codegen"; +export * from "./enums"; +export * from "./userTypes"; diff --git a/packages/store/ts/config/v2/input.ts b/packages/store/ts/config/v2/input.ts new file mode 100644 index 0000000000..f74d583564 --- /dev/null +++ b/packages/store/ts/config/v2/input.ts @@ -0,0 +1,43 @@ +import { Hex } from "viem"; +import { Codegen, Enums, TableCodegen, UserTypes } from "./output"; +import { Scope } from "./scope"; + +export type SchemaInput = { + readonly [key: string]: string; +}; + +export type ScopedSchemaInput = { + readonly [key: string]: keyof scope["types"]; +}; + +export type TableInput = { + readonly schema: SchemaInput; + readonly key: readonly string[]; + readonly tableId?: Hex; + readonly name: string; + readonly namespace?: string; + readonly type?: "table" | "offchainTable"; + readonly codegen?: Partial; +}; + +export type TablesInput = { + readonly [key: string]: TableInput; +}; + +export type StoreInput = { + readonly namespace?: string; + readonly tables: TablesInput; + readonly userTypes?: UserTypes; + readonly enums?: Enums; + readonly codegen?: Partial; +}; + +/******** Variations with shorthands ********/ + +export type TableShorthandInput = SchemaInput | string; + +export type TablesWithShorthandsInput = { + [key: string]: TableInput | TableShorthandInput; +}; + +export type StoreWithShorthandsInput = Omit & { tables: TablesWithShorthandsInput }; diff --git a/packages/store/ts/config/v2/output.ts b/packages/store/ts/config/v2/output.ts index 9b770459ae..f8a83ba812 100644 --- a/packages/store/ts/config/v2/output.ts +++ b/packages/store/ts/config/v2/output.ts @@ -19,7 +19,7 @@ export type KeySchema = { }; }; -export type TableCodegenOptions = { +export type TableCodegen = { readonly directory: string; readonly tableIdArgument: boolean; readonly storeArgument: boolean; @@ -29,22 +29,22 @@ export type TableCodegenOptions = { export type Table = BaseTable & { readonly keySchema: KeySchema; readonly valueSchema: Schema; - readonly codegen: TableCodegenOptions; + readonly codegen: TableCodegen; }; -export type CodegenOptions = { +export type Codegen = { readonly storeImportPath: string; readonly userTypesFilename: string; readonly codegenDirectory: string; readonly codegenIndexFilename: string; }; -export type Config = { +export type Store = { readonly tables: { readonly [namespacedTableName: string]: Table; }; readonly userTypes: UserTypes; readonly enums: Enums; readonly namespace: string; - readonly codegen: CodegenOptions; + readonly codegen: Codegen; }; diff --git a/packages/store/ts/config/v2/schema.test.ts b/packages/store/ts/config/v2/schema.test.ts index 7c3c1228cb..deb3d7da7a 100644 --- a/packages/store/ts/config/v2/schema.test.ts +++ b/packages/store/ts/config/v2/schema.test.ts @@ -1,5 +1,5 @@ import { describe, it } from "vitest"; -import { resolveSchema } from "./schema"; +import { defineSchema } from "./schema"; import { Schema } from "./output"; import { extendScope, AbiTypeScope } from "./scope"; import { attest } from "@arktype/attest"; @@ -7,7 +7,7 @@ import { attest } from "@arktype/attest"; describe("resolveSchema", () => { it("should map user types to their primitive type", () => { const scope = extendScope(AbiTypeScope, { CustomType: "address" }); - const resolved = resolveSchema({ regular: "uint256", user: "CustomType" }, scope); + const resolved = defineSchema({ regular: "uint256", user: "CustomType" }, scope); const expected = { regular: { type: "uint256", @@ -29,7 +29,7 @@ describe("resolveSchema", () => { it("should throw if a type is not part of the scope", () => { const scope = extendScope(AbiTypeScope, { CustomType: "address" }); attest(() => - resolveSchema( + defineSchema( { regular: "uint256", // @ts-expect-error Type '"NotACustomType"' is not assignable to type 'AbiType | "CustomType"'. @@ -44,7 +44,7 @@ describe("resolveSchema", () => { it("should extend the output Schema type", () => { const scope = extendScope(AbiTypeScope, { CustomType: "address" }); - const resolved = resolveSchema({ regular: "uint256", user: "CustomType" }, scope); + const resolved = defineSchema({ regular: "uint256", user: "CustomType" }, scope); attest(); }); }); diff --git a/packages/store/ts/config/v2/schema.ts b/packages/store/ts/config/v2/schema.ts index aa0a7a5693..a272f5b857 100644 --- a/packages/store/ts/config/v2/schema.ts +++ b/packages/store/ts/config/v2/schema.ts @@ -1,43 +1,63 @@ -import { evaluate } from "@arktype/util"; -import { AbiTypeScope } from "./scope"; -import { hasOwnKey } from "./generics"; +import { conform, evaluate } from "@arktype/util"; +import { AbiTypeScope, Scope } from "./scope"; +import { hasOwnKey, isObject } from "./generics"; +import { SchemaInput } from "./input"; -export type SchemaInput = { - [key: string]: keyof scope["types"]; +export type validateSchema = { + [key in keyof schema]: conform; }; -export type resolveSchema, scope extends AbiTypeScope> = evaluate<{ +export function validateSchema( + schema: unknown, + scope: scope = AbiTypeScope as unknown as scope, +): asserts schema is SchemaInput { + if (!isObject(schema)) { + throw new Error(`Expected schema, received ${JSON.stringify(schema)}`); + } + + for (const internalType of Object.values(schema)) { + if (!hasOwnKey(scope.types, internalType)) { + throw new Error(`"${String(internalType)}" is not a valid type in this scope.`); + } + } +} + +export type resolveSchema = evaluate<{ readonly [key in keyof schema]: { /** the Solidity primitive ABI type */ - readonly type: scope["types"][schema[key]]; + readonly type: scope["types"][schema[key] & keyof scope["types"]]; /** the user defined type or Solidity primitive ABI type */ readonly internalType: schema[key]; }; }>; -export function resolveSchema, scope extends AbiTypeScope = AbiTypeScope>( +export function resolveSchema( schema: schema, - scope: scope = AbiTypeScope as scope, + scope: scope = AbiTypeScope as unknown as scope, ): resolveSchema { + validateSchema(schema, scope); + return Object.fromEntries( - Object.entries(schema).map(([key, internalType]) => { - if (hasOwnKey(scope.types, internalType)) { - return [ - key, - { - type: scope.types[internalType], - internalType, - }, - ]; - } - throw new Error(`"${String(internalType)}" is not a valid type in this scope.`); - }), - ) as resolveSchema; + Object.entries(schema).map(([key, internalType]) => [ + key, + { + type: scope.types[internalType as keyof typeof scope.types], + internalType, + }, + ]), + ) as unknown as resolveSchema; } -export function isSchemaInput( - input: unknown, +export function defineSchema( + schema: validateSchema, scope: scope = AbiTypeScope as scope, -): input is SchemaInput { +): resolveSchema { + return resolveSchema(schema, scope) as resolveSchema; +} + +export function isSchemaInput( + input: unknown, + scope: scope = AbiTypeScope as unknown as scope, +): input is SchemaInput { return typeof input === "object" && input != null && Object.values(input).every((key) => hasOwnKey(scope.types, key)); } diff --git a/packages/store/ts/config/v2/scope.test.ts b/packages/store/ts/config/v2/scope.test.ts index f2894e0944..eb7705b6eb 100644 --- a/packages/store/ts/config/v2/scope.test.ts +++ b/packages/store/ts/config/v2/scope.test.ts @@ -1,10 +1,10 @@ import { attest } from "@arktype/attest"; import { describe, it } from "vitest"; -import { AbiTypeScope, EmptyScope, ScopeOptions, extendScope, getStaticAbiTypeKeys } from "./scope"; +import { AbiTypeScope, Scope, ScopeOptions, extendScope, getStaticAbiTypeKeys } from "./scope"; describe("extendScope", () => { it("should extend the provided scope", () => { - const extendedScope = extendScope(EmptyScope, { static: "uint256", dynamic: "string" }); + const extendedScope = extendScope(Scope, { static: "uint256", dynamic: "string" }); attest>(extendedScope).type.toString.snap( '{ types: { static: "uint256"; dynamic: "string"; }; }', ); diff --git a/packages/store/ts/config/v2/scope.ts b/packages/store/ts/config/v2/scope.ts index 7343ee6a36..ec1ba28478 100644 --- a/packages/store/ts/config/v2/scope.ts +++ b/packages/store/ts/config/v2/scope.ts @@ -1,10 +1,10 @@ import { Dict, evaluate } from "@arktype/util"; -import { SchemaInput } from "./schema"; +import { SchemaInput } from "./input"; import { StaticAbiType, schemaAbiTypes } from "@latticexyz/schema-type/internal"; import { AbiType } from "./output"; -export const EmptyScope = { types: {} } as const satisfies ScopeOptions; -export type EmptyScope = typeof EmptyScope; +export const Scope = { types: {} } as const satisfies ScopeOptions; +export type Scope = typeof Scope; export type AbiTypeScope = ScopeOptions<{ [t in AbiType]: t }>; export const AbiTypeScope = { @@ -16,13 +16,13 @@ export type ScopeOptions = Dict, - scope extends AbiTypeScope = AbiTypeScope, -> = SchemaInput extends types + schema extends SchemaInput, + scope extends Scope = AbiTypeScope, +> = SchemaInput extends schema ? string : { - [key in keyof types]: scope["types"][types[key]] extends StaticAbiType ? key : never; - }[keyof types]; + [key in keyof schema]: scope["types"][schema[key] & keyof scope["types"]] extends StaticAbiType ? key : never; + }[keyof schema]; export type extendScope> = evaluate< ScopeOptions> diff --git a/packages/store/ts/config/v2/store.test.ts b/packages/store/ts/config/v2/store.test.ts index 68b6baba70..99ae22140d 100644 --- a/packages/store/ts/config/v2/store.test.ts +++ b/packages/store/ts/config/v2/store.test.ts @@ -1,104 +1,21 @@ import { describe, it } from "vitest"; -import { resolveStoreConfig } from "./store"; +import { defineStore } from "./store"; import { Config } from "./output"; import { attest } from "@arktype/attest"; import { resourceToHex } from "@latticexyz/common"; import { CODEGEN_DEFAULTS, TABLE_CODEGEN_DEFAULTS } from "./defaults"; -describe("resolveStoreConfig", () => { - it("should accept a shorthand store config as input and expand it", () => { - const config = resolveStoreConfig({ tables: { Name: "address" } }); - const expected = { +describe("defineStore", () => { + it("should return the full config given a full config with one key", () => { + const config = defineStore({ tables: { - Name: { - tableId: resourceToHex({ type: "table", namespace: "", name: "Name" }), - schema: { - id: { - type: "bytes32", - internalType: "bytes32", - }, - value: { - type: "address", - internalType: "address", - }, - }, - keySchema: { - id: { - type: "bytes32", - internalType: "bytes32", - }, - }, - valueSchema: { - value: { - type: "address", - internalType: "address", - }, - }, - key: ["id"], - name: "Name", - namespace: "", - codegen: { ...TABLE_CODEGEN_DEFAULTS, dataStruct: false as boolean }, - type: "table", + Example: { + schema: { id: "address", name: "string", age: "uint256" }, + key: ["age"], }, }, - userTypes: {}, - enums: {}, - namespace: "", - codegen: CODEGEN_DEFAULTS, - } as const; - - attest(config).equals(expected); - }); - - it("should accept a user type as input and expand it", () => { - const config = resolveStoreConfig({ - tables: { Name: "CustomType" }, - userTypes: { CustomType: { type: "address", filePath: "path/to/file" } }, }); - const expected = { - tables: { - Name: { - tableId: resourceToHex({ type: "table", namespace: "", name: "Name" }), - schema: { - id: { - type: "bytes32", - internalType: "bytes32", - }, - value: { - type: "address", - internalType: "CustomType", - }, - }, - keySchema: { - id: { - type: "bytes32", - internalType: "bytes32", - }, - }, - valueSchema: { - value: { - type: "address", - internalType: "CustomType", - }, - }, - key: ["id"], - name: "Name", - namespace: "", - codegen: { ...TABLE_CODEGEN_DEFAULTS, dataStruct: false as boolean }, - type: "table", - }, - }, - userTypes: { CustomType: { type: "address", filePath: "path/to/file" } }, - enums: {}, - namespace: "", - codegen: CODEGEN_DEFAULTS, - } as const; - - attest(config).equals(expected); - }); - it("given a schema with a key field with static ABI type, it should use `id` as single key", () => { - const config = resolveStoreConfig({ tables: { Example: { id: "address", name: "string", age: "uint256" } } }); const expected = { tables: { Example: { @@ -118,74 +35,22 @@ describe("resolveStoreConfig", () => { }, }, keySchema: { - id: { - type: "address", - internalType: "address", - }, - }, - valueSchema: { - name: { - type: "string", - internalType: "string", - }, age: { type: "uint256", internalType: "uint256", }, }, - key: ["id"], - name: "Example", - namespace: "", - codegen: { ...TABLE_CODEGEN_DEFAULTS, dataStruct: true as boolean }, - type: "table", - }, - }, - userTypes: {}, - enums: {}, - namespace: "", - codegen: CODEGEN_DEFAULTS, - } as const; - - attest(config).equals(expected); - }); - - it("given a schema with a key field with static custom type, it should use `id` as single key", () => { - const config = resolveStoreConfig({ tables: { Example: { id: "address", name: "string", age: "uint256" } } }); - const expected = { - tables: { - Example: { - tableId: resourceToHex({ type: "table", namespace: "", name: "Example" }), - schema: { - id: { - type: "address", - internalType: "address", - }, - name: { - type: "string", - internalType: "string", - }, - age: { - type: "uint256", - internalType: "uint256", - }, - }, - keySchema: { + valueSchema: { id: { type: "address", internalType: "address", }, - }, - valueSchema: { name: { type: "string", internalType: "string", }, - age: { - type: "uint256", - internalType: "uint256", - }, }, - key: ["id"], + key: ["age"], name: "Example", namespace: "", codegen: { ...TABLE_CODEGEN_DEFAULTS, dataStruct: true as boolean }, @@ -201,83 +66,48 @@ describe("resolveStoreConfig", () => { attest(config).equals(expected); }); - it("throw an error if the shorthand doesn't include a key field", () => { - attest(() => - resolveStoreConfig({ - 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. - resolveStoreConfig({ 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(() => - resolveStoreConfig({ - // @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 return the full config given a full config with one key", () => { - const config = resolveStoreConfig({ + it("should return the full config given a full config with one key and user types", () => { + const config = defineStore({ tables: { Example: { - schema: { id: "address", name: "string", age: "uint256" }, + 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 expected = { tables: { Example: { tableId: resourceToHex({ type: "table", namespace: "", name: "Example" }), schema: { id: { - type: "address", - internalType: "address", + type: "string", + internalType: "dynamic", }, name: { type: "string", internalType: "string", }, age: { - type: "uint256", - internalType: "uint256", + type: "address", + internalType: "static", }, }, keySchema: { age: { - type: "uint256", - internalType: "uint256", + type: "address", + internalType: "static", }, }, valueSchema: { id: { - type: "address", - internalType: "address", + type: "string", + internalType: "dynamic", }, name: { type: "string", @@ -291,7 +121,10 @@ describe("resolveStoreConfig", () => { type: "table", }, }, - userTypes: {}, + userTypes: { + static: { type: "address", filePath: "path/to/file" }, + dynamic: { type: "string", filePath: "path/to/file" }, + }, enums: {}, namespace: "", codegen: CODEGEN_DEFAULTS, @@ -300,132 +133,68 @@ describe("resolveStoreConfig", () => { attest(config).equals(expected); }); - it("should return the full config given a full config with one key and user types", () => { - const config = resolveStoreConfig({ + it("should return the full config given a full config with two key", () => { + const config = defineStore({ tables: { Example: { - schema: { id: "dynamic", name: "string", age: "static" }, - key: ["age"], + schema: { id: "address", name: "string", age: "uint256" }, + key: ["age", "id"], }, }, - userTypes: { - static: { type: "address", filePath: "path/to/file" }, - dynamic: { type: "string", filePath: "path/to/file" }, - }, }); + const expected = { tables: { Example: { tableId: resourceToHex({ type: "table", namespace: "", name: "Example" }), schema: { id: { - type: "string", - internalType: "dynamic", + type: "address", + internalType: "address", }, name: { type: "string", internalType: "string", }, age: { - type: "address", - internalType: "static", + type: "uint256", + internalType: "uint256", }, }, keySchema: { age: { + type: "uint256", + internalType: "uint256", + }, + id: { type: "address", - internalType: "static", + internalType: "address", }, }, valueSchema: { - id: { - type: "string", - internalType: "dynamic", - }, name: { type: "string", internalType: "string", }, }, - key: ["age"], + key: ["age", "id"], name: "Example", namespace: "", - codegen: { ...TABLE_CODEGEN_DEFAULTS, dataStruct: true as boolean }, + codegen: { ...TABLE_CODEGEN_DEFAULTS, dataStruct: false as boolean }, type: "table", }, }, - userTypes: { - static: { type: "address", filePath: "path/to/file" }, - dynamic: { type: "string", filePath: "path/to/file" }, - }, + userTypes: {}, enums: {}, namespace: "", codegen: CODEGEN_DEFAULTS, } as const; attest(config).equals(expected); - }), - it("should return the full config given a full config with two key", () => { - const config = resolveStoreConfig({ - tables: { - Example: { - schema: { id: "address", name: "string", age: "uint256" }, - key: ["age", "id"], - }, - }, - }); - const expected = { - tables: { - Example: { - tableId: resourceToHex({ type: "table", namespace: "", name: "Example" }), - schema: { - id: { - type: "address", - internalType: "address", - }, - name: { - type: "string", - internalType: "string", - }, - age: { - type: "uint256", - internalType: "uint256", - }, - }, - keySchema: { - age: { - type: "uint256", - internalType: "uint256", - }, - id: { - type: "address", - internalType: "address", - }, - }, - valueSchema: { - name: { - type: "string", - internalType: "string", - }, - }, - key: ["age", "id"], - name: "Example", - namespace: "", - codegen: { ...TABLE_CODEGEN_DEFAULTS, dataStruct: false as boolean }, - type: "table", - }, - }, - userTypes: {}, - enums: {}, - namespace: "", - codegen: CODEGEN_DEFAULTS, - } as const; - - attest(config).equals(expected); - }); + }); it("should resolve two tables in the config with different schemas", () => { - const config = resolveStoreConfig({ + const config = defineStore({ tables: { First: { schema: { firstKey: "address", firstName: "string", firstAge: "uint256" }, @@ -437,6 +206,7 @@ describe("resolveStoreConfig", () => { }, }, }); + const expected = { tables: { First: { @@ -526,7 +296,7 @@ describe("resolveStoreConfig", () => { }); it("should resolve two tables in the config with different schemas and user types", () => { - const config = resolveStoreConfig({ + const config = defineStore({ tables: { First: { schema: { firstKey: "Static", firstName: "Dynamic", firstAge: "uint256" }, @@ -542,6 +312,7 @@ describe("resolveStoreConfig", () => { Dynamic: { type: "string", filePath: "path/to/file" }, }, }); + const expected = { tables: { First: { @@ -635,7 +406,7 @@ describe("resolveStoreConfig", () => { it("should throw if referring to fields of different tables", () => { attest(() => - resolveStoreConfig({ + defineStore({ tables: { First: { schema: { firstKey: "address", firstName: "string", firstAge: "uint256" }, @@ -655,7 +426,7 @@ describe("resolveStoreConfig", () => { it("should throw an error if the provided key is not a static field", () => { attest(() => - resolveStoreConfig({ + defineStore({ tables: { Example: { schema: { id: "address", name: "string", age: "uint256" }, @@ -671,7 +442,7 @@ describe("resolveStoreConfig", () => { it("should throw an error if the provided key is not a static field with user types", () => { attest(() => - resolveStoreConfig({ + defineStore({ tables: { Example: { schema: { id: "address", name: "Dynamic", age: "uint256" }, @@ -689,7 +460,7 @@ describe("resolveStoreConfig", () => { }); it("should return the full config given a full config with enums and user types", () => { - const config = resolveStoreConfig({ + const config = defineStore({ tables: { Example: { schema: { id: "dynamic", name: "ValidNames", age: "static" }, @@ -760,27 +531,28 @@ describe("resolveStoreConfig", () => { }); it("should use the root namespace as default namespace", () => { - const config = resolveStoreConfig({}); + const config = defineStore({}); attest<"">(config.namespace).equals(""); }); it("should use pipe through non-default namespaces", () => { - const config = resolveStoreConfig({ namespace: "custom" }); + const config = defineStore({ namespace: "custom" }); attest<"custom">(config.namespace).equals("custom"); }); it("should extend the output Config type", () => { - const config = resolveStoreConfig({ - tables: { Name: "CustomType" }, + const config = defineStore({ + tables: { Name: { schema: { id: "address" }, key: ["id"] } }, userTypes: { CustomType: { type: "address", filePath: "path/to/file" } }, }); + attest(); }); it("should use the global namespace instead for tables", () => { - const config = resolveStoreConfig({ + const config = defineStore({ namespace: "namespace", tables: { Example: { @@ -796,4 +568,18 @@ describe("resolveStoreConfig", () => { resourceToHex({ type: "table", name: "Example", namespace: "namespace" }), ); }); + + it("should throw if a string is passed in as schema", () => { + // @ts-expect-error Invalid table config + attest(() => defineStore({ tables: { Invalid: "uint256" } })) + .throws('Expected full table config, got `"uint256"`') + .type.errors("Expected full table config"); + }); + + it("should show a type error if an invalid schema is passed in", () => { + // @ts-expect-error Key `invalidKey` does not exist in TableInput + attest(() => defineStore({ tables: { Invalid: { invalidKey: 1 } } })).type.errors( + "Key `invalidKey` does not exist in TableInput", + ); + }); }); diff --git a/packages/store/ts/config/v2/store.ts b/packages/store/ts/config/v2/store.ts index 7edb55e764..c730ae4d93 100644 --- a/packages/store/ts/config/v2/store.ts +++ b/packages/store/ts/config/v2/store.ts @@ -1,132 +1,13 @@ import { evaluate, narrow } from "@arktype/util"; -import { get, isObject } from "./generics"; -import { SchemaInput } from "./schema"; -import { TableInput, resolveTableConfig, validateTableConfig } from "./table"; -import { AbiTypeScope, extendScope } from "./scope"; -import { isSchemaAbiType } from "@latticexyz/schema-type/internal"; -import { UserTypes, Enums, CodegenOptions } from "./output"; -import { isTableShorthandInput, resolveTableShorthand, validateTableShorthand } from "./tableShorthand"; -import { CODEGEN_DEFAULTS, CONFIG_DEFAULTS } from "./defaults"; +import { get, hasOwnKey, mergeIfUndefined } from "./generics"; +import { UserTypes } from "./output"; +import { CONFIG_DEFAULTS } from "./defaults"; import { mapObject } from "@latticexyz/common/utils"; - -export type StoreConfigInput = { - namespace?: string; - tables: StoreTablesConfigInput>; - userTypes?: userTypes; - enums?: enums; - codegen?: Partial; -}; - -export type StoreTablesConfigInput = { - [key: string]: TableInput, scope>; -}; - -export type validateStoreTablesConfig = { - [key in keyof input]: validateTableConfig; -}; - -export function validateStoreTablesConfig( - input: unknown, - scope: scope, -): asserts input is StoreTablesConfigInput { - if (isObject(input)) { - for (const table of Object.values(input)) { - validateTableConfig(table, scope); - } - return; - } - throw new Error(`Expected store config, received ${JSON.stringify(input)}`); -} - -export type resolveStoreTablesConfig< - input, - scope extends AbiTypeScope = AbiTypeScope, - defaultNamespace extends string = typeof CONFIG_DEFAULTS.namespace, -> = evaluate<{ - // TODO: we currently can't apply `tableWithDefaults` here because the config could be a shorthand here - readonly [key in keyof input]: resolveTableConfig; -}>; - -export function resolveStoreTablesConfig< - input, - scope extends AbiTypeScope = AbiTypeScope, - defaultNamespace extends string = typeof CONFIG_DEFAULTS.namespace, ->( - input: input, - scope: scope = AbiTypeScope as scope, - // TODO: ideally the namespace would be passed in with the table input from higher levels - // but this is currently not possible since the table input could be a shorthand - defaultNamespace: defaultNamespace = CONFIG_DEFAULTS.namespace as defaultNamespace, -): resolveStoreTablesConfig { - if (typeof input !== "object" || input == null) { - throw new Error(`Expected tables config, received ${JSON.stringify(input)}`); - } - - return Object.fromEntries( - Object.entries(input).map(([key, table]) => { - const fullInput = isTableShorthandInput(table, scope) - ? resolveTableShorthand(table as validateTableShorthand, scope) - : table; - - return [key, resolveTableConfig(fullInput, scope, key, defaultNamespace)]; - }), - ) as unknown as resolveStoreTablesConfig; -} - -type extractInternalType = { [key in keyof userTypes]: userTypes[key]["type"] }; - -function extractInternalType(userTypes: userTypes): extractInternalType { - return mapObject(userTypes, (userType) => userType.type); -} - -export type scopeWithUserTypes = UserTypes extends userTypes - ? scope - : userTypes extends UserTypes - ? extendScope> - : scope; - -function isUserTypes(userTypes: unknown): userTypes is UserTypes { - return ( - typeof userTypes === "object" && - userTypes != null && - Object.values(userTypes).every((userType) => isSchemaAbiType(userType.type)) - ); -} - -export function scopeWithUserTypes( - userTypes: userTypes, - scope: scope = AbiTypeScope as scope, -): scopeWithUserTypes { - return (isUserTypes(userTypes) ? extendScope(scope, extractInternalType(userTypes)) : scope) as scopeWithUserTypes< - userTypes, - scope - >; -} - -function isEnums(enums: unknown): enums is Enums { - return ( - typeof enums === "object" && - enums != null && - Object.values(enums).every((item) => Array.isArray(item) && item.every((element) => typeof element === "string")) - ); -} - -export type scopeWithEnums = Enums extends enums - ? scope - : enums extends Enums - ? extendScope - : scope; - -export function scopeWithEnums( - enums: enums, - scope: scope = AbiTypeScope as scope, -): scopeWithEnums { - if (isEnums(enums)) { - const enumScope = Object.fromEntries(Object.keys(enums).map((key) => [key, "uint8" as const])); - return extendScope(scope, enumScope) as scopeWithEnums; - } - return scope as scopeWithEnums; -} +import { StoreInput } from "./input"; +import { resolveTables, validateTables } from "./tables"; +import { scopeWithUserTypes, validateUserTypes } from "./userTypes"; +import { resolveEnums, scopeWithEnums } from "./enums"; +import { resolveCodegen } from "./codegen"; export type extendedScope = scopeWithEnums, scopeWithUserTypes>>; @@ -134,46 +15,62 @@ export function extendedScope(input: input): extendedScope { return scopeWithEnums(get(input, "enums"), scopeWithUserTypes(get(input, "userTypes"))); } -export type validateStoreConfig = { - [key in keyof input]: key extends "tables" - ? validateStoreTablesConfig> +export type validateStore = { + [key in keyof store]: key extends "tables" + ? validateTables> : key extends "userTypes" ? UserTypes : key extends "enums" - ? narrow - : key extends keyof StoreConfigInput - ? StoreConfigInput[key] - : input[key]; + ? narrow + : key extends keyof StoreInput + ? StoreInput[key] + : never; }; -export type resolveEnums = { readonly [key in keyof enums]: Readonly }; - -export type resolveCodegen = { - [key in keyof CodegenOptions]: key extends keyof options ? options[key] : (typeof CODEGEN_DEFAULTS)[key]; -}; +export function validateStore(store: unknown): asserts store is StoreInput { + const scope = extendedScope(store); + if (hasOwnKey(store, "tables")) { + validateTables(store.tables, scope); + } -export function resolveCodegen(options: options): resolveCodegen { - return Object.fromEntries( - Object.entries(CODEGEN_DEFAULTS).map(([key, defaultValue]) => [key, get(options, key) ?? defaultValue]), - ) as resolveCodegen; + if (hasOwnKey(store, "userTypes")) { + validateUserTypes(store.userTypes); + } } -export type resolveStoreConfig = evaluate<{ - readonly tables: "tables" extends keyof input - ? resolveStoreTablesConfig, get & string> +export type resolveStore = evaluate<{ + readonly tables: "tables" extends keyof store + ? resolveTables< + { + [key in keyof store["tables"]]: mergeIfUndefined< + store["tables"][key], + { namespace: get } + >; + }, + extendedScope + > : {}; - readonly userTypes: "userTypes" extends keyof input ? input["userTypes"] : {}; - readonly enums: "enums" extends keyof input ? resolveEnums : {}; - readonly namespace: "namespace" extends keyof input ? input["namespace"] : (typeof CONFIG_DEFAULTS)["namespace"]; - readonly codegen: "codegen" extends keyof input ? resolveCodegen : resolveCodegen<{}>; + readonly userTypes: "userTypes" extends keyof store ? store["userTypes"] : {}; + readonly enums: "enums" extends keyof store ? resolveEnums : {}; + readonly namespace: "namespace" extends keyof store ? store["namespace"] : (typeof CONFIG_DEFAULTS)["namespace"]; + readonly codegen: "codegen" extends keyof store ? resolveCodegen : resolveCodegen<{}>; }>; -export function resolveStoreConfig(input: validateStoreConfig): resolveStoreConfig { +export function resolveStore(store: store): resolveStore { + validateStore(store); + return { - tables: resolveStoreTablesConfig(get(input, "tables") ?? {}, extendedScope(input), get(input, "namespace")), - userTypes: get(input, "userTypes") ?? {}, - enums: get(input, "enums") ?? {}, - namespace: get(input, "namespace") ?? CONFIG_DEFAULTS["namespace"], - codegen: resolveCodegen(get(input, "codegen")), - } as resolveStoreConfig; + tables: resolveTables( + mapObject(store.tables ?? {}, (table) => mergeIfUndefined(table, { namespace: store.namespace })), + extendedScope(store), + ), + userTypes: store.userTypes ?? {}, + enums: store.enums ?? {}, + namespace: store.namespace ?? CONFIG_DEFAULTS["namespace"], + codegen: resolveCodegen(store.codegen), + } as unknown as resolveStore; +} + +export function defineStore(store: validateStore): resolveStore { + return resolveStore(store) as resolveStore; } diff --git a/packages/store/ts/config/v2/storeWithShorthands.test.ts b/packages/store/ts/config/v2/storeWithShorthands.test.ts new file mode 100644 index 0000000000..30d80fd0c2 --- /dev/null +++ b/packages/store/ts/config/v2/storeWithShorthands.test.ts @@ -0,0 +1,276 @@ +import { describe, it } from "vitest"; +import { defineStoreWithShorthands } from "./storeWithShorthands"; +import { attest } from "@arktype/attest"; +import { resourceToHex } from "@latticexyz/common"; +import { CODEGEN_DEFAULTS, TABLE_CODEGEN_DEFAULTS } from "./defaults"; +import { defineStore } from "./store"; + +describe("defineStoreWithShorthands", () => { + it("should accept a shorthand store config as input and expand it", () => { + const config = defineStoreWithShorthands({ tables: { Name: "address" } }); + const expected = { + tables: { + Name: { + tableId: resourceToHex({ type: "table", namespace: "", name: "Name" }), + schema: { + id: { + type: "bytes32", + internalType: "bytes32", + }, + value: { + type: "address", + internalType: "address", + }, + }, + keySchema: { + id: { + type: "bytes32", + internalType: "bytes32", + }, + }, + valueSchema: { + value: { + type: "address", + internalType: "address", + }, + }, + key: ["id"], + name: "Name", + namespace: "", + codegen: { ...TABLE_CODEGEN_DEFAULTS, dataStruct: false as boolean }, + type: "table", + }, + }, + userTypes: {}, + enums: {}, + namespace: "", + codegen: CODEGEN_DEFAULTS, + } as const; + + attest(config).equals(expected); + }); + + it("should accept a user type as input and expand it", () => { + const config = defineStoreWithShorthands({ + tables: { Name: "CustomType" }, + userTypes: { CustomType: { type: "address", filePath: "path/to/file" } }, + }); + const expected = { + tables: { + Name: { + tableId: resourceToHex({ type: "table", namespace: "", name: "Name" }), + schema: { + id: { + type: "bytes32", + internalType: "bytes32", + }, + value: { + type: "address", + internalType: "CustomType", + }, + }, + keySchema: { + id: { + type: "bytes32", + internalType: "bytes32", + }, + }, + valueSchema: { + value: { + type: "address", + internalType: "CustomType", + }, + }, + key: ["id"], + name: "Name", + namespace: "", + codegen: { ...TABLE_CODEGEN_DEFAULTS, dataStruct: false as boolean }, + type: "table", + }, + }, + userTypes: { CustomType: { type: "address", filePath: "path/to/file" } }, + enums: {}, + namespace: "", + codegen: CODEGEN_DEFAULTS, + } as const; + + attest(config).equals(expected); + attest(expected); + }); + + it("given a schema with a key field with static ABI type, it should use `id` as single key", () => { + const config = defineStoreWithShorthands({ + tables: { Example: { id: "address", name: "string", age: "uint256" } }, + }); + const expected = { + tables: { + Example: { + tableId: resourceToHex({ type: "table", namespace: "", name: "Example" }), + schema: { + id: { + type: "address", + internalType: "address", + }, + name: { + type: "string", + internalType: "string", + }, + age: { + type: "uint256", + internalType: "uint256", + }, + }, + keySchema: { + id: { + type: "address", + internalType: "address", + }, + }, + valueSchema: { + name: { + type: "string", + internalType: "string", + }, + age: { + type: "uint256", + internalType: "uint256", + }, + }, + key: ["id"], + name: "Example", + namespace: "", + codegen: { ...TABLE_CODEGEN_DEFAULTS, dataStruct: true as boolean }, + type: "table", + }, + }, + userTypes: {}, + enums: {}, + namespace: "", + codegen: CODEGEN_DEFAULTS, + } as const; + + attest(config).equals(expected); + }); + + it("given a schema with a key field with static custom type, it should use `id` as single key", () => { + const config = defineStoreWithShorthands({ + tables: { Example: { id: "address", name: "string", age: "uint256" } }, + }); + const expected = { + tables: { + Example: { + tableId: resourceToHex({ type: "table", namespace: "", name: "Example" }), + schema: { + id: { + type: "address", + internalType: "address", + }, + name: { + type: "string", + internalType: "string", + }, + age: { + type: "uint256", + internalType: "uint256", + }, + }, + keySchema: { + id: { + type: "address", + internalType: "address", + }, + }, + valueSchema: { + name: { + type: "string", + internalType: "string", + }, + age: { + type: "uint256", + internalType: "uint256", + }, + }, + key: ["id"], + name: "Example", + namespace: "", + codegen: { ...TABLE_CODEGEN_DEFAULTS, dataStruct: true as boolean }, + type: "table", + }, + }, + userTypes: {}, + enums: {}, + namespace: "", + codegen: CODEGEN_DEFAULTS, + } as const; + + attest(config).equals(expected); + }); + + it("should pass through full table config inputs", () => { + const config = defineStoreWithShorthands({ + 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).equals(expected); + }); + + it("should throw if the shorthand doesn't include a key field", () => { + attest(() => + defineStoreWithShorthands({ + 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("should throw 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. + defineStoreWithShorthands({ 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("should throw if the shorthand config includes a non-static user type as key field", () => { + attest(() => + defineStoreWithShorthands({ + // @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 throw if the shorthand key is neither a custom nor ABI type", () => { + // @ts-expect-error Type '"NotAnAbiType"' is not assignable to type 'AbiType' + attest(() => defineStoreWithShorthands({ tables: { Invalid: "NotAnAbiType" } })) + .throws("Invalid ABI type. `NotAnAbiType` not found in scope.") + .type.errors(`Type '"NotAnAbiType"' is not assignable to type 'AbiType'`); + }); +}); diff --git a/packages/store/ts/config/v2/storeWithShorthands.ts b/packages/store/ts/config/v2/storeWithShorthands.ts new file mode 100644 index 0000000000..51a5902221 --- /dev/null +++ b/packages/store/ts/config/v2/storeWithShorthands.ts @@ -0,0 +1,49 @@ +import { mapObject } from "@latticexyz/common/utils"; +import { resolveStore, validateStore, extendedScope } from "./store"; +import { + isTableShorthandInput, + resolveTableShorthand, + resolveTablesWithShorthands, + validateTablesWithShorthands, +} from "./tableShorthand"; +import { hasOwnKey, isObject } from "./generics"; +import { StoreWithShorthandsInput } from "./input"; + +export type validateStoreWithShorthands = { + [key in keyof store]: key extends "tables" + ? validateTablesWithShorthands> + : validateStore[key]; +}; + +export function validateStoreWithShorthands(store: unknown): asserts store is StoreWithShorthandsInput { + const scope = extendedScope(store); + if (hasOwnKey(store, "tables") && isObject(store.tables)) { + validateTablesWithShorthands(store.tables, scope); + } +} + +export type resolveStoreWithShorthands = resolveStore<{ + [key in keyof store]: key extends "tables" + ? resolveTablesWithShorthands> + : store[key]; +}>; + +export function resolveStoreWithShorthands(store: store): resolveStoreWithShorthands { + validateStoreWithShorthands(store); + + const scope = extendedScope(store); + const fullConfig = { + ...store, + tables: mapObject(store.tables, (table) => { + return isTableShorthandInput(table) ? resolveTableShorthand(table, scope) : table; + }), + }; + + return resolveStore(fullConfig) as unknown as resolveStoreWithShorthands; +} + +export function defineStoreWithShorthands( + store: validateStoreWithShorthands, +): resolveStoreWithShorthands { + return resolveStoreWithShorthands(store) as resolveStoreWithShorthands; +} diff --git a/packages/store/ts/config/v2/table.test.ts b/packages/store/ts/config/v2/table.test.ts index 6c9de595ef..e54e9ee304 100644 --- a/packages/store/ts/config/v2/table.test.ts +++ b/packages/store/ts/config/v2/table.test.ts @@ -1,210 +1,59 @@ -import { describe, it } from "vitest"; import { attest } from "@arktype/attest"; -import { resolveTableConfig } from "./table"; -import { Table } from "./output"; -import { AbiTypeScope, extendScope } from "./scope"; -import { Hex } from "viem"; +import { describe, it } from "vitest"; +import { getStaticAbiTypeKeys, AbiTypeScope, extendScope } from "./scope"; +import { validateKeys, defineTable } from "./table"; import { TABLE_CODEGEN_DEFAULTS } from "./defaults"; - -describe("resolveTableConfig", () => { - it("should expand a single ABI type into a key/value schema", () => { - const table = resolveTableConfig("address"); - const expected = { - tableId: "0x" as Hex, - schema: { - id: { - type: "bytes32", - internalType: "bytes32", - }, - value: { - type: "address", - internalType: "address", - }, - }, - keySchema: { - id: { - type: "bytes32", - internalType: "bytes32", - }, - }, - valueSchema: { - value: { - type: "address", - internalType: "address", - }, - }, - key: ["id"], - name: "", - namespace: "", - codegen: { ...TABLE_CODEGEN_DEFAULTS, dataStruct: false as boolean }, - type: "table", - } as const; - - attest(table).equals(expected); - }); - - it("should expand a single custom type into a key/value schema", () => { - const scope = extendScope(AbiTypeScope, { CustomType: "address" }); - const table = resolveTableConfig("CustomType", scope); - const expected = { - tableId: "0x" as Hex, - schema: { - id: { - type: "bytes32", - internalType: "bytes32", - }, - value: { - type: "address", - internalType: "CustomType", - }, - }, - keySchema: { - id: { - type: "bytes32", - internalType: "bytes32", - }, - }, - valueSchema: { - value: { - type: "address", - internalType: "CustomType", - }, - }, - key: ["id"], - name: "", - namespace: "", - codegen: { ...TABLE_CODEGEN_DEFAULTS, dataStruct: false as boolean }, - type: "table", - } as const; - - attest(table).equals(expected); - }); - - it("should use `id` as single key if it has a static ABI type", () => { - const table = resolveTableConfig({ id: "address", name: "string", age: "uint256" }); - const expected = { - tableId: "0x" as Hex, - schema: { - id: { - type: "address", - internalType: "address", - }, - name: { - type: "string", - internalType: "string", - }, - age: { - type: "uint256", - internalType: "uint256", - }, - }, - keySchema: { - id: { - type: "address", - internalType: "address", - }, - }, - valueSchema: { - name: { - type: "string", - internalType: "string", - }, - age: { - type: "uint256", - internalType: "uint256", - }, - }, - key: ["id"], - name: "", - namespace: "", - codegen: { ...TABLE_CODEGEN_DEFAULTS, dataStruct: true as boolean }, - type: "table", - } as const; - - attest(table).equals(expected); - }); - - it("should use `id` as single key if it has a static custom type", () => { - const scope = extendScope(AbiTypeScope, { CustomType: "uint256" }); - const table = resolveTableConfig({ id: "CustomType", name: "string", age: "uint256" }, scope); - const expected = { - tableId: "0x" as Hex, - schema: { - id: { - type: "uint256", - internalType: "CustomType", - }, - name: { - type: "string", - internalType: "string", - }, - age: { - type: "uint256", - internalType: "uint256", - }, - }, - keySchema: { - id: { - type: "uint256", - internalType: "CustomType", - }, - }, - valueSchema: { - name: { - type: "string", - internalType: "string", - }, - age: { - type: "uint256", - internalType: "uint256", - }, - }, - key: ["id"], - name: "", - namespace: "", - codegen: { ...TABLE_CODEGEN_DEFAULTS, dataStruct: true as boolean }, - type: "table", - } as const; - - attest(table); - }); - - it("should throw if the shorthand key is a dynamic ABI type", () => { - // @ts-expect-error Invalid schema. Expected an `id` field with a static ABI type or an explicit `key` option. - attest(() => resolveTableConfig({ id: "string", name: "string", age: "uint256" })).throwsAndHasTypeError( - "Invalid schema. Expected an `id` field with a static ABI type or an explicit `key` option.", - ); - }); - - it("should throw if the shorthand key is a dyamic custom type", () => { - const scope = extendScope(AbiTypeScope, { CustomType: "bytes" }); - // @ts-expect-error Invalid schema. Expected an `id` field with a static ABI type or an explicit `key` option. - attest(() => resolveTableConfig({ id: "CustomType" }, scope)).throwsAndHasTypeError( - "Invalid schema. Expected an `id` field with a static ABI type or an explicit `key` option.", - ); - }); - - it("should throw if the shorthand key is neither a custom nor ABI type", () => { - // @ts-expect-error Argument of type '"NotAnAbiType"' is not assignable to parameter of type 'AbiType' - attest(() => resolveTableConfig("NotAnAbiType")) - .throws("Invalid ABI type. `NotAnAbiType` not found in scope.") - .type.errors(`Argument of type '"NotAnAbiType"' is not assignable to parameter of type 'AbiType'`); - }); - - it("should throw if the shorthand doesn't include a key field", () => { - // @ts-expect-error Invalid schema. Expected an `id` field with a static ABI type or an explicit `key` option. - attest(() => resolveTableConfig({ name: "string", age: "uint256" })).throwsAndHasTypeError( - "Invalid schema. Expected an `id` field with a static ABI type or an explicit `key` option.", - ); +import { resourceToHex } from "@latticexyz/common"; + +describe("validateKeys", () => { + it("should return a tuple of valid keys", () => { + attest< + ["static"], + validateKeys, ["static"]> + >(); + }); + + it("should return a tuple of valid keys with an extended scope", () => { + const scope = extendScope(AbiTypeScope, { static: "address", dynamic: "string" }); + + attest< + ["static", "customStatic"], + validateKeys< + getStaticAbiTypeKeys< + { static: "uint256"; dynamic: "string"; customStatic: "static"; customDynamic: "dynamic" }, + typeof scope + >, + ["static", "customStatic"] + > + >(); + }); + + it("should return a tuple of valid keys with an extended scope", () => { + const scope = extendScope(AbiTypeScope, { static: "address", dynamic: "string" }); + + attest< + ["static", "customStatic"], + validateKeys< + getStaticAbiTypeKeys< + { static: "uint256"; dynamic: "string"; customStatic: "static"; customDynamic: "dynamic" }, + typeof scope + >, + ["static", "customStatic"] + > + >(); }); +}); +describe("resolveTable", () => { it("should return the full config given a full config with one key", () => { - const table = resolveTableConfig({ + const table = defineTable({ schema: { id: "address", name: "string", age: "uint256" }, key: ["age"], + name: "", }); + const expected = { - tableId: "0x" as Hex, + tableId: resourceToHex({ type: "table", namespace: "", name: "" }), schema: { id: { type: "address", internalType: "address" }, name: { type: "string", internalType: "string" }, @@ -228,12 +77,13 @@ describe("resolveTableConfig", () => { }); it("should return the full config given a full config with two key", () => { - const table = resolveTableConfig({ + const table = defineTable({ schema: { id: "address", name: "string", age: "uint256" }, key: ["age", "id"], + name: "", }); const expected = { - tableId: "0x" as Hex, + tableId: resourceToHex({ type: "table", namespace: "", name: "" }), schema: { id: { type: "address", internalType: "address" }, name: { type: "string", internalType: "string" }, @@ -256,87 +106,33 @@ describe("resolveTableConfig", () => { attest(table).equals(expected); }); - it("should return the full config given a config with custom types as values", () => { - const scope = extendScope(AbiTypeScope, { CustomString: "string", CustomNumber: "uint256" }); - const table = resolveTableConfig({ id: "address", name: "CustomString", age: "CustomNumber" }, scope); - const expected = { - tableId: "0x" as Hex, - schema: { - id: { - type: "address", - internalType: "address", - }, - name: { - type: "string", - internalType: "CustomString", - }, - age: { - type: "uint256", - internalType: "CustomNumber", - }, - }, - keySchema: { - id: { - type: "address", - internalType: "address", - }, - }, - valueSchema: { - name: { - type: "string", - internalType: "CustomString", - }, - age: { - type: "uint256", - internalType: "CustomNumber", - }, - }, - key: ["id"], - name: "", - namespace: "", - codegen: { ...TABLE_CODEGEN_DEFAULTS, dataStruct: true as boolean }, - type: "table", - } as const; + it("should return the full config given a full config with custom type", () => { + const scope = extendScope(AbiTypeScope, { Static: "address", Dynamic: "string" }); - attest(table).equals(expected); - }); + const table = defineTable( + { + schema: { id: "Static", name: "Dynamic", age: "uint256" }, + key: ["age"], + name: "", + }, + scope, + ); - it("should return the full config given a config with custom type as key", () => { - const scope = extendScope(AbiTypeScope, { CustomString: "string", CustomNumber: "uint256" }); - const table = resolveTableConfig({ id: "CustomNumber", name: "CustomString", age: "CustomNumber" }, scope); const expected = { - tableId: "0x" as Hex, + tableId: resourceToHex({ type: "table", namespace: "", name: "" }), schema: { - id: { - type: "uint256", - internalType: "CustomNumber", - }, - name: { - type: "string", - internalType: "CustomString", - }, - age: { - type: "uint256", - internalType: "CustomNumber", - }, + id: { type: "address", internalType: "Static" }, + name: { type: "string", internalType: "Dynamic" }, + age: { type: "uint256", internalType: "uint256" }, }, keySchema: { - id: { - type: "uint256", - internalType: "CustomNumber", - }, + age: { type: "uint256", internalType: "uint256" }, }, valueSchema: { - name: { - type: "string", - internalType: "CustomString", - }, - age: { - type: "uint256", - internalType: "CustomNumber", - }, + id: { type: "address", internalType: "Static" }, + name: { type: "string", internalType: "Dynamic" }, }, - key: ["id"], + key: ["age"], name: "", namespace: "", codegen: { ...TABLE_CODEGEN_DEFAULTS, dataStruct: true as boolean }, @@ -348,10 +144,11 @@ describe("resolveTableConfig", () => { it("should throw if the provided key is a dynamic ABI type", () => { attest(() => - resolveTableConfig({ + defineTable({ schema: { id: "address", name: "string", age: "uint256" }, // @ts-expect-error Type '"name"' is not assignable to type '"id" | "age"' key: ["name"], + name: "", }), ) .throws('Invalid key. Expected `("id" | "age")[]`, received `["name"]`') @@ -361,11 +158,12 @@ describe("resolveTableConfig", () => { it("should throw if the provided key is a dynamic ABI type if user types are provided", () => { const scope = extendScope(AbiTypeScope, { CustomType: "string" }); attest(() => - resolveTableConfig( + defineTable( { schema: { id: "address", name: "string", age: "uint256" }, // @ts-expect-error Type '"name"' is not assignable to type '"id" | "age"' key: ["name"], + name: "", }, scope, ), @@ -377,11 +175,12 @@ describe("resolveTableConfig", () => { it("should throw if the provided key is a dynamic custom type", () => { const scope = extendScope(AbiTypeScope, { CustomType: "string" }); attest(() => - resolveTableConfig( + defineTable( { schema: { id: "CustomType", name: "string", age: "uint256" }, // @ts-expect-error Type '"id"' is not assignable to type '"age"' key: ["id"], + name: "", }, scope, ), @@ -393,11 +192,12 @@ describe("resolveTableConfig", () => { it("should throw if the provided key is neither a custom nor ABI type", () => { const scope = extendScope(AbiTypeScope, { CustomType: "string" }); attest(() => - resolveTableConfig( + defineTable( { schema: { id: "address", name: "string", age: "uint256" }, // @ts-expect-error Type '"NotAKey"' is not assignable to type '"id" | "age"' key: ["NotAKey"], + name: "", }, scope, ), @@ -405,10 +205,4 @@ describe("resolveTableConfig", () => { .throws('Invalid key. Expected `("id" | "age")[]`, received `["NotAKey"]`') .type.errors(`Type '"NotAKey"' is not assignable to type '"id" | "age"'`); }); - - it("should extend the output Table type", () => { - const scope = extendScope(AbiTypeScope, { CustomString: "string", CustomNumber: "uint256" }); - const table = resolveTableConfig({ id: "CustomNumber", name: "CustomString", age: "CustomNumber" }, scope); - attest(); - }); }); diff --git a/packages/store/ts/config/v2/table.ts b/packages/store/ts/config/v2/table.ts index 6bb7fb815b..1b724f58c6 100644 --- a/packages/store/ts/config/v2/table.ts +++ b/packages/store/ts/config/v2/table.ts @@ -1,99 +1,184 @@ -import { evaluate } from "@arktype/util"; -import { SchemaInput } from "./schema"; -import { AbiTypeScope } from "./scope"; -import { - TableShorthandInput, - isTableShorthandInput, - resolveTableShorthand, - validateTableShorthand, -} from "./tableShorthand"; -import { - TableFullInput, - ValidKeys, - isTableFullInput, - resolveTableFullConfig, - validateTableFull, - tableWithDefaults, -} from "./tableFull"; -import { CONFIG_DEFAULTS } from "./defaults"; - -export type TableInput< - schema extends SchemaInput = SchemaInput, - scope extends AbiTypeScope = AbiTypeScope, - key extends ValidKeys = ValidKeys, -> = TableFullInput | TableShorthandInput; - -export type validateTableConfig = - input extends TableShorthandInput - ? validateTableShorthand - : input extends string - ? validateTableShorthand - : validateTableFull; - -export function validateTableConfig( - input: unknown, - scope: scope, -): asserts input is TableInput, scope> { - if (isTableShorthandInput(input, scope)) { - return validateTableShorthand(input, scope); +import { ErrorMessage, conform, narrow } from "@arktype/util"; +import { isStaticAbiType } from "@latticexyz/schema-type/internal"; +import { Hex } from "viem"; +import { get, hasOwnKey } from "./generics"; +import { resolveSchema, validateSchema } from "./schema"; +import { AbiTypeScope, Scope, getStaticAbiTypeKeys } from "./scope"; +import { TableCodegen } from "./output"; +import { TABLE_CODEGEN_DEFAULTS, TABLE_DEFAULTS } from "./defaults"; +import { resourceToHex } from "@latticexyz/common"; +import { SchemaInput, TableInput } from "./input"; + +export type ValidKeys = readonly [ + getStaticAbiTypeKeys, + ...getStaticAbiTypeKeys[], +]; + +function getValidKeys( + schema: schema, + scope: scope = AbiTypeScope as unknown as scope, +): ValidKeys { + return Object.entries(schema) + .filter(([, internalType]) => hasOwnKey(scope.types, internalType) && isStaticAbiType(scope.types[internalType])) + .map(([key]) => key) as unknown as ValidKeys; +} + +export function isValidPrimaryKey( + key: unknown, + schema: schema, + scope: scope = AbiTypeScope as unknown as scope, +): key is ValidKeys { + return ( + Array.isArray(key) && + key.every( + (key) => + hasOwnKey(schema, key) && hasOwnKey(scope.types, schema[key]) && isStaticAbiType(scope.types[schema[key]]), + ) + ); +} + +/** @deprecated */ +export function isTableInput(input: unknown): input is TableInput { + return ( + typeof input === "object" && + input !== null && + hasOwnKey(input, "schema") && + hasOwnKey(input, "key") && + Array.isArray(input["key"]) + ); +} + +export type validateKeys = { + [i in keyof keys]: keys[i] extends validKeys ? keys[i] : validKeys; +}; + +export type validateTable = { + [key in keyof input]: key extends "key" + ? validateKeys, SchemaInput>, scope>, input[key]> + : key extends "schema" + ? validateSchema + : key extends "name" | "namespace" + ? narrow + : key extends keyof TableInput + ? TableInput[key] + : ErrorMessage<`Key \`${key & string}\` does not exist in TableInput`>; +}; + +export function validateTable( + input: input, + scope: scope = AbiTypeScope as unknown as scope, +): asserts input is TableInput & input { + if (typeof input !== "object" || input == null) { + throw new Error(`Expected full table config, got \`${JSON.stringify(input)}\``); + } + + if (!hasOwnKey(input, "schema")) { + throw new Error("Missing schema input"); } - validateTableFull(input, scope); + validateSchema(input.schema, scope); + + if (!hasOwnKey(input, "key") || !isValidPrimaryKey(input["key"], input["schema"], scope)) { + throw new Error( + `Invalid key. Expected \`(${getValidKeys(input["schema"], scope) + .map((item) => `"${String(item)}"`) + .join(" | ")})[]\`, received \`${ + hasOwnKey(input, "key") && Array.isArray(input.key) + ? `[${input.key.map((item) => `"${item}"`).join(", ")}]` + : "undefined" + }\``, + ); + } +} + +export type resolveTableCodegen = { + [key in keyof TableCodegen]-?: key extends keyof input["codegen"] + ? undefined extends input["codegen"][key] + ? key extends "dataStruct" + ? boolean + : key extends keyof typeof TABLE_CODEGEN_DEFAULTS + ? (typeof TABLE_CODEGEN_DEFAULTS)[key] + : never + : input["codegen"][key] + : // dataStruct isn't narrowed, because its value is conditional on the number of value schema fields + key extends "dataStruct" + ? boolean + : key extends keyof typeof TABLE_CODEGEN_DEFAULTS + ? (typeof TABLE_CODEGEN_DEFAULTS)[key] + : never; +}; + +export function resolveTableCodegen(input: input): resolveTableCodegen { + const options = input.codegen; + return { + directory: get(options, "directory") ?? TABLE_CODEGEN_DEFAULTS.directory, + tableIdArgument: get(options, "tableIdArgument") ?? TABLE_CODEGEN_DEFAULTS.tableIdArgument, + storeArgument: get(options, "storeArgument") ?? TABLE_CODEGEN_DEFAULTS.storeArgument, + // dataStruct is true if there are at least 2 value fields + dataStruct: get(options, "dataStruct") ?? Object.keys(input.schema).length - input.key.length > 1, + } satisfies TableCodegen as resolveTableCodegen; } -export type resolveTableConfig< - input, - scope extends AbiTypeScope = AbiTypeScope, - defaultName extends string = "", - defaultNamespace extends string = typeof CONFIG_DEFAULTS.namespace, -> = evaluate< - input extends TableShorthandInput - ? resolveTableFullConfig< - tableWithDefaults, defaultName, defaultNamespace, scope>, +export type resolveTable = input extends TableInput + ? { + readonly tableId: Hex; + readonly name: input["name"]; + readonly namespace: undefined extends input["namespace"] ? typeof TABLE_DEFAULTS.namespace : input["namespace"]; + readonly type: undefined extends input["type"] ? typeof TABLE_DEFAULTS.type : input["type"]; + readonly key: Readonly; + readonly schema: resolveSchema; + readonly keySchema: resolveSchema< + { + readonly [key in input["key"][number]]: input["schema"][key]; + }, + scope + >; + readonly valueSchema: resolveSchema< + { + readonly [key in Exclude]: input["schema"][key]; + }, scope - > - : input extends TableFullInput, scope> - ? resolveTableFullConfig, scope> - : never ->; - -/** - * If a shorthand table config is passed we expand it with sane defaults: - * - A single ABI type is turned into { schema: { id: "bytes32", value: INPUT }, key: ["id"] }. - * - A schema with a `id` field with static ABI type is turned into { schema: INPUT, key: ["id"] }. - * - A schema without a `id` field is invalid. - */ -export function resolveTableConfig< - input, - scope extends AbiTypeScope = AbiTypeScope, - // TODO: temporary fix to have access to the default name here. - // Will remove once there is a clearer separation between full config and shorthand config - defaultName extends string = "", - defaultNamespace extends string = typeof CONFIG_DEFAULTS.namespace, ->( - input: validateTableConfig, - scope: scope = AbiTypeScope as scope, - defaultName?: defaultName, - defaultNamespace?: defaultNamespace, -): resolveTableConfig { - if (isTableShorthandInput(input, scope)) { - const fullInput = resolveTableShorthand(input as validateTableShorthand, scope); - if (isTableFullInput(fullInput)) { - return resolveTableFullConfig( - // @ts-expect-error TODO: the base input type should be more permissive and constraints added via the validate helpers instead - tableWithDefaults(fullInput, defaultName, defaultNamespace), - scope, - ) as unknown as resolveTableConfig; + >; + readonly codegen: resolveTableCodegen; } - throw new Error("Resolved shorthand is not a valid full table input"); - } + : never; + +export function resolveTable( + input: input, + scope: scope = AbiTypeScope as unknown as scope, +): resolveTable { + validateTable(input, scope); + + const name = input.name; + const type = input.type ?? TABLE_DEFAULTS.type; + const namespace = input.namespace ?? TABLE_DEFAULTS.namespace; + const tableId = input.tableId ?? resourceToHex({ type, namespace, name }); - if (isTableFullInput(input)) { - return resolveTableFullConfig( - // @ts-expect-error TODO: the base input type should be more permissive and constraints added via the validate helpers instead - tableWithDefaults(input, defaultName, defaultNamespace), + return { + tableId, + name, + namespace, + type, + key: input.key, + schema: resolveSchema(input.schema, scope), + keySchema: resolveSchema( + Object.fromEntries( + Object.entries(input.schema).filter(([key]) => input.key.includes(key as (typeof input.key)[number])), + ), scope, - ) as unknown as resolveTableConfig; - } + ), + valueSchema: resolveSchema( + Object.fromEntries( + Object.entries(input.schema).filter(([key]) => !input.key.includes(key as (typeof input.key)[number])), + ), + scope, + ), + codegen: resolveTableCodegen(input), + } as unknown as resolveTable; +} - throw new Error("Invalid config input"); +export function defineTable( + input: validateTable, + scope: scope = AbiTypeScope as unknown as scope, +): resolveTable { + return resolveTable(input, scope) as resolveTable; } diff --git a/packages/store/ts/config/v2/tableFull.test.ts b/packages/store/ts/config/v2/tableFull.test.ts deleted file mode 100644 index 5add53e42b..0000000000 --- a/packages/store/ts/config/v2/tableFull.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { attest } from "@arktype/attest"; -import { describe, it } from "vitest"; -import { getStaticAbiTypeKeys, AbiTypeScope, extendScope } from "./scope"; -import { validateKeys } from "./tableFull"; - -describe("validateKeys", () => { - it("should return a tuple of valid keys", () => { - attest< - ["static"], - validateKeys, ["static"]> - >(); - }); - - it("should return a tuple of valid keys with an extended scope", () => { - const scope = extendScope(AbiTypeScope, { static: "address", dynamic: "string" }); - - attest< - ["static", "customStatic"], - validateKeys< - getStaticAbiTypeKeys< - { static: "uint256"; dynamic: "string"; customStatic: "static"; customDynamic: "dynamic" }, - typeof scope - >, - ["static", "customStatic"] - > - >(); - }); - - it("should return a tuple of valid keys with an extended scope", () => { - const scope = extendScope(AbiTypeScope, { static: "address", dynamic: "string" }); - - attest< - ["static", "customStatic"], - validateKeys< - getStaticAbiTypeKeys< - { static: "uint256"; dynamic: "string"; customStatic: "static"; customDynamic: "dynamic" }, - typeof scope - >, - ["static", "customStatic"] - > - >(); - }); -}); diff --git a/packages/store/ts/config/v2/tableFull.ts b/packages/store/ts/config/v2/tableFull.ts deleted file mode 100644 index 46216e40e7..0000000000 --- a/packages/store/ts/config/v2/tableFull.ts +++ /dev/null @@ -1,228 +0,0 @@ -import { conform, evaluate } from "@arktype/util"; -import { isStaticAbiType } from "@latticexyz/schema-type/internal"; -import { Hex } from "viem"; -import { get, hasOwnKey } from "./generics"; -import { SchemaInput, isSchemaInput, resolveSchema } from "./schema"; -import { AbiTypeScope, getStaticAbiTypeKeys } from "./scope"; -import { TableCodegenOptions } from "./output"; -import { TABLE_CODEGEN_DEFAULTS, TABLE_DEFAULTS } from "./defaults"; -import { resourceToHex } from "@latticexyz/common"; - -export type TableFullInput< - schema extends SchemaInput = SchemaInput, - scope extends AbiTypeScope = AbiTypeScope, - key extends ValidKeys = ValidKeys, -> = { - schema: schema; - key: key; - tableId?: Hex; - type?: "table" | "offchainTable"; - name?: string; - namespace?: string; - codegen?: Partial; -}; - -export type ValidKeys, scope extends AbiTypeScope> = readonly [ - getStaticAbiTypeKeys, - ...getStaticAbiTypeKeys[], -]; - -function getValidKeys, scope extends AbiTypeScope>( - schema: schema, - scope: scope = AbiTypeScope as scope, -): ValidKeys { - return Object.entries(schema) - .filter(([, internalType]) => hasOwnKey(scope.types, internalType) && isStaticAbiType(scope.types[internalType])) - .map(([key]) => key) as unknown as ValidKeys; -} - -export function isValidPrimaryKey, scope extends AbiTypeScope>( - key: unknown, - schema: schema, - scope: scope = AbiTypeScope as scope, -): key is ValidKeys { - return ( - Array.isArray(key) && - key.every( - (key) => - hasOwnKey(schema, key) && hasOwnKey(scope.types, schema[key]) && isStaticAbiType(scope.types[schema[key]]), - ) - ); -} - -export function isTableFullInput(input: unknown): input is TableFullInput { - return ( - typeof input === "object" && - input !== null && - hasOwnKey(input, "schema") && - hasOwnKey(input, "key") && - Array.isArray(input["key"]) - ); -} - -export type validateKeys = { - [i in keyof keys]: keys[i] extends validKeys ? keys[i] : validKeys; -}; - -export type validateTableFull = { - [key in keyof input]: key extends "key" - ? validateKeys, SchemaInput>, scope>, input[key]> - : key extends "schema" - ? conform> - : key extends keyof TableFullInput - ? TableFullInput[key] - : input[key]; -}; - -export function validateTableFull( - input: input, - scope: scope = AbiTypeScope as scope, -): asserts input is TableFullInput, scope> & input { - if (typeof input !== "object" || input == null) { - throw new Error(`Expected full table config, got ${JSON.stringify(input)}`); - } - - if (!hasOwnKey(input, "schema") || !isSchemaInput(input["schema"], scope)) { - throw new Error("Invalid schema input"); - } - - if (!hasOwnKey(input, "key") || !isValidPrimaryKey(input["key"], input["schema"], scope)) { - throw new Error( - `Invalid key. Expected \`(${getValidKeys(input["schema"], scope) - .map((item) => `"${String(item)}"`) - .join(" | ")})[]\`, received \`${ - hasOwnKey(input, "key") && Array.isArray(input.key) - ? `[${input.key.map((item) => `"${item}"`).join(", ")}]` - : "undefined" - }\``, - ); - } -} - -export type resolveTableCodegen< - input extends TableFullInput, scope>, - scope extends AbiTypeScope = AbiTypeScope, -> = { - [key in keyof TableCodegenOptions]-?: key extends keyof input["codegen"] - ? undefined extends input["codegen"][key] - ? key extends "dataStruct" - ? boolean - : key extends keyof typeof TABLE_CODEGEN_DEFAULTS - ? (typeof TABLE_CODEGEN_DEFAULTS)[key] - : never - : input["codegen"][key] - : // dataStruct isn't narrowed, because its value is conditional on the number of value schema fields - key extends "dataStruct" - ? boolean - : key extends keyof typeof TABLE_CODEGEN_DEFAULTS - ? (typeof TABLE_CODEGEN_DEFAULTS)[key] - : never; -}; - -export function resolveTableCodegen< - input extends TableFullInput, scope>, - scope extends AbiTypeScope = AbiTypeScope, ->(input: input, scope: scope): resolveTableCodegen { - const options = input.codegen; - return { - directory: get(options, "directory") ?? TABLE_CODEGEN_DEFAULTS.directory, - tableIdArgument: get(options, "tableIdArgument") ?? TABLE_CODEGEN_DEFAULTS.tableIdArgument, - storeArgument: get(options, "storeArgument") ?? TABLE_CODEGEN_DEFAULTS.storeArgument, - // dataStruct is true if there are at least 2 value fields - dataStruct: get(options, "dataStruct") ?? Object.keys(input.schema).length - input.key.length > 1, - } satisfies TableCodegenOptions as resolveTableCodegen; -} - -export type tableWithDefaults< - table extends TableFullInput, scope>, - defaultName extends string, - defaultNamespace extends string, - scope extends AbiTypeScope = AbiTypeScope, -> = { - [key in keyof TableFullInput]-?: undefined extends table[key] - ? key extends "name" - ? defaultName - : key extends "namespace" - ? defaultNamespace - : key extends "type" - ? typeof TABLE_DEFAULTS.type - : table[key] - : table[key]; -}; - -export function tableWithDefaults< - table extends TableFullInput, - defaultName extends string, - defaultNamespace extends string, ->( - table: table, - defaultName: defaultName, - defaultNamespace: defaultNamespace, -): tableWithDefaults { - return { - ...table, - tableId: - table.tableId ?? - (defaultName - ? resourceToHex({ type: TABLE_DEFAULTS.type, namespace: defaultNamespace, name: defaultName }) - : undefined), - name: table.name ?? defaultName, - namespace: table.namespace ?? defaultNamespace, - type: table.type ?? TABLE_DEFAULTS.type, - } as tableWithDefaults; -} - -export type resolveTableFullConfig< - input extends TableFullInput, scope>, - scope extends AbiTypeScope = AbiTypeScope, -> = evaluate<{ - readonly tableId: Hex; - readonly name: input["name"] extends undefined ? "" : input["name"]; - readonly namespace: input["namespace"] extends undefined ? "" : input["namespace"]; - readonly type: input["type"] extends undefined ? "table" : input["type"]; - readonly key: Readonly; - readonly schema: resolveSchema; - readonly keySchema: resolveSchema< - { - readonly [key in input["key"][number]]: input["schema"][key]; - }, - scope - >; - readonly valueSchema: resolveSchema< - { - readonly [key in Exclude]: input["schema"][key]; - }, - scope - >; - readonly codegen: resolveTableCodegen; -}>; - -export function resolveTableFullConfig< - input extends TableFullInput, scope>, - scope extends AbiTypeScope = AbiTypeScope, ->(input: input, scope: scope = AbiTypeScope as scope): resolveTableFullConfig { - validateTableFull(input, scope); - - return { - // TODO: require tableId and name as inputs - tableId: input.tableId ?? ("0x" as Hex), - name: input.name ?? ("" as const), - namespace: input.namespace ?? ("" as const), - type: input.type ?? ("table" as const), - key: input["key"], - schema: resolveSchema(input["schema"], scope), - keySchema: resolveSchema( - Object.fromEntries( - Object.entries(input["schema"]).filter(([key]) => input["key"].includes(key as input["key"][number])), - ), - scope, - ), - valueSchema: resolveSchema( - Object.fromEntries( - Object.entries(input["schema"]).filter(([key]) => !input["key"].includes(key as input["key"][number])), - ), - scope, - ), - codegen: resolveTableCodegen(input, scope), - } as unknown as resolveTableFullConfig; -} diff --git a/packages/store/ts/config/v2/tableShorthand.test.ts b/packages/store/ts/config/v2/tableShorthand.test.ts index cf086e6139..2989f58844 100644 --- a/packages/store/ts/config/v2/tableShorthand.test.ts +++ b/packages/store/ts/config/v2/tableShorthand.test.ts @@ -1,11 +1,11 @@ import { describe, it } from "vitest"; import { attest } from "@arktype/attest"; import { AbiTypeScope, extendScope } from "./scope"; -import { resolveTableShorthand } from "./tableShorthand"; +import { defineTableShorthand } from "./tableShorthand"; -describe("resolveTableShorthand", () => { +describe("defineTableShorthand", () => { it("should expand a single ABI type into a id/value schema", () => { - const table = resolveTableShorthand("address"); + const table = defineTableShorthand("address"); attest<{ schema: { @@ -24,7 +24,7 @@ describe("resolveTableShorthand", () => { it("should expand a single custom type into a id/value schema", () => { const scope = extendScope(AbiTypeScope, { CustomType: "uint256" }); - const table = resolveTableShorthand("CustomType", scope); + const table = defineTableShorthand("CustomType", scope); attest<{ schema: { @@ -44,7 +44,7 @@ describe("resolveTableShorthand", () => { it("should throw if the provided shorthand is not an ABI type and no user types are provided", () => { attest(() => // @ts-expect-error Argument of type '"NotAnAbiType"' is not assignable to parameter of type AbiType' - resolveTableShorthand("NotAnAbiType"), + defineTableShorthand("NotAnAbiType"), ) .throws("Invalid ABI type. `NotAnAbiType` not found in scope.") .type.errors(`Argument of type '"NotAnAbiType"' is not assignable to parameter of type 'AbiType'.`); @@ -55,7 +55,7 @@ describe("resolveTableShorthand", () => { attest(() => // @ts-expect-error Argument of type '"NotACustomType"' is not assignable to parameter of type AbiType | "CustomType" - resolveTableShorthand("NotACustomType", scope), + defineTableShorthand("NotACustomType", scope), ) .throws("Invalid ABI type. `NotACustomType` not found in scope.") .type.errors( @@ -64,7 +64,7 @@ describe("resolveTableShorthand", () => { }); it("should use `id` as single key if it has a static ABI type", () => { - const table = resolveTableShorthand({ id: "address", name: "string", age: "uint256" }); + const table = defineTableShorthand({ id: "address", name: "string", age: "uint256" }); attest<{ schema: { @@ -86,7 +86,7 @@ describe("resolveTableShorthand", () => { it("should throw an error if the shorthand doesn't include an `id` field", () => { attest(() => // @ts-expect-error Invalid schema. Expected an `id` field with a static ABI type or an explicit `key` option. - resolveTableShorthand({ name: "string", age: "uint256" }), + defineTableShorthand({ name: "string", age: "uint256" }), ).throwsAndHasTypeError( "Invalid schema. Expected an `id` field with a static ABI type or an explicit `key` option.", ); @@ -95,7 +95,7 @@ describe("resolveTableShorthand", () => { it("should throw an error if the shorthand config includes a non-static `id` field", () => { attest(() => // @ts-expect-error Invalid schema. Expected an `id` field with a static ABI type or an explicit `key` option. - resolveTableShorthand({ id: "string", name: "string", age: "uint256" }), + defineTableShorthand({ id: "string", name: "string", age: "uint256" }), ).throwsAndHasTypeError( "Invalid schema. Expected an `id` field with a static ABI type or an explicit `key` option.", ); @@ -104,7 +104,7 @@ describe("resolveTableShorthand", () => { it("should throw an error if an invalid type is passed in", () => { attest(() => // @ts-expect-error Type '"NotACustomType"' is not assignable to type 'AbiType'. - resolveTableShorthand({ id: "uint256", name: "NotACustomType" }), + defineTableShorthand({ id: "uint256", name: "NotACustomType" }), ) .throws("Invalid schema. Are you using invalid types or missing types in your scope?") .type.errors(`Type '"NotACustomType"' is not assignable to type 'AbiType'.`); @@ -112,7 +112,7 @@ describe("resolveTableShorthand", () => { it("should use `id` as single key if it has a static custom type", () => { const scope = extendScope(AbiTypeScope, { CustomType: "uint256" }); - const table = resolveTableShorthand({ id: "CustomType", name: "string", age: "uint256" }, scope); + const table = defineTableShorthand({ id: "CustomType", name: "string", age: "uint256" }, scope); attest<{ schema: { id: "CustomType"; name: "string"; age: "uint256" }; @@ -127,7 +127,7 @@ describe("resolveTableShorthand", () => { const scope = extendScope(AbiTypeScope, { CustomType: "bytes" }); attest(() => // @ts-expect-error "Error: Invalid schema. Expected an `id` field with a static ABI type or an explicit `key` option." - resolveTableShorthand({ id: "CustomType", name: "string", age: "uint256" }, scope), + defineTableShorthand({ id: "CustomType", name: "string", age: "uint256" }, scope), ).throwsAndHasTypeError( "Invalid schema. Expected an `id` field with a static ABI type or an explicit `key` option.", ); diff --git a/packages/store/ts/config/v2/tableShorthand.ts b/packages/store/ts/config/v2/tableShorthand.ts index e6b45d135e..f3c73dd3b1 100644 --- a/packages/store/ts/config/v2/tableShorthand.ts +++ b/packages/store/ts/config/v2/tableShorthand.ts @@ -1,48 +1,54 @@ -import { ErrorMessage, evaluate } from "@arktype/util"; +import { ErrorMessage, conform } from "@arktype/util"; import { isStaticAbiType } from "@latticexyz/schema-type/internal"; -import { hasOwnKey } from "./generics"; -import { SchemaInput, isSchemaInput } from "./schema"; -import { AbiTypeScope, getStaticAbiTypeKeys } from "./scope"; -import { TableFullInput } from "./tableFull"; +import { get, hasOwnKey, isObject, mergeIfUndefined } from "./generics"; +import { isSchemaInput } from "./schema"; +import { AbiTypeScope, Scope, getStaticAbiTypeKeys } from "./scope"; +import { SchemaInput, ScopedSchemaInput, TablesWithShorthandsInput } from "./input"; +import { TableShorthandInput } from "./input"; +import { validateTable } from "./table"; export type NoStaticKeyFieldError = ErrorMessage<"Invalid schema. Expected an `id` field with a static ABI type or an explicit `key` option.">; -export type TableShorthandInput = SchemaInput | keyof scope["types"]; - -export function isTableShorthandInput( - input: unknown, - scope: scope = AbiTypeScope as scope, -): input is TableShorthandInput { - return typeof input === "string" || isSchemaInput(input, scope); +export function isTableShorthandInput(shorthand: unknown): shorthand is TableShorthandInput { + return ( + typeof shorthand === "string" || + (isObject(shorthand) && Object.values(shorthand).every((value) => typeof value === "string")) + ); } +export type validateTableWithShorthand = table extends TableShorthandInput + ? validateTableShorthand + : validateTable; + // We don't use `conform` here because the restrictions we're imposing here are not native to typescript -export type validateTableShorthand = - input extends SchemaInput - ? // If a shorthand schema is provided, require it to have a static `id` field - "id" extends getStaticAbiTypeKeys - ? input - : NoStaticKeyFieldError - : input extends keyof scope["types"] - ? input - : input extends string - ? keyof scope["types"] - : SchemaInput; +export type validateTableShorthand = input extends SchemaInput + ? // If a shorthand schema is provided, require it to have a static `id` field + "id" extends getStaticAbiTypeKeys + ? // Require all values to be valid types in this scope + conform> + : NoStaticKeyFieldError + : // If a valid type from the scope is provided, accept it + input extends keyof scope["types"] + ? input + : // If the input is not a valid shorthand, return the expected type + input extends string + ? keyof scope["types"] + : ScopedSchemaInput; -export function validateTableShorthand( - input: unknown, - scope: scope = AbiTypeScope as scope, -): asserts input is TableShorthandInput { - if (typeof input === "string") { - if (hasOwnKey(scope.types, input)) { +export function validateTableShorthand( + shorthand: unknown, + scope: scope = AbiTypeScope as unknown as scope, +): asserts shorthand is TableShorthandInput { + if (typeof shorthand === "string") { + if (hasOwnKey(scope.types, shorthand)) { return; } - throw new Error(`Invalid ABI type. \`${input}\` not found in scope.`); + throw new Error(`Invalid ABI type. \`${shorthand}\` not found in scope.`); } - if (typeof input === "object" && input !== null) { - if (isSchemaInput(input, scope)) { - if (hasOwnKey(input, "id") && isStaticAbiType(scope.types[input["id"]])) { + if (typeof shorthand === "object" && shorthand !== null) { + if (isSchemaInput(shorthand, scope)) { + if (hasOwnKey(shorthand, "id") && isStaticAbiType(scope.types[shorthand.id as keyof typeof scope.types])) { return; } throw new Error(`Invalid schema. Expected an \`id\` field with a static ABI type or an explicit \`key\` option.`); @@ -52,39 +58,74 @@ export function validateTableShorthand = input extends keyof scope["types"] - ? evaluate< - TableFullInput< - { id: "bytes32"; value: input }, - scope, - ["id" & getStaticAbiTypeKeys<{ id: "bytes32"; value: input }, scope>] - > - > - : input extends SchemaInput - ? "id" extends getStaticAbiTypeKeys +export type resolveTableShorthand< + shorthand, + scope extends Scope = AbiTypeScope, +> = shorthand extends keyof scope["types"] + ? { schema: { id: "bytes32"; value: shorthand }; key: ["id"] } + : shorthand extends SchemaInput + ? "id" extends getStaticAbiTypeKeys ? // If the shorthand includes a static field called `id`, use it as `key` - evaluate> + { schema: shorthand; key: ["id"] } : never : never; -export function resolveTableShorthand( - input: validateTableShorthand, - scope: scope = AbiTypeScope as scope, -): resolveTableShorthand { - validateTableShorthand(input, scope); +export function resolveTableShorthand( + shorthand: shorthand, + scope: scope = AbiTypeScope as unknown as scope, +): resolveTableShorthand { + validateTableShorthand(shorthand, scope); - if (isSchemaInput(input, scope)) { + if (isSchemaInput(shorthand, scope)) { return { - schema: input, + schema: shorthand, key: ["id"], - } as resolveTableShorthand; + } as unknown as resolveTableShorthand; } return { schema: { id: "bytes32", - value: input, + value: shorthand, }, key: ["id"], - } as resolveTableShorthand; + } as unknown as resolveTableShorthand; +} + +export function defineTableShorthand( + shorthand: validateTableShorthand, + scope: scope = AbiTypeScope as unknown as scope, +): resolveTableShorthand { + return resolveTableShorthand(shorthand, scope) as resolveTableShorthand; +} + +/** + * If a shorthand is provided, it is resolved to a full config. + * If a full config is provided, it is passed through. + */ +export type resolveTableWithShorthand = table extends TableShorthandInput + ? resolveTableShorthand + : table; + +export type resolveTablesWithShorthands = { + [key in keyof input]: mergeIfUndefined, { name: key }>; +}; + +export type validateTablesWithShorthands = { + [key in keyof tables]: validateTableWithShorthand; +}; + +export function validateTablesWithShorthands( + tables: unknown, + scope: scope, +): asserts tables is TablesWithShorthandsInput { + if (isObject(tables)) { + for (const key of Object.keys(tables)) { + if (isTableShorthandInput(get(tables, key))) { + validateTableShorthand(get(tables, key), scope); + } else { + validateTable(get(tables, key), scope); + } + } + } } diff --git a/packages/store/ts/config/v2/tables.ts b/packages/store/ts/config/v2/tables.ts new file mode 100644 index 0000000000..6dadc6c97e --- /dev/null +++ b/packages/store/ts/config/v2/tables.ts @@ -0,0 +1,45 @@ +import { ErrorMessage, evaluate } from "@arktype/util"; +import { isObject, mergeIfUndefined } from "./generics"; +import { TablesInput } from "./input"; +import { Scope, AbiTypeScope } from "./scope"; +import { validateTable, resolveTable } from "./table"; + +export type validateTables = { + [key in keyof tables]: tables[key] extends object + ? validateTable + : ErrorMessage<`Expected full table config.`>; +}; + +export function validateTables( + input: unknown, + scope: scope, +): asserts input is TablesInput { + if (isObject(input)) { + for (const table of Object.values(input)) { + validateTable(table, scope); + } + return; + } + throw new Error(`Expected store config, received ${JSON.stringify(input)}`); +} + +export type resolveTables = evaluate<{ + readonly [key in keyof tables]: resolveTable, scope>; +}>; + +export function resolveTables( + tables: tables, + scope: scope = AbiTypeScope as unknown as scope, +): resolveTables { + validateTables(tables, scope); + + if (!isObject(tables)) { + throw new Error(`Expected tables config, received ${JSON.stringify(tables)}`); + } + + return Object.fromEntries( + Object.entries(tables).map(([key, table]) => { + return [key, resolveTable(mergeIfUndefined(table, { name: key }) as validateTable, scope)]; + }), + ) as unknown as resolveTables; +} diff --git a/packages/store/ts/config/v2/userTypes.ts b/packages/store/ts/config/v2/userTypes.ts new file mode 100644 index 0000000000..f038be84f0 --- /dev/null +++ b/packages/store/ts/config/v2/userTypes.ts @@ -0,0 +1,43 @@ +import { mapObject } from "@latticexyz/common/utils"; +import { UserTypes } from "./output"; +import { isSchemaAbiType } from "@latticexyz/schema-type/internal"; +import { AbiTypeScope, extendScope } from "./scope"; +import { hasOwnKey, isObject } from "./generics"; + +export type extractInternalType = { [key in keyof userTypes]: userTypes[key]["type"] }; + +export function extractInternalType(userTypes: userTypes): extractInternalType { + return mapObject(userTypes, (userType) => userType.type); +} + +export function isUserTypes(userTypes: unknown): userTypes is UserTypes { + return isObject(userTypes) && Object.values(userTypes).every((userType) => isSchemaAbiType(userType.type)); +} + +export type scopeWithUserTypes = UserTypes extends userTypes + ? scope + : userTypes extends UserTypes + ? extendScope> + : scope; + +export function scopeWithUserTypes( + userTypes: userTypes, + scope: scope = AbiTypeScope as scope, +): scopeWithUserTypes { + return (isUserTypes(userTypes) ? extendScope(scope, extractInternalType(userTypes)) : scope) as scopeWithUserTypes< + userTypes, + scope + >; +} + +export function validateUserTypes(userTypes: unknown): asserts userTypes is UserTypes { + if (!isObject(userTypes)) { + throw new Error(`Expected userTypes, received ${JSON.stringify(userTypes)}`); + } + + for (const { type } of Object.values(userTypes)) { + if (!hasOwnKey(AbiTypeScope.types, type)) { + throw new Error(`"${String(type)}" is not a valid ABI type.`); + } + } +} diff --git a/packages/world/ts/config/v2/codegen.ts b/packages/world/ts/config/v2/codegen.ts new file mode 100644 index 0000000000..1f7d797182 --- /dev/null +++ b/packages/world/ts/config/v2/codegen.ts @@ -0,0 +1,12 @@ +import { isObject, mergeIfUndefined } from "@latticexyz/store/config/v2"; +import { CODEGEN_DEFAULTS } from "./defaults"; + +export type resolveCodegen = codegen extends {} + ? mergeIfUndefined + : typeof CODEGEN_DEFAULTS; + +export function resolveCodegen(codegen: codegen): resolveCodegen { + return ( + isObject(codegen) ? mergeIfUndefined(codegen, CODEGEN_DEFAULTS) : CODEGEN_DEFAULTS + ) as resolveCodegen; +} diff --git a/packages/world/ts/config/v2/compat.test.ts b/packages/world/ts/config/v2/compat.test.ts index cf2c215641..6ab70ffc14 100644 --- a/packages/world/ts/config/v2/compat.test.ts +++ b/packages/world/ts/config/v2/compat.test.ts @@ -2,16 +2,16 @@ import { describe, it } from "vitest"; import { attest } from "@arktype/attest"; import { WorldConfig as WorldConfigV1 } from "../types"; import { StoreConfig as StoreConfigV1 } from "@latticexyz/store/config"; -import { Config } from "./output"; -import { configToV1 } from "./compat"; +import { World } from "./output"; +import { worldToV1 } from "./compat"; import { mudConfig } from "../../register"; -import { resolveWorldConfig } from "./world"; +import { defineWorld } from "./world"; describe("configToV1", () => { it("should transform the broad v2 output to the broad v1 output", () => { // Making the `worldContractName` prop required here since it is required on the output of `mudConfig` - attest>(); - attest, WorldConfigV1 & StoreConfigV1 & { worldContractName: string | undefined }>(); + attest>(); + attest, WorldConfigV1 & StoreConfigV1 & { worldContractName: string | undefined }>(); }); it("should transform a v2 store config output to the v1 config output", () => { @@ -56,7 +56,7 @@ describe("configToV1", () => { }, }) satisfies StoreConfigV1 & WorldConfigV1; - const configV2 = resolveWorldConfig({ + const configV2 = defineWorld({ enums: { TerrainType: ["None", "Ocean", "Grassland", "Desert"], }, @@ -90,7 +90,7 @@ describe("configToV1", () => { }, }); - attest(configToV1(configV2)).equals(configV1); - attest>(configV1); + attest(worldToV1(configV2)).equals(configV1); + attest>(configV1); }); }); diff --git a/packages/world/ts/config/v2/compat.ts b/packages/world/ts/config/v2/compat.ts index 4b80eb812b..fc1272a633 100644 --- a/packages/world/ts/config/v2/compat.ts +++ b/packages/world/ts/config/v2/compat.ts @@ -1,13 +1,12 @@ import { conform, mutable } from "@arktype/util"; -import { ModuleConfig } from "./input"; -import { Config, SystemsConfig } from "./output"; -import { configToV1 as storeConfigToV1, Config as StoreConfig } from "@latticexyz/store/config/v2"; +import { Module, World, Systems } from "./output"; +import { storeToV1, Store } from "@latticexyz/store/config/v2"; -type modulesToV1 = mutable<{ +type modulesToV1 = mutable<{ [key in keyof modules]: Required; }>; -function modulesToV1(modules: modules): modulesToV1 { +function modulesToV1(modules: modules): modulesToV1 { return modules.map((module) => ({ name: module.name, root: module.root ?? false, @@ -15,45 +14,45 @@ function modulesToV1(modules: modules): })) as modulesToV1; } -type systemsToV1 = { +type systemsToV1 = { [key in keyof systems]: { name?: systems[key]["name"]; registerFunctionSelectors: systems[key]["registerFunctionSelectors"]; } & ({ openAccess: true } | { openAccess: false; accessList: systems[key]["accessList"] }); }; -function systemsToV1(systems: systems): systemsToV1 { +function systemsToV1(systems: systems): systemsToV1 { return systems; } -export type configToV1 = config extends Config - ? storeConfigToV1 & { - systems: systemsToV1; - excludeSystems: mutable; - modules: modulesToV1; - worldContractName: config["deployment"]["customWorldContract"]; - postDeployScript: config["deployment"]["postDeployScript"]; - deploysDirectory: config["deployment"]["deploysDirectory"]; - worldsFile: config["deployment"]["worldsFile"]; - worldInterfaceName: config["codegen"]["worldInterfaceName"]; - worldgenDirectory: config["codegen"]["worldgenDirectory"]; - worldImportPath: config["codegen"]["worldImportPath"]; +export type worldToV1 = world extends World + ? storeToV1 & { + systems: systemsToV1; + excludeSystems: mutable; + modules: modulesToV1; + worldContractName: world["deployment"]["customWorldContract"]; + postDeployScript: world["deployment"]["postDeployScript"]; + deploysDirectory: world["deployment"]["deploysDirectory"]; + worldsFile: world["deployment"]["worldsFile"]; + worldInterfaceName: world["codegen"]["worldInterfaceName"]; + worldgenDirectory: world["codegen"]["worldgenDirectory"]; + worldImportPath: world["codegen"]["worldImportPath"]; } : never; -export function configToV1(config: conform): configToV1 { +export function worldToV1(world: conform): worldToV1 { const v1WorldConfig = { - systems: systemsToV1(config.systems), - excludeSystems: config.excludeSystems, - modules: modulesToV1(config.modules), - worldContractName: config.deployment.customWorldContract, - postDeployScript: config.deployment.postDeployScript, - deploysDirectory: config.deployment.deploysDirectory, - worldsFile: config.deployment.worldsFile, - worldInterfaceName: config.codegen.worldInterfaceName, - worldgenDirectory: config.codegen.worldgenDirectory, - worldImportPath: config.codegen.worldImportPath, + systems: systemsToV1(world.systems), + excludeSystems: world.excludeSystems, + modules: modulesToV1(world.modules), + worldContractName: world.deployment.customWorldContract, + postDeployScript: world.deployment.postDeployScript, + deploysDirectory: world.deployment.deploysDirectory, + worldsFile: world.deployment.worldsFile, + worldInterfaceName: world.codegen.worldInterfaceName, + worldgenDirectory: world.codegen.worldgenDirectory, + worldImportPath: world.codegen.worldImportPath, }; - return { ...storeConfigToV1(config as StoreConfig), ...v1WorldConfig } as configToV1; + return { ...storeToV1(world as Store), ...v1WorldConfig } as worldToV1; } diff --git a/packages/world/ts/config/v2/defaults.ts b/packages/world/ts/config/v2/defaults.ts index cfcf8b9504..94c2cf1248 100644 --- a/packages/world/ts/config/v2/defaults.ts +++ b/packages/world/ts/config/v2/defaults.ts @@ -19,6 +19,9 @@ export const DEPLOYMENT_DEFAULTS = { export const CONFIG_DEFAULTS = { systems: {}, + tables: {}, excludeSystems: [] as string[], modules: [], + codegen: CODEGEN_DEFAULTS, + deployment: DEPLOYMENT_DEFAULTS, } as const; diff --git a/packages/world/ts/config/v2/deployment.ts b/packages/world/ts/config/v2/deployment.ts new file mode 100644 index 0000000000..9aeee565a9 --- /dev/null +++ b/packages/world/ts/config/v2/deployment.ts @@ -0,0 +1,12 @@ +import { mergeIfUndefined, isObject } from "@latticexyz/store/config/v2"; +import { DEPLOYMENT_DEFAULTS } from "./defaults"; + +export type resolveDeployment = deployment extends {} + ? mergeIfUndefined + : typeof DEPLOYMENT_DEFAULTS; + +export function resolveDeployment(deployment: deployment): resolveDeployment { + return ( + isObject(deployment) ? mergeIfUndefined(deployment, DEPLOYMENT_DEFAULTS) : DEPLOYMENT_DEFAULTS + ) as resolveDeployment; +} diff --git a/packages/world/ts/config/v2/input.ts b/packages/world/ts/config/v2/input.ts index 8ca8b459e6..fb7c7dc6e4 100644 --- a/packages/world/ts/config/v2/input.ts +++ b/packages/world/ts/config/v2/input.ts @@ -1,17 +1,8 @@ import { evaluate } from "@arktype/util"; -import { UserTypes, Enums, StoreConfigInput } from "@latticexyz/store/config/v2"; -import { DynamicResolution, ValueWithType } from "./dynamicResolution"; +import { StoreInput, StoreWithShorthandsInput } from "@latticexyz/store/config/v2"; +import { Module } from "./output"; -export type ModuleConfig = { - /** The name of the module */ - name: string; - /** Should this module be installed as a root module? */ - root?: boolean; - /** Arguments to be passed to the module's install method */ - args?: (ValueWithType | DynamicResolution)[]; -}; - -export type SystemConfigInput = { +export type SystemInput = { /** The full resource selector consists of namespace and name */ name?: string; /** @@ -28,9 +19,9 @@ export type SystemConfigInput = { accessList?: string[]; }; -export type SystemsConfigInput = { [key: string]: SystemConfigInput }; +export type SystemsInput = { [key: string]: SystemInput }; -export type DeploymentConfigInput = { +export type DeploymentInput = { /** * Script to execute after the deployment is complete (Default "PostDeploy"). * Script must be placed in the forge scripts directory (see foundry.toml) and have a ".s.sol" extension. @@ -42,7 +33,7 @@ export type DeploymentConfigInput = { worldsFile?: string; }; -export type CodegenConfigInput = { +export type CodegenInput = { /** The name of the World interface to generate. (Default `IWorld`) */ worldInterfaceName?: string; /** Directory to output system and world interfaces of `worldgen` (Default "world") */ @@ -51,8 +42,8 @@ export type CodegenConfigInput = { worldImportPath?: string; }; -export type WorldConfigInput = evaluate< - StoreConfigInput & { +export type WorldInput = evaluate< + StoreInput & { namespaces?: NamespacesInput; /** * Contracts named *System will be deployed by default @@ -61,18 +52,22 @@ export type WorldConfigInput; export type NamespacesInput = { [key: string]: NamespaceInput }; -export type NamespaceInput = Pick; +export type NamespaceInput = Pick; + +/******** Variations with shorthands ********/ + +export type WorldWithShorthandsInput = Omit & Pick; diff --git a/packages/world/ts/config/v2/namespaces.ts b/packages/world/ts/config/v2/namespaces.ts new file mode 100644 index 0000000000..b1132e2b81 --- /dev/null +++ b/packages/world/ts/config/v2/namespaces.ts @@ -0,0 +1,57 @@ +import { + Scope, + AbiTypeScope, + validateTables, + isObject, + hasOwnKey, + resolveTable, + mergeIfUndefined, + get, + extendedScope, +} from "@latticexyz/store/config/v2"; +import { NamespacesInput } from "./input"; + +export type namespacedTableKeys = "namespaces" extends keyof world + ? "tables" extends keyof world["namespaces"][keyof world["namespaces"]] + ? `${keyof world["namespaces"] & string}__${keyof world["namespaces"][keyof world["namespaces"]]["tables"] & + string}` + : never + : never; + +export type validateNamespaces = { + [namespace in keyof namespaces]: { + [key in keyof namespaces[namespace]]: key extends "tables" + ? validateTables + : namespaces[namespace][key]; + }; +}; + +export function validateNamespaces( + namespaces: unknown, + scope: scope, +): asserts namespaces is NamespacesInput { + if (isObject(namespaces)) { + for (const namespace of Object.values(namespaces)) { + if (!hasOwnKey(namespace, "tables")) { + throw new Error(`Expected namespace config, received ${JSON.stringify(namespace)}`); + } + validateTables(namespace.tables, scope); + } + return; + } + throw new Error(`Expected namespaces config, received ${JSON.stringify(namespaces)}`); +} + +export type resolveNamespacedTables = "namespaces" extends keyof world + ? { + readonly [key in namespacedTableKeys]: key extends `${infer namespace}__${infer table}` + ? resolveTable< + mergeIfUndefined< + get, namespace>, "tables">, table>, + { name: table; namespace: namespace } + >, + extendedScope + > + : never; + } + : {}; diff --git a/packages/world/ts/config/v2/output.ts b/packages/world/ts/config/v2/output.ts index 0c378cd3a8..1983668030 100644 --- a/packages/world/ts/config/v2/output.ts +++ b/packages/world/ts/config/v2/output.ts @@ -1,7 +1,16 @@ -import { Config as StoreConfig, Table } from "@latticexyz/store/config/v2"; -import { ModuleConfig } from "./input"; +import { Store } from "@latticexyz/store/config/v2"; +import { DynamicResolution, ValueWithType } from "./dynamicResolution"; -export type SystemConfig = { +export type Module = { + /** The name of the module */ + readonly name: string; + /** Should this module be installed as a root module? */ + readonly root?: boolean; + /** Arguments to be passed to the module's install method */ + readonly args?: (ValueWithType | DynamicResolution)[]; +}; + +export type System = { /** The name of the system contract. Becomes part of the `systemId`. */ readonly name: string; /** @@ -18,9 +27,9 @@ export type SystemConfig = { readonly accessList: string[]; }; -export type SystemsConfig = { readonly [key: string]: SystemConfig }; +export type Systems = { readonly [key: string]: System }; -export type DeploymentConfig = { +export type Deployment = { /** The name of a custom World contract to deploy. If no name is provided, a default MUD World is deployed */ readonly customWorldContract: string | undefined; /** @@ -34,7 +43,7 @@ export type DeploymentConfig = { readonly worldsFile: string; }; -export type CodegenConfig = { +export type Codegen = { /** The name of the World interface to generate. (Default `IWorld`) */ readonly worldInterfaceName: string; /** Directory to output system and world interfaces of `worldgen` (Default "world") */ @@ -43,21 +52,14 @@ export type CodegenConfig = { readonly worldImportPath: string; }; -export type Config = StoreConfig & { - readonly namespaces: { - readonly [namespace: string]: { - readonly tables: { - readonly [tableName: string]: Table; - }; - }; - }; - readonly systems: SystemsConfig; +export type World = Store & { + readonly systems: Systems; /** Systems to exclude from automatic deployment */ readonly excludeSystems: readonly string[]; /** Modules to in the World */ - readonly modules: readonly ModuleConfig[]; + readonly modules: readonly Module[]; /** Deployment config */ - readonly deployment: DeploymentConfig; + readonly deployment: Deployment; /** Codegen config */ - readonly codegen: CodegenConfig; + readonly codegen: Codegen; }; diff --git a/packages/world/ts/config/v2/systems.ts b/packages/world/ts/config/v2/systems.ts new file mode 100644 index 0000000000..9440b0a5de --- /dev/null +++ b/packages/world/ts/config/v2/systems.ts @@ -0,0 +1,12 @@ +import { mapObject } from "@latticexyz/common/utils"; +import { SYSTEM_DEFAULTS } from "../defaults"; +import { SystemsInput } from "./input"; +import { mergeIfUndefined } from "@latticexyz/store/config/v2"; + +export type resolveSystems = { + [system in keyof systems]: mergeIfUndefined; +}; + +export function resolveSystems(systems: systems): resolveSystems { + return mapObject(systems, (system) => mergeIfUndefined(system, SYSTEM_DEFAULTS)); +} diff --git a/packages/world/ts/config/v2/world.test.ts b/packages/world/ts/config/v2/world.test.ts index 5aebe0711e..7c7428b48b 100644 --- a/packages/world/ts/config/v2/world.test.ts +++ b/packages/world/ts/config/v2/world.test.ts @@ -1,15 +1,15 @@ import { describe, it } from "vitest"; -import { resolveWorldConfig } from "./world"; -import { Config } from "./output"; +import { defineWorld } from "./world"; import { attest } from "@arktype/attest"; import { resourceToHex } from "@latticexyz/common"; import { TABLE_CODEGEN_DEFAULTS, CODEGEN_DEFAULTS as STORE_CODEGEN_DEFAULTS } from "@latticexyz/store/config/v2"; import { CODEGEN_DEFAULTS as WORLD_CODEGEN_DEFAULTS, DEPLOYMENT_DEFAULTS, CONFIG_DEFAULTS } from "./defaults"; +import { World } from "./output"; const CODEGEN_DEFAULTS = { ...STORE_CODEGEN_DEFAULTS, ...WORLD_CODEGEN_DEFAULTS }; -describe("resolveWorldConfig", () => { +describe("defineWorld", () => { it("should resolve namespaced tables", () => { - const config = resolveWorldConfig({ + const config = defineWorld({ namespaces: { ExampleNamespace: { tables: { @@ -27,6 +27,8 @@ describe("resolveWorldConfig", () => { }); const expected = { + ...CONFIG_DEFAULTS, + codegen: CODEGEN_DEFAULTS, tables: { ExampleNamespace__ExampleTable: { tableId: resourceToHex({ type: "table", namespace: "ExampleNamespace", name: "ExampleTable" }), @@ -67,63 +69,16 @@ describe("resolveWorldConfig", () => { type: "table", }, }, - namespaces: { - ExampleNamespace: { - tables: { - ExampleTable: { - tableId: resourceToHex({ type: "table", namespace: "ExampleNamespace", name: "ExampleTable" }), - schema: { - id: { - type: "address", - internalType: "address", - }, - value: { - type: "uint256", - internalType: "uint256", - }, - dynamic: { - type: "string", - internalType: "string", - }, - }, - keySchema: { - id: { - type: "address", - internalType: "address", - }, - }, - valueSchema: { - value: { - type: "uint256", - internalType: "uint256", - }, - dynamic: { - type: "string", - internalType: "string", - }, - }, - key: ["id"], - name: "ExampleTable", - namespace: "ExampleNamespace", - codegen: { ...TABLE_CODEGEN_DEFAULTS, dataStruct: true as boolean }, - type: "table", - }, - }, - }, - }, userTypes: {}, enums: {}, - codegen: CODEGEN_DEFAULTS, namespace: "", - deployment: DEPLOYMENT_DEFAULTS, - ...CONFIG_DEFAULTS, } as const; attest(config).equals(expected); }); it("should resolve namespaced table config with user types and enums", () => { - const config = resolveWorldConfig({ + const config = defineWorld({ namespaces: { ExampleNamespace: { tables: { @@ -148,6 +103,8 @@ describe("resolveWorldConfig", () => { }); const expected = { + ...CONFIG_DEFAULTS, + codegen: CODEGEN_DEFAULTS, tables: { ExampleNamespace__ExampleTable: { tableId: resourceToHex({ type: "table", namespace: "ExampleNamespace", name: "ExampleTable" }), @@ -188,50 +145,6 @@ describe("resolveWorldConfig", () => { type: "table", }, }, - namespaces: { - ExampleNamespace: { - tables: { - ExampleTable: { - tableId: resourceToHex({ type: "table", namespace: "ExampleNamespace", name: "ExampleTable" }), - schema: { - id: { - type: "address", - internalType: "Static", - }, - value: { - type: "uint8", - internalType: "MyEnum", - }, - dynamic: { - type: "string", - internalType: "Dynamic", - }, - }, - keySchema: { - id: { - type: "address", - internalType: "Static", - }, - }, - valueSchema: { - value: { - type: "uint8", - internalType: "MyEnum", - }, - dynamic: { - type: "string", - internalType: "Dynamic", - }, - }, - key: ["id"], - name: "ExampleTable", - namespace: "ExampleNamespace", - codegen: { ...TABLE_CODEGEN_DEFAULTS, dataStruct: true as boolean }, - type: "table", - }, - }, - }, - }, userTypes: { Static: { type: "address", filePath: "path/to/file" }, Dynamic: { type: "string", filePath: "path/to/file" }, @@ -239,17 +152,14 @@ describe("resolveWorldConfig", () => { enums: { MyEnum: ["First", "Second"], }, - codegen: CODEGEN_DEFAULTS, namespace: "", - deployment: DEPLOYMENT_DEFAULTS, - ...CONFIG_DEFAULTS, } as const; attest(config).equals(expected); }); - it("should extend the output Config type", () => { - const config = resolveWorldConfig({ + it("should extend the output World type", () => { + const config = defineWorld({ namespaces: { ExampleNamespace: { tables: { @@ -273,11 +183,11 @@ describe("resolveWorldConfig", () => { }, }); - attest(); + attest(); }); it("should not use the global namespace for namespaced tables", () => { - const config = resolveWorldConfig({ + const config = defineWorld({ namespace: "namespace", namespaces: { AnotherOne: { @@ -298,108 +208,20 @@ describe("resolveWorldConfig", () => { ); }); - describe("should have the same output as `resolveWorldConfig` for store config inputs", () => { - it("should accept a shorthand store config as input and expand it", () => { - const config = resolveWorldConfig({ tables: { Name: "address" } }); - const expected = { + 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: { - Name: { - tableId: resourceToHex({ type: "table", namespace: "", name: "Name" }), - schema: { - id: { - type: "bytes32", - internalType: "bytes32", - }, - value: { - type: "address", - internalType: "address", - }, - }, - keySchema: { - id: { - type: "bytes32", - internalType: "bytes32", - }, - }, - valueSchema: { - value: { - type: "address", - internalType: "address", - }, - }, - key: ["id"], - name: "Name", - namespace: "", - codegen: { ...TABLE_CODEGEN_DEFAULTS, dataStruct: false as boolean }, - type: "table", + Example: { + schema: { id: "address", name: "string", age: "uint256" }, + key: ["age"], }, }, - userTypes: {}, - enums: {}, - namespace: "", - codegen: CODEGEN_DEFAULTS, - namespaces: {}, - deployment: DEPLOYMENT_DEFAULTS, - ...CONFIG_DEFAULTS, - } as const; - - attest(config).equals(expected); - attest(expected); - }); - - it("should accept a user type as input and expand it", () => { - const config = resolveWorldConfig({ - tables: { Name: "CustomType" }, - userTypes: { CustomType: { type: "address", filePath: "path/to/file" } }, }); - const expected = { - tables: { - Name: { - tableId: resourceToHex({ type: "table", namespace: "", name: "Name" }), - schema: { - id: { - type: "bytes32", - internalType: "bytes32", - }, - value: { - type: "address", - internalType: "CustomType", - }, - }, - keySchema: { - id: { - type: "bytes32", - internalType: "bytes32", - }, - }, - valueSchema: { - value: { - type: "address", - internalType: "CustomType", - }, - }, - key: ["id"], - name: "Name", - namespace: "", - codegen: { ...TABLE_CODEGEN_DEFAULTS, dataStruct: false as boolean }, - type: "table", - }, - }, - userTypes: { CustomType: { type: "address", filePath: "path/to/file" } }, - enums: {}, - namespace: "", - codegen: CODEGEN_DEFAULTS, - namespaces: {}, - deployment: DEPLOYMENT_DEFAULTS, - ...CONFIG_DEFAULTS, - } as const; - - attest(config).equals(expected); - }); - it("given a schema with a key field with static ABI type, it should use `id` as single key", () => { - const config = resolveWorldConfig({ tables: { Example: { id: "address", name: "string", age: "uint256" } } }); const expected = { + ...CONFIG_DEFAULTS, + codegen: CODEGEN_DEFAULTS, tables: { Example: { tableId: resourceToHex({ type: "table", namespace: "", name: "Example" }), @@ -418,77 +240,22 @@ describe("resolveWorldConfig", () => { }, }, keySchema: { - id: { - type: "address", - internalType: "address", - }, - }, - valueSchema: { - name: { - type: "string", - internalType: "string", - }, age: { type: "uint256", internalType: "uint256", }, }, - key: ["id"], - name: "Example", - namespace: "", - codegen: { ...TABLE_CODEGEN_DEFAULTS, dataStruct: true as boolean }, - type: "table", - }, - }, - userTypes: {}, - enums: {}, - namespace: "", - codegen: CODEGEN_DEFAULTS, - namespaces: {}, - deployment: DEPLOYMENT_DEFAULTS, - ...CONFIG_DEFAULTS, - } as const; - - attest(config).equals(expected); - }); - - it("given a schema with a key field with static custom type, it should use `id` as single key", () => { - const config = resolveWorldConfig({ tables: { Example: { id: "address", name: "string", age: "uint256" } } }); - const expected = { - tables: { - Example: { - tableId: resourceToHex({ type: "table", namespace: "", name: "Example" }), - schema: { - id: { - type: "address", - internalType: "address", - }, - name: { - type: "string", - internalType: "string", - }, - age: { - type: "uint256", - internalType: "uint256", - }, - }, - keySchema: { + valueSchema: { id: { type: "address", internalType: "address", }, - }, - valueSchema: { name: { type: "string", internalType: "string", }, - age: { - type: "uint256", - internalType: "uint256", - }, }, - key: ["id"], + key: ["age"], name: "Example", namespace: "", codegen: { ...TABLE_CODEGEN_DEFAULTS, dataStruct: true as boolean }, @@ -498,92 +265,54 @@ describe("resolveWorldConfig", () => { userTypes: {}, enums: {}, namespace: "", - codegen: CODEGEN_DEFAULTS, - namespaces: {}, - deployment: DEPLOYMENT_DEFAULTS, - ...CONFIG_DEFAULTS, } as const; attest(config).equals(expected); }); - it("throw an error if the shorthand doesn't include a key field", () => { - attest(() => - resolveWorldConfig({ - 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. - resolveWorldConfig({ 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(() => - resolveWorldConfig({ - // @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 return the full config given a full config with one key", () => { - const config = resolveWorldConfig({ + it("should return the full config given a full config with one key and user types", () => { + const config = defineWorld({ tables: { Example: { - schema: { id: "address", name: "string", age: "uint256" }, + 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 expected = { + ...CONFIG_DEFAULTS, + codegen: CODEGEN_DEFAULTS, tables: { Example: { tableId: resourceToHex({ type: "table", namespace: "", name: "Example" }), schema: { id: { - type: "address", - internalType: "address", + type: "string", + internalType: "dynamic", }, name: { type: "string", internalType: "string", }, age: { - type: "uint256", - internalType: "uint256", + type: "address", + internalType: "static", }, }, keySchema: { age: { - type: "uint256", - internalType: "uint256", + type: "address", + internalType: "static", }, }, valueSchema: { id: { - type: "address", - internalType: "address", + type: "string", + internalType: "dynamic", }, name: { type: "string", @@ -597,150 +326,80 @@ describe("resolveWorldConfig", () => { type: "table", }, }, - userTypes: {}, + userTypes: { + static: { type: "address", filePath: "path/to/file" }, + dynamic: { type: "string", filePath: "path/to/file" }, + }, enums: {}, namespace: "", - codegen: CODEGEN_DEFAULTS, - namespaces: {}, - deployment: DEPLOYMENT_DEFAULTS, - ...CONFIG_DEFAULTS, } as const; attest(config).equals(expected); }); - it("should return the full config given a full config with one key and user types", () => { - const config = resolveWorldConfig({ + it("should return the full config given a full config with two key", () => { + const config = defineWorld({ tables: { Example: { - schema: { id: "dynamic", name: "string", age: "static" }, - key: ["age"], + schema: { id: "address", name: "string", age: "uint256" }, + key: ["age", "id"], }, }, - userTypes: { - static: { type: "address", filePath: "path/to/file" }, - dynamic: { type: "string", filePath: "path/to/file" }, - }, }); const expected = { + ...CONFIG_DEFAULTS, + codegen: CODEGEN_DEFAULTS, tables: { Example: { tableId: resourceToHex({ type: "table", namespace: "", name: "Example" }), schema: { id: { - type: "string", - internalType: "dynamic", + type: "address", + internalType: "address", }, name: { type: "string", internalType: "string", }, age: { - type: "address", - internalType: "static", + type: "uint256", + internalType: "uint256", }, }, keySchema: { age: { + type: "uint256", + internalType: "uint256", + }, + id: { type: "address", - internalType: "static", + internalType: "address", }, }, valueSchema: { - id: { - type: "string", - internalType: "dynamic", - }, name: { type: "string", internalType: "string", }, }, - key: ["age"], + key: ["age", "id"], name: "Example", namespace: "", - codegen: { ...TABLE_CODEGEN_DEFAULTS, dataStruct: true as boolean }, + codegen: { ...TABLE_CODEGEN_DEFAULTS, dataStruct: false as boolean }, type: "table", }, }, - userTypes: { - static: { type: "address", filePath: "path/to/file" }, - dynamic: { type: "string", filePath: "path/to/file" }, - }, + userTypes: {}, enums: {}, namespace: "", - codegen: CODEGEN_DEFAULTS, - namespaces: {}, deployment: DEPLOYMENT_DEFAULTS, - ...CONFIG_DEFAULTS, } as const; attest(config).equals(expected); - }), - it("should return the full config given a full config with two key", () => { - const config = resolveWorldConfig({ - tables: { - Example: { - schema: { id: "address", name: "string", age: "uint256" }, - key: ["age", "id"], - }, - }, - }); - const expected = { - tables: { - Example: { - tableId: resourceToHex({ type: "table", namespace: "", name: "Example" }), - schema: { - id: { - type: "address", - internalType: "address", - }, - name: { - type: "string", - internalType: "string", - }, - age: { - type: "uint256", - internalType: "uint256", - }, - }, - keySchema: { - age: { - type: "uint256", - internalType: "uint256", - }, - id: { - type: "address", - internalType: "address", - }, - }, - valueSchema: { - name: { - type: "string", - internalType: "string", - }, - }, - key: ["age", "id"], - name: "Example", - namespace: "", - codegen: { ...TABLE_CODEGEN_DEFAULTS, dataStruct: false as boolean }, - type: "table", - }, - }, - userTypes: {}, - enums: {}, - namespace: "", - codegen: CODEGEN_DEFAULTS, - namespaces: {}, - deployment: DEPLOYMENT_DEFAULTS, - ...CONFIG_DEFAULTS, - } as const; - - attest(config).equals(expected); - }); + }); it("should resolve two tables in the config with different schemas", () => { - const config = resolveWorldConfig({ + const config = defineWorld({ tables: { First: { schema: { firstKey: "address", firstName: "string", firstAge: "uint256" }, @@ -753,6 +412,8 @@ describe("resolveWorldConfig", () => { }, }); const expected = { + ...CONFIG_DEFAULTS, + codegen: CODEGEN_DEFAULTS, tables: { First: { tableId: resourceToHex({ type: "table", namespace: "", name: "First" }), @@ -834,17 +495,13 @@ describe("resolveWorldConfig", () => { userTypes: {}, enums: {}, namespace: "", - codegen: CODEGEN_DEFAULTS, - namespaces: {}, - deployment: DEPLOYMENT_DEFAULTS, - ...CONFIG_DEFAULTS, } as const; attest(config).equals(expected); }); it("should resolve two tables in the config with different schemas and user types", () => { - const config = resolveWorldConfig({ + const config = defineWorld({ tables: { First: { schema: { firstKey: "Static", firstName: "Dynamic", firstAge: "uint256" }, @@ -860,7 +517,10 @@ describe("resolveWorldConfig", () => { Dynamic: { type: "string", filePath: "path/to/file" }, }, }); + const expected = { + ...CONFIG_DEFAULTS, + codegen: CODEGEN_DEFAULTS, tables: { First: { tableId: resourceToHex({ type: "table", namespace: "", name: "First" }), @@ -945,10 +605,6 @@ describe("resolveWorldConfig", () => { }, enums: {}, namespace: "", - codegen: CODEGEN_DEFAULTS, - namespaces: {}, - deployment: DEPLOYMENT_DEFAULTS, - ...CONFIG_DEFAULTS, } as const; attest(config).equals(expected); @@ -956,7 +612,7 @@ describe("resolveWorldConfig", () => { it("should throw if referring to fields of different tables", () => { attest(() => - resolveWorldConfig({ + defineWorld({ tables: { First: { schema: { firstKey: "address", firstName: "string", firstAge: "uint256" }, @@ -976,7 +632,7 @@ describe("resolveWorldConfig", () => { it("should throw an error if the provided key is not a static field", () => { attest(() => - resolveWorldConfig({ + defineWorld({ tables: { Example: { schema: { id: "address", name: "string", age: "uint256" }, @@ -992,7 +648,7 @@ describe("resolveWorldConfig", () => { it("should throw an error if the provided key is not a static field with user types", () => { attest(() => - resolveWorldConfig({ + defineWorld({ tables: { Example: { schema: { id: "address", name: "Dynamic", age: "uint256" }, @@ -1010,7 +666,7 @@ describe("resolveWorldConfig", () => { }); it("should return the full config given a full config with enums and user types", () => { - const config = resolveWorldConfig({ + const config = defineWorld({ tables: { Example: { schema: { id: "dynamic", name: "ValidNames", age: "static" }, @@ -1026,6 +682,8 @@ describe("resolveWorldConfig", () => { }, }); const expected = { + ...CONFIG_DEFAULTS, + codegen: CODEGEN_DEFAULTS, tables: { Example: { tableId: resourceToHex({ type: "table", namespace: "", name: "Example" }), @@ -1074,37 +732,35 @@ describe("resolveWorldConfig", () => { ValidNames: ["first", "second"], }, namespace: "", - codegen: CODEGEN_DEFAULTS, - namespaces: {}, - deployment: DEPLOYMENT_DEFAULTS, - ...CONFIG_DEFAULTS, } as const; attest(config).equals(expected); + attest(expected).equals(expected); }); it("should use the root namespace as default namespace", () => { - const config = resolveWorldConfig({}); + const config = defineWorld({}); attest<"">(config.namespace).equals(""); }); it("should use pipe through non-default namespaces", () => { - const config = resolveWorldConfig({ namespace: "custom" }); + const config = defineWorld({ namespace: "custom" }); attest<"custom">(config.namespace).equals("custom"); }); - it("should extend the output Config type", () => { - const config = resolveWorldConfig({ - tables: { Name: "CustomType" }, + 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" } }, }); - attest(); + + attest(); }); it("should use the global namespace instead for tables", () => { - const config = resolveWorldConfig({ + const config = defineWorld({ namespace: "namespace", tables: { Example: { diff --git a/packages/world/ts/config/v2/world.ts b/packages/world/ts/config/v2/world.ts index d38f1d5f2c..16fb12fcee 100644 --- a/packages/world/ts/config/v2/world.ts +++ b/packages/world/ts/config/v2/world.ts @@ -1,231 +1,125 @@ import { conform, evaluate, narrow } from "@arktype/util"; -import { resourceToHex } from "@latticexyz/common"; import { mapObject } from "@latticexyz/common/utils"; import { UserTypes, - resolveStoreConfig, - resolveStoreTablesConfig, extendedScope, - resolveTableConfig, - AbiTypeScope, get, - isTableShorthandInput, - resolveTableShorthand, - validateTableShorthand, - resolveTableFullConfig, - validateStoreTablesConfig, - validateTableFull, - isObject, - hasOwnKey, - Table, + resolveTable, + validateTable, resolveCodegen as resolveStoreCodegen, + mergeIfUndefined, + validateTables, + resolveStore, + resolveTables, + Store, + hasOwnKey, + validateStore, + isObject, } from "@latticexyz/store/config/v2"; -import { Config } from "./output"; -import { NamespacesInput, SystemsConfigInput, WorldConfigInput } from "./input"; -import { DEPLOYMENT_DEFAULTS, CODEGEN_DEFAULTS, CONFIG_DEFAULTS, SYSTEM_DEFAULTS } from "./defaults"; - -export type validateNamespaces = { - [namespace in keyof input]: { - [key in keyof input[namespace]]: key extends "tables" - ? validateStoreTablesConfig - : input[namespace][key]; - }; -}; - -function validateNamespaces( - input: unknown, - scope: scope, -): asserts input is NamespacesInput { - if (isObject(input)) { - for (const namespace of Object.values(input)) { - if (!hasOwnKey(namespace, "tables")) { - throw new Error(`Expected namespace config, received ${JSON.stringify(namespace)}`); - } - validateStoreTablesConfig(namespace.tables, scope); - } - return; - } - throw new Error(`Expected namespaces config, received ${JSON.stringify(input)}`); -} - -export type validateWorldConfig = { - readonly [key in keyof input]: key extends "tables" - ? validateStoreTablesConfig> +import { SystemsInput, WorldInput } from "./input"; +import { CONFIG_DEFAULTS } from "./defaults"; +import { Tables } from "@latticexyz/store"; +import { resolveSystems } from "./systems"; +import { resolveNamespacedTables, validateNamespaces } from "./namespaces"; +import { resolveCodegen } from "./codegen"; +import { resolveDeployment } from "./deployment"; + +export type validateWorld = { + readonly [key in keyof world]: key extends "tables" + ? validateTables> : key extends "userTypes" ? UserTypes : key extends "enums" - ? narrow + ? narrow : key extends "namespaces" - ? validateNamespaces> - : key extends keyof WorldConfigInput - ? conform - : input[key]; + ? validateNamespaces> + : key extends keyof WorldInput + ? conform + : world[key]; }; -export type namespacedTableKeys = "namespaces" extends keyof input - ? "tables" extends keyof input["namespaces"][keyof input["namespaces"]] - ? `${keyof input["namespaces"] & string}__${keyof input["namespaces"][keyof input["namespaces"]]["tables"] & - string}` - : never - : never; +export function validateWorld(world: unknown): asserts world is WorldInput { + const scope = extendedScope(world); + validateStore(world); -export type resolveDeploymentConfig = { - readonly customWorldContract: "customWorldContract" extends keyof input - ? input["customWorldContract"] - : typeof DEPLOYMENT_DEFAULTS.customWorldContract; - readonly postDeployScript: "postDeployScript" extends keyof input - ? input["postDeployScript"] - : typeof DEPLOYMENT_DEFAULTS.postDeployScript; - readonly deploysDirectory: "deploysDirectory" extends keyof input - ? input["deploysDirectory"] - : typeof DEPLOYMENT_DEFAULTS.deploysDirectory; - readonly worldsFile: "worldsFile" extends keyof input ? input["worldsFile"] : typeof DEPLOYMENT_DEFAULTS.worldsFile; -}; - -export function resolveDeploymentConfig(input: input): resolveDeploymentConfig { - return { - customWorldContract: get(input, "customWorldContract") ?? DEPLOYMENT_DEFAULTS.customWorldContract, - postDeployScript: get(input, "postDeployScript") ?? DEPLOYMENT_DEFAULTS.postDeployScript, - deploysDirectory: get(input, "deploysDirectory") ?? DEPLOYMENT_DEFAULTS.deploysDirectory, - worldsFile: get(input, "worldsFile") ?? DEPLOYMENT_DEFAULTS.worldsFile, - } as resolveDeploymentConfig; -} - -export type resolveCodegenConfig = { - readonly worldInterfaceName: "worldInterfaceName" extends keyof input - ? input["worldInterfaceName"] - : typeof CODEGEN_DEFAULTS.worldInterfaceName; - readonly worldgenDirectory: "worldgenDirectory" extends keyof input - ? input["worldgenDirectory"] - : typeof CODEGEN_DEFAULTS.worldgenDirectory; - readonly worldImportPath: "worldImportPath" extends keyof input - ? input["worldImportPath"] - : typeof CODEGEN_DEFAULTS.worldImportPath; -}; - -export function resolveCodegenConfig(input: input): resolveCodegenConfig { - return { - worldInterfaceName: get(input, "worldInterfaceName") ?? CODEGEN_DEFAULTS.worldInterfaceName, - worldgenDirectory: get(input, "worldgenDirectory") ?? CODEGEN_DEFAULTS.worldgenDirectory, - worldImportPath: get(input, "worldImportPath") ?? CODEGEN_DEFAULTS.worldImportPath, - } as resolveCodegenConfig; -} - -export type resolveSystemsConfig = { - [system in keyof systems]: { - readonly name: systems[system]["name"]; - readonly registerFunctionSelectors: systems[system]["registerFunctionSelectors"] extends undefined - ? typeof SYSTEM_DEFAULTS.registerFunctionSelectors - : systems[system]["registerFunctionSelectors"]; - readonly openAccess: systems[system]["openAccess"] extends undefined - ? typeof SYSTEM_DEFAULTS.openAccess - : systems[system]["openAccess"]; - readonly accessList: systems[system]["accessList"] extends undefined - ? typeof SYSTEM_DEFAULTS.accessList - : systems[system]["accessList"]; - }; -}; - -export function resolveSystemsConfig( - systems: systems, -): resolveSystemsConfig { - return mapObject( - systems, - (system) => - ({ - name: system.name, - registerFunctionSelectors: system.registerFunctionSelectors ?? SYSTEM_DEFAULTS.registerFunctionSelectors, - openAccess: system.openAccess ?? SYSTEM_DEFAULTS.openAccess, - accessList: system.accessList ?? SYSTEM_DEFAULTS.accessList, - }) as resolveSystemsConfig[keyof systems], - ); + if (hasOwnKey(world, "namespaces")) { + if (!isObject(world.namespaces)) { + throw new Error(`Expected namespaces, received ${JSON.stringify(world.namespaces)}`); + } + for (const namespace of Object.values(world.namespaces)) { + if (hasOwnKey(namespace, "tables")) { + validateTables(namespace.tables, scope); + } + } + } } -export type resolveWorldConfig = evaluate< - resolveStoreConfig & { - readonly tables: "namespaces" extends keyof input - ? { - readonly [key in namespacedTableKeys]: key extends `${infer namespace}__${infer table}` - ? resolveTableConfig< - get, namespace>, "tables">, table>, - extendedScope, - table, - namespace - > - : never; - } - : {}; - readonly namespaces: "namespaces" extends keyof input - ? { - [namespaceKey in keyof input["namespaces"]]: { - readonly tables: resolveStoreTablesConfig< - get, - extendedScope, - namespaceKey & string - >; - }; - } - : {}; - readonly systems: "systems" extends keyof input ? input["systems"] : typeof CONFIG_DEFAULTS.systems; - readonly excludeSystems: "excludeSystems" extends keyof input - ? input["excludeSystems"] - : typeof CONFIG_DEFAULTS.excludeSystems; - readonly modules: "modules" extends keyof input ? input["modules"] : typeof CONFIG_DEFAULTS.modules; - readonly deployment: resolveDeploymentConfig<"deployment" extends keyof input ? input["deployment"] : {}>; - readonly codegen: resolveCodegenConfig<"codegen" extends keyof input ? input["codegen"] : {}>; - } +export type resolveWorld = evaluate< + resolveStore & + mergeIfUndefined< + { tables: resolveNamespacedTables } & Omit< + { + [key in keyof world]: key extends "systems" + ? resolveSystems + : key extends "deployment" + ? resolveDeployment + : key extends "codegen" + ? resolveCodegen + : world[key]; + }, + "namespaces" | keyof Store + >, + typeof CONFIG_DEFAULTS + > >; -export function resolveWorldConfig(input: validateWorldConfig): resolveWorldConfig { - const scope = extendedScope(input); - const namespace = get(input, "namespace") ?? ""; +export function resolveWorld(world: world): resolveWorld { + validateWorld(world); + + const scope = extendedScope(world); + const namespace = get(world, "namespace") ?? ""; - const namespaces = get(input, "namespaces") ?? {}; + const namespaces = get(world, "namespaces") ?? {}; validateNamespaces(namespaces, scope); - const rootTables = get(input, "tables") ?? {}; - validateStoreTablesConfig(rootTables, scope); - - const resolvedNamespaces = mapObject(namespaces, (namespace, namespaceKey) => ({ - tables: mapObject(namespace.tables, (table, tableKey) => { - const fullInput = isTableShorthandInput(table, scope) - ? resolveTableShorthand(table as validateTableShorthand, scope) - : table; - validateTableFull(fullInput, scope); - return resolveTableFullConfig( - { - ...fullInput, - tableId: - fullInput.tableId ?? - resourceToHex({ type: "table", namespace: namespaceKey as string, name: tableKey as string }), - name: fullInput.name ?? (tableKey as string), - namespace: fullInput.namespace ?? (namespaceKey as string), - }, - scope, - ) as Table; - }) as Config["namespaces"][string]["tables"], - })) as Config["namespaces"]; + const rootTables = get(world, "tables") ?? {}; + validateTables(rootTables, scope); const resolvedNamespacedTables = Object.fromEntries( - Object.entries(resolvedNamespaces) + Object.entries(namespaces) .map(([namespaceKey, namespace]) => - Object.entries(namespace.tables).map(([tableKey, table]) => [`${namespaceKey}__${tableKey}`, table]), + Object.entries(namespace.tables).map(([tableKey, table]) => { + validateTable(table, scope); + return [ + `${namespaceKey}__${tableKey}`, + resolveTable(mergeIfUndefined(table, { namespace: namespaceKey, name: tableKey }), scope), + ]; + }), ) .flat(), - ) as Config["tables"]; + ) as Tables; + + const resolvedRootTables = resolveTables( + mapObject(rootTables, (table) => mergeIfUndefined(table, { namespace })), + scope, + ); - const resolvedRootTables = resolveStoreTablesConfig(rootTables, scope, namespace as string); + return mergeIfUndefined( + { + tables: { ...resolvedRootTables, ...resolvedNamespacedTables }, + userTypes: world.userTypes ?? {}, + enums: world.enums ?? {}, + namespace, + codegen: mergeIfUndefined(resolveStoreCodegen(world.codegen), resolveCodegen(world.codegen)), + deployment: resolveDeployment(world.deployment), + systems: resolveSystems(world.systems ?? CONFIG_DEFAULTS.systems), + excludeSystems: get(world, "excludeSystems"), + modules: world.modules, + }, + CONFIG_DEFAULTS, + ) as unknown as resolveWorld; +} - return { - tables: { ...resolvedRootTables, ...resolvedNamespacedTables }, - namespaces: resolvedNamespaces, - userTypes: get(input, "userTypes") ?? {}, - enums: get(input, "enums") ?? {}, - namespace, - codegen: { ...resolveStoreCodegen(get(input, "codegen")), ...resolveCodegenConfig(get(input, "codegen")) }, - deployment: resolveDeploymentConfig(get(input, "deployment")), - systems: resolveSystemsConfig(get(input, "systems") ?? CONFIG_DEFAULTS.systems), - excludeSystems: get(input, "excludeSystems") ?? CONFIG_DEFAULTS.excludeSystems, - modules: get(input, "modules") ?? CONFIG_DEFAULTS.modules, - } as unknown as resolveWorldConfig; +export function defineWorld(world: validateWorld): resolveWorld { + return resolveWorld(world) as unknown as resolveWorld; } diff --git a/packages/world/ts/config/v2/worldWithShorthands.test.ts b/packages/world/ts/config/v2/worldWithShorthands.test.ts new file mode 100644 index 0000000000..ad920fbb2f --- /dev/null +++ b/packages/world/ts/config/v2/worldWithShorthands.test.ts @@ -0,0 +1,398 @@ +import { describe, it } from "vitest"; +import { defineWorldWithShorthands } from "./worldWithShorthands"; +import { attest } from "@arktype/attest"; +import { resourceToHex } from "@latticexyz/common"; +import { TABLE_CODEGEN_DEFAULTS, CODEGEN_DEFAULTS as STORE_CODEGEN_DEFAULTS } from "@latticexyz/store/config/v2"; +import { CODEGEN_DEFAULTS as WORLD_CODEGEN_DEFAULTS, CONFIG_DEFAULTS } from "./defaults"; +const CODEGEN_DEFAULTS = { ...STORE_CODEGEN_DEFAULTS, ...WORLD_CODEGEN_DEFAULTS }; + +describe("defineWorldWithShorthands", () => { + it("should resolve namespaced shorthand table config with user types and enums", () => { + const config = defineWorldWithShorthands({ + namespaces: { + ExampleNamespace: { + 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: { + ExampleNamespace__ExampleTable: { + tableId: resourceToHex({ type: "table", namespace: "ExampleNamespace", name: "ExampleTable" }), + schema: { + id: { + type: "bytes32", + internalType: "bytes32", + }, + value: { + type: "address", + internalType: "Static", + }, + }, + keySchema: { + id: { + type: "bytes32", + internalType: "bytes32", + }, + }, + valueSchema: { + value: { + type: "address", + internalType: "Static", + }, + }, + key: ["id"], + name: "ExampleTable", + namespace: "ExampleNamespace", + codegen: { ...TABLE_CODEGEN_DEFAULTS, dataStruct: false as boolean }, + type: "table", + }, + }, + userTypes: { + Static: { type: "address", filePath: "path/to/file" as string }, + Dynamic: { type: "string", filePath: "path/to/file" as string }, + }, + enums: { + MyEnum: ["First", "Second"], + }, + namespace: "", + } as const; + + attest(config).equals(expected); + }); + + it("should resolve namespaced shorthand schema table config with user types and enums", () => { + const config = defineWorldWithShorthands({ + namespaces: { + ExampleNamespace: { + 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: { + ExampleNamespace__ExampleTable: { + tableId: resourceToHex({ type: "table", namespace: "ExampleNamespace", name: "ExampleTable" }), + schema: { + id: { + type: "address", + internalType: "Static", + }, + value: { + type: "uint8", + internalType: "MyEnum", + }, + dynamic: { + type: "string", + internalType: "Dynamic", + }, + }, + keySchema: { + id: { + type: "address", + internalType: "Static", + }, + }, + valueSchema: { + value: { + type: "uint8", + internalType: "MyEnum", + }, + dynamic: { + type: "string", + internalType: "Dynamic", + }, + }, + key: ["id"], + name: "ExampleTable", + namespace: "ExampleNamespace", + codegen: { ...TABLE_CODEGEN_DEFAULTS, dataStruct: true as boolean }, + type: "table", + }, + }, + userTypes: { + Static: { type: "address", filePath: "path/to/file" as string }, + Dynamic: { type: "string", filePath: "path/to/file" as string }, + }, + enums: { + MyEnum: ["First", "Second"], + }, + namespace: "", + } as const; + + attest(config).equals(expected); + }); + + it("should accept a shorthand store config as input and expand it", () => { + const config = defineWorldWithShorthands({ tables: { Name: "address" } }); + + const expected = { + ...CONFIG_DEFAULTS, + codegen: CODEGEN_DEFAULTS, + tables: { + Name: { + tableId: resourceToHex({ type: "table", namespace: "", name: "Name" }), + schema: { + id: { + type: "bytes32", + internalType: "bytes32", + }, + value: { + type: "address", + internalType: "address", + }, + }, + keySchema: { + id: { + type: "bytes32", + internalType: "bytes32", + }, + }, + valueSchema: { + value: { + type: "address", + internalType: "address", + }, + }, + key: ["id"], + name: "Name", + namespace: "", + codegen: { ...TABLE_CODEGEN_DEFAULTS, dataStruct: false as boolean }, + type: "table", + }, + }, + userTypes: {}, + enums: {}, + namespace: "", + } as const; + + attest(config).equals(expected); + attest(expected); + }); + + it("should accept a user type as input and expand it", () => { + const config = defineWorldWithShorthands({ + tables: { Name: "CustomType" }, + userTypes: { CustomType: { type: "address", filePath: "path/to/file" } }, + }); + const expected = { + ...CONFIG_DEFAULTS, + codegen: CODEGEN_DEFAULTS, + tables: { + Name: { + tableId: resourceToHex({ type: "table", namespace: "", name: "Name" }), + schema: { + id: { + type: "bytes32", + internalType: "bytes32", + }, + value: { + type: "address", + internalType: "CustomType", + }, + }, + keySchema: { + id: { + type: "bytes32", + internalType: "bytes32", + }, + }, + valueSchema: { + value: { + type: "address", + internalType: "CustomType", + }, + }, + key: ["id"], + name: "Name", + namespace: "", + codegen: { ...TABLE_CODEGEN_DEFAULTS, dataStruct: false as boolean }, + type: "table", + }, + }, + userTypes: { CustomType: { type: "address", filePath: "path/to/file" as string } }, + enums: {}, + namespace: "", + } as const; + + attest(config).equals(expected); + }); + + it("given a schema with a key field with static ABI type, it should use `id` as single key", () => { + const config = defineWorldWithShorthands({ + tables: { Example: { id: "address", name: "string", age: "uint256" } }, + }); + const expected = { + ...CONFIG_DEFAULTS, + codegen: CODEGEN_DEFAULTS, + tables: { + Example: { + tableId: resourceToHex({ type: "table", namespace: "", name: "Example" }), + schema: { + id: { + type: "address", + internalType: "address", + }, + name: { + type: "string", + internalType: "string", + }, + age: { + type: "uint256", + internalType: "uint256", + }, + }, + keySchema: { + id: { + type: "address", + internalType: "address", + }, + }, + valueSchema: { + name: { + type: "string", + internalType: "string", + }, + age: { + type: "uint256", + internalType: "uint256", + }, + }, + key: ["id"], + name: "Example", + namespace: "", + codegen: { ...TABLE_CODEGEN_DEFAULTS, dataStruct: true as boolean }, + type: "table", + }, + }, + userTypes: {}, + enums: {}, + namespace: "", + } as const; + + attest(config).equals(expected); + }); + + it("given a schema with a key field with static custom type, it should use `id` as single key", () => { + const config = defineWorldWithShorthands({ + tables: { Example: { id: "address", name: "string", age: "uint256" } }, + }); + const expected = { + ...CONFIG_DEFAULTS, + codegen: CODEGEN_DEFAULTS, + tables: { + Example: { + tableId: resourceToHex({ type: "table", namespace: "", name: "Example" }), + schema: { + id: { + type: "address", + internalType: "address", + }, + name: { + type: "string", + internalType: "string", + }, + age: { + type: "uint256", + internalType: "uint256", + }, + }, + keySchema: { + id: { + type: "address", + internalType: "address", + }, + }, + valueSchema: { + name: { + type: "string", + internalType: "string", + }, + age: { + type: "uint256", + internalType: "uint256", + }, + }, + key: ["id"], + name: "Example", + namespace: "", + codegen: { ...TABLE_CODEGEN_DEFAULTS, dataStruct: true as boolean }, + type: "table", + }, + }, + userTypes: {}, + enums: {}, + namespace: "", + } as const; + + attest(config).equals(expected); + }); + + it("throw an error if the shorthand doesn't include a key field", () => { + attest(() => + defineWorldWithShorthands({ + 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. + defineWorldWithShorthands({ 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(() => + defineWorldWithShorthands({ + // @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.", + ); + }); +}); diff --git a/packages/world/ts/config/v2/worldWithShorthands.ts b/packages/world/ts/config/v2/worldWithShorthands.ts new file mode 100644 index 0000000000..01dfa282cc --- /dev/null +++ b/packages/world/ts/config/v2/worldWithShorthands.ts @@ -0,0 +1,89 @@ +import { + AbiTypeScope, + extendedScope, + get, + hasOwnKey, + isObject, + isTableShorthandInput, + resolveTableShorthand, + resolveTablesWithShorthands, + validateTablesWithShorthands, + validateTableShorthand, + Scope, +} from "@latticexyz/store/config/v2"; +import { mapObject } from "@latticexyz/common/utils"; +import { resolveWorld, validateWorld } from "./world"; +import { WorldWithShorthandsInput } from "./input"; +import { validateNamespaces } from "./namespaces"; + +export type resolveWorldWithShorthands = resolveWorld<{ + [key in keyof world]: key extends "tables" + ? resolveTablesWithShorthands> + : key extends "namespaces" + ? { + [namespaceKey in keyof world[key]]: { + [namespaceProp in keyof world[key][namespaceKey]]: namespaceProp extends "tables" + ? resolveTablesWithShorthands> + : world[key][namespaceKey][namespaceProp]; + }; + } + : world[key]; +}>; + +export type validateWorldWithShorthands = { + [key in keyof world]: key extends "tables" + ? validateTablesWithShorthands> + : key extends "namespaces" + ? validateNamespacesWithShorthands> + : validateWorld[key]; +}; + +function validateWorldWithShorthands(world: unknown): asserts world is WorldWithShorthandsInput { + const scope = extendedScope(world); + if (hasOwnKey(world, "tables")) { + validateTablesWithShorthands(world.tables, scope); + } + + if (hasOwnKey(world, "namespaces") && isObject(world.namespaces)) { + for (const namespaceKey of Object.keys(world.namespaces)) { + validateTablesWithShorthands(get(get(world.namespaces, namespaceKey), "tables") ?? {}, scope); + } + } +} + +export type validateNamespacesWithShorthands = { + [namespace in keyof namespaces]: { + [key in keyof namespaces[namespace]]: key extends "tables" + ? validateTablesWithShorthands + : validateNamespaces[key]; + }; +}; + +export function resolveWorldWithShorthands(world: world): resolveWorldWithShorthands { + validateWorldWithShorthands(world); + + const scope = extendedScope(world); + const tables = mapObject(world.tables ?? {}, (table) => { + return isTableShorthandInput(table) + ? resolveTableShorthand(table as validateTableShorthand, scope) + : table; + }); + const namespaces = mapObject(world.namespaces ?? {}, (namespace) => ({ + ...namespace, + tables: mapObject(namespace.tables ?? {}, (table) => { + return isTableShorthandInput(table) + ? resolveTableShorthand(table as validateTableShorthand, scope) + : table; + }), + })); + + const fullConfig = { ...world, tables, namespaces }; + + return resolveWorld(fullConfig) as unknown as resolveWorldWithShorthands; +} + +export function defineWorldWithShorthands( + world: validateWorldWithShorthands, +): resolveWorldWithShorthands { + return resolveWorldWithShorthands(world) as resolveWorldWithShorthands; +} diff --git a/test/mock-game-contracts/mud.config.ts b/test/mock-game-contracts/mud.config.ts index a2e2dfe1cb..214f3045a4 100644 --- a/test/mock-game-contracts/mud.config.ts +++ b/test/mock-game-contracts/mud.config.ts @@ -1,5 +1,5 @@ import { mudConfig } from "@latticexyz/world/register"; -import { resolveStoreConfig } from "@latticexyz/store/config/v2"; +import { defineWorld } from "@latticexyz/world/config/v2"; export default mudConfig({ enums: { @@ -61,7 +61,7 @@ export default mudConfig({ }, }); -export const configV2 = resolveStoreConfig({ +export const configV2 = defineWorld({ enums: { TerrainType: ["None", "Ocean", "Grassland", "Desert"], },