Skip to content

Commit

Permalink
feat(store-sync): sync to zustand (#1843)
Browse files Browse the repository at this point in the history
  • Loading branch information
holic authored Nov 2, 2023
1 parent 307a9e5 commit fa77635
Show file tree
Hide file tree
Showing 29 changed files with 649 additions and 80 deletions.
23 changes: 23 additions & 0 deletions .changeset/silver-nails-explain.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion examples/minimal/packages/client-vanilla/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
<body>
<script type="module" src="/src/index.ts"></script>
<div>
<div>Counter: <span id="counter">0</span></div>
<div>Counter: <span id="counter">??</span></div>
<button onclick="window.increment()">Increment</button>
<button onclick="window.willRevert()">Revert</button>
</div>
Expand Down
39 changes: 19 additions & 20 deletions examples/minimal/packages/client-vanilla/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,39 +1,40 @@
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 () => {
// set gas limit so we skip estimation and can test tx revert
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 () => {
Expand All @@ -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) => {
Expand All @@ -66,6 +66,5 @@ if (import.meta.env.DEV) {
worldAddress: network.worldContract.address,
worldAbi: network.worldContract.abi,
write$: network.write$,
recsWorld: network.world,
});
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -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<typeof createSystemCalls>;

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 {
Expand Down
5 changes: 1 addition & 4 deletions examples/minimal/packages/client-vanilla/src/mud/setup.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,13 @@
import { createClientComponents } from "./createClientComponents";
import { createSystemCalls } from "./createSystemCalls";
import { setupNetwork } from "./setupNetwork";

export type SetupResult = Awaited<ReturnType<typeof setup>>;

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,
};
}
11 changes: 4 additions & 7 deletions examples/minimal/packages/client-vanilla/src/mud/setupNetwork.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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$,
Expand Down
3 changes: 0 additions & 3 deletions examples/minimal/packages/client-vanilla/src/mud/world.ts

This file was deleted.

2 changes: 1 addition & 1 deletion examples/minimal/packages/contracts/worlds.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@
"blockNumber": 21817970
},
"31337": {
"address": "0x6e9474e9c83676b9a71133ff96db43e7aa0a4342"
"address": "0x6d584a9c0bd815104ab1fe92087c74450f7845d4"
}
}
9 changes: 7 additions & 2 deletions packages/store-sync/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
"*": {
Expand All @@ -32,6 +33,9 @@
],
"trpc-indexer": [
"./src/trpc-indexer/index.ts"
],
"zustand": [
"./src/zustand/index.ts"
]
}
},
Expand Down Expand Up @@ -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",
Expand Down
27 changes: 14 additions & 13 deletions packages/store-sync/src/common.ts
Original file line number Diff line number Diff line change
@@ -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}`;
Expand Down Expand Up @@ -101,15 +107,10 @@ export type StorageAdapterLog = Partial<StoreEventsLog> & UnionPick<StoreEventsL
export type StorageAdapterBlock = { blockNumber: BlockLogs["blockNumber"]; logs: StorageAdapterLog[] };
export type StorageAdapter = (block: StorageAdapterBlock) => Promise<void>;

// 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,
});
8 changes: 8 additions & 0 deletions packages/store-sync/src/flattenSchema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { mapObject } from "@latticexyz/common/utils";
import { ValueSchema } from "@latticexyz/store";

export function flattenSchema<schema extends ValueSchema>(
schema: schema
): { readonly [k in keyof schema]: schema[k]["type"] } {
return mapObject(schema, (value) => value.type);
}
4 changes: 2 additions & 2 deletions packages/store-sync/src/isTableRegistrationLog.ts
Original file line number Diff line number Diff line change
@@ -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;
}
2 changes: 1 addition & 1 deletion packages/store-sync/src/logToTable.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
21 changes: 21 additions & 0 deletions packages/store-sync/src/zustand/common.ts
Original file line number Diff line number Diff line change
@@ -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<table extends Table> = {
/** Internal unique ID */
readonly id: string;
readonly table: table;
readonly keyTuple: readonly Hex[];
readonly key: SchemaToPrimitives<table["keySchema"]>;
readonly value: SchemaToPrimitives<table["valueSchema"]>;
};
71 changes: 71 additions & 0 deletions packages/store-sync/src/zustand/createStorageAdapter.test.ts
Original file line number Diff line number Diff line change
@@ -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,
],
}
`);
});
});
Loading

0 comments on commit fa77635

Please sign in to comment.