Skip to content

Commit

Permalink
feat(store-sync): recs sync adapter (latticexyz#3486)
Browse files Browse the repository at this point in the history
  • Loading branch information
holic authored Jan 22, 2025
1 parent 0812178 commit 227db4d
Show file tree
Hide file tree
Showing 11 changed files with 196 additions and 84 deletions.
29 changes: 29 additions & 0 deletions .changeset/lucky-maps-tell.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
"@latticexyz/store-sync": patch
---

Added an RECS sync adapter to be used with `SyncProvider` in React apps.

```tsx
import { WagmiProvider } from "wagmi";
import { QueryClientProvider } from "@tanstack/react-query";
import { SyncProvider } from "@latticexyz/store-sync/react";
import { createSyncAdapter } from "@latticexyz/store-sync/recs";
import { createWorld } from "@latticexyz/recs";
import config from "./mud.config";

const world = createWorld();
const { syncAdapter, components } = createSyncAdapter({ world, config });

export function App() {
return (
<WagmiProvider config={wagmiConfig}>
<QueryClientProvider client={queryClient}>
<SyncProvider chainId={chainId} address={worldAddress} startBlock={startBlock} adapter={syncAdapter}>
{children}
</SyncProvider>
</QueryClientProvider>
</WagmiProvider>
);
}
```
8 changes: 5 additions & 3 deletions packages/store-sync/src/react/SyncProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,15 @@ export function SyncProvider({ chainId, adapter, children, ...syncOptions }: Pro
});

useEffect(() => {
const sub = result.data?.storedBlockLogs$.subscribe({
if (!result.data) return;

const sub = result.data.storedBlockLogs$.subscribe({
error: (error) => console.error("got sync error", error),
});
return (): void => {
sub?.unsubscribe();
sub.unsubscribe();
};
}, [result.data?.storedBlockLogs$]);
}, [result.data]);

return <SyncContext.Provider value={result}>{children}</SyncContext.Provider>;
}
4 changes: 4 additions & 0 deletions packages/store-sync/src/recs/common.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import { Metadata } from "@latticexyz/recs";
import { KeySchema, ValueSchema } from "@latticexyz/protocol-parser/internal";
import { Table } from "@latticexyz/config";

export type StoreComponentMetadata = Metadata & {
componentName: string;
tableName: string;
table: Table;
// TODO: migrate to store's KeySchema/ValueSchema
/** @deprecated Derive this schema from `component.metadata.table` instead. */
keySchema: KeySchema;
/** @deprecated Derive this schema from `component.metadata.table` instead. */
valueSchema: ValueSchema;
};
85 changes: 30 additions & 55 deletions packages/store-sync/src/recs/createStorageAdapter.ts
Original file line number Diff line number Diff line change
@@ -1,87 +1,58 @@
import { Tables } from "@latticexyz/config";
import { Table, Tables } from "@latticexyz/config";
import { debug } from "./debug";
import { World as RecsWorld, getComponentValue, hasComponent, removeComponent, setComponent } from "@latticexyz/recs";
import { defineInternalComponents } from "./defineInternalComponents";
import { getTableEntity } from "./getTableEntity";
import { World as RecsWorld, getComponentValue, removeComponent, setComponent } from "@latticexyz/recs";
import { hexToResource, resourceToLabel, spliceHex } from "@latticexyz/common";
import { decodeValueArgs } from "@latticexyz/protocol-parser/internal";
import { decodeValueArgs, getSchemaTypes, getValueSchema } from "@latticexyz/protocol-parser/internal";
import { Hex, size } from "viem";
import { isTableRegistrationLog } from "../isTableRegistrationLog";
import { logToTable } from "../logToTable";
import { hexKeyTupleToEntity } from "./hexKeyTupleToEntity";
import { StorageAdapter, StorageAdapterBlock } from "../common";
import { singletonEntity } from "./singletonEntity";
import { tablesToComponents } from "./tablesToComponents";
import { merge } from "@ark/util";

export type CreateStorageAdapterOptions<tables extends Tables> = {
export type CreateStorageAdapterOptions<tables extends Tables = {}> = {
world: RecsWorld;
/** @deprecated Use `const components = tablesToComponents(world, tables)` instead. */
tables: tables;
shouldSkipUpdateStream?: () => boolean;
};

export type CreateStorageAdapterResult<tables extends Tables> = {
export type CreateStorageAdapterResult<tables extends Tables = {}> = {
storageAdapter: StorageAdapter;
components: merge<tablesToComponents<tables>, ReturnType<typeof defineInternalComponents>>;
/** @deprecated Use `const components = tablesToComponents(world, tables)` instead. */
components: tablesToComponents<tables>;
};

export function createStorageAdapter<tables extends Tables>({
export function createStorageAdapter<tables extends Tables = {}>({
world,
tables,
tables = {} as tables,
shouldSkipUpdateStream,
}: CreateStorageAdapterOptions<tables>): CreateStorageAdapterResult<tables> {
world.registerEntity({ id: singletonEntity });

const components = {
...tablesToComponents(world, tables),
...defineInternalComponents(world),
} as CreateStorageAdapterResult<tables>["components"];

async function recsStorageAdapter({ logs }: StorageAdapterBlock): Promise<void> {
const newTables = logs.filter(isTableRegistrationLog).map(logToTable);
for (const newTable of newTables) {
const tableEntity = getTableEntity(newTable);
if (hasComponent(components.RegisteredTables, tableEntity)) {
console.warn("table already registered, ignoring", {
newTable,
existingTable: getComponentValue(components.RegisteredTables, tableEntity)?.table,
});
} else {
setComponent(
components.RegisteredTables,
tableEntity,
{ table: newTable },
{ skipUpdateStream: shouldSkipUpdateStream?.() },
);
}
}
// kept for backwards compat
const components = tablesToComponents(world, tables) as CreateStorageAdapterResult<tables>["components"];

async function storageAdapter({ logs }: StorageAdapterBlock): Promise<void> {
for (const log of logs) {
const { namespace, name } = hexToResource(log.args.tableId);
const table = getComponentValue(
components.RegisteredTables,
getTableEntity({ address: log.address, namespace, name }),
)?.table;
if (!table) {
debug(`skipping update for unknown table: ${resourceToLabel({ namespace, name })} at ${log.address}`);
continue;
}

const component = world.components.find((c) => c.id === table.tableId);
const tableId = log.args.tableId;
const component = world.components.find((c) => c.id === tableId);
if (!component) {
debug(
`skipping update for unknown component: ${table.tableId} (${resourceToLabel({
namespace,
name,
})}). Available components: ${Object.keys(components)}`,
`skipping update for unknown component: ${tableId} (${resourceToLabel(hexToResource(tableId))}). Available components: ${Object.keys(components)}`,
);
continue;
}
const table = component.metadata?.table as Table | undefined;
if (!table) {
debug(`skipping update for unknown table: ${resourceToLabel(hexToResource(tableId))} at ${log.address}`);
continue;
}

const valueSchema = getSchemaTypes(getValueSchema(table));
const entity = hexKeyTupleToEntity(log.args.keyTuple);

if (log.eventName === "Store_SetRecord") {
const value = decodeValueArgs(table.valueSchema, log.args);
const value = decodeValueArgs(valueSchema, log.args);
debug("setting component", {
namespace: table.namespace,
name: table.name,
Expand All @@ -104,7 +75,7 @@ export function createStorageAdapter<tables extends Tables>({
const previousValue = getComponentValue(component, entity);
const previousStaticData = (previousValue?.__staticData as Hex) ?? "0x";
const newStaticData = spliceHex(previousStaticData, log.args.start, size(log.args.data), log.args.data);
const newValue = decodeValueArgs(table.valueSchema, {
const newValue = decodeValueArgs(valueSchema, {
staticData: newStaticData,
encodedLengths: (previousValue?.__encodedLengths as Hex) ?? "0x",
dynamicData: (previousValue?.__dynamicData as Hex) ?? "0x",
Expand Down Expand Up @@ -132,7 +103,7 @@ export function createStorageAdapter<tables extends Tables>({
const previousValue = getComponentValue(component, entity);
const previousDynamicData = (previousValue?.__dynamicData as Hex) ?? "0x";
const newDynamicData = spliceHex(previousDynamicData, log.args.start, log.args.deleteCount, log.args.data);
const newValue = decodeValueArgs(table.valueSchema, {
const newValue = decodeValueArgs(valueSchema, {
staticData: (previousValue?.__staticData as Hex) ?? "0x",
// TODO: handle unchanged encoded lengths
encodedLengths: log.args.encodedLengths,
Expand Down Expand Up @@ -168,5 +139,9 @@ export function createStorageAdapter<tables extends Tables>({
}
}

return { storageAdapter: recsStorageAdapter, components };
return {
storageAdapter,
/** @deprecated Use `const components = tablesToComponents(world, tables)` instead. */
components,
};
}
69 changes: 69 additions & 0 deletions packages/store-sync/src/recs/createSyncAdapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { Component as RecsComponent, World as RecsWorld, getComponentValue, setComponent } from "@latticexyz/recs";
import { createStorageAdapter } from "./createStorageAdapter";
import { SyncStep } from "../SyncStep";
import { SyncAdapter } from "../common";
import { createStoreSync } from "../createStoreSync";
import { singletonEntity } from "./singletonEntity";
import { Store as StoreConfig } from "@latticexyz/store";
import { registerComponents } from "./registerComponents";

export type CreateSyncAdapterOptions<config extends StoreConfig> = {
world: RecsWorld;
config: config;
};

export function createSyncAdapter<const config extends StoreConfig>({
world,
config,
}: CreateSyncAdapterOptions<config>): {
syncAdapter: SyncAdapter;
components: registerComponents<config>;
} {
const components = registerComponents({ world, config });

const syncAdapter: SyncAdapter = (opts) => {
// TODO: clear component values?

const { storageAdapter } = createStorageAdapter({
world,
tables: {},
shouldSkipUpdateStream: (): boolean => {
const value = getComponentValue(components.SyncProgress, singletonEntity);
console.log("should skip update?", value);
return value?.step !== SyncStep.LIVE;
},
});

return createStoreSync({
...opts,
storageAdapter,
onProgress: ({ step, percentage, latestBlockNumber, lastBlockNumberProcessed, message }) => {
// already live, no need for more progress updates
if (getComponentValue(components.SyncProgress, singletonEntity)?.step === SyncStep.LIVE) return;

console.log("setting component", { step, percentage });
setComponent(components.SyncProgress, singletonEntity, {
step,
percentage,
latestBlockNumber,
lastBlockNumberProcessed,
message,
});

// when we switch to live, trigger update for all entities in all components
if (step === SyncStep.LIVE) {
for (const _component of Object.values(components)) {
// downcast component for easier calling of generic methods on all components
const component = _component as RecsComponent;
for (const entity of component.entities()) {
const value = getComponentValue(component, entity);
component.update$.next({ component, entity, value: [value, value] });
}
}
}
},
});
};

return { syncAdapter, components };
}
6 changes: 0 additions & 6 deletions packages/store-sync/src/recs/defineInternalComponents.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,9 @@
import { World, defineComponent, Type, Component, Schema, Metadata } from "@latticexyz/recs";
import { Table } from "../common";

export type InternalComponents = ReturnType<typeof defineInternalComponents>;

export function defineInternalComponents(world: World) {
return {
RegisteredTables: defineComponent<{ table: Type.T }, Metadata, Table>(
world,
{ table: Type.T },
{ metadata: { componentName: "RegisteredTables" } },
),
SyncProgress: defineComponent(
world,
{
Expand Down
6 changes: 5 additions & 1 deletion packages/store-sync/src/recs/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
export * from "./common";
export * from "./createStorageAdapter";
export * from "./createSyncAdapter";
export * from "./decodeEntity";
export * from "./encodeEntity";
export * from "./entityToHexKeyTuple";
export * from "./hexKeyTupleToEntity";
export * from "./isStoreComponent";
export * from "./createStorageAdapter";
export * from "./registerComponents";
export * from "./singletonEntity";
export * from "./syncToRecs";
export * from "./tableToComponent";
export * from "./tablesToComponents";
1 change: 1 addition & 0 deletions packages/store-sync/src/recs/isStoreComponent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export function isStoreComponent<S extends Schema = Schema>(
return (
component.metadata?.componentName != null &&
component.metadata?.tableName != null &&
component.metadata?.table != null &&
component.metadata?.keySchema != null &&
component.metadata?.valueSchema != null
);
Expand Down
33 changes: 33 additions & 0 deletions packages/store-sync/src/recs/registerComponents.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { World as RecsWorld } from "@latticexyz/recs";
import { mudTables } from "../common";
import { tablesToComponents } from "./tablesToComponents";
import { Store as StoreConfig } from "@latticexyz/store";
import { merge } from "@ark/util";
import { configToTables } from "../configToTables";
import { defineInternalComponents } from "./defineInternalComponents";
import { Tables } from "@latticexyz/config";

export type registerComponents<config extends StoreConfig, extraTables extends Tables = {}> = merge<
merge<
merge<tablesToComponents<configToTables<config>>, tablesToComponents<extraTables>>,
tablesToComponents<mudTables>
>,
ReturnType<typeof defineInternalComponents>
>;

export function registerComponents<const config extends StoreConfig, const extraTables extends Tables = {}>({
world,
config,
extraTables = {} as extraTables,
}: {
world: RecsWorld;
config: config;
extraTables?: extraTables;
}): registerComponents<config, extraTables> {
return {
...tablesToComponents(world, configToTables(config) as configToTables<config>),
...tablesToComponents(world, extraTables),
...tablesToComponents(world, mudTables),
...defineInternalComponents(world),
} as never;
}
21 changes: 8 additions & 13 deletions packages/store-sync/src/recs/syncToRecs.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
import { Tables } from "@latticexyz/config";
import { Store as StoreConfig } from "@latticexyz/store";
import { Component as RecsComponent, World as RecsWorld, getComponentValue, setComponent } from "@latticexyz/recs";
import { SyncOptions, SyncResult, mudTables } from "../common";
import { CreateStorageAdapterResult, createStorageAdapter } from "./createStorageAdapter";
import { SyncOptions, SyncResult } from "../common";
import { createStorageAdapter } from "./createStorageAdapter";
import { createStoreSync } from "../createStoreSync";
import { singletonEntity } from "./singletonEntity";
import { SyncStep } from "../SyncStep";
import { configToTables } from "../configToTables";
import { merge } from "@ark/util";
import { registerComponents } from "./registerComponents";

export type SyncToRecsOptions<
config extends StoreConfig = StoreConfig,
Expand All @@ -20,26 +19,22 @@ export type SyncToRecsOptions<
};

export type SyncToRecsResult<config extends StoreConfig, extraTables extends Tables> = SyncResult & {
components: CreateStorageAdapterResult<merge<merge<configToTables<config>, extraTables>, mudTables>>["components"];
components: registerComponents<config, extraTables>;
stopSync: () => void;
};

export async function syncToRecs<config extends StoreConfig, extraTables extends Tables = {}>({
export async function syncToRecs<const config extends StoreConfig, const extraTables extends Tables = {}>({
world,
config,
tables: extraTables = {} as extraTables,
startSync = true,
...syncOptions
}: SyncToRecsOptions<config, extraTables>): Promise<SyncToRecsResult<config, extraTables>> {
const tables = {
...configToTables(config),
...extraTables,
...mudTables,
};
const components = registerComponents({ world, config, extraTables });

const { storageAdapter, components } = createStorageAdapter({
const { storageAdapter } = createStorageAdapter({
world,
tables,
tables: {},
shouldSkipUpdateStream: (): boolean =>
getComponentValue(components.SyncProgress, singletonEntity)?.step !== SyncStep.LIVE,
});
Expand Down
Loading

0 comments on commit 227db4d

Please sign in to comment.