diff --git a/.changeset/popular-lies-teach.md b/.changeset/popular-lies-teach.md new file mode 100644 index 0000000000..ce1af37089 --- /dev/null +++ b/.changeset/popular-lies-teach.md @@ -0,0 +1,5 @@ +--- +"@latticexyz/stash": patch +--- + +Consolidated how state changes are applied and subscribers notified. Stash subscribers now receive an ordered list of state updates rather than an object. diff --git a/packages/stash/src/actions/applyUpdates.ts b/packages/stash/src/actions/applyUpdates.ts new file mode 100644 index 0000000000..6ce7f688c2 --- /dev/null +++ b/packages/stash/src/actions/applyUpdates.ts @@ -0,0 +1,91 @@ +import { schemaAbiTypeToDefaultValue } from "@latticexyz/schema-type/internal"; +import { Key, Stash, TableRecord, TableUpdates } from "../common"; +import { encodeKey } from "./encodeKey"; +import { Table } from "@latticexyz/config"; +import { registerTable } from "./registerTable"; + +export type PendingStashUpdate = { + table: table; + key: Key
; + value: undefined | Partial>; +}; + +export type ApplyUpdatesArgs = { + stash: Stash; + updates: PendingStashUpdate[]; +}; + +type PendingUpdates = { + [namespaceLabel: string]: { + [tableLabel: string]: TableUpdates; + }; +}; + +const pendingStashUpdates = new Map(); + +export function applyUpdates({ stash, updates }: ApplyUpdatesArgs): void { + const pendingUpdates = pendingStashUpdates.get(stash) ?? {}; + if (!pendingStashUpdates.has(stash)) pendingStashUpdates.set(stash, pendingUpdates); + + for (const { table, key, value } of updates) { + if (stash.get().config[table.namespaceLabel]?.[table.label] == null) { + registerTable({ stash, table }); + } + const tableState = ((stash._.state.records[table.namespaceLabel] ??= {})[table.label] ??= {}); + const encodedKey = encodeKey({ table, key }); + const prevRecord = tableState[encodedKey]; + + // create new record, preserving field order + const nextRecord = + value == null + ? undefined + : Object.fromEntries( + Object.entries(table.schema).map(([fieldName, { type }]) => [ + fieldName, + key[fieldName] ?? // Use provided key fields + value[fieldName] ?? // Or provided value fields + prevRecord?.[fieldName] ?? // Keep existing non-overridden fields + schemaAbiTypeToDefaultValue[type], // Default values for new fields + ]), + ); + + // apply update to state + if (nextRecord != null) { + tableState[encodedKey] = nextRecord; + } else { + delete tableState[encodedKey]; + } + + // add update to pending updates for notifying subscribers + const tableUpdates = ((pendingUpdates[table.namespaceLabel] ??= {})[table.label] ??= []); + tableUpdates.push({ + table, + key, + previous: prevRecord, + current: nextRecord, + }); + } + + queueMicrotask(() => { + notifySubscribers(stash); + }); +} + +function notifySubscribers(stash: Stash) { + const pendingUpdates = pendingStashUpdates.get(stash); + if (!pendingUpdates) return; + + // Notify table subscribers + for (const [namespaceLabel, namespaceUpdates] of Object.entries(pendingUpdates)) { + for (const [tableLabel, tableUpdates] of Object.entries(namespaceUpdates)) { + stash._.tableSubscribers[namespaceLabel]?.[tableLabel]?.forEach((subscriber) => subscriber(tableUpdates)); + } + } + // Notify stash subscribers + const updates = Object.values(pendingUpdates) + .map((namespaceUpdates) => Object.values(namespaceUpdates)) + .flat(2); + stash._.storeSubscribers.forEach((subscriber) => subscriber({ type: "records", updates })); + + pendingStashUpdates.delete(stash); +} diff --git a/packages/stash/src/actions/deleteRecord.ts b/packages/stash/src/actions/deleteRecord.ts index df8125d213..5e0b75230a 100644 --- a/packages/stash/src/actions/deleteRecord.ts +++ b/packages/stash/src/actions/deleteRecord.ts @@ -1,7 +1,6 @@ import { Table } from "@latticexyz/config"; import { Key, Stash } from "../common"; -import { encodeKey } from "./encodeKey"; -import { registerTable } from "./registerTable"; +import { applyUpdates } from "./applyUpdates"; export type DeleteRecordArgs
= { stash: Stash; @@ -12,26 +11,5 @@ export type DeleteRecordArgs
= { export type DeleteRecordResult = void; export function deleteRecord
({ stash, table, key }: DeleteRecordArgs
): DeleteRecordResult { - const { namespaceLabel, label } = table; - - if (stash.get().config[namespaceLabel] == null) { - registerTable({ stash, table }); - } - - const encodedKey = encodeKey({ table, key }); - const prevRecord = stash.get().records[namespaceLabel]?.[label]?.[encodedKey]; - - // Early return if this record doesn't exist - if (prevRecord == null) return; - - // Delete record - delete stash._.state.records[namespaceLabel]?.[label]?.[encodedKey]; - - // Notify table subscribers - const updates = { [encodedKey]: { prev: prevRecord && { ...prevRecord }, current: undefined } }; - stash._.tableSubscribers[namespaceLabel]?.[label]?.forEach((subscriber) => subscriber(updates)); - - // Notify stash subscribers - const storeUpdate = { config: {}, records: { [namespaceLabel]: { [label]: updates } } }; - stash._.storeSubscribers.forEach((subscriber) => subscriber(storeUpdate)); + applyUpdates({ stash, updates: [{ table, key, value: undefined }] }); } diff --git a/packages/stash/src/actions/getTable.test.ts b/packages/stash/src/actions/getTable.test.ts index 31002e8f9e..55e405e676 100644 --- a/packages/stash/src/actions/getTable.test.ts +++ b/packages/stash/src/actions/getTable.test.ts @@ -297,6 +297,8 @@ describe("getTable", () => { describe("subscribe", () => { it("should notify subscriber of table change", () => { + vi.useFakeTimers({ toFake: ["queueMicrotask"] }); + const config1 = defineTable({ label: "table1", schema: { a: "address", b: "uint256", c: "uint32" }, @@ -315,28 +317,36 @@ describe("getTable", () => { table1.subscribe({ subscriber }); table1.setRecord({ key: { a: "0x00" }, value: { b: 1n, c: 2 } }); + vi.advanceTimersToNextTimer(); expect(subscriber).toHaveBeenCalledTimes(1); - expect(subscriber).toHaveBeenNthCalledWith(1, { - "0x00": { - prev: undefined, + expect(subscriber).toHaveBeenNthCalledWith(1, [ + { + table: config1, + key: { a: "0x00" }, + previous: undefined, current: { a: "0x00", b: 1n, c: 2 }, }, - }); + ]); // Expect unrelated updates to not notify subscribers table2.setRecord({ key: { a: "0x01" }, value: { b: 1n, c: 2 } }); + vi.advanceTimersToNextTimer(); + expect(subscriber).toHaveBeenCalledTimes(1); table1.setRecord({ key: { a: "0x00" }, value: { b: 1n, c: 3 } }); + vi.advanceTimersToNextTimer(); expect(subscriber).toHaveBeenCalledTimes(2); - expect(subscriber).toHaveBeenNthCalledWith(2, { - "0x00": { - prev: { a: "0x00", b: 1n, c: 2 }, + expect(subscriber).toHaveBeenNthCalledWith(2, [ + { + table: config1, + key: { a: "0x00" }, + previous: { a: "0x00", b: 1n, c: 2 }, current: { a: "0x00", b: 1n, c: 3 }, }, - }); + ]); }); }); }); diff --git a/packages/stash/src/actions/getTable.ts b/packages/stash/src/actions/getTable.ts index 3338b69ee5..1998f8403e 100644 --- a/packages/stash/src/actions/getTable.ts +++ b/packages/stash/src/actions/getTable.ts @@ -67,7 +67,7 @@ export function getTable
({ stash, table }: GetTableArgs) => getRecords({ stash, table, ...args }), setRecord: (args: TableBoundSetRecordArgs
) => setRecord({ stash, table, ...args }), setRecords: (args: TableBoundSetRecordsArgs
) => setRecords({ stash, table, ...args }), - subscribe: (args: TableBoundSubscribeTableArgs) => subscribeTable({ stash, table, ...args }), + subscribe: (args: TableBoundSubscribeTableArgs
) => subscribeTable({ stash, table, ...args }), // TODO: dynamically add setters and getters for individual fields of the table }; diff --git a/packages/stash/src/actions/registerTable.ts b/packages/stash/src/actions/registerTable.ts index b33c582fb5..68716bf7b6 100644 --- a/packages/stash/src/actions/registerTable.ts +++ b/packages/stash/src/actions/registerTable.ts @@ -27,11 +27,9 @@ export function registerTable
({ (stash._.tableSubscribers[namespaceLabel] ??= {})[label] ??= new Set(); // Notify stash subscribers - const storeUpdate = { - config: { [namespaceLabel]: { [label]: { prev: undefined, current: tableConfig } } }, - records: {}, - }; - stash._.storeSubscribers.forEach((subscriber) => subscriber(storeUpdate)); + stash._.storeSubscribers.forEach((subscriber) => + subscriber({ type: "config", updates: [{ previous: undefined, current: tableConfig }] }), + ); return getTable({ stash, table }); } diff --git a/packages/stash/src/actions/setRecord.ts b/packages/stash/src/actions/setRecord.ts index 12df6ce23c..281060a54e 100644 --- a/packages/stash/src/actions/setRecord.ts +++ b/packages/stash/src/actions/setRecord.ts @@ -1,6 +1,6 @@ -import { Key, TableRecord, Stash } from "../common"; -import { setRecords } from "./setRecords"; import { Table } from "@latticexyz/config"; +import { Key, TableRecord, Stash } from "../common"; +import { applyUpdates } from "./applyUpdates"; export type SetRecordArgs
= { stash: Stash; @@ -12,12 +12,5 @@ export type SetRecordArgs
= { export type SetRecordResult = void; export function setRecord
({ stash, table, key, value }: SetRecordArgs
): SetRecordResult { - setRecords({ - stash, - table, - records: [ - // Stored record should include key - { ...value, ...key }, - ], - }); + applyUpdates({ stash, updates: [{ table, key, value }] }); } diff --git a/packages/stash/src/actions/setRecords.ts b/packages/stash/src/actions/setRecords.ts index 110f8332a9..7604027f8b 100644 --- a/packages/stash/src/actions/setRecords.ts +++ b/packages/stash/src/actions/setRecords.ts @@ -1,8 +1,7 @@ -import { dynamicAbiTypeToDefaultValue, staticAbiTypeToDefaultValue } from "@latticexyz/schema-type/internal"; -import { Stash, TableRecord, TableUpdates } from "../common"; -import { encodeKey } from "./encodeKey"; import { Table } from "@latticexyz/config"; -import { registerTable } from "./registerTable"; +import { getKey, getValue } from "@latticexyz/protocol-parser/internal"; +import { Stash, TableRecord } from "../common"; +import { applyUpdates } from "./applyUpdates"; export type SetRecordsArgs
= { stash: Stash; @@ -13,38 +12,12 @@ export type SetRecordsArgs
= { export type SetRecordsResult = void; export function setRecords
({ stash, table, records }: SetRecordsArgs
): SetRecordsResult { - const { namespaceLabel, label, schema } = table; - - if (stash.get().config[namespaceLabel]?.[label] == null) { - registerTable({ stash, table }); - } - - // Construct table updates - const updates: TableUpdates = {}; - for (const record of records) { - const encodedKey = encodeKey({ table, key: record as never }); - const prevRecord = stash.get().records[namespaceLabel]?.[label]?.[encodedKey]; - const newRecord = Object.fromEntries( - Object.keys(schema).map((fieldName) => [ - fieldName, - record[fieldName] ?? // Override provided record fields - prevRecord?.[fieldName] ?? // Keep existing non-overridden fields - staticAbiTypeToDefaultValue[schema[fieldName] as never] ?? // Default values for new fields - dynamicAbiTypeToDefaultValue[schema[fieldName] as never], - ]), - ); - updates[encodedKey] = { prev: prevRecord, current: newRecord }; - } - - // Update records - for (const [encodedKey, { current }] of Object.entries(updates)) { - ((stash._.state.records[namespaceLabel] ??= {})[label] ??= {})[encodedKey] = current as never; - } - - // Notify table subscribers - stash._.tableSubscribers[namespaceLabel]?.[label]?.forEach((subscriber) => subscriber(updates)); - - // Notify stash subscribers - const storeUpdate = { config: {}, records: { [namespaceLabel]: { [label]: updates } } }; - stash._.storeSubscribers.forEach((subscriber) => subscriber(storeUpdate)); + applyUpdates({ + stash, + updates: Object.values(records).map((record) => ({ + table, + key: getKey(table, record), + value: getValue(table, record), + })), + }); } diff --git a/packages/stash/src/actions/subscribeQuery.test.ts b/packages/stash/src/actions/subscribeQuery.test.ts index cf50949889..830a20feee 100644 --- a/packages/stash/src/actions/subscribeQuery.test.ts +++ b/packages/stash/src/actions/subscribeQuery.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, it, vi, expect } from "vitest"; -import { QueryUpdate, subscribeQuery } from "./subscribeQuery"; +import { QueryUpdates, subscribeQuery } from "./subscribeQuery"; import { attest } from "@ark/attest"; import { defineStore } from "@latticexyz/store"; import { In, Matches } from "../queryFragments"; @@ -32,6 +32,7 @@ describe("defineQuery", () => { beforeEach(() => { stash = createStash(config); + vi.useFakeTimers({ toFake: ["queueMicrotask"] }); // Add some mock data const items = ["0xgold", "0xsilver"] as const; @@ -45,6 +46,8 @@ describe("defineQuery", () => { setRecord({ stash, table: Inventory, key: { player: `0x${String(i)}`, item }, value: { amount: i } }); } } + + vi.advanceTimersToNextTimer(); }); it("should return the matching keys and keep it updated", () => { @@ -55,113 +58,211 @@ describe("defineQuery", () => { }); setRecord({ stash, table: Health, key: { player: `0x2` }, value: { health: 2 } }); + vi.advanceTimersToNextTimer(); - attest(result.keys).snap({ - "0x2": { player: "0x2" }, - "0x3": { player: "0x3" }, - "0x4": { player: "0x4" }, - }); + attest(result.keys).snap({ "0x3": { player: "0x3" }, "0x4": { player: "0x4" }, "0x2": { player: "0x2" } }); }); it("should notify subscribers when a matching key is updated", () => { - let lastUpdate: unknown; - const subscriber = vi.fn((update: QueryUpdate) => (lastUpdate = update)); - const result = subscribeQuery({ stash, query: [Matches(Position, { x: 4 }), In(Health)] }); - result.subscribe(subscriber); + let lastUpdates: unknown; + const subscriber = vi.fn((updates: QueryUpdates) => (lastUpdates = updates)); - setRecord({ stash, table: Position, key: { player: "0x4" }, value: { y: 2 } }); + subscribeQuery({ + stash, + query: [Matches(Position, { x: 4 }), In(Health)], + subscriber, + }); + vi.advanceTimersToNextTimer(); expect(subscriber).toBeCalledTimes(1); - attest(lastUpdate).snap({ - records: { - namespace1: { - Position: { - "0x4": { - prev: { player: "0x4", x: 4, y: 1 }, - current: { player: "0x4", x: 4, y: 2 }, - }, + attest(lastUpdates).snap([{ key: { player: "0x4" }, type: "enter" }]); + + setRecord({ stash, table: Position, key: { player: "0x4" }, value: { y: 2 } }); + vi.advanceTimersToNextTimer(); + + expect(subscriber).toBeCalledTimes(2); + attest(lastUpdates).snap([ + { + table: { + label: "Position", + type: "table", + namespace: "namespace1", + namespaceLabel: "namespace1", + name: "Position", + tableId: "0x74626e616d6573706163653100000000506f736974696f6e0000000000000000", + schema: { + player: { type: "bytes32", internalType: "bytes32" }, + x: { type: "int32", internalType: "int32" }, + y: { type: "int32", internalType: "int32" }, }, + key: ["player"], + codegen: { outputDirectory: "tables", tableIdArgument: false, storeArgument: false, dataStruct: true }, + deploy: { disabled: false }, }, + key: { player: "0x4" }, + previous: { player: "0x4", x: 4, y: 1 }, + current: { player: "0x4", x: 4, y: 2 }, + type: "update", }, - keys: { "0x4": { player: "0x4" } }, - types: { "0x4": "update" }, - }); + ]); }); it("should notify subscribers when a new key matches", () => { - let lastUpdate: unknown; - const subscriber = vi.fn((update: QueryUpdate) => (lastUpdate = update)); - const result = subscribeQuery({ stash, query: [In(Position), In(Health)] }); - result.subscribe(subscriber); + let lastUpdates: unknown; + const subscriber = vi.fn((updates: QueryUpdates) => (lastUpdates = updates)); + subscribeQuery({ stash, query: [In(Position), In(Health)], subscriber }); + + vi.advanceTimersToNextTimer(); + expect(subscriber).toBeCalledTimes(1); + attest(lastUpdates).snap([ + { key: { player: "0x3" }, type: "enter" }, + { key: { player: "0x4" }, type: "enter" }, + ]); setRecord({ stash, table: Health, key: { player: `0x2` }, value: { health: 2 } }); + vi.advanceTimersToNextTimer(); - expect(subscriber).toBeCalledTimes(1); - attest(lastUpdate).snap({ - records: { - namespace1: { - Health: { - "0x2": { - prev: undefined, - current: { player: `0x2`, health: 2 }, - }, + expect(subscriber).toBeCalledTimes(2); + attest(lastUpdates).snap([ + { + table: { + label: "Health", + type: "table", + namespace: "namespace1", + namespaceLabel: "namespace1", + name: "Health", + tableId: "0x74626e616d65737061636531000000004865616c746800000000000000000000", + schema: { + player: { type: "bytes32", internalType: "bytes32" }, + health: { type: "uint32", internalType: "uint32" }, }, + key: ["player"], + codegen: { outputDirectory: "tables", tableIdArgument: false, storeArgument: false, dataStruct: false }, + deploy: { disabled: false }, }, + key: { player: "0x2" }, + previous: "(undefined)", + current: { player: "0x2", health: 2 }, + type: "enter", }, - keys: { "0x2": { player: "0x2" } }, - types: { "0x2": "enter" }, - }); + ]); }); it("should notify subscribers when a key doesn't match anymore", () => { - let lastUpdate: unknown; - const subscriber = vi.fn((update: QueryUpdate) => (lastUpdate = update)); - const result = subscribeQuery({ stash, query: [In(Position), In(Health)] }); - result.subscribe(subscriber); + let lastUpdates: unknown; + const subscriber = vi.fn((updates: QueryUpdates) => (lastUpdates = updates)); + subscribeQuery({ stash, query: [In(Position), In(Health)], subscriber }); + + vi.advanceTimersToNextTimer(); + expect(subscriber).toBeCalledTimes(1); + attest(lastUpdates).snap([ + { key: { player: "0x3" }, type: "enter" }, + { key: { player: "0x4" }, type: "enter" }, + ]); deleteRecord({ stash, table: Position, key: { player: `0x3` } }); + vi.advanceTimersToNextTimer(); - expect(subscriber).toBeCalledTimes(1); - attest(lastUpdate).snap({ - records: { - namespace1: { - Position: { - "0x3": { - prev: { player: "0x3", x: 3, y: 2 }, - current: undefined, - }, + expect(subscriber).toBeCalledTimes(2); + attest(lastUpdates).snap([ + { + table: { + label: "Position", + type: "table", + namespace: "namespace1", + namespaceLabel: "namespace1", + name: "Position", + tableId: "0x74626e616d6573706163653100000000506f736974696f6e0000000000000000", + schema: { + player: { type: "bytes32", internalType: "bytes32" }, + x: { type: "int32", internalType: "int32" }, + y: { type: "int32", internalType: "int32" }, }, + key: ["player"], + codegen: { outputDirectory: "tables", tableIdArgument: false, storeArgument: false, dataStruct: true }, + deploy: { disabled: false }, }, + key: { player: "0x3" }, + previous: { player: "0x3", x: 3, y: 2 }, + current: "(undefined)", + type: "exit", }, - keys: { "0x3": { player: "0x3" } }, - types: { "0x3": "exit" }, - }); + ]); }); it("should notify initial subscribers with initial query result", () => { - let lastUpdate: unknown; - const subscriber = vi.fn((update: QueryUpdate) => (lastUpdate = update)); - subscribeQuery({ stash, query: [In(Position), In(Health)], options: { initialSubscribers: [subscriber] } }); + let lastUpdates: unknown; + const subscriber = vi.fn((updates: QueryUpdates) => (lastUpdates = updates)); + subscribeQuery({ stash, query: [In(Position), In(Health)], subscriber }); expect(subscriber).toBeCalledTimes(1); - attest(lastUpdate).snap({ - keys: { - "0x3": { player: "0x3" }, - "0x4": { player: "0x4" }, - }, - records: { - namespace1: { - Position: { - "0x3": { prev: undefined, current: { player: "0x3", x: 3, y: 2 } }, - "0x4": { prev: undefined, current: { player: "0x4", x: 4, y: 1 } }, + attest(lastUpdates).snap([ + { key: { player: "0x3" }, type: "enter" }, + { key: { player: "0x4" }, type: "enter" }, + ]); + }); + + it("should notify once for multiple updates", () => { + let lastUpdates: unknown; + const subscriber = vi.fn((updates: QueryUpdates) => (lastUpdates = updates)); + subscribeQuery({ stash, query: [In(Position), In(Health)], subscriber }); + + vi.advanceTimersToNextTimer(); + expect(subscriber).toBeCalledTimes(1); + attest(lastUpdates).snap([ + { key: { player: "0x3" }, type: "enter" }, + { key: { player: "0x4" }, type: "enter" }, + ]); + + // Update multiple records but only advance timer once + setRecord({ stash, table: Health, key: { player: `0x2` }, value: { health: 2 } }); + setRecord({ stash, table: Health, key: { player: `0x1` }, value: { health: 1 } }); + vi.advanceTimersToNextTimer(); + + expect(subscriber).toBeCalledTimes(2); + attest(lastUpdates).snap([ + { + table: { + label: "Health", + type: "table", + namespace: "namespace1", + namespaceLabel: "namespace1", + name: "Health", + tableId: "0x74626e616d65737061636531000000004865616c746800000000000000000000", + schema: { + player: { type: "bytes32", internalType: "bytes32" }, + health: { type: "uint32", internalType: "uint32" }, }, - Health: { - "0x3": { prev: undefined, current: { player: "0x3", health: 3 } }, - "0x4": { prev: undefined, current: { player: "0x4", health: 4 } }, + key: ["player"], + codegen: { outputDirectory: "tables", tableIdArgument: false, storeArgument: false, dataStruct: false }, + deploy: { disabled: false }, + }, + key: { player: "0x2" }, + previous: "(undefined)", + current: { player: "0x2", health: 2 }, + type: "enter", + }, + { + table: { + label: "Health", + type: "table", + namespace: "namespace1", + namespaceLabel: "namespace1", + name: "Health", + tableId: "0x74626e616d65737061636531000000004865616c746800000000000000000000", + schema: { + player: { type: "bytes32", internalType: "bytes32" }, + health: { type: "uint32", internalType: "uint32" }, }, + key: ["player"], + codegen: { outputDirectory: "tables", tableIdArgument: false, storeArgument: false, dataStruct: false }, + deploy: { disabled: false }, }, + key: { player: "0x1" }, + previous: "(undefined)", + current: { player: "0x1", health: 1 }, + type: "enter", }, - types: { "0x3": "enter", "0x4": "enter" }, - }); + ]); }); }); diff --git a/packages/stash/src/actions/subscribeQuery.ts b/packages/stash/src/actions/subscribeQuery.ts index 9405fdd51a..c67f0178e7 100644 --- a/packages/stash/src/actions/subscribeQuery.ts +++ b/packages/stash/src/actions/subscribeQuery.ts @@ -12,17 +12,15 @@ import { getNamespaceTables, getConfig, getQueryConfig, + getAllTables, + Key, + TableRecord, } from "../common"; -import { decodeKey } from "./decodeKey"; import { getTable } from "./getTable"; import { runQuery } from "./runQuery"; +import { encodeKey } from "./encodeKey"; -export type SubscribeQueryOptions = CommonQueryOptions & { - // Skip the initial `runQuery` to initialize the query result. - // Only updates after the query was defined are considered in the result. - skipInitialRun?: boolean; - initialSubscribers?: QuerySubscriber[]; -}; +export type SubscribeQueryOptions = CommonQueryOptions; export type QueryTableUpdates = { [namespace in getNamespaces]: { @@ -30,20 +28,25 @@ export type QueryTableUpdates = { }; }; -export type QueryUpdate = { - records: QueryTableUpdates; - keys: Keys; - types: { - [key: string]: "enter" | "update" | "exit"; - }; +export type QueryUpdateType = "enter" | "update" | "exit"; +export type QueryUpdate
= { + key: Key
; + type: QueryUpdateType; + table?: table; + previous?: TableRecord
; + current?: TableRecord
; }; +export type QueryUpdates
= QueryUpdate
[]; -export type QuerySubscriber = (update: QueryUpdate) => void; +export type QuerySubscriber = ( + updates: QueryUpdates>, +) => void; export type SubscribeQueryArgs = { stash: Stash; query: query; - options?: SubscribeQueryOptions>; + subscriber?: QuerySubscriber>; + options?: SubscribeQueryOptions; }; export type SubscribeQueryResult = CommonQueryResult & { @@ -62,97 +65,71 @@ export type SubscribeQueryResult = CommonQueryResul export function subscribeQuery({ stash, query, + subscriber, options, }: SubscribeQueryArgs): SubscribeQueryResult { - const initialRun = options?.skipInitialRun - ? undefined - : runQuery({ - stash, - query, - options: { - // Pass the initial keys - initialKeys: options?.initialKeys, - // Request initial records if there are initial subscribers - includeRecords: options?.initialSubscribers && options.initialSubscribers.length > 0, - }, - }); + const initialRun = runQuery({ + stash, + query, + options: { + // Pass the initial keys + initialKeys: options?.initialKeys, + includeRecords: false, + }, + }); const matching: Keys = initialRun?.keys ?? {}; - const subscribers = new Set(options?.initialSubscribers as QuerySubscriber[]); + const subscribers = new Set(subscriber ? [subscriber as QuerySubscriber] : []); const subscribe = (subscriber: QuerySubscriber>): Unsubscribe => { subscribers.add(subscriber as QuerySubscriber); return () => subscribers.delete(subscriber as QuerySubscriber); }; - const updateQueryResult = ({ namespaceLabel, label }: Table, tableUpdates: TableUpdates) => { - const update: QueryUpdate = { - records: { [namespaceLabel]: { [label]: tableUpdates } }, - keys: {}, - types: {}, - }; + const updateQueryResult = (tableUpdates: TableUpdates) => { + const updates: QueryUpdates = []; - for (const key of Object.keys(tableUpdates)) { - const matchedKey = matching[key]; + for (const update of tableUpdates) { + const encodedKey = encodeKey({ table: update.table, key: update.key }); + const matchedKey = matching[encodedKey]; + const { namespaceLabel, label } = update.table; if (matchedKey != null) { - update.keys[key] = matchedKey; // If the key matched before, check if the relevant fragments (accessing this table) still match const relevantFragments = query.filter((f) => f.table.namespace === namespaceLabel && f.table.label === label); - const match = relevantFragments.every((f) => f.pass(stash, key)); + const match = relevantFragments.every((f) => f.pass(stash, encodedKey)); if (match) { // If all relevant fragments still match, the key still matches the query. - update.types[key] = "update"; + updates.push({ ...update, type: "update" }); } else { // If one of the relevant fragments don't match anymore, the key doesn't pass the query anymore. - delete matching[key]; - update.types[key] = "exit"; + delete matching[encodedKey]; + updates.push({ ...update, type: "exit" }); } } else { // If this key didn't match the query before, check all fragments - const match = query.every((f) => f.pass(stash, key)); + const match = query.every((f) => f.pass(stash, encodedKey)); if (match) { // Since the key schema of query fragments has to match, we can pick any fragment to decode they key - const decodedKey = decodeKey({ stash, table: query[0].table, encodedKey: key }); - matching[key] = decodedKey; - update.keys[key] = decodedKey; - update.types[key] = "enter"; + matching[encodedKey] = update.key; + updates.push({ ...update, type: "enter" }); } } } // Notify subscribers - subscribers.forEach((subscriber) => subscriber(update)); + subscribers.forEach((subscriber) => subscriber(updates)); }; - // Subscribe to each table's update stream and stash the unsubscribers + // Subscribe to each table's update stream and return the unsubscribers const unsubsribers = query.map((fragment) => getTable({ stash, table: fragment.table }).subscribe({ - subscriber: (updates) => updateQueryResult(fragment.table, updates), + subscriber: (updates) => updateQueryResult(updates), }), ); - const unsubscribe = () => unsubsribers.forEach((unsub) => unsub()); - // TODO: find a more elegant way to do this - if (subscribers.size > 0 && initialRun?.records) { - // Convert records from the initial run to TableUpdate format - const records: QueryTableUpdates = {}; - for (const namespace of Object.keys(initialRun.records)) { - for (const table of Object.keys(initialRun.records[namespace])) { - (records[namespace] ??= {})[table] = Object.fromEntries( - Object.entries(initialRun.records[namespace][table]).map(([key, record]) => [ - key, - { prev: undefined, current: record }, - ]), - ) as never; - } - } - - // Convert keys to types format - const types = Object.fromEntries(Object.keys(matching).map((key) => [key, "enter" as const])); - - // Notify initial subscribers - subscribers.forEach((subscriber) => subscriber({ keys: matching, records, types })); - } + // Notify initial subscribers + const updates: QueryUpdates = Object.values(matching).map((key) => ({ key, type: "enter" })); + subscribers.forEach((subscriber) => subscriber(updates)); return { keys: matching, subscribe, unsubscribe }; } diff --git a/packages/stash/src/actions/subscribeStash.test.ts b/packages/stash/src/actions/subscribeStash.test.ts index 887ca1dbc6..d78a6512dc 100644 --- a/packages/stash/src/actions/subscribeStash.test.ts +++ b/packages/stash/src/actions/subscribeStash.test.ts @@ -3,9 +3,12 @@ import { describe, expect, it, vi } from "vitest"; import { createStash } from "../createStash"; import { subscribeStash } from "./subscribeStash"; import { setRecord } from "./setRecord"; +import { deleteRecord } from "./deleteRecord"; describe("subscribeStash", () => { - it("should notify subscriber of any stash change", () => { + it("should notify subscriber of any stash change", async () => { + vi.useFakeTimers({ toFake: ["queueMicrotask"] }); + const config = defineStore({ namespaces: { namespace1: { @@ -33,54 +36,137 @@ describe("subscribeStash", () => { subscribeStash({ stash, subscriber }); setRecord({ stash, table: config.tables.namespace1__table1, key: { a: "0x00" }, value: { b: 1n, c: 2 } }); + vi.advanceTimersToNextTimer(); expect(subscriber).toHaveBeenCalledTimes(1); - expect(subscriber).toHaveBeenNthCalledWith(1, { - config: {}, - records: { - namespace1: { - table1: { - "0x00": { - prev: undefined, - current: { a: "0x00", b: 1n, c: 2 }, - }, - }, + expect(subscriber).toHaveBeenCalledWith({ + type: "records", + updates: [ + { + table: config.tables.namespace1__table1, + key: { a: "0x00" }, + previous: undefined, + current: { a: "0x00", b: 1n, c: 2 }, }, - }, + ], }); setRecord({ stash, table: config.tables.namespace2__table2, key: { a: "0x01" }, value: { b: 1n, c: 2 } }); + vi.advanceTimersToNextTimer(); expect(subscriber).toHaveBeenCalledTimes(2); expect(subscriber).toHaveBeenNthCalledWith(2, { - config: {}, - records: { - namespace2: { - table2: { - "0x01": { - prev: undefined, - current: { a: "0x01", b: 1n, c: 2 }, - }, - }, + type: "records", + updates: [ + { + table: config.tables.namespace2__table2, + key: { a: "0x01" }, + previous: undefined, + current: { a: "0x01", b: 1n, c: 2 }, }, - }, + ], }); setRecord({ stash, table: config.tables.namespace2__table2, key: { a: "0x01" }, value: { b: 1n, c: 3 } }); + vi.advanceTimersToNextTimer(); expect(subscriber).toHaveBeenCalledTimes(3); expect(subscriber).toHaveBeenNthCalledWith(3, { - config: {}, - records: { - namespace2: { - table2: { - "0x01": { - prev: { a: "0x01", b: 1n, c: 2 }, - current: { a: "0x01", b: 1n, c: 3 }, + type: "records", + updates: [ + { + table: config.tables.namespace2__table2, + key: { a: "0x01" }, + previous: { a: "0x01", b: 1n, c: 2 }, + current: { a: "0x01", b: 1n, c: 3 }, + }, + ], + }); + + deleteRecord({ stash, table: config.tables.namespace2__table2, key: { a: "0x01" } }); + vi.advanceTimersToNextTimer(); + + expect(subscriber).toHaveBeenCalledTimes(4); + expect(subscriber).toHaveBeenNthCalledWith(4, { + type: "records", + updates: [ + { + table: config.tables.namespace2__table2, + key: { a: "0x01" }, + previous: { a: "0x01", b: 1n, c: 3 }, + current: undefined, + }, + ], + }); + }); + + it("should notify subscriber of singleton table changes", () => { + vi.useFakeTimers({ toFake: ["queueMicrotask"] }); + + const config = defineStore({ + namespaces: { + app: { + tables: { + config: { + schema: { enabled: "bool" }, + key: [], }, }, }, }, }); + + const stash = createStash(config); + const subscriber = vi.fn(); + + subscribeStash({ stash, subscriber }); + + setRecord({ stash, table: config.tables.app__config, key: {}, value: { enabled: true } }); + vi.advanceTimersToNextTimer(); + + expect(subscriber).toHaveBeenCalledTimes(1); + expect(subscriber).toHaveBeenNthCalledWith(1, { + type: "records", + updates: [ + { + table: config.tables.app__config, + key: {}, + previous: undefined, + current: { enabled: true }, + }, + ], + }); + + setRecord({ stash, table: config.tables.app__config, key: {}, value: { enabled: false } }); + vi.advanceTimersToNextTimer(); + + expect(subscriber).toHaveBeenCalledTimes(2); + expect(subscriber).toHaveBeenNthCalledWith(2, { + type: "records", + updates: [ + { + table: config.tables.app__config, + key: {}, + previous: { enabled: true }, + current: { enabled: false }, + }, + ], + }); + + deleteRecord({ stash, table: config.tables.app__config, key: {} }); + vi.advanceTimersToNextTimer(); + + expect(subscriber).toHaveBeenCalledTimes(3); + expect(subscriber).toHaveBeenNthCalledWith(3, { + type: "records", + updates: [ + { + table: config.tables.app__config, + key: {}, + previous: { enabled: false }, + current: undefined, + }, + ], + }); }); }); diff --git a/packages/stash/src/actions/subscribeTable.test.ts b/packages/stash/src/actions/subscribeTable.test.ts index a1e6ab1ca4..364c7493e3 100644 --- a/packages/stash/src/actions/subscribeTable.test.ts +++ b/packages/stash/src/actions/subscribeTable.test.ts @@ -6,6 +6,8 @@ import { setRecord } from "./setRecord"; describe("subscribeTable", () => { it("should notify subscriber of table change", () => { + vi.useFakeTimers({ toFake: ["queueMicrotask"] }); + const config = defineStore({ namespaces: { namespace1: { @@ -35,27 +37,35 @@ describe("subscribeTable", () => { subscribeTable({ stash, table: table1, subscriber }); setRecord({ stash, table: table1, key: { a: "0x00" }, value: { b: 1n, c: 2 } }); + vi.advanceTimersToNextTimer(); expect(subscriber).toHaveBeenCalledTimes(1); - expect(subscriber).toHaveBeenNthCalledWith(1, { - "0x00": { - prev: undefined, + expect(subscriber).toHaveBeenNthCalledWith(1, [ + { + table: table1, + key: { a: "0x00" }, + previous: undefined, current: { a: "0x00", b: 1n, c: 2 }, }, - }); + ]); // Expect unrelated updates to not notify subscribers setRecord({ stash, table: table2, key: { a: "0x01" }, value: { b: 1n, c: 2 } }); + vi.advanceTimersToNextTimer(); + expect(subscriber).toHaveBeenCalledTimes(1); setRecord({ stash, table: table1, key: { a: "0x00" }, value: { b: 1n, c: 3 } }); + vi.advanceTimersToNextTimer(); expect(subscriber).toHaveBeenCalledTimes(2); - expect(subscriber).toHaveBeenNthCalledWith(2, { - "0x00": { - prev: { a: "0x00", b: 1n, c: 2 }, + expect(subscriber).toHaveBeenNthCalledWith(2, [ + { + table: table1, + key: { a: "0x00" }, + previous: { a: "0x00", b: 1n, c: 2 }, current: { a: "0x00", b: 1n, c: 3 }, }, - }); + ]); }); }); diff --git a/packages/stash/src/actions/subscribeTable.ts b/packages/stash/src/actions/subscribeTable.ts index 2a8cba3bea..aa55327029 100644 --- a/packages/stash/src/actions/subscribeTable.ts +++ b/packages/stash/src/actions/subscribeTable.ts @@ -21,6 +21,6 @@ export function subscribeTable
({ registerTable({ stash, table }); } - stash._.tableSubscribers[namespaceLabel]?.[label]?.add(subscriber); - return () => stash._.tableSubscribers[namespaceLabel]?.[label]?.delete(subscriber); + stash._.tableSubscribers[namespaceLabel]?.[label]?.add(subscriber as TableUpdatesSubscriber); + return () => stash._.tableSubscribers[namespaceLabel]?.[label]?.delete(subscriber as TableUpdatesSubscriber); } diff --git a/packages/stash/src/common.ts b/packages/stash/src/common.ts index ed5c09d7e9..c1adbfed23 100644 --- a/packages/stash/src/common.ts +++ b/packages/stash/src/common.ts @@ -19,6 +19,18 @@ export type getNamespaceTables< namespace extends keyof config["namespaces"], > = keyof config["namespaces"][namespace]["tables"]; +type namespacedTableLabels = keyof { + [key in keyof config["namespaces"] as `${key & string}__${keyof config["namespaces"][key]["tables"] & string}`]: null; +}; + +type namespacedTables = { + [key in namespacedTableLabels]: key extends `${infer namespaceLabel}__${infer tableLabel}` + ? config["namespaces"][namespaceLabel]["tables"][tableLabel] + : never; +}; + +export type getAllTables = namespacedTables[keyof namespacedTables]; + export type getConfig< config extends StoreConfig, namespace extends keyof config["namespaces"] | undefined, @@ -118,38 +130,33 @@ export type MutableState = { }; export type TableUpdate
= { - prev: TableRecord
| undefined; + table: table; + key: Key
; + previous: TableRecord
| undefined; current: TableRecord
| undefined; }; -export type TableUpdates
= { [key: string]: TableUpdate
}; +export type TableUpdates
= TableUpdate
[]; export type TableUpdatesSubscriber
= (updates: TableUpdates
) => void; export type TableSubscribers = { - [namespace: string]: { - [table: string]: Set; + [namespaceLabel: string]: { + [tableLabel: string]: Set; }; }; -export type ConfigUpdate = { prev: Table | undefined; current: Table }; +export type ConfigUpdate = { previous: Table | undefined; current: Table }; -export type StoreUpdates = { - config: { - [namespace: string]: { - [table: string]: ConfigUpdate; +export type StoreUpdates = + | { + type: "config"; + updates: ConfigUpdate[]; + } + | { + type: "records"; + updates: TableUpdates>; }; - }; - records: { - [namespace in getNamespaces]: { - [table in getNamespaceTables]: TableUpdates>; - }; - } & { - [namespace: string]: { - [table: string]: TableUpdates; - }; - }; -}; export type StoreUpdatesSubscriber = (updates: StoreUpdates) => void; diff --git a/packages/stash/src/createStash.test.ts b/packages/stash/src/createStash.test.ts index e92c361839..de19cb3424 100644 --- a/packages/stash/src/createStash.test.ts +++ b/packages/stash/src/createStash.test.ts @@ -95,6 +95,8 @@ describe("createStash", () => { describe("subscribeTable", () => { it("should notify listeners on table updates", () => { + vi.useFakeTimers({ toFake: ["queueMicrotask"] }); + const config = defineStore({ namespace: "namespace1", tables: { @@ -124,41 +126,52 @@ describe("createStash", () => { key: { field2: 1, field3: 2 }, value: { field1: "hello" }, }); + vi.advanceTimersToNextTimer(); - expect(listener).toHaveBeenNthCalledWith(1, { - "1|2": { - prev: undefined, + expect(listener).toHaveBeenNthCalledWith(1, [ + { + table, + key: { field2: 1, field3: 2 }, + previous: undefined, current: { field1: "hello", field2: 1, field3: 2 }, }, - }); + ]); stash.setRecord({ table, key: { field2: 1, field3: 2 }, value: { field1: "world" }, }); + vi.advanceTimersToNextTimer(); - expect(listener).toHaveBeenNthCalledWith(2, { - "1|2": { - prev: { field1: "hello", field2: 1, field3: 2 }, + expect(listener).toHaveBeenNthCalledWith(2, [ + { + table, + key: { field2: 1, field3: 2 }, + previous: { field1: "hello", field2: 1, field3: 2 }, current: { field1: "world", field2: 1, field3: 2 }, }, - }); + ]); stash.deleteRecord({ table, key: { field2: 1, field3: 2 }, }); + vi.advanceTimersToNextTimer(); - expect(listener).toHaveBeenNthCalledWith(3, { - "1|2": { - prev: { field1: "world", field2: 1, field3: 2 }, + expect(listener).toHaveBeenNthCalledWith(3, [ + { + table, + key: { field2: 1, field3: 2 }, + previous: { field1: "world", field2: 1, field3: 2 }, current: undefined, }, - }); + ]); }); it("should not notify listeners after they have been removed", () => { + vi.useFakeTimers({ toFake: ["queueMicrotask"] }); + const config = defineStore({ namespace: "namespace1", tables: { @@ -188,13 +201,16 @@ describe("createStash", () => { key: { field2: 1, field3: 2 }, value: { field1: "hello" }, }); + vi.advanceTimersToNextTimer(); - expect(subscriber).toHaveBeenNthCalledWith(1, { - "1|2": { - prev: undefined, + expect(subscriber).toHaveBeenNthCalledWith(1, [ + { + table, + key: { field2: 1, field3: 2 }, + previous: undefined, current: { field1: "hello", field2: 1, field3: 2 }, }, - }); + ]); unsubscribe(); @@ -203,6 +219,7 @@ describe("createStash", () => { key: { field2: 1, field3: 2 }, value: { field1: "world" }, }); + vi.advanceTimersToNextTimer(); expect(subscriber).toBeCalledTimes(1); }); @@ -210,6 +227,8 @@ describe("createStash", () => { describe("subscribeStash", () => { it("should notify listeners on stash updates", () => { + vi.useFakeTimers({ toFake: ["queueMicrotask"] }); + const config = defineStore({ namespace: "namespace1", tables: { @@ -236,19 +255,18 @@ describe("createStash", () => { key: { field2: 1, field3: 2 }, value: { field1: "hello" }, }); + vi.advanceTimersToNextTimer(); expect(subscriber).toHaveBeenNthCalledWith(1, { - config: {}, - records: { - namespace1: { - table1: { - "1|2": { - prev: undefined, - current: { field1: "hello", field2: 1, field3: 2 }, - }, - }, + type: "records", + updates: [ + { + table: table, + key: { field2: 1, field3: 2 }, + previous: undefined, + current: { field1: "hello", field2: 1, field3: 2 }, }, - }, + ], }); stash.setRecord({ @@ -256,81 +274,66 @@ describe("createStash", () => { key: { field2: 1, field3: 2 }, value: { field1: "world" }, }); + vi.advanceTimersToNextTimer(); expect(subscriber).toHaveBeenNthCalledWith(2, { - config: {}, - records: { - namespace1: { - table1: { - "1|2": { - prev: { field1: "hello", field2: 1, field3: 2 }, - current: { field1: "world", field2: 1, field3: 2 }, - }, - }, + type: "records", + updates: [ + { + table: table, + key: { field2: 1, field3: 2 }, + previous: { field1: "hello", field2: 1, field3: 2 }, + current: { field1: "world", field2: 1, field3: 2 }, }, - }, + ], }); stash.deleteRecord({ table, key: { field2: 1, field3: 2 }, }); + vi.advanceTimersToNextTimer(); expect(subscriber).toHaveBeenNthCalledWith(3, { - config: {}, - records: { - namespace1: { - table1: { - "1|2": { - prev: { field1: "world", field2: 1, field3: 2 }, - current: undefined, - }, - }, + type: "records", + updates: [ + { + table: table, + key: { field2: 1, field3: 2 }, + previous: { field1: "world", field2: 1, field3: 2 }, + current: undefined, }, - }, + ], }); + const table2 = defineTable({ + namespaceLabel: "namespace2", + label: "table2", + schema: { field1: "uint256", value: "uint256" }, + key: ["field1"], + }); stash.registerTable({ - table: defineTable({ - namespaceLabel: "namespace2", - label: "table2", - schema: { field1: "uint256", value: "uint256" }, - key: ["field1"], - }), + table: table2, }); + vi.advanceTimersToNextTimer(); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { codegen, deploy, ...table2Rest } = table2; expect(subscriber).toHaveBeenNthCalledWith(4, { - config: { - namespace2: { - table2: { - current: { - key: ["field1"], - label: "table2", - name: "table2", - namespace: "namespace2", - namespaceLabel: "namespace2", - schema: { - field1: { - internalType: "uint256", - type: "uint256", - }, - value: { - internalType: "uint256", - type: "uint256", - }, - }, - tableId: "0x74626e616d65737061636532000000007461626c653200000000000000000000", - type: "table", - }, - prev: undefined, - }, + type: "config", + updates: [ + { + previous: undefined, + current: table2Rest, }, - }, - records: {}, + ], }); }); it("should not notify listeners after they have been removed", () => { + vi.useFakeTimers({ toFake: ["queueMicrotask"] }); + const config = defineStore({ namespace: "namespace1", tables: { @@ -359,19 +362,18 @@ describe("createStash", () => { key: { field2: 1, field3: 2 }, value: { field1: "hello" }, }); + vi.advanceTimersToNextTimer(); expect(subscriber).toHaveBeenNthCalledWith(1, { - config: {}, - records: { - namespace1: { - table1: { - "1|2": { - prev: undefined, - current: { field1: "hello", field2: 1, field3: 2 }, - }, - }, + type: "records", + updates: [ + { + table, + key: { field2: 1, field3: 2 }, + previous: undefined, + current: { field1: "hello", field2: 1, field3: 2 }, }, - }, + ], }); unsubscribe(); @@ -381,6 +383,7 @@ describe("createStash", () => { key: { field2: 1, field3: 2 }, value: { field1: "world" }, }); + vi.advanceTimersToNextTimer(); expect(subscriber).toBeCalledTimes(1); }); diff --git a/packages/stash/src/decorators/defaultActions.test.ts b/packages/stash/src/decorators/defaultActions.test.ts index e3a128c612..fc96b5f156 100644 --- a/packages/stash/src/decorators/defaultActions.test.ts +++ b/packages/stash/src/decorators/defaultActions.test.ts @@ -5,8 +5,8 @@ import { createStash } from "../createStash"; import { defineTable } from "@latticexyz/store/internal"; import { In } from "../queryFragments"; import { Hex } from "viem"; -import { runQuery } from "../actions"; import { StoreRecords, getQueryConfig } from "../common"; +import { runQuery } from "../actions/runQuery"; describe("stash with default actions", () => { describe("decodeKey", () => { @@ -462,6 +462,8 @@ describe("stash with default actions", () => { describe("subscribeStash", () => { it("should notify subscriber of any stash change", () => { + vi.useFakeTimers({ toFake: ["queueMicrotask"] }); + const config = defineStore({ namespaces: { namespace1: { @@ -481,26 +483,27 @@ describe("stash with default actions", () => { stash.subscribeStash({ subscriber }); stash.setRecord({ table: config.tables.namespace1__table1, key: { a: "0x00" }, value: { b: 1n, c: 2 } }); + vi.advanceTimersToNextTimer(); expect(subscriber).toHaveBeenCalledTimes(1); expect(subscriber).toHaveBeenNthCalledWith(1, { - config: {}, - records: { - namespace1: { - table1: { - "0x00": { - prev: undefined, - current: { a: "0x00", b: 1n, c: 2 }, - }, - }, + type: "records", + updates: [ + { + table: config.tables.namespace1__table1, + key: { a: "0x00" }, + previous: undefined, + current: { a: "0x00", b: 1n, c: 2 }, }, - }, + ], }); }); }); describe("subscribeTable", () => { it("should notify subscriber of table change", () => { + vi.useFakeTimers({ toFake: ["queueMicrotask"] }); + const config = defineStore({ namespaces: { namespace1: { @@ -530,28 +533,36 @@ describe("stash with default actions", () => { stash.subscribeTable({ table: table1, subscriber }); stash.setRecord({ table: table1, key: { a: "0x00" }, value: { b: 1n, c: 2 } }); + vi.advanceTimersToNextTimer(); expect(subscriber).toHaveBeenCalledTimes(1); - expect(subscriber).toHaveBeenNthCalledWith(1, { - "0x00": { - prev: undefined, + expect(subscriber).toHaveBeenNthCalledWith(1, [ + { + table: table1, + key: { a: "0x00" }, + previous: undefined, current: { a: "0x00", b: 1n, c: 2 }, }, - }); + ]); // Expect unrelated updates to not notify subscribers stash.setRecord({ table: table2, key: { a: "0x01" }, value: { b: 1n, c: 2 } }); + vi.advanceTimersToNextTimer(); + expect(subscriber).toHaveBeenCalledTimes(1); stash.setRecord({ table: table1, key: { a: "0x00" }, value: { b: 1n, c: 3 } }); + vi.advanceTimersToNextTimer(); expect(subscriber).toHaveBeenCalledTimes(2); - expect(subscriber).toHaveBeenNthCalledWith(2, { - "0x00": { - prev: { a: "0x00", b: 1n, c: 2 }, + expect(subscriber).toHaveBeenNthCalledWith(2, [ + { + table: table1, + key: { a: "0x00" }, + previous: { a: "0x00", b: 1n, c: 2 }, current: { a: "0x00", b: 1n, c: 3 }, }, - }); + ]); }); }); }); diff --git a/packages/stash/src/decorators/defaultActions.ts b/packages/stash/src/decorators/defaultActions.ts index 207b605c6f..4719b54371 100644 --- a/packages/stash/src/decorators/defaultActions.ts +++ b/packages/stash/src/decorators/defaultActions.ts @@ -1,4 +1,4 @@ -import { Query, Stash, StoreConfig } from "../common"; +import { Query, Stash, StoreConfig, StoreUpdatesSubscriber } from "../common"; import { DecodeKeyArgs, DecodeKeyResult, decodeKey } from "../actions/decodeKey"; import { DeleteRecordArgs, DeleteRecordResult, deleteRecord } from "../actions/deleteRecord"; import { EncodeKeyArgs, EncodeKeyResult, encodeKey } from "../actions/encodeKey"; @@ -79,7 +79,7 @@ export function defaultActions(stash: Stash) subscribeQuery: (args: StashBoundSubscribeQueryArgs) => subscribeQuery({ stash, ...args }), subscribeStash: (args: StashBoundSubscribeStashArgs) => - subscribeStash({ stash, ...args }), + subscribeStash({ stash, subscriber: args.subscriber as StoreUpdatesSubscriber }), subscribeTable:
(args: StashBoundSubscribeTableArgs
) => subscribeTable({ stash, ...args }), }; diff --git a/packages/stash/src/exports/internal.ts b/packages/stash/src/exports/internal.ts index 37b08e10f6..15905683b1 100644 --- a/packages/stash/src/exports/internal.ts +++ b/packages/stash/src/exports/internal.ts @@ -1,4 +1,22 @@ export * from "../createStash"; export * from "../common"; export * from "../queryFragments"; -export * from "../actions"; + +export * from "../actions/applyUpdates"; +export * from "../actions/decodeKey"; +export * from "../actions/deleteRecord"; +export * from "../actions/encodeKey"; +export * from "../actions/extend"; +export * from "../actions/getTableConfig"; +export * from "../actions/getKeys"; +export * from "../actions/getRecord"; +export * from "../actions/getRecords"; +export * from "../actions/getTable"; +export * from "../actions/getTables"; +export * from "../actions/registerTable"; +export * from "../actions/runQuery"; +export * from "../actions/setRecord"; +export * from "../actions/setRecords"; +export * from "../actions/subscribeQuery"; +export * from "../actions/subscribeStash"; +export * from "../actions/subscribeTable"; diff --git a/packages/stash/src/react/useStash.test.ts b/packages/stash/src/react/useStash.test.ts index d64fbb4426..94cabedb70 100644 --- a/packages/stash/src/react/useStash.test.ts +++ b/packages/stash/src/react/useStash.test.ts @@ -1,16 +1,19 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import { renderHook, act } from "@testing-library/react-hooks"; import { useStash } from "./useStash"; import { defineStore } from "@latticexyz/store"; import { createStash } from "../createStash"; import isEqual from "fast-deep-equal"; -import { getRecord, getRecords } from "../actions"; import { Hex } from "viem"; +import { getRecords } from "../actions/getRecords"; +import { getRecord } from "../actions/getRecord"; // TODO: migrate to ark/attest snapshots for better formatting + typechecking describe("useStash", () => { it("returns a single record", async () => { + vi.useFakeTimers({ toFake: ["queueMicrotask"] }); + const config = defineStore({ namespaces: { game: { @@ -31,12 +34,16 @@ describe("useStash", () => { ({ player }) => useStash(stash, (state) => getRecord({ state, table: Position, key: { player } })), { initialProps: { player } }, ); + vi.advanceTimersToNextTimer(); + expect(result.all.length).toBe(1); expect(result.current).toMatchInlineSnapshot(`undefined`); act(() => { stash.setRecord({ table: Position, key: { player }, value: { x: 1, y: 2 } }); + vi.advanceTimersToNextTimer(); }); + // Expect update to have triggered rerender expect(result.all.length).toBe(2); expect(result.current).toMatchInlineSnapshot(` @@ -49,14 +56,18 @@ describe("useStash", () => { act(() => { stash.setRecord({ table: Position, key: { player: "0x01" }, value: { x: 1, y: 2 } }); + vi.advanceTimersToNextTimer(); }); + // Expect unrelated update to not have triggered rerender expect(result.all.length).toBe(2); // Expect update to have triggered rerender act(() => { stash.setRecord({ table: Position, key: { player }, value: { x: 1, y: 3 } }); + vi.advanceTimersToNextTimer(); }); + expect(result.all.length).toBe(3); expect(result.current).toMatchInlineSnapshot(` { @@ -79,12 +90,15 @@ describe("useStash", () => { act(() => { stash.setRecord({ table: Position, key: { player: "0x0" }, value: { x: 5, y: 0 } }); + vi.advanceTimersToNextTimer(); }); // Expect unrelated update to not have triggered rerender expect(result.all.length).toBe(4); }); it("returns records of a table using equality function", async () => { + vi.useFakeTimers({ toFake: ["queueMicrotask"] }); + const config = defineStore({ namespaces: { game: { @@ -104,11 +118,14 @@ describe("useStash", () => { const { result } = renderHook(() => useStash(stash, (state) => Object.values(getRecords({ state, table: Position })), { isEqual }), ); + vi.advanceTimersToNextTimer(); + expect(result.all.length).toBe(1); expect(result.current).toMatchInlineSnapshot(`[]`); act(() => { stash.setRecord({ table: Position, key: { player }, value: { x: 1, y: 2 } }); + vi.advanceTimersToNextTimer(); }); expect(result.all.length).toBe(2); expect(result.current).toMatchInlineSnapshot(` @@ -123,6 +140,7 @@ describe("useStash", () => { act(() => { stash.setRecord({ table: Position, key: { player: "0x01" }, value: { x: 1, y: 2 } }); + vi.advanceTimersToNextTimer(); }); expect(result.all.length).toBe(3); expect(result.current).toMatchInlineSnapshot(` @@ -142,6 +160,7 @@ describe("useStash", () => { act(() => { stash.setRecord({ table: Position, key: { player }, value: { x: 1, y: 3 } }); + vi.advanceTimersToNextTimer(); }); expect(result.all.length).toBe(4); expect(result.current).toMatchInlineSnapshot(` @@ -159,4 +178,100 @@ describe("useStash", () => { ] `); }); + + it("memoizes results", async () => { + vi.useFakeTimers({ toFake: ["queueMicrotask"] }); + + const config = defineStore({ + namespaces: { + game: { + tables: { + Position: { + schema: { player: "address", x: "uint32", y: "uint32" }, + key: ["player"], + }, + Counter: { + schema: { count: "uint32" }, + key: [], + }, + }, + }, + }, + }); + const { Position, Counter } = config.namespaces.game.tables; + const stash = createStash(config); + const player: Hex = "0x00"; + + const { result } = renderHook(() => + useStash( + stash, + (state) => + Object.values(getRecords({ state, table: Position })).filter((position) => position.player === player), + { isEqual }, + ), + ); + vi.advanceTimersToNextTimer(); + + expect(result.all.length).toBe(1); + expect(result.current).toMatchInlineSnapshot(`[]`); + + act(() => { + stash.setRecord({ table: Counter, key: {}, value: { count: 1 } }); + stash.setRecord({ table: Counter, key: {}, value: { count: 2 } }); + vi.advanceTimersToNextTimer(); + }); + act(() => { + stash.setRecord({ table: Counter, key: {}, value: { count: 3 } }); + stash.setRecord({ table: Counter, key: {}, value: { count: 4 } }); + vi.advanceTimersToNextTimer(); + }); + + expect(result.all.length).toBe(1); + expect(result.current).toMatchInlineSnapshot(`[]`); + + act(() => { + stash.setRecord({ table: Position, key: { player }, value: { x: 1, y: 2 } }); + vi.advanceTimersToNextTimer(); + }); + expect(result.all.length).toBe(2); + expect(result.current).toMatchInlineSnapshot(` + [ + { + "player": "0x00", + "x": 1, + "y": 2, + }, + ] + `); + + act(() => { + stash.setRecord({ table: Position, key: { player: "0x01" }, value: { x: 1, y: 2 } }); + vi.advanceTimersToNextTimer(); + }); + expect(result.all.length).toBe(2); + expect(result.current).toMatchInlineSnapshot(` + [ + { + "player": "0x00", + "x": 1, + "y": 2, + }, + ] + `); + + act(() => { + stash.setRecord({ table: Position, key: { player }, value: { x: 1, y: 3 } }); + vi.advanceTimersToNextTimer(); + }); + expect(result.all.length).toBe(3); + expect(result.current).toMatchInlineSnapshot(` + [ + { + "player": "0x00", + "x": 1, + "y": 3, + }, + ] + `); + }); }); diff --git a/packages/stash/src/react/useStash.ts b/packages/stash/src/react/useStash.ts index 700b6cbb47..a410621b11 100644 --- a/packages/stash/src/react/useStash.ts +++ b/packages/stash/src/react/useStash.ts @@ -1,8 +1,8 @@ import { useDebugValue, useSyncExternalStore } from "react"; -import { subscribeStash } from "../actions"; import { StoreConfig, Stash, State } from "../common"; import { isEqual } from "./isEqual"; import { memoize } from "./memoize"; +import { subscribeStash } from "../actions/subscribeStash"; export type UseStashOptions = { /** diff --git a/packages/stash/tsconfig.json b/packages/stash/tsconfig.json index 65b4611165..7d172d5905 100644 --- a/packages/stash/tsconfig.json +++ b/packages/stash/tsconfig.json @@ -2,7 +2,8 @@ "extends": "../../tsconfig.json", "compilerOptions": { "outDir": "dist", - "noUncheckedIndexedAccess": true + "noUncheckedIndexedAccess": true, + "noErrorTruncation": true }, "include": ["src"] }