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"]
}