From fa77635839e760a9de5fc8959ee492b7a4d8a7cd Mon Sep 17 00:00:00 2001 From: Kevin Ingersoll Date: Thu, 2 Nov 2023 11:57:50 +0000 Subject: [PATCH] feat(store-sync): sync to zustand (#1843) --- .changeset/silver-nails-explain.md | 23 +++ .../packages/client-vanilla/index.html | 2 +- .../packages/client-vanilla/src/index.ts | 39 ++-- .../src/mud/createClientComponents.ts | 10 - .../src/mud/createSystemCalls.ts | 10 +- .../packages/client-vanilla/src/mud/setup.ts | 5 +- .../client-vanilla/src/mud/setupNetwork.ts | 11 +- .../packages/client-vanilla/src/mud/world.ts | 3 - .../minimal/packages/contracts/worlds.json | 2 +- packages/store-sync/package.json | 9 +- packages/store-sync/src/common.ts | 27 +-- packages/store-sync/src/flattenSchema.ts | 8 + .../store-sync/src/isTableRegistrationLog.ts | 4 +- packages/store-sync/src/logToTable.test.ts | 2 +- packages/store-sync/src/zustand/common.ts | 21 ++ .../src/zustand/createStorageAdapter.test.ts | 71 +++++++ .../src/zustand/createStorageAdapter.ts | 186 ++++++++++++++++++ .../store-sync/src/zustand/createStore.ts | 71 +++++++ packages/store-sync/src/zustand/debug.ts | 3 + packages/store-sync/src/zustand/getId.test.ts | 15 ++ packages/store-sync/src/zustand/getId.ts | 11 ++ packages/store-sync/src/zustand/index.ts | 6 + .../store-sync/src/zustand/logToTable.test.ts | 52 +++++ packages/store-sync/src/zustand/logToTable.ts | 35 ++++ .../store-sync/src/zustand/syncToZustand.ts | 65 ++++++ packages/store-sync/tsup.config.ts | 1 + packages/store/ts/common.ts | 32 ++- .../ts/config/experimental/resolveConfig.ts | 2 +- pnpm-lock.yaml | 3 + 29 files changed, 649 insertions(+), 80 deletions(-) create mode 100644 .changeset/silver-nails-explain.md delete mode 100644 examples/minimal/packages/client-vanilla/src/mud/createClientComponents.ts delete mode 100644 examples/minimal/packages/client-vanilla/src/mud/world.ts create mode 100644 packages/store-sync/src/flattenSchema.ts create mode 100644 packages/store-sync/src/zustand/common.ts create mode 100644 packages/store-sync/src/zustand/createStorageAdapter.test.ts create mode 100644 packages/store-sync/src/zustand/createStorageAdapter.ts create mode 100644 packages/store-sync/src/zustand/createStore.ts create mode 100644 packages/store-sync/src/zustand/debug.ts create mode 100644 packages/store-sync/src/zustand/getId.test.ts create mode 100644 packages/store-sync/src/zustand/getId.ts create mode 100644 packages/store-sync/src/zustand/index.ts create mode 100644 packages/store-sync/src/zustand/logToTable.test.ts create mode 100644 packages/store-sync/src/zustand/logToTable.ts create mode 100644 packages/store-sync/src/zustand/syncToZustand.ts diff --git a/.changeset/silver-nails-explain.md b/.changeset/silver-nails-explain.md new file mode 100644 index 0000000000..593e518a95 --- /dev/null +++ b/.changeset/silver-nails-explain.md @@ -0,0 +1,23 @@ +--- +"@latticexyz/store-sync": minor +--- + +Added a Zustand storage adapter and corresponding `syncToZustand` method for use in vanilla and React apps. It's used much like the other sync methods, except it returns a bound store and set of typed tables. + +```ts +import { syncToZustand } from "@latticexyz/store-sync/zustand"; +import config from "contracts/mud.config"; + +const { tables, useStore, latestBlock$, storedBlockLogs$, waitForTransaction } = await syncToZustand({ + config, + ... +}); + +// in vanilla apps +const positions = useStore.getState().getRecords(tables.Position); + +// in React apps +const positions = useStore((state) => state.getRecords(tables.Position)); +``` + +This change will be shortly followed by an update to our templates that uses Zustand as the default client data store and sync method. diff --git a/examples/minimal/packages/client-vanilla/index.html b/examples/minimal/packages/client-vanilla/index.html index 017fdd9c4b..2be5aca84d 100644 --- a/examples/minimal/packages/client-vanilla/index.html +++ b/examples/minimal/packages/client-vanilla/index.html @@ -8,7 +8,7 @@
-
Counter: 0
+
Counter: ??
diff --git a/examples/minimal/packages/client-vanilla/src/index.ts b/examples/minimal/packages/client-vanilla/src/index.ts index 8666b71a68..07db012b23 100644 --- a/examples/minimal/packages/client-vanilla/src/index.ts +++ b/examples/minimal/packages/client-vanilla/src/index.ts @@ -1,31 +1,33 @@ import { setup } from "./mud/setup"; import mudConfig from "contracts/mud.config"; -const { components, network } = await setup(); -const { worldContract, waitForTransaction } = network; +const { + network, + network: { tables, useStore, worldContract, waitForTransaction }, + systemCalls, +} = await setup(); -// Components expose a stream that triggers when the component is updated. -components.CounterTable.update$.subscribe((update) => { - const [nextValue, prevValue] = update.value; - console.log("Counter updated", update, { nextValue, prevValue }); - document.getElementById("counter")!.innerHTML = String(nextValue?.value ?? "unset"); +// TODO: provide slice helpers and show subscribing to slices +useStore.subscribe((state) => { + const value = state.getValue(tables.CounterTable, {}); + if (value) { + document.getElementById("counter")!.innerHTML = String(value.value); + } }); -components.MessageTable.update$.subscribe((update) => { - console.log("Message received", update); - const [nextValue] = update.value; - - const ele = document.getElementById("chat-output")!; - ele.innerHTML = ele.innerHTML + `${new Date().toLocaleString()}: ${nextValue?.value}\n`; +// TODO: provide slice helpers and show subscribing to slices +useStore.subscribe((state, prevState) => { + const record = state.getRecord(tables.MessageTable, {}); + if (record && record !== prevState.records[record.id]) { + document.getElementById("chat-output")!.innerHTML += `${new Date().toLocaleString()}: ${record?.value.value}\n`; + } }); // Just for demonstration purposes: we create a global function that can be // called to invoke the Increment system contract via the world. (See IncrementSystem.sol.) (window as any).increment = async () => { - const tx = await worldContract.write.increment(); - - console.log("increment tx", tx); - console.log("increment result", await waitForTransaction(tx)); + const result = await systemCalls.increment(); + console.log("increment result", result); }; (window as any).willRevert = async () => { @@ -33,7 +35,6 @@ components.MessageTable.update$.subscribe((update) => { const tx = await worldContract.write.willRevert({ gas: 100000n }); console.log("willRevert tx", tx); - console.log("willRevert result", await waitForTransaction(tx)); }; (window as any).sendMessage = async () => { @@ -46,7 +47,6 @@ components.MessageTable.update$.subscribe((update) => { const tx = await worldContract.write.sendMessage([msg]); console.log("sendMessage tx", tx); - console.log("sendMessage result", await waitForTransaction(tx)); }; document.getElementById("chat-form")?.addEventListener("submit", (e) => { @@ -66,6 +66,5 @@ if (import.meta.env.DEV) { worldAddress: network.worldContract.address, worldAbi: network.worldContract.abi, write$: network.write$, - recsWorld: network.world, }); } diff --git a/examples/minimal/packages/client-vanilla/src/mud/createClientComponents.ts b/examples/minimal/packages/client-vanilla/src/mud/createClientComponents.ts deleted file mode 100644 index 5058cc380b..0000000000 --- a/examples/minimal/packages/client-vanilla/src/mud/createClientComponents.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { SetupNetworkResult } from "./setupNetwork"; - -export type ClientComponents = ReturnType; - -export function createClientComponents({ components }: SetupNetworkResult) { - return { - ...components, - // add your client components or overrides here - }; -} diff --git a/examples/minimal/packages/client-vanilla/src/mud/createSystemCalls.ts b/examples/minimal/packages/client-vanilla/src/mud/createSystemCalls.ts index 1e0c674320..bcf7166250 100644 --- a/examples/minimal/packages/client-vanilla/src/mud/createSystemCalls.ts +++ b/examples/minimal/packages/client-vanilla/src/mud/createSystemCalls.ts @@ -1,18 +1,12 @@ -import { getComponentValue } from "@latticexyz/recs"; -import { ClientComponents } from "./createClientComponents"; import { SetupNetworkResult } from "./setupNetwork"; -import { singletonEntity } from "@latticexyz/store-sync/recs"; export type SystemCalls = ReturnType; -export function createSystemCalls( - { worldContract, waitForTransaction }: SetupNetworkResult, - { CounterTable }: ClientComponents -) { +export function createSystemCalls({ tables, useStore, worldContract, waitForTransaction }: SetupNetworkResult) { const increment = async () => { const tx = await worldContract.write.increment(); await waitForTransaction(tx); - return getComponentValue(CounterTable, singletonEntity); + return useStore.getState().getRecord(tables.CounterTable, {})?.value.value; }; return { diff --git a/examples/minimal/packages/client-vanilla/src/mud/setup.ts b/examples/minimal/packages/client-vanilla/src/mud/setup.ts index 4f79edd8f3..8ff1037428 100644 --- a/examples/minimal/packages/client-vanilla/src/mud/setup.ts +++ b/examples/minimal/packages/client-vanilla/src/mud/setup.ts @@ -1,4 +1,3 @@ -import { createClientComponents } from "./createClientComponents"; import { createSystemCalls } from "./createSystemCalls"; import { setupNetwork } from "./setupNetwork"; @@ -6,11 +5,9 @@ export type SetupResult = Awaited>; export async function setup() { const network = await setupNetwork(); - const components = createClientComponents(network); - const systemCalls = createSystemCalls(network, components); + const systemCalls = createSystemCalls(network); return { network, - components, systemCalls, }; } diff --git a/examples/minimal/packages/client-vanilla/src/mud/setupNetwork.ts b/examples/minimal/packages/client-vanilla/src/mud/setupNetwork.ts index 6c8e716ef3..4574062b9d 100644 --- a/examples/minimal/packages/client-vanilla/src/mud/setupNetwork.ts +++ b/examples/minimal/packages/client-vanilla/src/mud/setupNetwork.ts @@ -1,8 +1,7 @@ import { createPublicClient, fallback, webSocket, http, createWalletClient, Hex, parseEther, ClientConfig } from "viem"; import { createFaucetService } from "@latticexyz/services/faucet"; -import { encodeEntity, syncToRecs } from "@latticexyz/store-sync/recs"; +import { syncToZustand } from "@latticexyz/store-sync/zustand"; import { getNetworkConfig } from "./getNetworkConfig"; -import { world } from "./world"; import IWorldAbi from "contracts/out/IWorld.sol/IWorld.abi.json"; import { createBurnerAccount, getContract, transportObserver, ContractWrite } from "@latticexyz/common"; import { Subject, share } from "rxjs"; @@ -36,8 +35,7 @@ export async function setupNetwork() { onWrite: (write) => write$.next(write), }); - const { components, latestBlock$, storedBlockLogs$, waitForTransaction } = await syncToRecs({ - world, + const { tables, useStore, latestBlock$, storedBlockLogs$, waitForTransaction } = await syncToZustand({ config: mudConfig, address: networkConfig.worldAddress as Hex, publicClient, @@ -69,9 +67,8 @@ export async function setupNetwork() { } return { - world, - components, - playerEntity: encodeEntity({ address: "address" }, { address: burnerWalletClient.account.address }), + tables, + useStore, publicClient, walletClient: burnerWalletClient, latestBlock$, diff --git a/examples/minimal/packages/client-vanilla/src/mud/world.ts b/examples/minimal/packages/client-vanilla/src/mud/world.ts deleted file mode 100644 index ef9fb2be24..0000000000 --- a/examples/minimal/packages/client-vanilla/src/mud/world.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { createWorld } from "@latticexyz/recs"; - -export const world = createWorld(); diff --git a/examples/minimal/packages/contracts/worlds.json b/examples/minimal/packages/contracts/worlds.json index 2d47ae4158..5006b1be1d 100644 --- a/examples/minimal/packages/contracts/worlds.json +++ b/examples/minimal/packages/contracts/worlds.json @@ -4,6 +4,6 @@ "blockNumber": 21817970 }, "31337": { - "address": "0x6e9474e9c83676b9a71133ff96db43e7aa0a4342" + "address": "0x6d584a9c0bd815104ab1fe92087c74450f7845d4" } } \ No newline at end of file diff --git a/packages/store-sync/package.json b/packages/store-sync/package.json index 4d4bb772b1..3939841bc0 100644 --- a/packages/store-sync/package.json +++ b/packages/store-sync/package.json @@ -14,7 +14,8 @@ "./postgres": "./dist/postgres/index.js", "./recs": "./dist/recs/index.js", "./sqlite": "./dist/sqlite/index.js", - "./trpc-indexer": "./dist/trpc-indexer/index.js" + "./trpc-indexer": "./dist/trpc-indexer/index.js", + "./zustand": "./dist/zustand/index.js" }, "typesVersions": { "*": { @@ -32,6 +33,9 @@ ], "trpc-indexer": [ "./src/trpc-indexer/index.ts" + ], + "zustand": [ + "./src/zustand/index.ts" ] } }, @@ -63,7 +67,8 @@ "sql.js": "^1.8.0", "superjson": "^1.12.4", "viem": "1.14.0", - "zod": "^3.21.4" + "zod": "^3.21.4", + "zustand": "^4.3.7" }, "devDependencies": { "@types/debug": "^4.1.7", diff --git a/packages/store-sync/src/common.ts b/packages/store-sync/src/common.ts index 6caa2f46d7..fa4989dca5 100644 --- a/packages/store-sync/src/common.ts +++ b/packages/store-sync/src/common.ts @@ -1,10 +1,16 @@ import { Address, Block, Hex, Log, PublicClient } from "viem"; -import { StoreConfig, StoreEventsAbiItem, StoreEventsAbi, resolveUserTypes } from "@latticexyz/store"; -import storeConfig from "@latticexyz/store/mud.config"; +import { StoreConfig, StoreEventsAbiItem, StoreEventsAbi, resolveConfig } from "@latticexyz/store"; import { Observable } from "rxjs"; -import { resourceToHex } from "@latticexyz/common"; import { UnionPick } from "@latticexyz/common/type-utils"; import { KeySchema, TableRecord, ValueSchema } from "@latticexyz/protocol-parser"; +import storeConfig from "@latticexyz/store/mud.config"; +import worldConfig from "@latticexyz/world/mud.config"; +import { flattenSchema } from "./flattenSchema"; + +/** @internal Temporary workaround until we redo our config parsing and can pull this directly from the config (https://github.com/latticexyz/mud/issues/1668) */ +export const storeTables = resolveConfig(storeConfig).tables; +/** @internal Temporary workaround until we redo our config parsing and can pull this directly from the config (https://github.com/latticexyz/mud/issues/1668) */ +export const worldTables = resolveConfig(worldConfig).tables; export type ChainId = number; export type WorldId = `${ChainId}:${Address}`; @@ -101,15 +107,10 @@ export type StorageAdapterLog = Partial & UnionPick Promise; -// TODO: adjust when we get namespace support (https://github.com/latticexyz/mud/issues/994) and when table has namespace key (https://github.com/latticexyz/mud/issues/1201) -// TODO: adjust when schemas are automatically resolved +export const schemasTableId = storeTables.Tables.tableId; export const schemasTable = { - ...storeConfig.tables.Tables, - valueSchema: resolveUserTypes(storeConfig.tables.Tables.valueSchema, storeConfig.userTypes), + ...storeTables.Tables, + // TODO: remove once we've got everything using the new Table shape + keySchema: flattenSchema(storeTables.Tables.keySchema), + valueSchema: flattenSchema(storeTables.Tables.valueSchema), }; - -export const schemasTableId = resourceToHex({ - type: schemasTable.offchainOnly ? "offchainTable" : "table", - namespace: storeConfig.namespace, - name: schemasTable.name, -}); diff --git a/packages/store-sync/src/flattenSchema.ts b/packages/store-sync/src/flattenSchema.ts new file mode 100644 index 0000000000..7cc9a92d90 --- /dev/null +++ b/packages/store-sync/src/flattenSchema.ts @@ -0,0 +1,8 @@ +import { mapObject } from "@latticexyz/common/utils"; +import { ValueSchema } from "@latticexyz/store"; + +export function flattenSchema( + schema: schema +): { readonly [k in keyof schema]: schema[k]["type"] } { + return mapObject(schema, (value) => value.type); +} diff --git a/packages/store-sync/src/isTableRegistrationLog.ts b/packages/store-sync/src/isTableRegistrationLog.ts index b14f8911b5..c71ad1f3e4 100644 --- a/packages/store-sync/src/isTableRegistrationLog.ts +++ b/packages/store-sync/src/isTableRegistrationLog.ts @@ -1,7 +1,7 @@ -import { StorageAdapterLog, schemasTableId } from "./common"; +import { StorageAdapterLog, storeTables } from "./common"; export function isTableRegistrationLog( log: StorageAdapterLog ): log is StorageAdapterLog & { eventName: "Store_SetRecord" } { - return log.eventName === "Store_SetRecord" && log.args.tableId === schemasTableId; + return log.eventName === "Store_SetRecord" && log.args.tableId === storeTables.Tables.tableId; } diff --git a/packages/store-sync/src/logToTable.test.ts b/packages/store-sync/src/logToTable.test.ts index 8fb7475552..f50e813bb5 100644 --- a/packages/store-sync/src/logToTable.test.ts +++ b/packages/store-sync/src/logToTable.test.ts @@ -13,7 +13,7 @@ describe("logToTable", () => { staticData: // eslint-disable-next-line max-len "0x0060030220202000000000000000000000000000000000000000000000000000002001005f000000000000000000000000000000000000000000000000000000006003025f5f5fc4c40000000000000000000000000000000000000000000000", - encodedLengths: "0x000000000000000000000000000000000000022000000000a0000000000002c0", // "0x00000000000000000000000000000000000000a00000000220000000000002c0", + encodedLengths: "0x000000000000000000000000000000000000022000000000a0000000000002c0", dynamicData: // eslint-disable-next-line max-len "0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000077461626c654964000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000500000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000000120000000000000000000000000000000000000000000000000000000000000016000000000000000000000000000000000000000000000000000000000000001a0000000000000000000000000000000000000000000000000000000000000000b6669656c644c61796f757400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000096b6579536368656d610000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000b76616c7565536368656d610000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000012616269456e636f6465644b65794e616d657300000000000000000000000000000000000000000000000000000000000000000000000000000000000000000014616269456e636f6465644669656c644e616d6573000000000000000000000000", diff --git a/packages/store-sync/src/zustand/common.ts b/packages/store-sync/src/zustand/common.ts new file mode 100644 index 0000000000..80598c24bb --- /dev/null +++ b/packages/store-sync/src/zustand/common.ts @@ -0,0 +1,21 @@ +import { Table, SchemaToPrimitives } from "@latticexyz/store"; +import { Hex } from "viem"; + +export type RawRecord = { + /** Internal unique ID */ + readonly id: string; + readonly tableId: Hex; + readonly keyTuple: readonly Hex[]; + readonly staticData: Hex; + readonly encodedLengths: Hex; + readonly dynamicData: Hex; +}; + +export type TableRecord = { + /** Internal unique ID */ + readonly id: string; + readonly table: table; + readonly keyTuple: readonly Hex[]; + readonly key: SchemaToPrimitives; + readonly value: SchemaToPrimitives; +}; diff --git a/packages/store-sync/src/zustand/createStorageAdapter.test.ts b/packages/store-sync/src/zustand/createStorageAdapter.test.ts new file mode 100644 index 0000000000..883345075f --- /dev/null +++ b/packages/store-sync/src/zustand/createStorageAdapter.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; +import mudConfig from "../../../../e2e/packages/contracts/mud.config"; +import worldRpcLogs from "../../../../test-data/world-logs.json"; +import { groupLogsByBlockNumber } from "@latticexyz/block-logs-stream"; +import { StoreEventsLog } from "../common"; +import { RpcLog, formatLog, decodeEventLog, Hex } from "viem"; +import { resolveConfig, storeEventsAbi } from "@latticexyz/store"; +import { createStorageAdapter } from "./createStorageAdapter"; +import { createStore } from "./createStore"; + +const tables = resolveConfig(mudConfig).tables; + +// TODO: make test-data a proper package and export this +const blocks = groupLogsByBlockNumber( + worldRpcLogs.map((log) => { + const { eventName, args } = decodeEventLog({ + abi: storeEventsAbi, + data: log.data as Hex, + topics: log.topics as [Hex, ...Hex[]], + strict: true, + }); + return formatLog(log as unknown as RpcLog, { args, eventName: eventName as string }) as StoreEventsLog; + }) +); + +describe("createStorageAdapter", () => { + it("sets component values from logs", async () => { + const useStore = createStore({ tables }); + const storageAdapter = createStorageAdapter({ store: useStore }); + + for (const block of blocks) { + await storageAdapter(block); + } + + expect(useStore.getState().getRecords(tables.NumberList)).toMatchInlineSnapshot(` + { + "0x746200000000000000000000000000004e756d6265724c697374000000000000:0x": { + "id": "0x746200000000000000000000000000004e756d6265724c697374000000000000:0x", + "key": {}, + "keyTuple": [], + "table": { + "keySchema": {}, + "name": "NumberList", + "namespace": "", + "tableId": "0x746200000000000000000000000000004e756d6265724c697374000000000000", + "valueSchema": { + "value": { + "type": "uint32[]", + }, + }, + }, + "value": { + "value": [ + 420, + 69, + ], + }, + }, + } + `); + + expect(useStore.getState().getValue(tables.NumberList, {})).toMatchInlineSnapshot(` + { + "value": [ + 420, + 69, + ], + } + `); + }); +}); diff --git a/packages/store-sync/src/zustand/createStorageAdapter.ts b/packages/store-sync/src/zustand/createStorageAdapter.ts new file mode 100644 index 0000000000..bdb0dc4b38 --- /dev/null +++ b/packages/store-sync/src/zustand/createStorageAdapter.ts @@ -0,0 +1,186 @@ +import { Tables } from "@latticexyz/store"; +import { StorageAdapter } from "../common"; +import { RawRecord } from "./common"; +import { ZustandStore } from "./createStore"; +import { isTableRegistrationLog } from "../isTableRegistrationLog"; +import { logToTable } from "./logToTable"; +import { hexToResource, spliceHex } from "@latticexyz/common"; +import { debug } from "./debug"; +import { getId } from "./getId"; +import { size } from "viem"; +import { decodeKey, decodeValueArgs } from "@latticexyz/protocol-parser"; +import { flattenSchema } from "../flattenSchema"; +import { isDefined } from "@latticexyz/common/utils"; + +export type CreateStorageAdapterOptions = { + store: ZustandStore; +}; + +export function createStorageAdapter({ + store, +}: CreateStorageAdapterOptions): StorageAdapter { + return async function zustandStorageAdapter({ blockNumber, logs }) { + // TODO: clean this up so that we do one store write per block + + const previousTables = store.getState().tables; + const newTables = logs + .filter(isTableRegistrationLog) + .map(logToTable) + .filter((newTable) => { + const existingTable = previousTables[newTable.tableId]; + if (existingTable) { + console.warn("table already registered, ignoring", { + newTable, + existingTable, + }); + return false; + } + return true; + }); + if (newTables.length) { + store.setState({ + tables: { + ...previousTables, + ...Object.fromEntries(newTables.map((table) => [table.tableId, table])), + }, + }); + } + + const updatedIds: string[] = []; + const deletedIds: string[] = []; + + for (const log of logs) { + const table = store.getState().tables[log.args.tableId]; + if (!table) { + const { namespace, name } = hexToResource(log.args.tableId); + debug(`skipping update for unknown table: ${namespace}:${name} at ${log.address}`); + console.log(store.getState().tables, log.args.tableId); + continue; + } + + const id = getId(log.args); + + if (log.eventName === "Store_SetRecord") { + debug("setting record", { + namespace: table.namespace, + name: table.name, + id, + log, + }); + updatedIds.push(id); + store.setState({ + rawRecords: { + ...store.getState().rawRecords, + [id]: { + id, + tableId: log.args.tableId, + keyTuple: log.args.keyTuple, + staticData: log.args.staticData, + encodedLengths: log.args.encodedLengths, + dynamicData: log.args.dynamicData, + }, + }, + }); + } else if (log.eventName === "Store_SpliceStaticData") { + debug("splicing static data", { + namespace: table.namespace, + name: table.name, + id, + log, + }); + updatedIds.push(id); + const previousRecord = (store.getState().rawRecords[id] as RawRecord | undefined) ?? { + id, + tableId: log.args.tableId, + keyTuple: log.args.keyTuple, + staticData: "0x", + encodedLengths: "0x", + dynamicData: "0x", + }; + const staticData = spliceHex(previousRecord.staticData, log.args.start, size(log.args.data), log.args.data); + store.setState({ + rawRecords: { + ...store.getState().rawRecords, + [id]: { + ...previousRecord, + staticData, + }, + }, + }); + } else if (log.eventName === "Store_SpliceDynamicData") { + debug("splicing dynamic data", { + namespace: table.namespace, + name: table.name, + id, + log, + }); + updatedIds.push(id); + const previousRecord = (store.getState().rawRecords[id] as RawRecord | undefined) ?? { + id, + tableId: log.args.tableId, + keyTuple: log.args.keyTuple, + staticData: "0x", + encodedLengths: "0x", + dynamicData: "0x", + }; + const encodedLengths = log.args.encodedLengths; + const dynamicData = spliceHex(previousRecord.dynamicData, log.args.start, log.args.deleteCount, log.args.data); + store.setState({ + rawRecords: { + ...store.getState().rawRecords, + [id]: { + ...previousRecord, + encodedLengths, + dynamicData, + }, + }, + }); + } else if (log.eventName === "Store_DeleteRecord") { + debug("deleting record", { + namespace: table.namespace, + name: table.name, + id, + log, + }); + deletedIds.push(id); + const { [id]: deletedRecord, ...rawRecords } = store.getState().rawRecords; + store.setState({ rawRecords }); + } + } + + if (!updatedIds.length && !deletedIds.length) return; + + const records = { + ...Object.fromEntries(Object.entries(store.getState().records).filter(([id]) => !deletedIds.includes(id))), + ...Object.fromEntries( + updatedIds + .map((id) => { + const rawRecord = store.getState().rawRecords[id]; + if (!rawRecord) { + console.warn("no raw record found for updated ID", id); + return; + } + const table = store.getState().tables[rawRecord.tableId]; + if (!table) { + console.warn("no table found for record", rawRecord); + return; + } + // TODO: warn if no table + return [ + id, + { + id, + table: store.getState().tables[rawRecord.tableId], + keyTuple: rawRecord.keyTuple, + key: decodeKey(flattenSchema(table.keySchema), rawRecord.keyTuple), + value: decodeValueArgs(flattenSchema(table.valueSchema), rawRecord), + }, + ]; + }) + .filter(isDefined) + ), + }; + + store.setState({ records }); + }; +} diff --git a/packages/store-sync/src/zustand/createStore.ts b/packages/store-sync/src/zustand/createStore.ts new file mode 100644 index 0000000000..d2dc23bb22 --- /dev/null +++ b/packages/store-sync/src/zustand/createStore.ts @@ -0,0 +1,71 @@ +import { SchemaToPrimitives, Table, Tables } from "@latticexyz/store"; +import { StoreApi, UseBoundStore, create } from "zustand"; +import { RawRecord, TableRecord } from "./common"; +import { Hex, concatHex } from "viem"; +import { encodeKey } from "@latticexyz/protocol-parser"; +import { flattenSchema } from "../flattenSchema"; +import { getId } from "./getId"; + +type TableRecords
= { + readonly [id: string]: TableRecord
; +}; + +// TODO: split this into distinct stores and combine (https://docs.pmnd.rs/zustand/guides/typescript#slices-pattern)? + +export type ZustandState = { + /** Tables derived from table registration store events */ + readonly tables: { + readonly [tableId: Hex]: Table; + }; + /** Raw records (bytes) derived from store events */ + readonly rawRecords: { + readonly [id: string]: RawRecord; + }; + /** Decoded table records derived from raw records */ + readonly records: { + readonly [id: string]: TableRecord; + }; + readonly getRecords:
(table: table) => TableRecords
; + readonly getRecord:
( + table: table, + key: SchemaToPrimitives + ) => TableRecord
| undefined; + readonly getValue:
( + table: table, + key: SchemaToPrimitives + ) => TableRecord
["value"] | undefined; +}; + +export type ZustandStore = UseBoundStore>>; + +export type CreateStoreOptions = { + tables: tables; +}; + +export function createStore(opts: CreateStoreOptions): ZustandStore { + return create>((set, get) => ({ + tables: {}, + rawRecords: {}, + records: {}, + getRecords:
(table: table): TableRecords
=> { + const records = get().records; + return Object.fromEntries( + Object.entries(records).filter(([id, record]) => record.table.tableId === table.tableId) + ) as unknown as TableRecords
; + }, + getRecord:
( + table: table, + key: SchemaToPrimitives + ): TableRecord
| undefined => { + const keyTuple = encodeKey(flattenSchema(table.keySchema), key); + const id = getId({ tableId: table.tableId, keyTuple }); + return get().records[id] as unknown as TableRecord
| undefined; + }, + getValue:
( + table: table, + key: SchemaToPrimitives + ): TableRecord
["value"] | undefined => { + return get().getRecord(table, key)?.value; + }, + })); +} diff --git a/packages/store-sync/src/zustand/debug.ts b/packages/store-sync/src/zustand/debug.ts new file mode 100644 index 0000000000..67cd7cbf12 --- /dev/null +++ b/packages/store-sync/src/zustand/debug.ts @@ -0,0 +1,3 @@ +import { debug as parentDebug } from "../debug"; + +export const debug = parentDebug.extend("zustand"); diff --git a/packages/store-sync/src/zustand/getId.test.ts b/packages/store-sync/src/zustand/getId.test.ts new file mode 100644 index 0000000000..e47b398a4e --- /dev/null +++ b/packages/store-sync/src/zustand/getId.test.ts @@ -0,0 +1,15 @@ +import { describe, it, expect } from "vitest"; +import { getId } from "./getId"; + +describe("getId", () => { + it("should convert a store event log to a unique ID", async () => { + expect( + getId({ + tableId: "0x74626d756473746f72650000000000005461626c657300000000000000000000", + keyTuple: ["0x74626d756473746f72650000000000005461626c657300000000000000000000"], + }) + ).toMatchInlineSnapshot( + '"0x74626d756473746f72650000000000005461626c657300000000000000000000:0x74626d756473746f72650000000000005461626c657300000000000000000000"' + ); + }); +}); diff --git a/packages/store-sync/src/zustand/getId.ts b/packages/store-sync/src/zustand/getId.ts new file mode 100644 index 0000000000..deff6adc5a --- /dev/null +++ b/packages/store-sync/src/zustand/getId.ts @@ -0,0 +1,11 @@ +import { Hex, concatHex } from "viem"; + +type GetIdOptions = { + readonly tableId: Hex; + readonly keyTuple: readonly Hex[]; +}; + +export function getId({ tableId, keyTuple }: GetIdOptions): string { + // TODO: pass in keyTuple directly once types are fixed (https://github.com/wagmi-dev/viem/pull/1417) + return `${tableId}:${concatHex([...keyTuple])}`; +} diff --git a/packages/store-sync/src/zustand/index.ts b/packages/store-sync/src/zustand/index.ts new file mode 100644 index 0000000000..4709dc2367 --- /dev/null +++ b/packages/store-sync/src/zustand/index.ts @@ -0,0 +1,6 @@ +export * from "./common"; +export * from "./createStorageAdapter"; +export * from "./createStore"; +export * from "./getId"; +export * from "./logToTable"; +export * from "./syncToZustand"; diff --git a/packages/store-sync/src/zustand/logToTable.test.ts b/packages/store-sync/src/zustand/logToTable.test.ts new file mode 100644 index 0000000000..010d25b7ed --- /dev/null +++ b/packages/store-sync/src/zustand/logToTable.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from "vitest"; +import { logToTable } from "./logToTable"; + +describe("logToTable", () => { + it("should convert a table registration log to table object", async () => { + expect( + logToTable({ + address: "0x3Aa5ebB10DC797CAC828524e59A333d0A371443c", + eventName: "Store_SetRecord", + args: { + tableId: "0x74626d756473746f72650000000000005461626c657300000000000000000000", + keyTuple: ["0x74626d756473746f72650000000000005461626c657300000000000000000000"], + staticData: + // eslint-disable-next-line max-len + "0x0060030220202000000000000000000000000000000000000000000000000000002001005f000000000000000000000000000000000000000000000000000000006003025f5f5fc4c40000000000000000000000000000000000000000000000", + encodedLengths: "0x000000000000000000000000000000000000022000000000a0000000000002c0", + dynamicData: + // eslint-disable-next-line max-len + "0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000077461626c654964000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000500000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000000120000000000000000000000000000000000000000000000000000000000000016000000000000000000000000000000000000000000000000000000000000001a0000000000000000000000000000000000000000000000000000000000000000b6669656c644c61796f757400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000096b6579536368656d610000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000b76616c7565536368656d610000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000012616269456e636f6465644b65794e616d657300000000000000000000000000000000000000000000000000000000000000000000000000000000000000000014616269456e636f6465644669656c644e616d6573000000000000000000000000", + }, + }) + ).toMatchInlineSnapshot(` + { + "keySchema": { + "tableId": { + "type": "bytes32", + }, + }, + "name": "Tables", + "namespace": "mudstore", + "tableId": "0x74626d756473746f72650000000000005461626c657300000000000000000000", + "valueSchema": { + "abiEncodedFieldNames": { + "type": "bytes", + }, + "abiEncodedKeyNames": { + "type": "bytes", + }, + "fieldLayout": { + "type": "bytes32", + }, + "keySchema": { + "type": "bytes32", + }, + "valueSchema": { + "type": "bytes32", + }, + }, + } + `); + }); +}); diff --git a/packages/store-sync/src/zustand/logToTable.ts b/packages/store-sync/src/zustand/logToTable.ts new file mode 100644 index 0000000000..8d2b32e4e7 --- /dev/null +++ b/packages/store-sync/src/zustand/logToTable.ts @@ -0,0 +1,35 @@ +import { hexToResource } from "@latticexyz/common"; +import { hexToSchema, decodeValue } from "@latticexyz/protocol-parser"; +import { concatHex, decodeAbiParameters, parseAbiParameters } from "viem"; +import { StorageAdapterLog, schemasTable } from "../common"; +import { Table } from "@latticexyz/store"; + +export function logToTable(log: StorageAdapterLog & { eventName: "Store_SetRecord" }): Table { + const [tableId, ...otherKeys] = log.args.keyTuple; + if (otherKeys.length) { + console.warn("registerSchema event is expected to have only one key in key tuple, but got multiple", log); + } + + const table = hexToResource(tableId); + + const value = decodeValue( + schemasTable.valueSchema, + concatHex([log.args.staticData, log.args.encodedLengths, log.args.dynamicData]) + ); + + const keySchema = hexToSchema(value.keySchema); + const valueSchema = hexToSchema(value.valueSchema); + + const keyNames = decodeAbiParameters(parseAbiParameters("string[]"), value.abiEncodedKeyNames)[0]; + const fieldNames = decodeAbiParameters(parseAbiParameters("string[]"), value.abiEncodedFieldNames)[0]; + + const valueAbiTypes = [...valueSchema.staticFields, ...valueSchema.dynamicFields]; + + return { + tableId, + namespace: table.namespace, + name: table.name, + keySchema: Object.fromEntries(keySchema.staticFields.map((abiType, i) => [keyNames[i], { type: abiType }])), + valueSchema: Object.fromEntries(valueAbiTypes.map((abiType, i) => [fieldNames[i], { type: abiType }])), + }; +} diff --git a/packages/store-sync/src/zustand/syncToZustand.ts b/packages/store-sync/src/zustand/syncToZustand.ts new file mode 100644 index 0000000000..fe8d0950c4 --- /dev/null +++ b/packages/store-sync/src/zustand/syncToZustand.ts @@ -0,0 +1,65 @@ +import { ResolvedStoreConfig, StoreConfig, Tables, resolveConfig } from "@latticexyz/store"; +import { SyncOptions, SyncResult, storeTables, worldTables } from "../common"; +import { createStoreSync } from "../createStoreSync"; +import { ZustandStore } from "./createStore"; +import { createStore } from "./createStore"; +import { createStorageAdapter } from "./createStorageAdapter"; +import { Address } from "viem"; + +type AllTables = ResolvedStoreConfig["tables"] & + extraTables & + typeof storeTables & + typeof worldTables; + +type SyncToZustandOptions = SyncOptions & { + // require address for now to keep the data model + retrieval simpler + address: Address; + config: config; + tables?: extraTables; + store?: ZustandStore>; + startSync?: boolean; +}; + +type SyncToZustandResult = SyncResult & { + tables: AllTables; + useStore: ZustandStore>; + stopSync: () => void; +}; + +export async function syncToZustand({ + config, + tables: extraTables, + store, + startSync = true, + ...syncOptions +}: SyncToZustandOptions): Promise> { + // TODO: migrate this once we redo config to return fully resolved tables (https://github.com/latticexyz/mud/issues/1668) + // TODO: move store/world tables into `resolveConfig` + const resolvedConfig = resolveConfig(config); + const tables = { + ...resolvedConfig.tables, + ...extraTables, + ...storeTables, + ...worldTables, + } as AllTables; + + const useStore = store ?? createStore({ tables }); + const storageAdapter = createStorageAdapter({ store: useStore }); + + const storeSync = await createStoreSync({ + storageAdapter, + ...syncOptions, + }); + + const sub = startSync ? storeSync.storedBlockLogs$.subscribe() : null; + const stopSync = (): void => { + sub?.unsubscribe(); + }; + + return { + ...storeSync, + tables, + useStore, + stopSync, + }; +} diff --git a/packages/store-sync/tsup.config.ts b/packages/store-sync/tsup.config.ts index 7bc63a088a..e2345f3a5b 100644 --- a/packages/store-sync/tsup.config.ts +++ b/packages/store-sync/tsup.config.ts @@ -7,6 +7,7 @@ export default defineConfig({ "src/postgres/index.ts", "src/recs/index.ts", "src/trpc-indexer/index.ts", + "src/zustand/index.ts", ], target: "esnext", format: ["esm"], diff --git a/packages/store/ts/common.ts b/packages/store/ts/common.ts index f1b17075e3..a4d57e182c 100644 --- a/packages/store/ts/common.ts +++ b/packages/store/ts/common.ts @@ -2,14 +2,32 @@ import { SchemaAbiType, SchemaAbiTypeToPrimitiveType, StaticAbiType } from "@lat import { FieldData, FullSchemaConfig, StoreConfig } from "./config"; import { Hex } from "viem"; -export type KeySchema = Record; -export type ValueSchema = Record; +export type KeySchema = { + readonly [k: string]: { + readonly type: StaticAbiType; + }; +}; +export type ValueSchema = { + readonly [k: string]: { + readonly type: SchemaAbiType; + }; +}; + export type Table = { - tableId: Hex; - namespace: string; - name: string; - keySchema: KeySchema; - valueSchema: ValueSchema; + readonly tableId: Hex; + readonly namespace: string; + readonly name: string; + readonly keySchema: KeySchema; + readonly valueSchema: ValueSchema; +}; + +export type Tables = { + readonly [k: string]: Table; +}; + +/** Map a table schema like `{ value: { type: "uint256" } }` to its primitive types like `{ value: bigint }` */ +export type SchemaToPrimitives = { + readonly [key in keyof schema]: SchemaAbiTypeToPrimitiveType; }; export type ConfigFieldTypeToSchemaAbiType> = T extends SchemaAbiType diff --git a/packages/store/ts/config/experimental/resolveConfig.ts b/packages/store/ts/config/experimental/resolveConfig.ts index ea869ddc56..83354fa8f5 100644 --- a/packages/store/ts/config/experimental/resolveConfig.ts +++ b/packages/store/ts/config/experimental/resolveConfig.ts @@ -118,7 +118,7 @@ function resolveTable< >, namespace, name, - tableId: resourceToHex({ type: "table", namespace, name }), + tableId: resourceToHex({ type: tableConfig.offchainOnly ? "offchainTable" : "table", namespace, name }), }; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a8be88a688..400a0ab169 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -948,6 +948,9 @@ importers: zod: specifier: ^3.21.4 version: 3.21.4 + zustand: + specifier: ^4.3.7 + version: 4.3.7(react@18.2.0) devDependencies: '@types/debug': specifier: ^4.1.7