From e639eb8da9121e8ff1f9d33bd912bb3bb45c67e1 Mon Sep 17 00:00:00 2001 From: alvrs Date: Thu, 15 Aug 2024 15:53:39 +0100 Subject: [PATCH 01/18] feat(stash): add stash, the new MUD client state library --- packages/stash/CHANGELOG.md | 1 + packages/stash/README.md | 3 + packages/stash/package.json | 59 ++ packages/stash/src/README.md | 24 + packages/stash/src/actions/decodeKey.test.ts | 28 + packages/stash/src/actions/decodeKey.ts | 22 + .../stash/src/actions/deleteRecord.test.ts | 98 +++ packages/stash/src/actions/deleteRecord.ts | 37 ++ packages/stash/src/actions/encodeKey.test.ts | 46 ++ packages/stash/src/actions/encodeKey.ts | 27 + packages/stash/src/actions/extend.test.ts | 15 + packages/stash/src/actions/extend.ts | 15 + packages/stash/src/actions/getConfig.test.ts | 42 ++ packages/stash/src/actions/getConfig.ts | 14 + packages/stash/src/actions/getKeys.test.ts | 34 ++ packages/stash/src/actions/getKeys.ts | 21 + packages/stash/src/actions/getRecord.test.ts | 96 +++ packages/stash/src/actions/getRecord.ts | 16 + packages/stash/src/actions/getRecords.test.ts | 42 ++ packages/stash/src/actions/getRecords.ts | 31 + packages/stash/src/actions/getTable.test.ts | 342 +++++++++++ packages/stash/src/actions/getTable.ts | 68 +++ packages/stash/src/actions/getTables.test.ts | 67 +++ packages/stash/src/actions/getTables.ts | 32 + packages/stash/src/actions/index.ts | 17 + .../stash/src/actions/registerTable.test.ts | 42 ++ packages/stash/src/actions/registerTable.ts | 40 ++ packages/stash/src/actions/runQuery.test.ts | 158 +++++ packages/stash/src/actions/runQuery.ts | 84 +++ packages/stash/src/actions/setRecord.test.ts | 100 ++++ packages/stash/src/actions/setRecord.ts | 23 + packages/stash/src/actions/setRecords.test.ts | 82 +++ packages/stash/src/actions/setRecords.ts | 50 ++ .../stash/src/actions/subscribeQuery.test.ts | 167 ++++++ packages/stash/src/actions/subscribeQuery.ts | 157 +++++ .../stash/src/actions/subscribeStore.test.ts | 86 +++ packages/stash/src/actions/subscribeStore.ts | 16 + .../stash/src/actions/subscribeTable.test.ts | 61 ++ packages/stash/src/actions/subscribeTable.ts | 21 + packages/stash/src/apiEquality.test.ts | 47 ++ packages/stash/src/bench.ts | 64 ++ packages/stash/src/boundTable.test.ts | 51 ++ packages/stash/src/common.ts | 167 ++++++ packages/stash/src/createStash.test.ts | 413 +++++++++++++ packages/stash/src/createStash.ts | 55 ++ packages/stash/src/decorators/default.test.ts | 557 ++++++++++++++++++ packages/stash/src/decorators/default.ts | 86 +++ packages/stash/src/exports/index.ts | 6 + packages/stash/src/exports/internal.ts | 4 + packages/stash/src/exports/recs.ts | 1 + packages/stash/src/perf.test.ts | 432 ++++++++++++++ packages/stash/src/queryFragment.test.ts | 19 + packages/stash/src/queryFragments.ts | 82 +++ packages/stash/src/recordEquals.ts | 26 + packages/stash/src/write.bench.ts | 229 +++++++ packages/stash/tsconfig.json | 7 + packages/stash/tsup.config.ts | 14 + packages/stash/vitest.config.ts | 7 + packages/stash/vitestSetup.ts | 1 + pnpm-lock.yaml | 316 ++++++++-- 60 files changed, 4828 insertions(+), 40 deletions(-) create mode 100644 packages/stash/CHANGELOG.md create mode 100644 packages/stash/README.md create mode 100644 packages/stash/package.json create mode 100644 packages/stash/src/README.md create mode 100644 packages/stash/src/actions/decodeKey.test.ts create mode 100644 packages/stash/src/actions/decodeKey.ts create mode 100644 packages/stash/src/actions/deleteRecord.test.ts create mode 100644 packages/stash/src/actions/deleteRecord.ts create mode 100644 packages/stash/src/actions/encodeKey.test.ts create mode 100644 packages/stash/src/actions/encodeKey.ts create mode 100644 packages/stash/src/actions/extend.test.ts create mode 100644 packages/stash/src/actions/extend.ts create mode 100644 packages/stash/src/actions/getConfig.test.ts create mode 100644 packages/stash/src/actions/getConfig.ts create mode 100644 packages/stash/src/actions/getKeys.test.ts create mode 100644 packages/stash/src/actions/getKeys.ts create mode 100644 packages/stash/src/actions/getRecord.test.ts create mode 100644 packages/stash/src/actions/getRecord.ts create mode 100644 packages/stash/src/actions/getRecords.test.ts create mode 100644 packages/stash/src/actions/getRecords.ts create mode 100644 packages/stash/src/actions/getTable.test.ts create mode 100644 packages/stash/src/actions/getTable.ts create mode 100644 packages/stash/src/actions/getTables.test.ts create mode 100644 packages/stash/src/actions/getTables.ts create mode 100644 packages/stash/src/actions/index.ts create mode 100644 packages/stash/src/actions/registerTable.test.ts create mode 100644 packages/stash/src/actions/registerTable.ts create mode 100644 packages/stash/src/actions/runQuery.test.ts create mode 100644 packages/stash/src/actions/runQuery.ts create mode 100644 packages/stash/src/actions/setRecord.test.ts create mode 100644 packages/stash/src/actions/setRecord.ts create mode 100644 packages/stash/src/actions/setRecords.test.ts create mode 100644 packages/stash/src/actions/setRecords.ts create mode 100644 packages/stash/src/actions/subscribeQuery.test.ts create mode 100644 packages/stash/src/actions/subscribeQuery.ts create mode 100644 packages/stash/src/actions/subscribeStore.test.ts create mode 100644 packages/stash/src/actions/subscribeStore.ts create mode 100644 packages/stash/src/actions/subscribeTable.test.ts create mode 100644 packages/stash/src/actions/subscribeTable.ts create mode 100644 packages/stash/src/apiEquality.test.ts create mode 100644 packages/stash/src/bench.ts create mode 100644 packages/stash/src/boundTable.test.ts create mode 100644 packages/stash/src/common.ts create mode 100644 packages/stash/src/createStash.test.ts create mode 100644 packages/stash/src/createStash.ts create mode 100644 packages/stash/src/decorators/default.test.ts create mode 100644 packages/stash/src/decorators/default.ts create mode 100644 packages/stash/src/exports/index.ts create mode 100644 packages/stash/src/exports/internal.ts create mode 100644 packages/stash/src/exports/recs.ts create mode 100644 packages/stash/src/perf.test.ts create mode 100644 packages/stash/src/queryFragment.test.ts create mode 100644 packages/stash/src/queryFragments.ts create mode 100644 packages/stash/src/recordEquals.ts create mode 100644 packages/stash/src/write.bench.ts create mode 100644 packages/stash/tsconfig.json create mode 100644 packages/stash/tsup.config.ts create mode 100644 packages/stash/vitest.config.ts create mode 100644 packages/stash/vitestSetup.ts diff --git a/packages/stash/CHANGELOG.md b/packages/stash/CHANGELOG.md new file mode 100644 index 0000000000..8ae6429759 --- /dev/null +++ b/packages/stash/CHANGELOG.md @@ -0,0 +1 @@ +# @latticexyz/stash diff --git a/packages/stash/README.md b/packages/stash/README.md new file mode 100644 index 0000000000..809d0e6255 --- /dev/null +++ b/packages/stash/README.md @@ -0,0 +1,3 @@ +# Stash + +High performance client store and query engine for MUD diff --git a/packages/stash/package.json b/packages/stash/package.json new file mode 100644 index 0000000000..f834197159 --- /dev/null +++ b/packages/stash/package.json @@ -0,0 +1,59 @@ +{ + "name": "@latticexyz/stash", + "version": "2.0.12", + "description": "High performance client store and query engine for MUD", + "repository": { + "type": "git", + "url": "https://github.com/latticexyz/mud.git", + "directory": "packages/stash" + }, + "license": "MIT", + "type": "module", + "exports": { + ".": "./dist/index.js", + "./internal": "./dist/internal.js", + "./recs": "./dist/recs.js" + }, + "typesVersions": { + "*": { + "index": [ + "./dist/index.d.ts" + ], + "internal": [ + "./dist/internal.d.ts" + ] + } + }, + "files": [ + "dist" + ], + "scripts": { + "bench": "tsx src/bench.ts", + "build": "tsup", + "clean": "rimraf dist", + "dev": "tsup --watch", + "test": "vitest typecheck --run --passWithNoTests && vitest --run --passWithNoTests", + "test:ci": "pnpm run test" + }, + "dependencies": { + "@arktype/util": "0.0.40", + "@latticexyz/config": "workspace:*", + "@latticexyz/protocol-parser": "workspace:*", + "@latticexyz/schema-type": "workspace:*", + "@latticexyz/store": "workspace:*", + "react": "^18.2.0", + "viem": "2.9.20" + }, + "devDependencies": { + "@arktype/attest": "0.7.5", + "@testing-library/react": "^16.0.0", + "@testing-library/react-hooks": "^8.0.1", + "@types/react": "18.2.22", + "react-dom": "^18.2.0", + "tsup": "^6.7.0", + "vitest": "0.34.6" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/stash/src/README.md b/packages/stash/src/README.md new file mode 100644 index 0000000000..3fd7cc2fb5 --- /dev/null +++ b/packages/stash/src/README.md @@ -0,0 +1,24 @@ +# TODOs + +- Set up performance benchmarks for setting records, reading records, running queries, updating records with subscribers +- Replace objects with Maps for performance ? (https://rehmat-sayany.medium.com/using-map-over-objects-in-javascript-a-performance-benchmark-ff2f351851ed) + - could be useful for TableRecords, Keys +- maybe add option to include records in the query result? +- Maybe turn `entityKey` into a tagged string? So we could make it the return type of `encodeKey`, + and allow us to use Symbol (to reduce memory overhead) or something else later without breaking change +- add more query fragments - ie GreaterThan, LessThan, Range, etc +- we might be able to enable different key shapes if we add something like a `keySelector` +- getKeySchema expects a full table as type, but only needs schema and key + +Ideas + +- Update streams: + - if each query added a new subscriber to the main state, each state update would trigger _every_ subscriber + - instead we could add a subscriber per table, which can be subscribed to again, so we only have to iterate through all tables once, + and then through all subscribers per table (only those who care about updates to this table) +- Query fragments: + - Instead of pre-defined query types (Has, HasValue, Not, NotValue), could we define fragments in a self-contained way, so + it's easy to add a new type of query fragment without changing the core code? + - The main complexity is the logic to initialize the initial set with the first query fragment, + but it's probably not that critical - we could just run the first fragment on all entities of the first table, + unless an initialSet is provided. diff --git a/packages/stash/src/actions/decodeKey.test.ts b/packages/stash/src/actions/decodeKey.test.ts new file mode 100644 index 0000000000..f8826ee2f5 --- /dev/null +++ b/packages/stash/src/actions/decodeKey.test.ts @@ -0,0 +1,28 @@ +import { describe, it } from "vitest"; +import { createStash } from "../createStash"; +import { defineStore } from "@latticexyz/store/config/v2"; +import { setRecord } from "./setRecord"; +import { encodeKey } from "./encodeKey"; +import { attest } from "@ark/attest"; +import { decodeKey } from "./decodeKey"; + +describe("decodeKey", () => { + it("should decode an encoded table key", () => { + const config = defineStore({ + namespace: "namespace1", + tables: { + table1: { + schema: { field1: "string", field2: "uint32", field3: "uint256" }, + key: ["field2", "field3"], + }, + }, + }); + const stash = createStash(config); + const table = config.namespaces.namespace1.tables.table1; + const key = { field2: 1, field3: 2n }; + setRecord({ stash, table, key, record: { field1: "hello" } }); + + const encodedKey = encodeKey({ table, key }); + attest(decodeKey({ stash, table, encodedKey })).equals({ field2: 1, field3: 2n }); + }); +}); diff --git a/packages/stash/src/actions/decodeKey.ts b/packages/stash/src/actions/decodeKey.ts new file mode 100644 index 0000000000..6f6767eb77 --- /dev/null +++ b/packages/stash/src/actions/decodeKey.ts @@ -0,0 +1,22 @@ +import { Table } from "@latticexyz/config"; +import { Key, Stash } from "../common"; + +export type DecodeKeyArgs = { + stash: Stash; + table: table; + encodedKey: string; +}; + +export type DecodeKeyResult
= Key
; + +export function decodeKey
({ + stash, + table, + encodedKey, +}: DecodeKeyArgs
): DecodeKeyResult
{ + const { namespaceLabel, label, key } = table; + const record = stash.get().records[namespaceLabel][label][encodedKey]; + + // Typecast needed because record values could be arrays, but we know they are not if they are key fields + return Object.fromEntries(Object.entries(record).filter(([field]) => key.includes(field))) as never; +} diff --git a/packages/stash/src/actions/deleteRecord.test.ts b/packages/stash/src/actions/deleteRecord.test.ts new file mode 100644 index 0000000000..921e6ccee2 --- /dev/null +++ b/packages/stash/src/actions/deleteRecord.test.ts @@ -0,0 +1,98 @@ +import { attest } from "@ark/attest"; +import { defineStore } from "@latticexyz/store"; +import { describe, it } from "vitest"; +import { createStash } from "../createStash"; +import { setRecord } from "./setRecord"; +import { deleteRecord } from "./deleteRecord"; + +describe("deleteRecord", () => { + it("should delete a record from the stash", () => { + const config = defineStore({ + namespace: "namespace1", + tables: { + table1: { + schema: { + field1: "string", + field2: "uint32", + field3: "int32", + }, + key: ["field2", "field3"], + }, + }, + }); + + const table = config.namespaces.namespace1.tables.table1; + + const stash = createStash(config); + + setRecord({ + stash, + table, + key: { field2: 1, field3: 2 }, + record: { field1: "hello" }, + }); + + setRecord({ + stash, + table, + key: { field2: 3, field3: 1 }, + record: { field1: "world" }, + }); + + deleteRecord({ + stash, + table, + key: { field2: 1, field3: 2 }, + }); + + attest(stash.get().records).snap({ + namespace1: { + table1: { + "3|1": { + field1: "world", + field2: 3, + field3: 1, + }, + }, + }, + }); + }); + + it("should throw a type error if an invalid key is provided", () => { + const config = defineStore({ + namespace: "namespace1", + tables: { + table1: { + schema: { + field1: "string", + field2: "uint32", + field3: "int32", + }, + key: ["field2", "field3"], + }, + }, + }); + + const table = config.namespaces.namespace1.tables.table1; + + const stash = createStash(config); + + attest(() => + deleteRecord({ + stash, + table, + // @ts-expect-error Property 'field3' is missing in type '{ field2: number; }' + key: { field2: 1 }, + }), + ).type.errors(`Property 'field3' is missing in type '{ field2: number; }'`); + + attest(() => + deleteRecord({ + stash, + table, + // @ts-expect-error Type 'string' is not assignable to type 'number' + key: { field2: 1, field3: "invalid" }, + }), + ).type.errors(`Type 'string' is not assignable to type 'number'`); + }); +}); diff --git a/packages/stash/src/actions/deleteRecord.ts b/packages/stash/src/actions/deleteRecord.ts new file mode 100644 index 0000000000..ff8a04e475 --- /dev/null +++ b/packages/stash/src/actions/deleteRecord.ts @@ -0,0 +1,37 @@ +import { Table } from "@latticexyz/config"; +import { Key, Stash } from "../common"; +import { encodeKey } from "./encodeKey"; +import { registerTable } from "./registerTable"; + +export type DeleteRecordArgs
= { + stash: Stash; + table: table; + key: Key
; +}; + +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)); +} diff --git a/packages/stash/src/actions/encodeKey.test.ts b/packages/stash/src/actions/encodeKey.test.ts new file mode 100644 index 0000000000..10fd0d77a7 --- /dev/null +++ b/packages/stash/src/actions/encodeKey.test.ts @@ -0,0 +1,46 @@ +import { attest } from "@ark/attest"; +import { describe, it } from "vitest"; +import { encodeKey } from "./encodeKey"; +import { defineTable } from "@latticexyz/store/config/v2"; + +describe("encodeKey", () => { + it("should encode a key to a string", () => { + const table = defineTable({ + label: "test", + schema: { field1: "uint32", field2: "uint256", field3: "string" }, + key: ["field1", "field2"], + }); + attest(encodeKey({ table, key: { field1: 1, field2: 2n } })).snap("1|2"); + }); + + it("should throw a type error if an invalid key is provided", () => { + const table = defineTable({ + label: "test", + schema: { field1: "uint32", field2: "uint256", field3: "string" }, + key: ["field1", "field2"], + }); + + attest(() => + encodeKey({ + table, + // @ts-expect-error Property 'field2' is missing in type '{ field1: number; }' + key: { + field1: 1, + }, + }), + ) + .throws(`Provided key is missing field field2.`) + .type.errors(`Property 'field2' is missing in type '{ field1: number; }'`); + + attest( + encodeKey({ + table, + key: { + field1: 1, + // @ts-expect-error Type 'string' is not assignable to type 'bigint'. + field2: "invalid", + }, + }), + ).type.errors(`Type 'string' is not assignable to type 'bigint'.`); + }); +}); diff --git a/packages/stash/src/actions/encodeKey.ts b/packages/stash/src/actions/encodeKey.ts new file mode 100644 index 0000000000..5336a6493a --- /dev/null +++ b/packages/stash/src/actions/encodeKey.ts @@ -0,0 +1,27 @@ +import { Table } from "@latticexyz/config"; +import { Key } from "../common"; + +export type EncodeKeyArgs
= { + table: table; + key: Key
; +}; + +export type EncodeKeyResult = string; + +/** + * Encode a key object into a string that can be used as index in the stash + * TODO: Benchmark performance of this function + */ +export function encodeKey
({ table, key }: EncodeKeyArgs
): EncodeKeyResult { + const { key: keyOrder } = table; + + return keyOrder + .map((keyName) => { + const keyValue = key[keyName as never]; + if (keyValue == null) { + throw new Error(`Provided key is missing field ${keyName}.`); + } + return keyValue; + }) + .join("|"); +} diff --git a/packages/stash/src/actions/extend.test.ts b/packages/stash/src/actions/extend.test.ts new file mode 100644 index 0000000000..8eae889237 --- /dev/null +++ b/packages/stash/src/actions/extend.test.ts @@ -0,0 +1,15 @@ +import { describe, it } from "vitest"; +import { attest } from "@ark/attest"; +import { createStash } from "../createStash"; +import { extend } from "./extend"; + +describe("extend", () => { + it("should extend the stash API", () => { + const stash = createStash(); + const actions = { additional: (a: T): T => a }; + const extended = extend({ stash: stash, actions }); + + attest(); + attest(Object.keys(extended)).equals([...Object.keys(stash), "additional"]); + }); +}); diff --git a/packages/stash/src/actions/extend.ts b/packages/stash/src/actions/extend.ts new file mode 100644 index 0000000000..01742da7a9 --- /dev/null +++ b/packages/stash/src/actions/extend.ts @@ -0,0 +1,15 @@ +import { Stash } from "../common"; + +export type ExtendArgs = { + stash: stash; + actions: actions; +}; + +export type ExtendResult = stash & actions; + +export function extend({ + stash, + actions, +}: ExtendArgs): ExtendResult { + return { ...stash, ...actions }; +} diff --git a/packages/stash/src/actions/getConfig.test.ts b/packages/stash/src/actions/getConfig.test.ts new file mode 100644 index 0000000000..01475d8734 --- /dev/null +++ b/packages/stash/src/actions/getConfig.test.ts @@ -0,0 +1,42 @@ +import { defineTable } from "@latticexyz/store/config/v2"; +import { describe, it } from "vitest"; +import { createStash } from "../createStash"; +import { attest } from "@ark/attest"; +import { getConfig } from "./getConfig"; +import { registerTable } from "./registerTable"; + +describe("getConfig", () => { + it("should return the config of the given table", () => { + const { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + codegen: _1, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + deploy: _2, + ...rootTable + } = defineTable({ + label: "test", + schema: { field1: "address", field2: "string" }, + key: ["field1"], + }); + + const { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + codegen: _3, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + deploy: _4, + ...namespacedTable + } = defineTable({ + namespace: "namespace", + label: "test", + schema: { field1: "address", field2: "string" }, + key: ["field1"], + }); + + const stash = createStash(); + registerTable({ stash: stash, table: rootTable }); + registerTable({ stash: stash, table: namespacedTable }); + + attest(getConfig({ stash: stash, table: { label: "test" } })).equals(rootTable); + attest(getConfig({ stash: stash, table: { label: "test", namespaceLabel: "namespace" } })).equals(namespacedTable); + }); +}); diff --git a/packages/stash/src/actions/getConfig.ts b/packages/stash/src/actions/getConfig.ts new file mode 100644 index 0000000000..11ce416266 --- /dev/null +++ b/packages/stash/src/actions/getConfig.ts @@ -0,0 +1,14 @@ +import { Table } from "@latticexyz/config"; +import { Stash } from "../common"; + +export type GetConfigArgs = { + stash: Stash; + table: { label: string; namespaceLabel?: string }; +}; + +export type GetConfigResult
= table; + +export function getConfig({ stash, table }: GetConfigArgs): GetConfigResult
{ + const { namespaceLabel, label } = table; + return stash.get().config[namespaceLabel ?? ""][label]; +} diff --git a/packages/stash/src/actions/getKeys.test.ts b/packages/stash/src/actions/getKeys.test.ts new file mode 100644 index 0000000000..7cc061f0cc --- /dev/null +++ b/packages/stash/src/actions/getKeys.test.ts @@ -0,0 +1,34 @@ +import { defineStore } from "@latticexyz/store"; +import { describe, it } from "vitest"; +import { createStash } from "../createStash"; +import { setRecord } from "./setRecord"; +import { attest } from "@ark/attest"; +import { getKeys } from "./getKeys"; + +describe("getKeys", () => { + it("should return the key map of a table", () => { + const config = defineStore({ + tables: { + test: { + schema: { + player: "int32", + match: "int32", + x: "int32", + y: "int32", + }, + key: ["player", "match"], + }, + }, + }); + const table = config.tables.test; + const stash = createStash(config); + + setRecord({ stash, table, key: { player: 1, match: 2 }, record: { x: 3, y: 4 } }); + setRecord({ stash, table, key: { player: 5, match: 6 }, record: { x: 7, y: 8 } }); + + attest<{ [encodedKey: string]: { player: number; match: number } }>(getKeys({ stash, table })).snap({ + "1|2": { player: 1, match: 2 }, + "5|6": { player: 5, match: 6 }, + }); + }); +}); diff --git a/packages/stash/src/actions/getKeys.ts b/packages/stash/src/actions/getKeys.ts new file mode 100644 index 0000000000..eee1f31c5d --- /dev/null +++ b/packages/stash/src/actions/getKeys.ts @@ -0,0 +1,21 @@ +import { Table } from "@latticexyz/config"; +import { Stash, Keys } from "../common"; +import { decodeKey } from "./decodeKey"; + +export type GetKeysArgs
= { + stash: Stash; + table: table; +}; + +export type GetKeysResult
= Keys
; + +export function getKeys
({ stash, table }: GetKeysArgs
): GetKeysResult
{ + const { namespaceLabel, label } = table; + + return Object.fromEntries( + Object.keys(stash.get().records[namespaceLabel][label]).map((encodedKey) => [ + encodedKey, + decodeKey({ stash, table, encodedKey }), + ]), + ); +} diff --git a/packages/stash/src/actions/getRecord.test.ts b/packages/stash/src/actions/getRecord.test.ts new file mode 100644 index 0000000000..f2871b000d --- /dev/null +++ b/packages/stash/src/actions/getRecord.test.ts @@ -0,0 +1,96 @@ +import { attest } from "@ark/attest"; +import { defineStore } from "@latticexyz/store"; +import { describe, it } from "vitest"; +import { createStash } from "../createStash"; +import { setRecord } from "./setRecord"; +import { getRecord } from "./getRecord"; + +describe("getRecord", () => { + it("should get a record by key from the table", () => { + const config = defineStore({ + namespace: "namespace1", + tables: { + table1: { + schema: { + field1: "string", + field2: "uint32", + field3: "int32", + }, + key: ["field2", "field3"], + }, + }, + }); + + const table = config.namespaces.namespace1.tables.table1; + + const stash = createStash(config); + + setRecord({ + stash, + table, + key: { field2: 1, field3: 2 }, + record: { field1: "hello" }, + }); + + setRecord({ + stash, + table, + key: { field2: 2, field3: 1 }, + record: { field1: "world" }, + }); + + attest( + getRecord({ + stash, + table, + key: { field2: 1, field3: 2 }, + }), + ).snap({ field1: "hello", field2: 1, field3: 2 }); + + attest<{ field1: string; field2: number; field3: number }>( + getRecord({ + stash, + table, + key: { field2: 2, field3: 1 }, + }), + ).snap({ field1: "world", field2: 2, field3: 1 }); + }); + + it("should throw a type error if the key type doesn't match", () => { + const config = defineStore({ + namespace: "namespace1", + tables: { + table1: { + schema: { + field1: "string", + field2: "uint32", + field3: "int32", + }, + key: ["field2", "field3"], + }, + }, + }); + + const table = config.namespaces.namespace1.tables.table1; + + const stash = createStash(config); + + attest(() => + getRecord({ + stash, + table, + // @ts-expect-error Property 'field3' is missing in type '{ field2: number; }' + key: { field2: 1 }, + }), + ).type.errors(`Property 'field3' is missing in type '{ field2: number; }'`); + + attest(() => + getRecord({ + stash, + table, + // @ts-expect-error Type 'string' is not assignable to type 'number' + key: { field2: 1, field3: "invalid" }, + }), + ).type.errors(`Type 'string' is not assignable to type 'number'`); + }); +}); diff --git a/packages/stash/src/actions/getRecord.ts b/packages/stash/src/actions/getRecord.ts new file mode 100644 index 0000000000..ebdb141a2c --- /dev/null +++ b/packages/stash/src/actions/getRecord.ts @@ -0,0 +1,16 @@ +import { Table } from "@latticexyz/config"; +import { Key, Stash, TableRecord } from "../common"; +import { encodeKey } from "./encodeKey"; + +export type GetRecordArgs
= { + stash: Stash; + table: table; + key: Key
; +}; + +export type GetRecordResult
= TableRecord
; + +export function getRecord
({ stash, table, key }: GetRecordArgs
): GetRecordResult
{ + const { namespaceLabel, label } = table; + return stash.get().records[namespaceLabel][label][encodeKey({ table, key })]; +} diff --git a/packages/stash/src/actions/getRecords.test.ts b/packages/stash/src/actions/getRecords.test.ts new file mode 100644 index 0000000000..dd3bc33c32 --- /dev/null +++ b/packages/stash/src/actions/getRecords.test.ts @@ -0,0 +1,42 @@ +import { defineStore } from "@latticexyz/store"; +import { describe, it } from "vitest"; +import { createStash } from "../createStash"; +import { setRecord } from "./setRecord"; +import { attest } from "@ark/attest"; +import { getRecords } from "./getRecords"; + +describe("getRecords", () => { + it("should get all records from a table", () => { + const config = defineStore({ + tables: { + test: { + schema: { + player: "int32", + match: "int32", + x: "uint256", + y: "uint256", + }, + key: ["player", "match"], + }, + }, + }); + const table = config.tables.test; + const stash = createStash(config); + + setRecord({ stash, table, key: { player: 1, match: 2 }, record: { x: 3n, y: 4n } }); + setRecord({ stash, table, key: { player: 5, match: 6 }, record: { x: 7n, y: 8n } }); + + attest<{ [encodedKey: string]: { player: number; match: number; x: bigint; y: bigint } }>( + getRecords({ stash, table }), + ).equals({ + "1|2": { player: 1, match: 2, x: 3n, y: 4n }, + "5|6": { player: 5, match: 6, x: 7n, y: 8n }, + }); + + attest<{ [encodedKey: string]: { player: number; match: number; x: bigint; y: bigint } }>( + getRecords({ stash, table, keys: [{ player: 1, match: 2 }] }), + ).equals({ + "1|2": { player: 1, match: 2, x: 3n, y: 4n }, + }); + }); +}); diff --git a/packages/stash/src/actions/getRecords.ts b/packages/stash/src/actions/getRecords.ts new file mode 100644 index 0000000000..9470cca33c --- /dev/null +++ b/packages/stash/src/actions/getRecords.ts @@ -0,0 +1,31 @@ +import { Table } from "@latticexyz/config"; +import { Key, Stash, TableRecords } from "../common"; +import { encodeKey } from "./encodeKey"; + +export type GetRecordsArgs
= { + stash: Stash; + table: table; + keys?: Key
[]; +}; + +export type GetRecordsResult
= TableRecords
; + +export function getRecords
({ + stash, + table, + keys, +}: GetRecordsArgs
): GetRecordsResult
{ + const { namespaceLabel, label } = table; + const records = stash.get().records[namespaceLabel][label]; + + if (!keys) { + return records; + } + + return Object.fromEntries( + keys.map((key) => { + const encodedKey = encodeKey({ table, key }); + return [encodedKey, records[encodedKey]]; + }), + ); +} diff --git a/packages/stash/src/actions/getTable.test.ts b/packages/stash/src/actions/getTable.test.ts new file mode 100644 index 0000000000..4de80db169 --- /dev/null +++ b/packages/stash/src/actions/getTable.test.ts @@ -0,0 +1,342 @@ +import { attest } from "@ark/attest"; +import { defineTable } from "@latticexyz/store/config/v2"; +import { describe, it, expect, vi } from "vitest"; +import { createStash } from "../createStash"; +import { getTable } from "./getTable"; + +describe("getTable", () => { + it("should return a bound table", () => { + const stash = createStash(); + const table = getTable({ + stash: stash, + table: defineTable({ + label: "table1", + namespace: "namespace1", + schema: { field1: "uint32", field2: "address" }, + key: ["field1"], + }), + }); + + attest(stash.get().config).snap({ + namespace1: { + table1: { + label: "table1", + type: "table", + namespace: "namespace1", + namespaceLabel: "namespace1", + name: "table1", + tableId: "0x74626e616d65737061636531000000007461626c653100000000000000000000", + schema: { + field1: { type: "uint32", internalType: "uint32" }, + field2: { type: "address", internalType: "address" }, + }, + key: ["field1"], + }, + }, + }); + + attest(stash.get().records).snap({ namespace1: { table1: {} } }); + expect(table.setRecord).toBeDefined(); + expect(table.getRecord).toBeDefined(); + }); + + describe("decodeKey", () => { + it("should decode an encoded table key", () => { + const stash = createStash(); + const table = stash.getTable({ + table: defineTable({ + label: "test", + schema: { field1: "string", field2: "uint32", field3: "uint256" }, + key: ["field2", "field3"], + }), + }); + + const key = { field2: 1, field3: 2n }; + table.setRecord({ key, record: { field1: "hello" } }); + + const encodedKey = table.encodeKey({ key }); + attest(table.decodeKey({ encodedKey })).equals({ field2: 1, field3: 2n }); + }); + }); + + describe("deleteRecord", () => { + it("should throw a type error if an invalid key is provided", () => { + const stash = createStash(); + const table = stash.getTable({ + table: defineTable({ + label: "test", + schema: { field1: "string", field2: "uint32", field3: "uint256" }, + key: ["field2", "field3"], + }), + }); + + attest(() => + table.deleteRecord({ + // @ts-expect-error Property 'field3' is missing in type '{ field2: number; }' + key: { field2: 1 }, + }), + ).type.errors(`Property 'field3' is missing in type '{ field2: number; }'`); + + attest(() => + table.deleteRecord({ + // @ts-expect-error Type 'string' is not assignable to type 'number' + key: { field2: 1, field3: "invalid" }, + }), + ).type.errors(`Type 'string' is not assignable to type 'bigint'`); + }); + }); + + describe("encodeKey", () => { + it("should throw a type error if an invalid key is provided", () => { + const stash = createStash(); + const table = stash.getTable({ + table: defineTable({ + label: "test", + schema: { field1: "string", field2: "uint32", field3: "uint256" }, + key: ["field2", "field3"], + }), + }); + + attest(() => + table.encodeKey({ + // @ts-expect-error Property 'field3' is missing in type '{ field2: number; }' + key: { + field2: 1, + }, + }), + ) + .throws(`Provided key is missing field field3.`) + .type.errors(`Property 'field3' is missing in type '{ field2: number; }'`); + + attest( + table.encodeKey({ + key: { + field2: 1, + // @ts-expect-error Type 'string' is not assignable to type 'bigint'. + field3: "invalid", + }, + }), + ).type.errors(`Type 'string' is not assignable to type 'bigint'.`); + }); + }); + + describe("getConfig", () => { + it("should return the config type of the given table", () => { + const stash = createStash(); + const config = defineTable({ + label: "test", + schema: { field1: "string", field2: "uint32", field3: "uint256" }, + key: ["field2", "field3"], + }); + const table = stash.getTable({ + table: config, + }); + attest>(); + }); + }); + + describe("getKeys", () => { + it("should return the key map of a table", () => { + const stash = createStash(); + const table = stash.getTable({ + table: defineTable({ + label: "test", + schema: { + player: "int32", + match: "int32", + x: "int32", + y: "int32", + }, + key: ["player", "match"], + }), + }); + + table.setRecord({ key: { player: 1, match: 2 }, record: { x: 3, y: 4 } }); + table.setRecord({ key: { player: 5, match: 6 }, record: { x: 7, y: 8 } }); + + attest<{ [encodedKey: string]: { player: number; match: number } }>(table.getKeys()).snap({ + "1|2": { player: 1, match: 2 }, + "5|6": { player: 5, match: 6 }, + }); + }); + }); + + describe("getRecord", () => { + it("should throw a type error if the key type doesn't match", () => { + const stash = createStash(); + const table = stash.getTable({ + table: defineTable({ + label: "test", + schema: { field1: "string", field2: "uint32", field3: "int32" }, + key: ["field2", "field3"], + }), + }); + + attest(() => + table.getRecord({ + // @ts-expect-error Property 'field3' is missing in type '{ field2: number; }' + key: { field2: 1 }, + }), + ).type.errors(`Property 'field3' is missing in type '{ field2: number; }'`); + + attest(() => + table.getRecord({ + // @ts-expect-error Type 'string' is not assignable to type 'number' + key: { field2: 1, field3: "invalid" }, + }), + ).type.errors(`Type 'string' is not assignable to type 'number'`); + }); + }); + + describe("getRecords", () => { + it("should get all records from a table", () => { + const config = defineTable({ + label: "test", + schema: { + player: "int32", + match: "int32", + x: "uint256", + y: "uint256", + }, + key: ["player", "match"], + }); + const stash = createStash(); + const table = stash.getTable({ table: config }); + + table.setRecord({ key: { player: 1, match: 2 }, record: { x: 3n, y: 4n } }); + table.setRecord({ key: { player: 5, match: 6 }, record: { x: 7n, y: 8n } }); + + attest<{ [encodedKey: string]: { player: number; match: number; x: bigint; y: bigint } }>( + table.getRecords(), + ).equals({ + "1|2": { player: 1, match: 2, x: 3n, y: 4n }, + "5|6": { player: 5, match: 6, x: 7n, y: 8n }, + }); + + attest<{ [encodedKey: string]: { player: number; match: number; x: bigint; y: bigint } }>( + table.getRecords({ keys: [{ player: 1, match: 2 }] }), + ).equals({ + "1|2": { player: 1, match: 2, x: 3n, y: 4n }, + }); + }); + }); + + describe("setRecord", () => { + it("should show a type warning if an invalid table, key or record is used", () => { + const config = defineTable({ + label: "test", + schema: { + field1: "string", + field2: "uint32", + field3: "int32", + }, + key: ["field2", "field3"], + }); + const stash = createStash(); + const table = stash.getTable({ table: config }); + + attest(() => + table.setRecord({ + // @ts-expect-error Property 'field2' is missing in type '{ field3: number; }' + key: { field3: 2 }, + record: { field1: "" }, + }), + ) + .throws("Provided key is missing field field2.") + .type.errors(`Property 'field2' is missing in type '{ field3: number; }`); + + attest(() => + table.setRecord({ + // @ts-expect-error Type 'string' is not assignable to type 'number'. + key: { field2: 1, field3: "invalid" }, + record: { field1: "" }, + }), + ).type.errors(`Type 'string' is not assignable to type 'number'.`); + + attest(() => + table.setRecord({ + key: { field2: 1, field3: 2 }, + // @ts-expect-error Type 'number' is not assignable to type 'string'. + record: { field1: 1 }, + }), + ).type.errors(`Type 'number' is not assignable to type 'string'.`); + }); + }); + + describe("setRecords", () => { + it("should show a type warning if an invalid table, key or record is used", () => { + const config = defineTable({ + label: "test", + schema: { + field1: "string", + field2: "uint32", + field3: "int32", + }, + key: ["field2", "field3"], + }); + const stash = createStash(); + const table = stash.getTable({ table: config }); + + attest(() => + table.setRecords({ + // @ts-expect-error Type '{ field1: string; }' is missing the following properties from type + records: [{ field1: "" }], + }), + ) + .throws("Provided key is missing field field2.") + .type.errors(`Type '{ field1: string; }' is missing the following properties from type`); + + attest(() => + table.setRecords({ + // @ts-expect-error Type 'number' is not assignable to type 'string'. + records: [{ field1: 1, field2: 1, field3: 2 }], + }), + ).type.errors(`Type 'number' is not assignable to type 'string'.`); + }); + }); + + describe("subscribe", () => { + it("should notify subscriber of table change", () => { + const config1 = defineTable({ + label: "table1", + schema: { a: "address", b: "uint256", c: "uint32" }, + key: ["a"], + }); + const config2 = defineTable({ + label: "table2", + schema: { a: "address", b: "uint256", c: "uint32" }, + key: ["a"], + }); + const stash = createStash(); + const table1 = stash.getTable({ table: config1 }); + const table2 = stash.getTable({ table: config2 }); + const subscriber = vi.fn(); + + table1.subscribe({ subscriber }); + + table1.setRecord({ key: { a: "0x00" }, record: { b: 1n, c: 2 } }); + + expect(subscriber).toHaveBeenCalledTimes(1); + expect(subscriber).toHaveBeenNthCalledWith(1, { + "0x00": { + prev: undefined, + current: { a: "0x00", b: 1n, c: 2 }, + }, + }); + + // Expect unrelated updates to not notify subscribers + table2.setRecord({ key: { a: "0x01" }, record: { b: 1n, c: 2 } }); + expect(subscriber).toHaveBeenCalledTimes(1); + + table1.setRecord({ key: { a: "0x00" }, record: { b: 1n, c: 3 } }); + + expect(subscriber).toHaveBeenCalledTimes(2); + expect(subscriber).toHaveBeenNthCalledWith(2, { + "0x00": { + prev: { 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 new file mode 100644 index 0000000000..636d6b0552 --- /dev/null +++ b/packages/stash/src/actions/getTable.ts @@ -0,0 +1,68 @@ +import { Table } from "@latticexyz/config"; +import { Stash } from "../common"; +import { DecodeKeyArgs, DecodeKeyResult, decodeKey } from "./decodeKey"; +import { DeleteRecordArgs, DeleteRecordResult, deleteRecord } from "./deleteRecord"; +import { EncodeKeyArgs, EncodeKeyResult, encodeKey } from "./encodeKey"; +import { GetConfigResult, getConfig } from "./getConfig"; +import { GetKeysResult, getKeys } from "./getKeys"; +import { GetRecordArgs, GetRecordResult, getRecord } from "./getRecord"; +import { GetRecordsArgs, GetRecordsResult, getRecords } from "./getRecords"; +import { SetRecordArgs, SetRecordResult, setRecord } from "./setRecord"; +import { SetRecordsArgs, SetRecordsResult, setRecords } from "./setRecords"; +import { SubscribeTableArgs, SubscribeTableResult, subscribeTable } from "./subscribeTable"; +import { registerTable } from "./registerTable"; + +export type TableBoundDecodeKeyArgs
= Omit, "stash" | "table">; +export type TableBoundDeleteRecordArgs
= Omit, "stash" | "table">; +export type TableBoundEncodeKeyArgs
= Omit, "stash" | "table">; +export type TableBoundGetRecordArgs
= Omit, "stash" | "table">; +export type TableBoundGetRecordsArgs
= Omit, "stash" | "table">; +export type TableBoundSetRecordArgs
= Omit, "stash" | "table">; +export type TableBoundSetRecordsArgs
= Omit, "stash" | "table">; +export type TableBoundSubscribeTableArgs
= Omit< + SubscribeTableArgs
, + "stash" | "table" +>; + +export type BoundTable
= { + decodeKey: (args: TableBoundDecodeKeyArgs
) => DecodeKeyResult
; + deleteRecord: (args: TableBoundDeleteRecordArgs
) => DeleteRecordResult; + encodeKey: (args: TableBoundEncodeKeyArgs
) => EncodeKeyResult; + getConfig: () => GetConfigResult
; + getKeys: () => GetKeysResult
; + getRecord: (args: TableBoundGetRecordArgs
) => GetRecordResult
; + getRecords: (args?: TableBoundGetRecordsArgs
) => GetRecordsResult
; + setRecord: (args: TableBoundSetRecordArgs
) => SetRecordResult; + setRecords: (args: TableBoundSetRecordsArgs
) => SetRecordsResult; + subscribe: (args: TableBoundSubscribeTableArgs
) => SubscribeTableResult; +}; + +export type GetTableArgs
= { + stash: Stash; + table: table; +}; + +export type GetTableResult
= BoundTable
; + +export function getTable
({ stash, table }: GetTableArgs
): GetTableResult
{ + const { namespaceLabel, label } = table; + + if (stash.get().config[namespaceLabel]?.[label] == null) { + registerTable({ stash, table }); + } + + return { + decodeKey: (args: TableBoundDecodeKeyArgs
) => decodeKey({ stash, table, ...args }), + deleteRecord: (args: TableBoundDeleteRecordArgs
) => deleteRecord({ stash, table, ...args }), + encodeKey: (args: TableBoundEncodeKeyArgs
) => encodeKey({ table, ...args }), + getConfig: () => getConfig({ stash, table }) as table, + getKeys: () => getKeys({ stash, table }), + getRecord: (args: TableBoundGetRecordArgs
) => getRecord({ stash, table, ...args }), + getRecords: (args?: TableBoundGetRecordsArgs
) => 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 }), + + // TODO: dynamically add setters and getters for individual fields of the table + }; +} diff --git a/packages/stash/src/actions/getTables.test.ts b/packages/stash/src/actions/getTables.test.ts new file mode 100644 index 0000000000..e04f174992 --- /dev/null +++ b/packages/stash/src/actions/getTables.test.ts @@ -0,0 +1,67 @@ +import { defineStore } from "@latticexyz/store"; +import { describe, it } from "vitest"; +import { createStash } from "../createStash"; +import { getTables } from "./getTables"; +import { attest } from "@ark/attest"; + +describe("getTables", () => { + it("should return bound tables for each registered table in the stash", () => { + const config = defineStore({ + namespaces: { + namespace1: { + tables: { + table1: { + schema: { a: "address", b: "uint256", c: "uint32" }, + key: ["a"], + }, + }, + }, + namespace2: { + tables: { + table2: { + schema: { a: "address", b: "uint256", c: "uint32" }, + key: ["a"], + }, + }, + }, + }, + }); + const stash = createStash(config); + const tables = getTables({ stash }); + + attest<"namespace1" | "namespace2", keyof typeof tables>(); + + attest<"table2", keyof typeof tables.namespace2>(); + + attest(tables).snap({ + namespace1: { + table1: { + decodeKey: "Function(decodeKey)", + deleteRecord: "Function(deleteRecord)", + encodeKey: "Function(encodeKey)", + getConfig: "Function(getConfig)", + getKeys: "Function(getKeys)", + getRecord: "Function(getRecord)", + getRecords: "Function(getRecords)", + setRecord: "Function(setRecord)", + setRecords: "Function(setRecords)", + subscribe: "Function(subscribe)", + }, + }, + namespace2: { + table2: { + decodeKey: "Function(decodeKey1)", + deleteRecord: "Function(deleteRecord1)", + encodeKey: "Function(encodeKey1)", + getConfig: "Function(getConfig1)", + getKeys: "Function(getKeys1)", + getRecord: "Function(getRecord1)", + getRecords: "Function(getRecords1)", + setRecord: "Function(setRecord1)", + setRecords: "Function(setRecords1)", + subscribe: "Function(subscribe1)", + }, + }, + }); + }); +}); diff --git a/packages/stash/src/actions/getTables.ts b/packages/stash/src/actions/getTables.ts new file mode 100644 index 0000000000..f9f42b94f7 --- /dev/null +++ b/packages/stash/src/actions/getTables.ts @@ -0,0 +1,32 @@ +import { Stash, StoreConfig, getNamespaces, getTableConfig, getNamespaceTables } from "../common"; +import { BoundTable, getTable } from "./getTable"; + +type MutableBoundTables = { + -readonly [namespace in getNamespaces]: { + -readonly [table in getNamespaceTables]: BoundTable>; + }; +}; + +export type BoundTables = { + [namespace in getNamespaces]: { + [table in getNamespaceTables]: BoundTable>; + }; +}; + +export type GetTablesArgs = { + stash: Stash; +}; + +export type GetTablesResult = BoundTables; + +export function getTables({ stash }: GetTablesArgs): GetTablesResult { + const boundTables: MutableBoundTables = {}; + const config = stash.get().config; + for (const namespace of Object.keys(config)) { + boundTables[namespace] ??= {}; + for (const label of Object.keys(config[namespace])) { + boundTables[namespace][label] = getTable({ stash, table: config[namespace][label] }) as never; + } + } + return boundTables as never; +} diff --git a/packages/stash/src/actions/index.ts b/packages/stash/src/actions/index.ts new file mode 100644 index 0000000000..eb44c0514c --- /dev/null +++ b/packages/stash/src/actions/index.ts @@ -0,0 +1,17 @@ +export * from "./decodeKey"; +export * from "./deleteRecord"; +export * from "./encodeKey"; +export * from "./extend"; +export * from "./getConfig"; +export * from "./getKeys"; +export * from "./getRecord"; +export * from "./getRecords"; +export * from "./getTable"; +export * from "./getTables"; +export * from "./registerTable"; +export * from "./runQuery"; +export * from "./setRecord"; +export * from "./setRecords"; +export * from "./subscribeQuery"; +export * from "./subscribeStore"; +export * from "./subscribeTable"; diff --git a/packages/stash/src/actions/registerTable.test.ts b/packages/stash/src/actions/registerTable.test.ts new file mode 100644 index 0000000000..dcb7a2db6e --- /dev/null +++ b/packages/stash/src/actions/registerTable.test.ts @@ -0,0 +1,42 @@ +import { attest } from "@ark/attest"; +import { defineTable } from "@latticexyz/store/config/v2"; +import { describe, it, expect } from "vitest"; +import { createStash } from "../createStash"; +import { registerTable } from "./registerTable"; + +describe("registerTable", () => { + it("should add a new table to the stash and return a bound table", () => { + const stash = createStash(); + const table = registerTable({ + stash: stash, + table: defineTable({ + label: "table1", + namespace: "namespace1", + schema: { field1: "uint32", field2: "address" }, + key: ["field1"], + }), + }); + + attest(stash.get().config).snap({ + namespace1: { + table1: { + label: "table1", + type: "table", + namespace: "namespace1", + namespaceLabel: "namespace1", + name: "table1", + tableId: "0x74626e616d65737061636531000000007461626c653100000000000000000000", + schema: { + field1: { type: "uint32", internalType: "uint32" }, + field2: { type: "address", internalType: "address" }, + }, + key: ["field1"], + }, + }, + }); + + attest(stash.get().records).snap({ namespace1: { table1: {} } }); + expect(table.setRecord).toBeDefined(); + expect(table.getRecord).toBeDefined(); + }); +}); diff --git a/packages/stash/src/actions/registerTable.ts b/packages/stash/src/actions/registerTable.ts new file mode 100644 index 0000000000..b93a198610 --- /dev/null +++ b/packages/stash/src/actions/registerTable.ts @@ -0,0 +1,40 @@ +import { Table } from "@latticexyz/config"; +import { getTable, BoundTable } from "./getTable"; +import { Stash } from "../common"; + +export type RegisterTableArgs
= { + stash: Stash; + table: table; +}; + +export type RegisterTableResult
= BoundTable
; + +export function registerTable
({ + stash, + table, +}: RegisterTableArgs
): RegisterTableResult
{ + // Pick only relevant keys from the table config, ignore keys like `codegen`, `deploy` + const { namespace, namespaceLabel, name, label, key, schema, type, tableId } = table; + const tableConfig = { namespace, namespaceLabel, name, label, key, schema, type, tableId }; + + // Set config for table + stash._.state.config[namespaceLabel] ??= {}; + stash._.state.config[namespaceLabel][label] = tableConfig; + + // Init records map for table + stash._.state.records[namespaceLabel] ??= {}; + stash._.state.records[namespaceLabel][label] ??= {}; + + // Init subscribers set for table + stash._.tableSubscribers[namespaceLabel] ??= {}; + 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)); + + return getTable({ stash, table }); +} diff --git a/packages/stash/src/actions/runQuery.test.ts b/packages/stash/src/actions/runQuery.test.ts new file mode 100644 index 0000000000..123d7ee795 --- /dev/null +++ b/packages/stash/src/actions/runQuery.test.ts @@ -0,0 +1,158 @@ +import { describe, beforeEach, it } from "vitest"; +import { attest } from "@arktype/attest"; +import { createStash } from "../createStash"; +import { runQuery } from "./runQuery"; +import { defineStore } from "@latticexyz/store"; +import { Stash, StoreRecords, getQueryConfig } from "../common"; +import { setRecord } from "./setRecord"; +import { In, MatchRecord, NotIn, NotMatchRecord } from "../queryFragments"; +import { Hex } from "viem"; + +describe("runQuery", () => { + let stash: Stash; + const config = defineStore({ + namespaces: { + namespace1: { + tables: { + Position: { + schema: { player: "bytes32", x: "int32", y: "int32" }, + key: ["player"], + }, + }, + }, + namespace2: { + tables: { + Inventory: { + schema: { player: "bytes32", item: "bytes32", amount: "uint32" }, + key: ["player", "item"], + }, + Health: { + schema: { player: "bytes32", health: "uint32" }, + key: ["player"], + }, + }, + }, + }, + }); + + const { Position } = config.namespaces.namespace1.tables; + const { Inventory, Health } = config.namespaces.namespace2.tables; + + beforeEach(() => { + stash = createStash(config); + + // Add some mock data + const items = ["0xgold", "0xsilver"] as const; + const num = 5; + for (let i = 0; i < num; i++) { + setRecord({ stash, table: Position, key: { player: `0x${String(i)}` }, record: { x: i, y: num - i } }); + if (i > 2) { + setRecord({ stash, table: Health, key: { player: `0x${String(i)}` }, record: { health: i } }); + } + for (const item of items) { + setRecord({ stash, table: Inventory, key: { player: `0x${String(i)}`, item }, record: { amount: i } }); + } + } + }); + + it("should return all keys in the Position table", () => { + const result = runQuery({ stash, query: [In(Position)] }); + attest(result).snap({ + keys: { + "0x0": { player: "0x0" }, + "0x1": { player: "0x1" }, + "0x2": { player: "0x2" }, + "0x3": { player: "0x3" }, + "0x4": { player: "0x4" }, + }, + }); + }); + + it("should return all keys that are in the Position and Health table", () => { + const result = runQuery({ stash, query: [In(Position), In(Health)] }); + attest(result).snap({ + keys: { + "0x3": { player: "0x3" }, + "0x4": { player: "0x4" }, + }, + }); + }); + + it("should return all keys that have Position.x = 4 and are included in Health", () => { + const result = runQuery({ stash, query: [MatchRecord(Position, { x: 4 }), In(Health)] }); + attest(result).snap({ keys: { "0x4": { player: "0x4" } } }); + }); + + it("should return all keys that are in Position but not Health", () => { + const result = runQuery({ stash, query: [In(Position), NotIn(Health)] }); + attest(result).snap({ + keys: { + "0x0": { player: "0x0" }, + "0x1": { player: "0x1" }, + "0x2": { player: "0x2" }, + }, + }); + }); + + it("should return all keys that don't include a gold item in the Inventory table", () => { + const result = runQuery({ stash, query: [NotMatchRecord(Inventory, { item: "0xgold" })] }); + attest(result).snap({ + keys: { + "0x0|0xsilver": { player: "0x0", item: "0xsilver" }, + "0x1|0xsilver": { player: "0x1", item: "0xsilver" }, + "0x2|0xsilver": { player: "0x2", item: "0xsilver" }, + "0x3|0xsilver": { player: "0x3", item: "0xsilver" }, + "0x4|0xsilver": { player: "0x4", item: "0xsilver" }, + }, + }); + }); + + it("should throw an error when tables with different key schemas are mixed", () => { + attest(() => + runQuery({ stash, query: [In(Position), MatchRecord(Inventory, { item: "0xgold", amount: 2 })] }), + ).throws("All tables in a query must share the same key schema"); + }); + + it("should include all matching records from the tables if includeRecords is set", () => { + const result = runQuery({ stash, query: [In(Position), In(Health)], options: { includeRecords: true } }); + attest(result).snap({ + keys: { + "0x3": { player: "0x3" }, + "0x4": { player: "0x4" }, + }, + records: { + namespace1: { + Position: { + "0x3": { player: "0x3", x: 3, y: 2 }, + "0x4": { player: "0x4", x: 4, y: 1 }, + }, + }, + namespace2: { + Health: { + "0x3": { player: "0x3", health: 3 }, + "0x4": { player: "0x4", health: 4 }, + }, + }, + }, + }); + }); + + it("should include `records` only if the `includeRecords` option is provided", () => { + const query = [In(Position)] as const; + const resultWithoutRecords = runQuery({ stash, query }); + attest(); + + const resultWithRecords = runQuery({ stash, query, options: { includeRecords: true } }); + attest>, (typeof resultWithRecords)["records"]>(); + }); + + it("should type the `records` in the result based on tables in the query", () => { + const result = runQuery({ stash, query: [In(Position), In(Health)], options: { includeRecords: true } }); + + attest<"namespace1" | "namespace2", keyof (typeof result)["records"]>(); + attest<"Position", keyof (typeof result)["records"]["namespace1"]>(); + attest<"Health", keyof (typeof result)["records"]["namespace2"]>(); + attest<{ player: Hex; x: number; y: number }, (typeof result)["records"]["namespace1"]["Position"][string]>(); + attest<{ player: Hex; health: number }, (typeof result)["records"]["namespace2"]["Health"][string]>(); + }); +}); diff --git a/packages/stash/src/actions/runQuery.ts b/packages/stash/src/actions/runQuery.ts new file mode 100644 index 0000000000..46cf77039f --- /dev/null +++ b/packages/stash/src/actions/runQuery.ts @@ -0,0 +1,84 @@ +import { getKeySchema } from "@latticexyz/protocol-parser/internal"; +import { + StoreRecords, + Query, + Stash, + MutableStoreRecords, + CommonQueryOptions, + CommonQueryResult, + getQueryConfig, +} from "../common"; +import { getConfig } from "./getConfig"; +import { getRecords } from "./getRecords"; + +export type RunQueryOptions = CommonQueryOptions & { + includeRecords?: boolean; +}; + +// TODO: is it feasible to type the stash records return type based on the query? +export type RunQueryArgs = { + stash: Stash; + query: query; + options?: options; +}; + +export type RunQueryResult< + query extends Query = Query, + options extends RunQueryOptions = RunQueryOptions, +> = CommonQueryResult & + (options extends { + includeRecords: true; + } + ? { + records: StoreRecords>; + } + : { records?: never }); + +export function runQuery({ + stash, + query, + options, +}: RunQueryArgs): RunQueryResult { + // Only allow fragments with matching table keys for now + // TODO: we might be able to enable this if we add something like a `keySelector` + const expectedKeySchema = getKeySchema(getConfig({ stash, table: query[0].table })); + for (const fragment of query) { + if ( + Object.values(expectedKeySchema).join("|") !== + Object.values(getKeySchema(getConfig({ stash, table: fragment.table }))).join("|") + ) { + throw new Error( + "All tables in a query must share the same key schema. Found mismatch when comparing tables: " + + fragment.table.label + + " and " + + query[0].table.label, + ); + } + } + + // Initial set of matching keys is either the provided `initialKeys` or all keys of the table of the first fragment + const keys = options?.initialKeys ?? query[0].getInitialKeys(stash); + + for (const fragment of query) { + // TODO: this might be more efficient if we would use a Map() instead of an object + for (const encodedKey of Object.keys(keys)) { + if (!fragment.match(stash, encodedKey)) { + delete keys[encodedKey]; + } + } + } + + // Early return if records are not requested + if (!options?.includeRecords) { + return { keys } as never; + } + + const records: MutableStoreRecords = {}; + for (const { table } of query) { + const { namespaceLabel, label } = table; + records[namespaceLabel] ??= {}; + const tableRecords = getRecords({ stash, table, keys: Object.values(keys) }); + records[namespaceLabel][label] ??= tableRecords; + } + return { keys, records } as never; +} diff --git a/packages/stash/src/actions/setRecord.test.ts b/packages/stash/src/actions/setRecord.test.ts new file mode 100644 index 0000000000..d0b020443b --- /dev/null +++ b/packages/stash/src/actions/setRecord.test.ts @@ -0,0 +1,100 @@ +import { defineStore } from "@latticexyz/store/config/v2"; +import { describe, it } from "vitest"; +import { createStash } from "../createStash"; +import { attest } from "@ark/attest"; +import { setRecord } from "./setRecord"; + +describe("setRecord", () => { + it("should add the record to the table", () => { + const config = defineStore({ + namespace: "namespace1", + tables: { + table1: { + schema: { + field1: "string", + field2: "uint32", + field3: "int32", + }, + key: ["field2", "field3"], + }, + }, + }); + + const table = config.namespaces.namespace1.tables.table1; + const stash = createStash(config); + + setRecord({ + stash, + table, + key: { field2: 1, field3: 2 }, + record: { field1: "hello" }, + }); + + setRecord({ + stash, + table, + key: { field2: 2, field3: 1 }, + record: { field1: "world" }, + }); + + attest(stash.get().records).snap({ + namespace1: { + table1: { + "1|2": { field1: "hello", field2: 1, field3: 2 }, + "2|1": { field1: "world", field2: 2, field3: 1 }, + }, + }, + }); + }); + + it("should show a type warning if an invalid table, key or record is used", () => { + const config = defineStore({ + namespace: "namespace1", + tables: { + table1: { + schema: { + field1: "string", + field2: "uint32", + field3: "int32", + }, + key: ["field2", "field3"], + }, + }, + }); + + const table = config.namespaces.namespace1.tables.table1; + const stash = createStash(config); + + attest(() => + setRecord({ + stash, + table, + // @ts-expect-error Property 'field2' is missing in type '{ field3: number; }' + key: { field3: 2 }, + record: { field1: "" }, + }), + ) + .throws("Provided key is missing field field2.") + .type.errors(`Property 'field2' is missing in type '{ field3: number; }`); + + attest(() => + setRecord({ + stash, + table, + // @ts-expect-error Type 'string' is not assignable to type 'number'. + key: { field2: 1, field3: "invalid" }, + record: { field1: "" }, + }), + ).type.errors(`Type 'string' is not assignable to type 'number'.`); + + attest(() => + setRecord({ + stash, + table, + key: { field2: 1, field3: 2 }, + // @ts-expect-error Type 'number' is not assignable to type 'string'. + record: { field1: 1 }, + }), + ).type.errors(`Type 'number' is not assignable to type 'string'.`); + }); +}); diff --git a/packages/stash/src/actions/setRecord.ts b/packages/stash/src/actions/setRecord.ts new file mode 100644 index 0000000000..6cbc45c600 --- /dev/null +++ b/packages/stash/src/actions/setRecord.ts @@ -0,0 +1,23 @@ +import { Key, TableRecord, Stash } from "../common"; +import { setRecords } from "./setRecords"; +import { Table } from "@latticexyz/config"; + +export type SetRecordArgs
= { + stash: Stash; + table: table; + key: Key
; + record: Partial>; +}; + +export type SetRecordResult = void; + +export function setRecord
({ stash, table, key, record }: SetRecordArgs
): SetRecordResult { + setRecords({ + stash, + table, + records: [ + // Stored record should include key + { ...record, ...key }, + ], + }); +} diff --git a/packages/stash/src/actions/setRecords.test.ts b/packages/stash/src/actions/setRecords.test.ts new file mode 100644 index 0000000000..8b54b87180 --- /dev/null +++ b/packages/stash/src/actions/setRecords.test.ts @@ -0,0 +1,82 @@ +import { defineStore } from "@latticexyz/store"; +import { describe, it } from "vitest"; +import { createStash } from "../createStash"; +import { setRecords } from "./setRecords"; +import { attest } from "@ark/attest"; + +describe("setRecords", () => { + it("should add the records to the table", () => { + const config = defineStore({ + namespace: "namespace1", + tables: { + table1: { + schema: { + field1: "string", + field2: "uint32", + field3: "int32", + }, + key: ["field2", "field3"], + }, + }, + }); + + const table = config.namespaces.namespace1.tables.table1; + const stash = createStash(config); + setRecords({ + stash, + table, + records: [ + { field1: "hello", field2: 1, field3: 2 }, + { field1: "world", field2: 2, field3: 1 }, + ], + }); + + attest(stash.get().records).snap({ + namespace1: { + table1: { + "1|2": { field1: "hello", field2: 1, field3: 2 }, + "2|1": { field1: "world", field2: 2, field3: 1 }, + }, + }, + }); + }); + + it("should show a type warning if an invalid table, key or record is used", () => { + const config = defineStore({ + namespace: "namespace1", + tables: { + table1: { + schema: { + field1: "string", + field2: "uint32", + field3: "int32", + }, + key: ["field2", "field3"], + }, + }, + }); + + const table = config.namespaces.namespace1.tables.table1; + const stash = createStash(config); + + attest(() => + setRecords({ + stash, + table, + // @ts-expect-error Type '{ field1: string; }' is missing the following properties from type + records: [{ field1: "" }], + }), + ) + .throws("Provided key is missing field field2.") + .type.errors(`Type '{ field1: string; }' is missing the following properties from type`); + + attest(() => + setRecords({ + stash, + table, + // @ts-expect-error Type 'number' is not assignable to type 'string'. + records: [{ field1: 1, field2: 1, field3: 2 }], + }), + ).type.errors(`Type 'number' is not assignable to type 'string'.`); + }); +}); diff --git a/packages/stash/src/actions/setRecords.ts b/packages/stash/src/actions/setRecords.ts new file mode 100644 index 0000000000..8e8e958d6a --- /dev/null +++ b/packages/stash/src/actions/setRecords.ts @@ -0,0 +1,50 @@ +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"; + +export type SetRecordsArgs
= { + stash: Stash; + table: table; + records: TableRecord
[]; +}; + +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)); +} diff --git a/packages/stash/src/actions/subscribeQuery.test.ts b/packages/stash/src/actions/subscribeQuery.test.ts new file mode 100644 index 0000000000..80ea8ff12c --- /dev/null +++ b/packages/stash/src/actions/subscribeQuery.test.ts @@ -0,0 +1,167 @@ +import { beforeEach, describe, it, vi, expect } from "vitest"; +import { QueryUpdate, subscribeQuery } from "./subscribeQuery"; +import { attest } from "@arktype/attest"; +import { defineStore } from "@latticexyz/store"; +import { In, MatchRecord } from "../queryFragments"; +import { deleteRecord } from "./deleteRecord"; +import { setRecord } from "./setRecord"; +import { Stash } from "../common"; +import { createStash } from "../createStash"; + +describe("defineQuery", () => { + let stash: Stash; + const config = defineStore({ + namespace: "namespace1", + tables: { + Position: { + schema: { player: "bytes32", x: "int32", y: "int32" }, + key: ["player"], + }, + Health: { + schema: { player: "bytes32", health: "uint32" }, + key: ["player"], + }, + Inventory: { + schema: { player: "bytes32", item: "bytes32", amount: "uint32" }, + key: ["player", "item"], + }, + }, + }); + + const { Position, Health, Inventory } = config.namespaces.namespace1.tables; + + beforeEach(() => { + stash = createStash(config); + + // Add some mock data + const items = ["0xgold", "0xsilver"] as const; + const num = 5; + for (let i = 0; i < num; i++) { + setRecord({ stash, table: Position, key: { player: `0x${String(i)}` }, record: { x: i, y: num - i } }); + if (i > 2) { + setRecord({ stash, table: Health, key: { player: `0x${String(i)}` }, record: { health: i } }); + } + for (const item of items) { + setRecord({ stash, table: Inventory, key: { player: `0x${String(i)}`, item }, record: { amount: i } }); + } + } + }); + + it("should return the matching keys and keep it updated", () => { + const result = subscribeQuery({ stash, query: [In(Position), In(Health)] }); + attest(result.keys).snap({ + "0x3": { player: "0x3" }, + "0x4": { player: "0x4" }, + }); + + setRecord({ stash, table: Health, key: { player: `0x2` }, record: { health: 2 } }); + + attest(result.keys).snap({ + "0x2": { player: "0x2" }, + "0x3": { player: "0x3" }, + "0x4": { player: "0x4" }, + }); + }); + + 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: [MatchRecord(Position, { x: 4 }), In(Health)] }); + result.subscribe(subscriber); + + setRecord({ stash, table: Position, key: { player: "0x4" }, record: { y: 2 } }); + + 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 }, + }, + }, + }, + }, + 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); + + setRecord({ stash, table: Health, key: { player: `0x2` }, record: { health: 2 } }); + + expect(subscriber).toBeCalledTimes(1); + attest(lastUpdate).snap({ + records: { + namespace1: { + Health: { + "0x2": { + prev: undefined, + current: { player: `0x2`, health: 2 }, + }, + }, + }, + }, + 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); + + deleteRecord({ stash, table: Position, key: { player: `0x3` } }); + + expect(subscriber).toBeCalledTimes(1); + attest(lastUpdate).snap({ + records: { + namespace1: { + Position: { + "0x3": { + prev: { player: "0x3", x: 3, y: 2 }, + current: undefined, + }, + }, + }, + }, + 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] } }); + + 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 } }, + }, + Health: { + "0x3": { prev: undefined, current: { player: "0x3", health: 3 } }, + "0x4": { prev: undefined, current: { player: "0x4", health: 4 } }, + }, + }, + }, + types: { "0x3": "enter", "0x4": "enter" }, + }); + }); +}); diff --git a/packages/stash/src/actions/subscribeQuery.ts b/packages/stash/src/actions/subscribeQuery.ts new file mode 100644 index 0000000000..a3ceb532a7 --- /dev/null +++ b/packages/stash/src/actions/subscribeQuery.ts @@ -0,0 +1,157 @@ +import { Table } from "@latticexyz/config"; +import { + TableUpdates, + Keys, + Unsubscribe, + Query, + Stash, + CommonQueryOptions, + CommonQueryResult, + StoreConfig, + getNamespaces, + getNamespaceTables, + getTableConfig, + getQueryConfig, +} from "../common"; +import { decodeKey } from "./decodeKey"; +import { getTable } from "./getTable"; +import { runQuery } from "./runQuery"; + +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[]; +}; + +// TODO: is it feasible to type the table updates based on the query? +type QueryTableUpdates = { + [namespace in getNamespaces]: { + [table in getNamespaceTables]: TableUpdates>; + }; +}; + +export type QueryUpdate = { + records: QueryTableUpdates; + keys: Keys; + types: { [key: string]: "enter" | "update" | "exit" }; +}; + +type QuerySubscriber = (update: QueryUpdate) => void; + +export type SubscribeQueryArgs = { + stash: Stash; + query: query; + options?: SubscribeQueryOptions>; +}; + +export type SubscribeQueryResult = CommonQueryResult & { + /** + * Subscribe to query updates. + * Returns a function to unsubscribe the provided subscriber. + */ + subscribe: (subscriber: QuerySubscriber>) => Unsubscribe; + /** + * Unsubscribe the query from all table updates. + * Note: this is different from unsubscribing a query subscriber. + */ + unsubscribe: () => void; +}; + +export function subscribeQuery({ + stash, + query, + 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 matching: Keys = initialRun?.keys ?? {}; + const subscribers = new Set(options?.initialSubscribers 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: {}, + }; + + for (const key of Object.keys(tableUpdates)) { + if (key in matching) { + update.keys[key] = matching[key]; + // 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.match(stash, key)); + if (match) { + // If all relevant fragments still match, the key still matches the query. + update.types[key] = "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"; + } + } else { + // If this key didn't match the query before, check all fragments + const match = query.every((f) => f.match(stash, key)); + 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"; + } + } + } + + // Notify subscribers + subscribers.forEach((subscriber) => subscriber(update)); + }; + + // Subscribe to each table's update stream and stash the unsubscribers + const unsubsribers = query.map((fragment) => + getTable({ stash, table: fragment.table }).subscribe({ + subscriber: (updates) => updateQueryResult(fragment.table, 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] ??= {}; + 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 })); + } + + return { keys: matching, subscribe, unsubscribe }; +} diff --git a/packages/stash/src/actions/subscribeStore.test.ts b/packages/stash/src/actions/subscribeStore.test.ts new file mode 100644 index 0000000000..11298f7562 --- /dev/null +++ b/packages/stash/src/actions/subscribeStore.test.ts @@ -0,0 +1,86 @@ +import { defineStore } from "@latticexyz/store"; +import { describe, expect, it, vi } from "vitest"; +import { createStash } from "../createStash"; +import { subscribeStore } from "./subscribeStore"; +import { setRecord } from "./setRecord"; + +describe("subscribeStore", () => { + it("should notify subscriber of any stash change", () => { + const config = defineStore({ + namespaces: { + namespace1: { + tables: { + table1: { + schema: { a: "address", b: "uint256", c: "uint32" }, + key: ["a"], + }, + }, + }, + namespace2: { + tables: { + table2: { + schema: { a: "address", b: "uint256", c: "uint32" }, + key: ["a"], + }, + }, + }, + }, + }); + + const stash = createStash(config); + const subscriber = vi.fn(); + + subscribeStore({ stash, subscriber }); + + setRecord({ stash, table: config.tables.namespace1__table1, key: { a: "0x00" }, record: { b: 1n, c: 2 } }); + + expect(subscriber).toHaveBeenCalledTimes(1); + expect(subscriber).toHaveBeenNthCalledWith(1, { + config: {}, + records: { + namespace1: { + table1: { + "0x00": { + prev: undefined, + current: { a: "0x00", b: 1n, c: 2 }, + }, + }, + }, + }, + }); + + setRecord({ stash, table: config.tables.namespace2__table2, key: { a: "0x01" }, record: { b: 1n, c: 2 } }); + + expect(subscriber).toHaveBeenCalledTimes(2); + expect(subscriber).toHaveBeenNthCalledWith(2, { + config: {}, + records: { + namespace2: { + table2: { + "0x01": { + prev: undefined, + current: { a: "0x01", b: 1n, c: 2 }, + }, + }, + }, + }, + }); + + setRecord({ stash, table: config.tables.namespace2__table2, key: { a: "0x01" }, record: { b: 1n, c: 3 } }); + + 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 }, + }, + }, + }, + }, + }); + }); +}); diff --git a/packages/stash/src/actions/subscribeStore.ts b/packages/stash/src/actions/subscribeStore.ts new file mode 100644 index 0000000000..f0e6ed119f --- /dev/null +++ b/packages/stash/src/actions/subscribeStore.ts @@ -0,0 +1,16 @@ +import { Stash, StoreConfig, StoreUpdatesSubscriber, Unsubscribe } from "../common"; + +export type SubscribeStoreArgs = { + stash: Stash; + subscriber: StoreUpdatesSubscriber; +}; + +export type SubscribeStoreResult = Unsubscribe; + +export function subscribeStore({ + stash, + subscriber, +}: SubscribeStoreArgs): SubscribeStoreResult { + stash._.storeSubscribers.add(subscriber as StoreUpdatesSubscriber); + return () => stash._.storeSubscribers.delete(subscriber as StoreUpdatesSubscriber); +} diff --git a/packages/stash/src/actions/subscribeTable.test.ts b/packages/stash/src/actions/subscribeTable.test.ts new file mode 100644 index 0000000000..72840b117a --- /dev/null +++ b/packages/stash/src/actions/subscribeTable.test.ts @@ -0,0 +1,61 @@ +import { defineStore } from "@latticexyz/store"; +import { describe, expect, it, vi } from "vitest"; +import { createStash } from "../createStash"; +import { subscribeTable } from "./subscribeTable"; +import { setRecord } from "./setRecord"; + +describe("subscribeTable", () => { + it("should notify subscriber of table change", () => { + const config = defineStore({ + namespaces: { + namespace1: { + tables: { + table1: { + schema: { a: "address", b: "uint256", c: "uint32" }, + key: ["a"], + }, + }, + }, + namespace2: { + tables: { + table2: { + schema: { a: "address", b: "uint256", c: "uint32" }, + key: ["a"], + }, + }, + }, + }, + }); + + const table1 = config.namespaces.namespace1.tables.table1; + const table2 = config.namespaces.namespace2.tables.table2; + const stash = createStash(config); + const subscriber = vi.fn(); + + subscribeTable({ stash, table: table1, subscriber }); + + setRecord({ stash, table: table1, key: { a: "0x00" }, record: { b: 1n, c: 2 } }); + + expect(subscriber).toHaveBeenCalledTimes(1); + expect(subscriber).toHaveBeenNthCalledWith(1, { + "0x00": { + prev: undefined, + current: { a: "0x00", b: 1n, c: 2 }, + }, + }); + + // Expect unrelated updates to not notify subscribers + setRecord({ stash, table: table2, key: { a: "0x01" }, record: { b: 1n, c: 2 } }); + expect(subscriber).toHaveBeenCalledTimes(1); + + setRecord({ stash, table: table1, key: { a: "0x00" }, record: { b: 1n, c: 3 } }); + + expect(subscriber).toHaveBeenCalledTimes(2); + expect(subscriber).toHaveBeenNthCalledWith(2, { + "0x00": { + prev: { 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 new file mode 100644 index 0000000000..f129ecea1a --- /dev/null +++ b/packages/stash/src/actions/subscribeTable.ts @@ -0,0 +1,21 @@ +import { Table } from "@latticexyz/config"; +import { Stash, TableUpdatesSubscriber, Unsubscribe } from "../common"; + +export type SubscribeTableArgs
= { + stash: Stash; + table: table; + subscriber: TableUpdatesSubscriber
; +}; + +export type SubscribeTableResult = Unsubscribe; + +export function subscribeTable
({ + stash, + table, + subscriber, +}: SubscribeTableArgs
): SubscribeTableResult { + const { namespaceLabel, label } = table; + + stash._.tableSubscribers[namespaceLabel][label].add(subscriber); + return () => stash._.tableSubscribers[namespaceLabel][label].delete(subscriber); +} diff --git a/packages/stash/src/apiEquality.test.ts b/packages/stash/src/apiEquality.test.ts new file mode 100644 index 0000000000..b63df25ba9 --- /dev/null +++ b/packages/stash/src/apiEquality.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { createStash } from "./createStash"; +import { attest } from "@ark/attest"; +import { BoundTable } from "./actions/getTable"; +import { DefaultActions } from "./decorators/default"; +import { defineTable } from "@latticexyz/store/config/v2"; + +describe("stash actions, bound table", () => { + const stash = createStash(); + const Position = stash.registerTable({ + table: defineTable({ + label: "Position", + schema: { player: "address", x: "uint32", y: "uint32" }, + key: ["player"], + }), + }); + + it("should expose the same functionality", () => { + const excludedStoreKeys = [ + "registerTable", + "getTable", + "getTables", + "runQuery", + "subscribeQuery", + "subscribeStore", + "subscribeTable", // renamed to subscribe in table API + "_", + "get", + ] as const; + + const excludedTableKeys = [ + "subscribe", // renamed from subscribeTable in stash API + ] as const; + + attest< + keyof Omit, + keyof Omit + >(); + attest< + keyof Omit, + keyof Omit + >(); + expect(Object.keys(Position).filter((key) => !excludedTableKeys.includes(key as never))).toEqual( + Object.keys(stash).filter((key) => !excludedStoreKeys.includes(key as never)), + ); + }); +}); diff --git a/packages/stash/src/bench.ts b/packages/stash/src/bench.ts new file mode 100644 index 0000000000..9238fe0125 --- /dev/null +++ b/packages/stash/src/bench.ts @@ -0,0 +1,64 @@ +import { bench } from "@ark/attest"; +import { defineStore } from "@latticexyz/store"; +import { createStash } from "./createStash"; +import { In } from "./queryFragments"; + +const config = defineStore({ + tables: { + Position: { + schema: { player: "address", x: "int32", y: "int32" }, + key: ["player"], + }, + }, +}); +const stash = createStash(config); + +bench("createStash", () => { + createStash( + defineStore({ + tables: { + Position: { + schema: { player: "address", x: "int32", y: "int32" }, + key: ["player"], + }, + }, + }), + ); +}).types([1690, "instantiations"]); + +bench("boundTable", () => { + const table = stash.getTable({ table: config.tables.Position }); + table.getRecord({ key: { player: "0x" } }); +}).types([108, "instantiations"]); + +bench("runQuery", () => { + const { Position } = config.tables; + stash.runQuery({ query: [In(Position)] }); +}).types([95, "instantiations"]); + +const filledStore = createStash(config); +const numItems = 10_000; +for (let i = 0; i < numItems; i++) { + filledStore.setRecord({ + table: config.tables.Position, + key: { player: `0x${i}` }, + record: { x: i, y: i }, + }); +} +bench("setRecord", () => { + filledStore.setRecord({ + table: config.tables.Position, + key: { player: `0x0` }, + record: { x: 1, y: 1 }, + }); +}).mark({ mean: [1.2, "us"], median: [1, "us"] }); + +bench("10x setRecord", () => { + for (let i = 0; i < 10; i++) { + filledStore.setRecord({ + table: config.tables.Position, + key: { player: `0x${i}` }, + record: { x: i + 1, y: i + 1 }, + }); + } +}).mark({ mean: [13, "us"], median: [12, "us"] }); diff --git a/packages/stash/src/boundTable.test.ts b/packages/stash/src/boundTable.test.ts new file mode 100644 index 0000000000..ee70d60d29 --- /dev/null +++ b/packages/stash/src/boundTable.test.ts @@ -0,0 +1,51 @@ +import { describe, beforeEach, it } from "vitest"; +import { attest } from "@arktype/attest"; +import { createStash } from "./createStash"; +import { BoundTable } from "./actions/getTable"; +import { Stash } from "./common"; +import { DefaultActions } from "./decorators/default"; +import { defineTable } from "@latticexyz/store/config/v2"; + +describe("BoundTable", () => { + const tableConfig = defineTable({ + label: "table1", + namespace: "namespace1", + schema: { field1: "uint32", field2: "address" }, + key: ["field1"], + }); + let table: BoundTable; + + let stash: Stash & DefaultActions; + + beforeEach(() => { + stash = createStash(); + table = stash.registerTable({ table: tableConfig }); + }); + + describe("setRecord", () => { + it("should set a record in the table", () => { + table.setRecord({ key: { field1: 1 }, record: { field2: "0x00" } }); + attest(stash.get().records).snap({ namespace1: { table1: { "1": { field1: 1, field2: "0x00" } } } }); + }); + + it("should throw a type error if the key or record type doesn't match", () => { + attest(() => + table.setRecord({ + key: { field1: 1 }, + // @ts-expect-error Type '"world"' is not assignable to type '`0x${string}`' + record: { field2: "world" }, + }), + ).type.errors("Type '\"world\"' is not assignable to type '`0x${string}`'"); + }); + }); + + describe("getRecord", () => { + it("should get a record from the table", () => { + table.setRecord({ key: { field1: 2 }, record: { field2: "0x01" } }); + attest(table.getRecord({ key: { field1: 2 } })).snap({ field1: 2, field2: "0x01" }); + }); + }); + + describe.todo("getRecords"); + describe.todo("getKeys"); +}); diff --git a/packages/stash/src/common.ts b/packages/stash/src/common.ts new file mode 100644 index 0000000000..048d0a822d --- /dev/null +++ b/packages/stash/src/common.ts @@ -0,0 +1,167 @@ +import { QueryFragment } from "./queryFragments"; +import { Table } from "@latticexyz/config"; +import { getKeySchema, getSchemaPrimitives } from "@latticexyz/protocol-parser/internal"; + +export type StoreConfig = { + namespaces: { + [namespaceLabel: string]: { + tables: { + [tableLabel: string]: Table; + }; + }; + }; +}; + +export type getNamespaces = keyof config["namespaces"]; + +export type getNamespaceTables< + config extends StoreConfig, + namespace extends keyof config["namespaces"], +> = keyof config["namespaces"][namespace]["tables"]; + +export type getTableConfig< + config extends StoreConfig, + namespace extends keyof config["namespaces"] | undefined, + table extends keyof config["namespaces"][namespace extends undefined ? "" : namespace]["tables"], +> = Omit; + +/** + * A Key is the unique identifier for a row in the table. + */ +export type Key
= getSchemaPrimitives>; + +/** + * A map from encoded key to decoded key + */ +export type Keys
= { [encodedKey: string]: Key
}; + +export type CommonQueryResult = { + /** + * Readyonly, mutable, includes currently matching keys. + */ + keys: Readonly; +}; + +export type CommonQueryOptions = { + initialKeys?: Keys; +}; + +export type Query = readonly [QueryFragment, ...QueryFragment[]]; + +type OmitNeverKeys = { [key in keyof T as T[key] extends never ? never : key]: T[key] }; + +export type getQueryConfig = { + namespaces: { + [namespaceLabel in query[number]["table"]["namespaceLabel"]]: { + tables: OmitNeverKeys<{ + [label in query[number]["table"]["label"]]: query[number]["table"] & { + namespaceLabel: namespaceLabel; + label: label; + }; + }>; + }; + }; +}; + +export type Unsubscribe = () => void; + +/** + * A TableRecord is one row of the table. It includes both the key and the value. + */ +export type TableRecord
= getSchemaPrimitives; + +export type TableLabel = string> = { + label: getNamespaceTables; + namespace?: namespace; +}; + +export type TableRecords
= { readonly [key: string]: TableRecord
}; + +export type MutableTableRecords
= { [key: string]: TableRecord
}; + +export type StoreRecords = { + readonly [namespace in getNamespaces]: { + readonly [table in getNamespaceTables]: TableRecords>; + }; +}; + +export type MutableStoreRecords = { + -readonly [namespace in getNamespaces]: { + -readonly [table in getNamespaceTables]: MutableTableRecords< + getTableConfig + >; + }; +}; + +export type State = { + readonly config: { + readonly [namespace in getNamespaces]: { + readonly [table in getNamespaceTables]: getTableConfig; + }; + }; + readonly records: StoreRecords; +}; + +export type MutableState = { + config: { + -readonly [namespace in getNamespaces]: { + -readonly [table in getNamespaceTables]: getTableConfig; + }; + }; + records: MutableStoreRecords; +}; + +export type TableUpdate
= { + prev: TableRecord
| undefined; + current: TableRecord
| undefined; +}; + +export type TableUpdates
= { [key: string]: TableUpdate
}; + +export type TableUpdatesSubscriber
= (updates: TableUpdates
) => void; + +export type TableSubscribers = { + [namespace: string]: { + [table: string]: Set; + }; +}; + +export type ConfigUpdate = { prev: Table | undefined; current: Table }; + +export type StoreUpdates = { + config: { + [namespace: string]: { + [table: string]: ConfigUpdate; + }; + }; + records: { + [namespace in getNamespaces]: { + [table in getNamespaceTables]: TableUpdates>; + }; + } & { + [namespace: string]: { + [table: string]: TableUpdates; + }; + }; +}; + +export type StoreUpdatesSubscriber = (updates: StoreUpdates) => void; + +export type StoreSubscribers = Set>; + +export type Stash = { + /** + * Get a readonly reference to the current state + */ + get: () => State; + /** + * Internal references for interacting with the state. + * @internal + * @deprecated Do not use this internal reference externally. + */ + _: { + tableSubscribers: TableSubscribers; + storeSubscribers: StoreSubscribers; + state: MutableState; + }; +}; diff --git a/packages/stash/src/createStash.test.ts b/packages/stash/src/createStash.test.ts new file mode 100644 index 0000000000..6486ea44ec --- /dev/null +++ b/packages/stash/src/createStash.test.ts @@ -0,0 +1,413 @@ +import { describe, expect, it, vi } from "vitest"; +import { attest } from "@arktype/attest"; +import { CreateStoreResult, createStash } from "./createStash"; +import { defineStore, defineTable } from "@latticexyz/store/config/v2"; +import { Hex } from "viem"; + +describe("createStash", () => { + it("should initialize the stash", () => { + const tablesConfig = defineStore({ + namespace: "namespace1", + tables: { + table1: { + schema: { + field1: "string", + field2: "uint32", + }, + key: ["field2"], + }, + }, + }); + const stash = createStash(tablesConfig); + + attest(stash.get().config).snap({ + namespace1: { + table1: { + label: "table1", + type: "table", + namespace: "namespace1", + namespaceLabel: "namespace1", + name: "table1", + tableId: "0x74626e616d65737061636531000000007461626c653100000000000000000000", + schema: { + field1: { type: "string", internalType: "string" }, + field2: { type: "uint32", internalType: "uint32" }, + }, + key: ["field2"], + }, + }, + }); + attest(stash.get().records).snap({ namespace1: { table1: {} } }); + }); + + it("should be typed with the config tables", () => { + const config = defineStore({ + namespace: "namespace1", + tables: { + table1: { + schema: { + field1: "string", + field2: "uint32", + }, + key: ["field2"], + }, + }, + }); + const stash = createStash(config); + stash.setRecord({ + table: config.namespaces.namespace1.tables.table1, + key: { field2: 1 }, + record: { field1: "hello" }, + }); + + attest>(stash); + attest<{ + config: { + namespace1: { + table1: { + label: "table1"; + type: "table"; + namespace: string; + namespaceLabel: "namespace1"; + name: string; + tableId: Hex; + schema: { + field1: { type: "string"; internalType: "string" }; + field2: { type: "uint32"; internalType: "uint32" }; + }; + key: readonly ["field2"]; + }; + }; + }; + records: { + namespace1: { + table1: { + [key: string]: { + field1: string; + field2: number; + }; + }; + }; + }; + }>(stash.get()); + }); + + describe("subscribeTable", () => { + it("should notify listeners on table updates", () => { + const config = defineStore({ + namespace: "namespace1", + tables: { + table1: { + schema: { + field1: "string", + field2: "uint32", + field3: "int32", + }, + key: ["field2", "field3"], + }, + }, + }); + + const table = config.namespaces.namespace1.tables.table1; + const stash = createStash(config); + + const listener = vi.fn(); + + stash.subscribeTable({ + table, + subscriber: listener, + }); + + stash.setRecord({ + table, + key: { field2: 1, field3: 2 }, + record: { field1: "hello" }, + }); + + expect(listener).toHaveBeenNthCalledWith(1, { + "1|2": { + prev: undefined, + current: { field1: "hello", field2: 1, field3: 2 }, + }, + }); + + stash.setRecord({ + table, + key: { field2: 1, field3: 2 }, + record: { field1: "world" }, + }); + + expect(listener).toHaveBeenNthCalledWith(2, { + "1|2": { + prev: { field1: "hello", field2: 1, field3: 2 }, + current: { field1: "world", field2: 1, field3: 2 }, + }, + }); + + stash.deleteRecord({ + table, + key: { field2: 1, field3: 2 }, + }); + + expect(listener).toHaveBeenNthCalledWith(3, { + "1|2": { + prev: { field1: "world", field2: 1, field3: 2 }, + current: undefined, + }, + }); + }); + + it("should not notify listeners after they have been removed", () => { + const config = defineStore({ + namespace: "namespace1", + tables: { + table1: { + schema: { + field1: "string", + field2: "uint32", + field3: "int32", + }, + key: ["field2", "field3"], + }, + }, + }); + + const table = config.namespaces.namespace1.tables.table1; + const stash = createStash(config); + + const subscriber = vi.fn(); + + const unsubscribe = stash.subscribeTable({ + table, + subscriber, + }); + + stash.setRecord({ + table, + key: { field2: 1, field3: 2 }, + record: { field1: "hello" }, + }); + + expect(subscriber).toHaveBeenNthCalledWith(1, { + "1|2": { + prev: undefined, + current: { field1: "hello", field2: 1, field3: 2 }, + }, + }); + + unsubscribe(); + + stash.setRecord({ + table, + key: { field2: 1, field3: 2 }, + record: { field1: "world" }, + }); + + expect(subscriber).toBeCalledTimes(1); + }); + }); + + describe("subscribeStore", () => { + it("should notify listeners on stash updates", () => { + const config = defineStore({ + namespace: "namespace1", + tables: { + table1: { + schema: { + field1: "string", + field2: "uint32", + field3: "int32", + }, + key: ["field2", "field3"], + }, + }, + }); + + const table = config.namespaces.namespace1.tables.table1; + const stash = createStash(config); + + const subscriber = vi.fn(); + + stash.subscribeStore({ subscriber }); + + stash.setRecord({ + table, + key: { field2: 1, field3: 2 }, + record: { field1: "hello" }, + }); + + expect(subscriber).toHaveBeenNthCalledWith(1, { + config: {}, + records: { + namespace1: { + table1: { + "1|2": { + prev: undefined, + current: { field1: "hello", field2: 1, field3: 2 }, + }, + }, + }, + }, + }); + + stash.setRecord({ + table, + key: { field2: 1, field3: 2 }, + record: { field1: "world" }, + }); + + expect(subscriber).toHaveBeenNthCalledWith(2, { + config: {}, + records: { + namespace1: { + table1: { + "1|2": { + prev: { field1: "hello", field2: 1, field3: 2 }, + current: { field1: "world", field2: 1, field3: 2 }, + }, + }, + }, + }, + }); + + stash.deleteRecord({ + table, + key: { field2: 1, field3: 2 }, + }); + + expect(subscriber).toHaveBeenNthCalledWith(3, { + config: {}, + records: { + namespace1: { + table1: { + "1|2": { + prev: { field1: "world", field2: 1, field3: 2 }, + current: undefined, + }, + }, + }, + }, + }); + + stash.registerTable({ + table: defineTable({ + namespace: "namespace2", + label: "table2", + schema: { field1: "uint256", value: "uint256" }, + key: ["field1"], + }), + }); + + 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, + }, + }, + }, + records: {}, + }); + }); + + it("should not notify listeners after they have been removed", () => { + const config = defineStore({ + namespace: "namespace1", + tables: { + table1: { + schema: { + field1: "string", + field2: "uint32", + field3: "int32", + }, + key: ["field2", "field3"], + }, + }, + }); + + const table = config.namespaces.namespace1.tables.table1; + const stash = createStash(config); + + const subscriber = vi.fn(); + + const unsubscribe = stash.subscribeStore({ + subscriber, + }); + + stash.setRecord({ + table, + key: { field2: 1, field3: 2 }, + record: { field1: "hello" }, + }); + + expect(subscriber).toHaveBeenNthCalledWith(1, { + config: {}, + records: { + namespace1: { + table1: { + "1|2": { + prev: undefined, + current: { field1: "hello", field2: 1, field3: 2 }, + }, + }, + }, + }, + }); + + unsubscribe(); + + stash.setRecord({ + table, + key: { field2: 1, field3: 2 }, + record: { field1: "world" }, + }); + + expect(subscriber).toBeCalledTimes(1); + }); + }); + + describe("getTables", () => { + it("should return an object of bound tables in the stash", () => { + const stash = createStash(); + stash.registerTable({ + table: defineTable({ + label: "table1", + namespace: "namespace1", + schema: { field1: "uint32", field2: "address" }, + key: ["field1"], + }), + }); + stash.registerTable({ + table: defineTable({ + label: "table2", + namespace: "namespace2", + schema: { field1: "uint32", field2: "address" }, + key: ["field1"], + }), + }); + const tables = stash.getTables(); + + expect(tables.namespace1.table1).toBeDefined(); + expect(tables.namespace2.table2).toBeDefined(); + }); + }); +}); diff --git a/packages/stash/src/createStash.ts b/packages/stash/src/createStash.ts new file mode 100644 index 0000000000..75bd82325d --- /dev/null +++ b/packages/stash/src/createStash.ts @@ -0,0 +1,55 @@ +import { MutableState, Stash, StoreConfig, StoreSubscribers, TableSubscribers } from "./common"; +import { DefaultActions, defaultActions } from "./decorators/default"; +import { extend } from "./actions/extend"; +import { Table } from "@latticexyz/store/config/v2"; + +export type Config = StoreConfig; + +export type CreateStoreResult = Stash & DefaultActions; + +/** + * Initializes a Zustand stash based on the provided table configs. + */ +export function createStash(storeConfig?: config): CreateStoreResult { + const tableSubscribers: TableSubscribers = {}; + const storeSubscribers: StoreSubscribers = new Set(); + + const state: MutableState = { + config: {}, + records: {}, + }; + + // Initialize the stash + if (storeConfig) { + for (const [namespace, { tables }] of Object.entries(storeConfig.namespaces)) { + for (const [table, fullTableConfig] of Object.entries(tables)) { + // Remove unused artifacts from the stash config + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { deploy, codegen, ...tableConfig } = { ...(fullTableConfig as Table) }; + + // Set config for tables + state.config[namespace] ??= {}; + state.config[namespace][table] = tableConfig; + + // Init records map for tables + state.records[namespace] ??= {}; + state.records[namespace][table] = {}; + + // Init subscribers set for tables + tableSubscribers[namespace] ??= {}; + tableSubscribers[namespace][table] ??= new Set(); + } + } + } + + const stash = { + get: () => state, + _: { + state, + tableSubscribers, + storeSubscribers, + }, + } satisfies Stash; + + return extend({ stash, actions: defaultActions(stash) }) as never; +} diff --git a/packages/stash/src/decorators/default.test.ts b/packages/stash/src/decorators/default.test.ts new file mode 100644 index 0000000000..d9c461a80c --- /dev/null +++ b/packages/stash/src/decorators/default.test.ts @@ -0,0 +1,557 @@ +import { attest } from "@ark/attest"; +import { defineStore } from "@latticexyz/store"; +import { describe, expect, it, vi } from "vitest"; +import { createStash } from "../createStash"; +import { defineTable } from "@latticexyz/store/config/v2"; +import { In } from "../queryFragments"; +import { Hex } from "viem"; +import { runQuery } from "../actions"; +import { StoreRecords, getQueryConfig } from "../common"; + +describe("stash with default actions", () => { + describe("decodeKey", () => { + it("should decode an encoded table key", () => { + const config = defineStore({ + namespace: "namespace1", + tables: { + table1: { + schema: { field1: "string", field2: "uint32", field3: "uint256" }, + key: ["field2", "field3"], + }, + }, + }); + const stash = createStash(config); + const table = config.namespaces.namespace1.tables.table1; + const key = { field2: 1, field3: 2n }; + stash.setRecord({ table, key, record: { field1: "hello" } }); + + const encodedKey = stash.encodeKey({ table, key }); + attest(stash.decodeKey({ table, encodedKey })).equals({ field2: 1, field3: 2n }); + }); + }); + + describe("deleteRecord", () => { + it("should throw a type error if an invalid key is provided", () => { + const config = defineStore({ + namespace: "namespace1", + tables: { + table1: { + schema: { + field1: "string", + field2: "uint32", + field3: "int32", + }, + key: ["field2", "field3"], + }, + }, + }); + + const table = config.namespaces.namespace1.tables.table1; + + const stash = createStash(config); + + attest(() => + stash.deleteRecord({ + table, + // @ts-expect-error Property 'field3' is missing in type '{ field2: number; }' + key: { field2: 1 }, + }), + ).type.errors(`Property 'field3' is missing in type '{ field2: number; }'`); + + attest(() => + stash.deleteRecord({ + table, + // @ts-expect-error Type 'string' is not assignable to type 'number' + key: { field2: 1, field3: "invalid" }, + }), + ).type.errors(`Type 'string' is not assignable to type 'number'`); + }); + }); + + describe("encodeKey", () => { + it("should throw a type error if an invalid key is provided", () => { + const config = defineStore({ + tables: { + test: { + schema: { field1: "uint32", field2: "uint256", field3: "string" }, + key: ["field1", "field2"], + }, + }, + }); + + const stash = createStash(config); + const table = config.tables.test; + + attest(() => + stash.encodeKey({ + table, + // @ts-expect-error Property 'field2' is missing in type '{ field1: number; }' + key: { + field1: 1, + }, + }), + ) + .throws(`Provided key is missing field field2.`) + .type.errors(`Property 'field2' is missing in type '{ field1: number; }'`); + + attest( + stash.encodeKey({ + table, + key: { + field1: 1, + // @ts-expect-error Type 'string' is not assignable to type 'bigint'. + field2: "invalid", + }, + }), + ).type.errors(`Type 'string' is not assignable to type 'bigint'.`); + }); + }); + + describe("getConfig", () => { + it("should return the config of the given table", () => { + const { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + codegen: _, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + deploy: __, + ...table + } = defineTable({ + namespace: "namespace", + label: "test", + schema: { field1: "address", field2: "string" }, + key: ["field1"], + }); + + const stash = createStash(); + stash.registerTable({ table }); + + attest(stash.getConfig({ table: { label: "test", namespaceLabel: "namespace" } })).equals(table); + }); + }); + + describe("getKeys", () => { + it("should return the key map of a table", () => { + const config = defineStore({ + tables: { + test: { + schema: { + player: "int32", + match: "int32", + x: "int32", + y: "int32", + }, + key: ["player", "match"], + }, + }, + }); + const table = config.tables.test; + const stash = createStash(config); + + stash.setRecord({ table, key: { player: 1, match: 2 }, record: { x: 3, y: 4 } }); + stash.setRecord({ table, key: { player: 5, match: 6 }, record: { x: 7, y: 8 } }); + + attest<{ [encodedKey: string]: { player: number; match: number } }>(stash.getKeys({ table })).snap({ + "1|2": { player: 1, match: 2 }, + "5|6": { player: 5, match: 6 }, + }); + }); + }); + + describe("getRecord", () => { + it("should get a record by key from the table", () => { + const config = defineStore({ + namespace: "namespace1", + tables: { + table1: { + schema: { + field1: "string", + field2: "uint32", + field3: "int32", + }, + key: ["field2", "field3"], + }, + }, + }); + + const table = config.namespaces.namespace1.tables.table1; + + const stash = createStash(config); + + stash.setRecord({ + table, + key: { field2: 2, field3: 1 }, + record: { field1: "world" }, + }); + + attest<{ field1: string; field2: number; field3: number }>( + stash.getRecord({ + table, + key: { field2: 2, field3: 1 }, + }), + ).snap({ field1: "world", field2: 2, field3: 1 }); + }); + + it("should throw a type error if the key type doesn't match", () => { + const config = defineStore({ + namespace: "namespace1", + tables: { + table1: { + schema: { + field1: "string", + field2: "uint32", + field3: "int32", + }, + key: ["field2", "field3"], + }, + }, + }); + + const table = config.namespaces.namespace1.tables.table1; + + const stash = createStash(config); + + attest(() => + stash.getRecord({ + table, + // @ts-expect-error Property 'field3' is missing in type '{ field2: number; }' + key: { field2: 1 }, + }), + ).type.errors(`Property 'field3' is missing in type '{ field2: number; }'`); + + attest(() => + stash.getRecord({ + table, + // @ts-expect-error Type 'string' is not assignable to type 'number' + key: { field2: 1, field3: "invalid" }, + }), + ).type.errors(`Type 'string' is not assignable to type 'number'`); + }); + }); + + describe("getRecords", () => { + it("should get all records from a table", () => { + const config = defineStore({ + tables: { + test: { + schema: { + player: "int32", + match: "int32", + x: "uint256", + y: "uint256", + }, + key: ["player", "match"], + }, + }, + }); + const table = config.tables.test; + const stash = createStash(config); + + stash.setRecord({ table, key: { player: 1, match: 2 }, record: { x: 3n, y: 4n } }); + stash.setRecord({ table, key: { player: 5, match: 6 }, record: { x: 7n, y: 8n } }); + + attest<{ [encodedKey: string]: { player: number; match: number; x: bigint; y: bigint } }>( + stash.getRecords({ table }), + ).equals({ + "1|2": { player: 1, match: 2, x: 3n, y: 4n }, + "5|6": { player: 5, match: 6, x: 7n, y: 8n }, + }); + + attest<{ [encodedKey: string]: { player: number; match: number; x: bigint; y: bigint } }>( + stash.getRecords({ table, keys: [{ player: 1, match: 2 }] }), + ).equals({ + "1|2": { player: 1, match: 2, x: 3n, y: 4n }, + }); + }); + }); + + describe("getTables", () => { + it("should return bound tables for each registered table in the stash", () => { + const config = defineStore({ + namespaces: { + namespace1: { + tables: { + table1: { + schema: { a: "address", b: "uint256", c: "uint32" }, + key: ["a"], + }, + }, + }, + namespace2: { + tables: { + table2: { + schema: { a: "address", b: "uint256", c: "uint32" }, + key: ["a"], + }, + }, + }, + }, + }); + const stash = createStash(config); + const tables = stash.getTables(); + + attest<"namespace1" | "namespace2", keyof typeof tables>(); + + attest<"table2", keyof typeof tables.namespace2>(); + + attest(tables).snap({ + namespace1: { + table1: { + decodeKey: "Function(decodeKey)", + deleteRecord: "Function(deleteRecord)", + encodeKey: "Function(encodeKey)", + getConfig: "Function(getConfig)", + getKeys: "Function(getKeys)", + getRecord: "Function(getRecord)", + getRecords: "Function(getRecords)", + setRecord: "Function(setRecord)", + setRecords: "Function(setRecords)", + subscribe: "Function(subscribe)", + }, + }, + namespace2: { + table2: { + decodeKey: "Function(decodeKey1)", + deleteRecord: "Function(deleteRecord1)", + encodeKey: "Function(encodeKey1)", + getConfig: "Function(getConfig1)", + getKeys: "Function(getKeys1)", + getRecord: "Function(getRecord1)", + getRecords: "Function(getRecords1)", + setRecord: "Function(setRecord1)", + setRecords: "Function(setRecords1)", + subscribe: "Function(subscribe1)", + }, + }, + }); + }); + }); + + describe("runQuery", () => { + const config = defineStore({ + namespaces: { + namespace1: { + tables: { + Position: { + schema: { player: "bytes32", x: "int32", y: "int32" }, + key: ["player"], + }, + }, + }, + namespace2: { + tables: { + Health: { + schema: { player: "bytes32", health: "uint32" }, + key: ["player"], + }, + }, + }, + }, + }); + const stash = createStash(config); + const { Position } = config.namespaces.namespace1.tables; + const { Health } = config.namespaces.namespace2.tables; + + it("should include `records` only if the `includeRecords` option is provided", () => { + const query = [In(Position)] as const; + const resultWithoutRecords = stash.runQuery({ query }); + attest(); + + const resultWithRecords = stash.runQuery({ query, options: { includeRecords: true } }); + attest>, (typeof resultWithRecords)["records"]>(); + }); + + it("should type the `records` in the result based on tables in the query", () => { + const result = runQuery({ stash, query: [In(Position), In(Health)], options: { includeRecords: true } }); + + attest<"namespace1" | "namespace2", keyof (typeof result)["records"]>(); + attest<"Position", keyof (typeof result)["records"]["namespace1"]>(); + attest<"Health", keyof (typeof result)["records"]["namespace2"]>(); + attest<{ player: Hex; x: number; y: number }, (typeof result)["records"]["namespace1"]["Position"][string]>(); + attest<{ player: Hex; health: number }, (typeof result)["records"]["namespace2"]["Health"][string]>(); + }); + }); + + describe("setRecord", () => { + it("should show a type warning if an invalid table, key or record is used", () => { + const config = defineStore({ + namespace: "namespace1", + tables: { + table1: { + schema: { + field1: "string", + field2: "uint32", + field3: "int32", + }, + key: ["field2", "field3"], + }, + }, + }); + + const table = config.namespaces.namespace1.tables.table1; + const stash = createStash(config); + + attest(() => + stash.setRecord({ + table, + // @ts-expect-error Property 'field2' is missing in type '{ field3: number; }' + key: { field3: 2 }, + record: { field1: "" }, + }), + ) + .throws("Provided key is missing field field2.") + .type.errors(`Property 'field2' is missing in type '{ field3: number; }`); + + attest(() => + stash.setRecord({ + table, + // @ts-expect-error Type 'string' is not assignable to type 'number'. + key: { field2: 1, field3: "invalid" }, + record: { field1: "" }, + }), + ).type.errors(`Type 'string' is not assignable to type 'number'.`); + + attest(() => + stash.setRecord({ + table, + key: { field2: 1, field3: 2 }, + // @ts-expect-error Type 'number' is not assignable to type 'string'. + record: { field1: 1 }, + }), + ).type.errors(`Type 'number' is not assignable to type 'string'.`); + }); + }); + + describe("setRecords", () => { + it("should show a type warning if an invalid table, key or record is used", () => { + const config = defineStore({ + namespace: "namespace1", + tables: { + table1: { + schema: { + field1: "string", + field2: "uint32", + field3: "int32", + }, + key: ["field2", "field3"], + }, + }, + }); + + const table = config.namespaces.namespace1.tables.table1; + const stash = createStash(config); + + attest(() => + stash.setRecords({ + table, + // @ts-expect-error Type '{ field1: string; }' is missing the following properties from type + records: [{ field1: "" }], + }), + ) + .throws("Provided key is missing field field2.") + .type.errors(`Type '{ field1: string; }' is missing the following properties from type`); + + attest(() => + stash.setRecords({ + table, + // @ts-expect-error Type 'number' is not assignable to type 'string'. + records: [{ field1: 1, field2: 1, field3: 2 }], + }), + ).type.errors(`Type 'number' is not assignable to type 'string'.`); + }); + }); + + describe("subscribeStore", () => { + it("should notify subscriber of any stash change", () => { + const config = defineStore({ + namespaces: { + namespace1: { + tables: { + table1: { + schema: { a: "address", b: "uint256", c: "uint32" }, + key: ["a"], + }, + }, + }, + }, + }); + + const stash = createStash(config); + const subscriber = vi.fn(); + + stash.subscribeStore({ subscriber }); + + stash.setRecord({ table: config.tables.namespace1__table1, key: { a: "0x00" }, record: { b: 1n, c: 2 } }); + + expect(subscriber).toHaveBeenCalledTimes(1); + expect(subscriber).toHaveBeenNthCalledWith(1, { + config: {}, + records: { + namespace1: { + table1: { + "0x00": { + prev: undefined, + current: { a: "0x00", b: 1n, c: 2 }, + }, + }, + }, + }, + }); + }); + }); + + describe("subscribeTable", () => { + it("should notify subscriber of table change", () => { + const config = defineStore({ + namespaces: { + namespace1: { + tables: { + table1: { + schema: { a: "address", b: "uint256", c: "uint32" }, + key: ["a"], + }, + }, + }, + namespace2: { + tables: { + table2: { + schema: { a: "address", b: "uint256", c: "uint32" }, + key: ["a"], + }, + }, + }, + }, + }); + + const table1 = config.namespaces.namespace1.tables.table1; + const table2 = config.namespaces.namespace2.tables.table2; + const stash = createStash(config); + const subscriber = vi.fn(); + + stash.subscribeTable({ table: table1, subscriber }); + + stash.setRecord({ table: table1, key: { a: "0x00" }, record: { b: 1n, c: 2 } }); + + expect(subscriber).toHaveBeenCalledTimes(1); + expect(subscriber).toHaveBeenNthCalledWith(1, { + "0x00": { + prev: undefined, + current: { a: "0x00", b: 1n, c: 2 }, + }, + }); + + // Expect unrelated updates to not notify subscribers + stash.setRecord({ table: table2, key: { a: "0x01" }, record: { b: 1n, c: 2 } }); + expect(subscriber).toHaveBeenCalledTimes(1); + + stash.setRecord({ table: table1, key: { a: "0x00" }, record: { b: 1n, c: 3 } }); + + expect(subscriber).toHaveBeenCalledTimes(2); + expect(subscriber).toHaveBeenNthCalledWith(2, { + "0x00": { + prev: { a: "0x00", b: 1n, c: 2 }, + current: { a: "0x00", b: 1n, c: 3 }, + }, + }); + }); + }); +}); diff --git a/packages/stash/src/decorators/default.ts b/packages/stash/src/decorators/default.ts new file mode 100644 index 0000000000..e7fa7b2245 --- /dev/null +++ b/packages/stash/src/decorators/default.ts @@ -0,0 +1,86 @@ +import { Query, Stash, StoreConfig } from "../common"; +import { DecodeKeyArgs, DecodeKeyResult, decodeKey } from "../actions/decodeKey"; +import { DeleteRecordArgs, DeleteRecordResult, deleteRecord } from "../actions/deleteRecord"; +import { EncodeKeyArgs, EncodeKeyResult, encodeKey } from "../actions/encodeKey"; +import { GetConfigArgs, GetConfigResult, getConfig } from "../actions/getConfig"; +import { GetKeysArgs, GetKeysResult, getKeys } from "../actions/getKeys"; +import { GetRecordArgs, GetRecordResult, getRecord } from "../actions/getRecord"; +import { GetRecordsArgs, GetRecordsResult, getRecords } from "../actions/getRecords"; +import { GetTableArgs, GetTableResult, getTable } from "../actions/getTable"; +import { GetTablesResult, getTables } from "../actions/getTables"; +import { RegisterTableArgs, RegisterTableResult, registerTable } from "../actions/registerTable"; +import { RunQueryArgs, RunQueryOptions, RunQueryResult, runQuery } from "../actions/runQuery"; +import { SetRecordArgs, SetRecordResult, setRecord } from "../actions/setRecord"; +import { SetRecordsArgs, SetRecordsResult, setRecords } from "../actions/setRecords"; +import { SubscribeQueryArgs, SubscribeQueryResult, subscribeQuery } from "../actions/subscribeQuery"; +import { SubscribeStoreArgs, SubscribeStoreResult, subscribeStore } from "../actions/subscribeStore"; +import { SubscribeTableArgs, SubscribeTableResult, subscribeTable } from "../actions/subscribeTable"; +import { Table } from "@latticexyz/config"; + +export type StashBoundDecodeKeyArgs
= Omit, "stash">; +export type StashBoundDeleteRecordArgs
= Omit, "stash">; +export type StashBoundEncodeKeyArgs
= EncodeKeyArgs
; +export type StashBoundGetConfigArgs = Omit; +export type StashBoundGetKeysArgs
= Omit, "stash">; +export type StashBoundGetRecordArgs
= Omit, "stash">; +export type StashBoundGetRecordsArgs
= Omit, "stash">; +export type StashBoundGetTableArgs
= Omit, "stash">; +export type StashBoundRegisterTableArgs
= Omit, "stash">; +export type StashBoundRunQueryArgs< + query extends Query = Query, + options extends RunQueryOptions = RunQueryOptions, +> = Omit, "stash">; +export type StashBoundSetRecordArgs
= Omit, "stash">; +export type StashBoundSetRecordsArgs
= Omit, "stash">; +export type StashBoundSubscribeQueryArgs = Omit, "stash">; +export type StashBoundSubscribeStoreArgs = Omit< + SubscribeStoreArgs, + "stash" +>; +export type StashBoundSubscribeTableArgs
= Omit, "stash">; + +export type DefaultActions = { + decodeKey:
(args: StashBoundDecodeKeyArgs
) => DecodeKeyResult
; + deleteRecord:
(args: StashBoundDeleteRecordArgs
) => DeleteRecordResult; + encodeKey:
(args: StashBoundEncodeKeyArgs
) => EncodeKeyResult; + getConfig: (args: StashBoundGetConfigArgs) => GetConfigResult; + getKeys:
(args: StashBoundGetKeysArgs
) => GetKeysResult
; + getRecord:
(args: StashBoundGetRecordArgs
) => GetRecordResult
; + getRecords:
(args: StashBoundGetRecordsArgs
) => GetRecordsResult
; + getTable:
(args: StashBoundGetTableArgs
) => GetTableResult
; + getTables: () => GetTablesResult; + registerTable:
(args: StashBoundRegisterTableArgs
) => RegisterTableResult
; + runQuery: ( + args: StashBoundRunQueryArgs, + ) => RunQueryResult; + setRecord:
(args: StashBoundSetRecordArgs
) => SetRecordResult; + setRecords:
(args: StashBoundSetRecordsArgs
) => SetRecordsResult; + subscribeQuery: (args: StashBoundSubscribeQueryArgs) => SubscribeQueryResult; + subscribeStore: (args: StashBoundSubscribeStoreArgs) => SubscribeStoreResult; + subscribeTable:
(args: StashBoundSubscribeTableArgs
) => SubscribeTableResult; +}; + +export function defaultActions(stash: Stash): DefaultActions { + return { + decodeKey:
(args: StashBoundDecodeKeyArgs
) => decodeKey({ stash, ...args }), + deleteRecord:
(args: StashBoundDeleteRecordArgs
) => deleteRecord({ stash, ...args }), + encodeKey:
(args: StashBoundEncodeKeyArgs
) => encodeKey(args), + getConfig: (args: StashBoundGetConfigArgs) => getConfig({ stash, ...args }), + getKeys:
(args: StashBoundGetKeysArgs
) => getKeys({ stash, ...args }), + getRecord:
(args: StashBoundGetRecordArgs
) => getRecord({ stash, ...args }), + getRecords:
(args: StashBoundGetRecordsArgs
) => getRecords({ stash, ...args }), + getTable:
(args: StashBoundGetTableArgs
) => getTable({ stash, ...args }), + getTables: () => getTables({ stash }), + registerTable:
(args: StashBoundRegisterTableArgs
) => registerTable({ stash, ...args }), + runQuery: (args: StashBoundRunQueryArgs) => + runQuery({ stash, ...args }), + setRecord:
(args: StashBoundSetRecordArgs
) => setRecord({ stash, ...args }), + setRecords:
(args: StashBoundSetRecordsArgs
) => setRecords({ stash, ...args }), + subscribeQuery: (args: StashBoundSubscribeQueryArgs) => + subscribeQuery({ stash, ...args }), + subscribeStore: (args: StashBoundSubscribeStoreArgs) => + subscribeStore({ stash, ...args }), + subscribeTable:
(args: StashBoundSubscribeTableArgs
) => + subscribeTable({ stash, ...args }), + }; +} diff --git a/packages/stash/src/exports/index.ts b/packages/stash/src/exports/index.ts new file mode 100644 index 0000000000..1e843685ef --- /dev/null +++ b/packages/stash/src/exports/index.ts @@ -0,0 +1,6 @@ +/** + * External exports. + * + * Be sure we're ready to commit to these being supported and changes made backward compatible! + */ +export {}; diff --git a/packages/stash/src/exports/internal.ts b/packages/stash/src/exports/internal.ts new file mode 100644 index 0000000000..37b08e10f6 --- /dev/null +++ b/packages/stash/src/exports/internal.ts @@ -0,0 +1,4 @@ +export * from "../createStash"; +export * from "../common"; +export * from "../queryFragments"; +export * from "../actions"; diff --git a/packages/stash/src/exports/recs.ts b/packages/stash/src/exports/recs.ts new file mode 100644 index 0000000000..02ed08b114 --- /dev/null +++ b/packages/stash/src/exports/recs.ts @@ -0,0 +1 @@ +export * from "../recs"; diff --git a/packages/stash/src/perf.test.ts b/packages/stash/src/perf.test.ts new file mode 100644 index 0000000000..7a5f6efbc0 --- /dev/null +++ b/packages/stash/src/perf.test.ts @@ -0,0 +1,432 @@ +import { describe, beforeEach, it } from "vitest"; +import { StoreApi, createStore as createZustandStore } from "zustand/vanilla"; +import { Component, Type, createEntity, createWorld, defineComponent, setComponent } from "@latticexyz/recs"; +import { mutative } from "zustand-mutative"; +import { defineStore } from "@latticexyz/store/config/v2"; +import { CreateStoreResult, createStash } from "./createStash"; + +export function printDuration(description: string, fn: () => unknown) { + const start = performance.now(); + fn(); + const end = performance.now(); + const duration = end - start; + console.log(description, duration); + return duration; +} + +type PositionSchema = { + x: number; + y: number; +}; + +type NameSchema = { + name: string; +}; + +type State = { + namespaces: { + app: { + Position: Record; + Name: Record; + }; + }; +}; + +type Actions = { + setPosition: (entity: string, position: PositionSchema) => void; +}; + +describe.skip("setting records in recs", () => { + let world: ReturnType; + let Position: Component<{ + x: Type.Number; + y: Type.Number; + }>; + + beforeEach(() => { + world = createWorld(); + defineComponent(world, { name: Type.String }); + Position = defineComponent(world, { x: Type.Number, y: Type.Number }); + }); + + it("[recs]: setting 10 records", () => { + printDuration("setting 10 records", () => { + for (let i = 0; i < 10; i++) { + const entity = createEntity(world); + setComponent(Position, entity, { x: i, y: i }); + } + }); + }); + + it("[recs]: setting 100 records", () => { + printDuration("setting 100 records", () => { + for (let i = 0; i < 100; i++) { + const entity = createEntity(world); + setComponent(Position, entity, { x: i, y: i }); + } + }); + }); + + it("[recs]: setting 1,000 records", () => { + printDuration("setting 1,000 records", () => { + for (let i = 0; i < 1_000; i++) { + const entity = createEntity(world); + setComponent(Position, entity, { x: i, y: i }); + } + }); + }); + + it("[recs]: setting 5,000 records", () => { + printDuration("setting 5,000 records", () => { + for (let i = 0; i < 5_000; i++) { + const entity = createEntity(world); + setComponent(Position, entity, { x: i, y: i }); + } + }); + }); + + it("[recs]: setting 10,000 records", () => { + printDuration("setting 10,000 records", () => { + for (let i = 0; i < 10_000; i++) { + const entity = createEntity(world); + setComponent(Position, entity, { x: i, y: i }); + } + }); + }); + + it("[recs]: setting 15,000 records", () => { + printDuration("setting 15,000 records", () => { + for (let i = 0; i < 15_000; i++) { + const entity = createEntity(world); + setComponent(Position, entity, { x: i, y: i }); + } + }); + }); + + it("[recs]: setting 20,000 records", () => { + printDuration("setting 20,000 records", () => { + for (let i = 0; i < 20_000; i++) { + const entity = createEntity(world); + setComponent(Position, entity, { x: i, y: i }); + } + }); + }); + + it("[recs]: setting 50,000 records", () => { + printDuration("setting 50,000 records", () => { + for (let i = 0; i < 50_000; i++) { + const entity = createEntity(world); + setComponent(Position, entity, { x: i, y: i }); + } + }); + }); + + it("[recs]: setting 100,000 records", () => { + printDuration("setting 100,000 records", () => { + for (let i = 0; i < 100_000; i++) { + const entity = createEntity(world); + setComponent(Position, entity, { x: i, y: i }); + } + }); + }); + + it("[recs]: setting 1,000,000 records", () => { + printDuration("setting 1,000,000 records", () => { + for (let i = 0; i < 1_000_000; i++) { + const entity = createEntity(world); + setComponent(Position, entity, { x: i, y: i }); + } + }); + }); +}); + +describe.skip("setting records in stash", () => { + let stash: CreateStoreResult; + const config = defineStore({ + tables: { + Position: { + schema: { player: "address", x: "uint32", y: "uint32" }, + key: ["player"], + }, + }, + }); + const Position = config.tables.Position; + + beforeEach(() => { + stash = createStash(config); + }); + + it("[stash]: setting 10 records", () => { + printDuration("setting 10 records", () => { + for (let i = 0; i < 10; i++) { + stash.setRecord({ table: Position, key: { player: `0x${i}` }, record: { x: i, y: i } }); + } + }); + }); + + it("[stash]: setting 100 records", () => { + printDuration("setting 100 records", () => { + for (let i = 0; i < 100; i++) { + stash.setRecord({ table: Position, key: { player: `0x${i}` }, record: { x: i, y: i } }); + } + }); + }); + + it("[stash]: setting 1,000 records", () => { + printDuration("setting 1,000 records", () => { + for (let i = 0; i < 1_000; i++) { + stash.setRecord({ table: Position, key: { player: `0x${i}` }, record: { x: i, y: i } }); + } + }); + }); + + it("[stash]: setting 5,000 records", () => { + printDuration("setting 5,000 records", () => { + for (let i = 0; i < 5_000; i++) { + stash.setRecord({ table: Position, key: { player: `0x${i}` }, record: { x: i, y: i } }); + } + }); + }); + + it("[stash]: setting 10,000 records", () => { + printDuration("setting 10,000 records", () => { + for (let i = 0; i < 10_000; i++) { + stash.setRecord({ table: Position, key: { player: `0x${i}` }, record: { x: i, y: i } }); + } + }); + }); + + it("[stash]: setting 15,000 records", () => { + printDuration("setting 15,000 records", () => { + for (let i = 0; i < 15_000; i++) { + stash.setRecord({ table: Position, key: { player: `0x${i}` }, record: { x: i, y: i } }); + } + }); + }); + + it("[stash]: setting 20,000 records", () => { + printDuration("setting 20,000 records", () => { + for (let i = 0; i < 20_000; i++) { + stash.setRecord({ table: Position, key: { player: `0x${i}` }, record: { x: i, y: i } }); + } + }); + }); + + it("[stash]: setting 50,000 records", () => { + printDuration("setting 50,000 records", () => { + for (let i = 0; i < 50_000; i++) { + stash.setRecord({ table: Position, key: { player: `0x${i}` }, record: { x: i, y: i } }); + } + }); + }); + + it("[stash]: setting 100,000 records", () => { + printDuration("setting 100,000 records", () => { + for (let i = 0; i < 100_000; i++) { + stash.setRecord({ table: Position, key: { player: `0x${i}` }, record: { x: i, y: i } }); + } + }); + }); + + it("[stash]: setting 1,000,000 records", () => { + printDuration("setting 1,000,000 records", () => { + for (let i = 0; i < 1_000_000; i++) { + stash.setRecord({ table: Position, key: { player: `0x${i}` }, record: { x: i, y: i } }); + } + }); + }); +}); + +describe.skip("setting records in zustand", () => { + let stash: StoreApi; + + beforeEach(() => { + stash = createZustandStore((set) => ({ + namespaces: { + app: { + Position: { + "0x": { + x: 0, + y: 0, + }, + }, + Name: { + "0x": { + name: "Some Name", + }, + }, + }, + }, + setPosition: (entity: string, position: PositionSchema) => + set((prev) => ({ + namespaces: { + app: { + ...prev.namespaces.app, + Position: { + ...prev.namespaces.app.Position, + [entity]: position, + }, + }, + }, + })), + })); + }); + + it("[zustand]: setting 10 records", () => { + printDuration("setting 10 records", () => { + for (let i = 0; i < 10; i++) { + stash.getState().setPosition(String(i), { x: i, y: i }); + } + }); + }); + + it("[zustand]: setting 100 records", () => { + printDuration("setting 100 records", () => { + for (let i = 0; i < 100; i++) { + stash.getState().setPosition(String(i), { x: i, y: i }); + } + }); + }); + + it("[zustand]: setting 1,000 records", () => { + printDuration("setting 1,000 records", () => { + for (let i = 0; i < 1_000; i++) { + stash.getState().setPosition(String(i), { x: i, y: i }); + } + }); + }); + + it("[zustand]: setting 5,000 records", () => { + printDuration("setting 5,000 records", () => { + for (let i = 0; i < 5_000; i++) { + stash.getState().setPosition(String(i), { x: i, y: i }); + } + }); + }); + + it("[zustand]: setting 10,000 records", () => { + printDuration("setting 10,000 records", () => { + for (let i = 0; i < 10_000; i++) { + stash.getState().setPosition(String(i), { x: i, y: i }); + } + }); + }); + + it("[zustand]: setting 15,000 records", () => { + printDuration("setting 15,000 records", () => { + for (let i = 0; i < 15_000; i++) { + stash.getState().setPosition(String(i), { x: i, y: i }); + } + }); + }); + + it("[zustand]: setting 20,000 records", () => { + printDuration("setting 20,000 records", () => { + for (let i = 0; i < 20_000; i++) { + stash.getState().setPosition(String(i), { x: i, y: i }); + } + }); + }); +}); + +describe.skip("setting records in zustand with mutative", () => { + let stash: StoreApi; + + beforeEach(() => { + stash = createZustandStore()( + mutative((set) => ({ + namespaces: { + app: { + Position: { + "0x": { + x: 0, + y: 0, + }, + }, + Name: { + "0x": { + name: "Some Name", + }, + }, + }, + }, + setPosition: (entity: string, position: PositionSchema) => + set((state) => { + state.namespaces.app.Position[entity] = position; + }), + })), + ); + }); + + it("[zustand mutative]: setting 10 records", () => { + printDuration("setting 10 records", () => { + for (let i = 0; i < 10; i++) { + stash.getState().setPosition(String(i), { x: i, y: i }); + } + }); + }); + + it("[zustand mutative]: setting 100 records", () => { + printDuration("setting 100 records", () => { + for (let i = 0; i < 100; i++) { + stash.getState().setPosition(String(i), { x: i, y: i }); + } + }); + }); + + it("[zustand mutative]: setting 1,000 records", () => { + printDuration("setting 1,000 records", () => { + for (let i = 0; i < 1_000; i++) { + stash.getState().setPosition(String(i), { x: i, y: i }); + } + }); + }); + + it("[zustand mutative]: setting 5,000 records", () => { + printDuration("setting 5,000 records", () => { + for (let i = 0; i < 5_000; i++) { + stash.getState().setPosition(String(i), { x: i, y: i }); + } + }); + }); + + it("[zustand mutative]: setting 10,000 records", () => { + printDuration("setting 10,000 records", () => { + for (let i = 0; i < 10_000; i++) { + stash.getState().setPosition(String(i), { x: i, y: i }); + } + }); + }); + + it("[zustand mutative]: setting 15,000 records", () => { + printDuration("setting 15,000 records", () => { + for (let i = 0; i < 15_000; i++) { + stash.getState().setPosition(String(i), { x: i, y: i }); + } + }); + }); + + it("[zustand mutative]: setting 20,000 records", () => { + printDuration("setting 20,000 records", () => { + for (let i = 0; i < 20_000; i++) { + stash.getState().setPosition(String(i), { x: i, y: i }); + } + }); + }); + + it("[zustand mutative]: setting 30,000 records", () => { + printDuration("setting 30,000 records", () => { + for (let i = 0; i < 30_000; i++) { + stash.getState().setPosition(String(i), { x: i, y: i }); + } + }); + }); + + it("[zustand mutative]: setting 40,000 records", () => { + printDuration("setting 40,000 records", () => { + for (let i = 0; i < 40_000; i++) { + stash.getState().setPosition(String(i), { x: i, y: i }); + } + }); + }); +}); diff --git a/packages/stash/src/queryFragment.test.ts b/packages/stash/src/queryFragment.test.ts new file mode 100644 index 0000000000..7970519879 --- /dev/null +++ b/packages/stash/src/queryFragment.test.ts @@ -0,0 +1,19 @@ +import { describe, it } from "vitest"; + +describe("queryFragments", () => { + describe("In", () => { + it.todo("should match all records in the table"); + }); + + describe("NotIn", () => { + it.todo("should match all records not in the table"); + }); + + describe("MatchRecord", () => { + it.todo("should match all records matching the provided partial value"); + }); + + describe("NotMatchRecord", () => { + it.todo("should match all records not matching the provided partial value"); + }); +}); diff --git a/packages/stash/src/queryFragments.ts b/packages/stash/src/queryFragments.ts new file mode 100644 index 0000000000..42b915da36 --- /dev/null +++ b/packages/stash/src/queryFragments.ts @@ -0,0 +1,82 @@ +import { Table } from "@latticexyz/config"; +import { Keys, Stash } from "./common"; +import { TableRecord } from "./common"; +import { recordMatches } from "./recordEquals"; +import { getRecords } from "./actions/getRecords"; +import { getKeys } from "./actions/getKeys"; + +// TODO: add more query fragments - ie GreaterThan, LessThan, Range, etc + +export type QueryFragment
= { + table: table; + /** + * Checking an individual table row for whether it matches the query fragment + */ + match: (stash: Stash, encodedKey: string) => boolean; + /** + * The keys that should be included in the query result if this is the first fragment in the query. + * This is to avoid having to iterate over each key in the first table if there is a more efficient + * way to get to the initial result. + */ + getInitialKeys: (stash: Stash) => Keys; +}; + +/** + * Matches all records that exist in the table. + * RECS equivalent: Has(Component) + */ +export function In
(table: table): QueryFragment
{ + const match = (stash: Stash, encodedKey: string) => encodedKey in getRecords({ stash, table }); + const getInitialKeys = (stash: Stash) => getKeys({ stash, table }); + return { table, match, getInitialKeys }; +} + +/** + * Matches all records that don't exist in the table. + * RECS equivalent: Not(Component) + */ +export function NotIn
(table: table): QueryFragment
{ + const match = (stash: Stash, encodedKey: string) => !(encodedKey in getRecords({ stash, table })); + const getInitialKeys = () => ({}); + return { table, match, getInitialKeys }; +} + +/** + * Matches all records that match the provided partial record. + * This works for both value and key, since both are part of the record. + * RECS equivalent (only for value match): HasValue(Component, value) + */ +export function MatchRecord
( + table: table, + partialRecord: Partial>, +): QueryFragment
{ + const match = (stash: Stash, encodedKey: string) => { + const record = getRecords({ stash, table })[encodedKey]; + return recordMatches(partialRecord, record); + }; + // TODO: this is a very naive and inefficient implementation for large tables, can be optimized via indexer tables + const getInitialKeys = (stash: Stash) => + Object.fromEntries(Object.entries(getKeys({ stash, table })).filter(([key]) => match(stash, key))); + return { table, match, getInitialKeys }; +} + +/** + * Matches all records that don't match the provided partial record. + * This works for both value and key, since both are part of the record. + * RECS equivalent (only for value match): NotValue(Component, value) + * @param table + * @param partialRecord + */ +export function NotMatchRecord
( + table: table, + partialRecord: Partial>, +): QueryFragment
{ + const match = (stash: Stash, encodedKey: string) => { + const record = getRecords({ stash, table })[encodedKey]; + return !recordMatches(partialRecord, record); + }; + // TODO: this is a very naive and inefficient implementation for large tables, can be optimized via indexer tables + const getInitialKeys = (stash: Stash) => + Object.fromEntries(Object.entries(getKeys({ stash, table })).filter(([key]) => match(stash, key))); + return { table, match, getInitialKeys }; +} diff --git a/packages/stash/src/recordEquals.ts b/packages/stash/src/recordEquals.ts new file mode 100644 index 0000000000..f5c52b3622 --- /dev/null +++ b/packages/stash/src/recordEquals.ts @@ -0,0 +1,26 @@ +import { TableRecord } from "./common"; + +/** + * Compare two {@link TableRecord}s. + * `a` can be a partial record, in which case only the keys present in `a` are compared to the corresponding keys in `b`. + * + * @param a Partial {@link TableRecord} to compare to `b` + * @param b ${@link TableRecord} to compare `a` to. + * @returns True if `a` equals `b` in the keys present in a or neither `a` nor `b` are defined, else false. + * + * @example + * ``` + * recordMatches({ x: 1, y: 2 }, { x: 1, y: 3 }) // returns false because value of y doesn't match + * recordMatches({ x: 1 }, { x: 1, y: 3 }) // returns true because x is equal and y is not present in a + * ``` + */ +export function recordMatches(a?: Partial, b?: TableRecord) { + if (!a && !b) return true; + if (!a || !b) return false; + + for (const key of Object.keys(a)) { + if (a[key] !== b[key]) return false; + } + + return true; +} diff --git a/packages/stash/src/write.bench.ts b/packages/stash/src/write.bench.ts new file mode 100644 index 0000000000..ceb2b55bc9 --- /dev/null +++ b/packages/stash/src/write.bench.ts @@ -0,0 +1,229 @@ +import { describe, bench } from "vitest"; +import { StoreApi, createStore } from "zustand/vanilla"; +import { Component, Type, createEntity, createWorld, defineComponent, setComponent } from "@latticexyz/recs"; +import { mutative } from "zustand-mutative"; + +export function printDuration(description: string, fn: () => unknown) { + const start = performance.now(); + fn(); + const end = performance.now(); + const duration = end - start; + console.log(description, duration); + return duration; +} + +type PositionSchema = { + x: number; + y: number; +}; + +type NameSchema = { + name: string; +}; + +type State = { + namespaces: { + app: { + Position: Record; + Name: Record; + }; + }; +}; + +type Actions = { + setPositions: (positions: { [entity: string]: PositionSchema }) => void; +}; + +let prefix = 0; +function generatePositions(numRecords: number): Record { + prefix++; + const positions: Record = {}; + for (let i = 0; i < numRecords; i++) { + positions[String(prefix) + "-" + String(i)] = { x: i, y: i }; + } + return positions; +} + +describe.skip.each([ + { initialRecords: 1_000, newRecords: 1 }, + { initialRecords: 1_000, newRecords: 100 }, + { initialRecords: 10_000, newRecords: 1 }, + { initialRecords: 10_000, newRecords: 100 }, + { initialRecords: 100_000, newRecords: 1 }, + { initialRecords: 100_000, newRecords: 100 }, + { initialRecords: 1_000_000, newRecords: 1 }, + { initialRecords: 1_000_000, newRecords: 100 }, + { initialRecords: 1_000_000, newRecords: 1_000 }, + { initialRecords: 1_000_000, newRecords: 10_000 }, + { initialRecords: 1_000_000, newRecords: 100_000 }, +])( + "[zustand]: setting $newRecords records in a stash with $initialRecords records", + ({ initialRecords, newRecords }) => { + let stash: StoreApi; + let positions: Record; + + function setupStore(numRecords: number) { + // Create stash + stash = createStore((set) => ({ + namespaces: { + app: { + Position: { + "0x": { + x: 0, + y: 0, + }, + }, + Name: { + "0x": { + name: "Some Name", + }, + }, + }, + }, + setPositions: (positions: { [entity: string]: PositionSchema }) => + set((prev) => ({ + namespaces: { + app: { + ...prev.namespaces.app, + Position: { + ...prev.namespaces.app.Position, + ...positions, + }, + }, + }, + })), + })); + + // Initialize stash with specified number of records + stash.getState().setPositions(generatePositions(numRecords)); + } + + bench( + "bench", + () => { + stash.getState().setPositions(positions); + }, + { + setup: () => { + setupStore(initialRecords); + positions = generatePositions(newRecords); + }, + iterations: 3, + }, + ); + }, +); + +describe.skip.each([ + { initialRecords: 1_000, newRecords: 1 }, + { initialRecords: 1_000, newRecords: 100 }, + { initialRecords: 10_000, newRecords: 1 }, + { initialRecords: 10_000, newRecords: 100 }, + { initialRecords: 100_000, newRecords: 1 }, + { initialRecords: 100_000, newRecords: 100 }, + { initialRecords: 1_000_000, newRecords: 1 }, + { initialRecords: 1_000_000, newRecords: 100 }, + { initialRecords: 1_000_000, newRecords: 1_000 }, + { initialRecords: 1_000_000, newRecords: 10_000 }, + { initialRecords: 1_000_000, newRecords: 100_000 }, +])( + "[zustand-mutative]: setting $newRecords records in a stash with $initialRecords records", + ({ initialRecords, newRecords }) => { + let stash: StoreApi; + let positions: Record; + + function setupStore(numRecords: number) { + // Create stash + stash = createStore()( + mutative((set) => ({ + namespaces: { + app: { + Position: { + "0x": { + x: 0, + y: 0, + }, + }, + Name: { + "0x": { + name: "Some Name", + }, + }, + }, + }, + setPositions: (positions: { [entity: string]: PositionSchema }) => + set((prev) => { + for (const [entity, position] of Object.entries(positions)) { + prev.namespaces.app.Position[entity] = position; + } + }), + })), + ); + + // Initialize stash with specified number of records + stash.getState().setPositions(generatePositions(numRecords)); + } + + bench( + "bench", + () => { + stash.getState().setPositions(positions); + }, + { + setup: () => { + setupStore(initialRecords); + positions = generatePositions(newRecords); + }, + iterations: 3, + }, + ); + }, +); + +describe.skip.each([ + { initialRecords: 1_000, newRecords: 1 }, + { initialRecords: 1_000, newRecords: 100 }, + { initialRecords: 10_000, newRecords: 1 }, + { initialRecords: 10_000, newRecords: 100 }, + { initialRecords: 100_000, newRecords: 1 }, + { initialRecords: 100_000, newRecords: 100 }, + { initialRecords: 1_000_000, newRecords: 1 }, + { initialRecords: 1_000_000, newRecords: 100 }, + { initialRecords: 1_000_000, newRecords: 1_000 }, + { initialRecords: 1_000_000, newRecords: 10_000 }, + // { initialRecords: 1_000_000, newRecords: 100_000 }, +])("[recs]: setting $newRecords records in a stash with $initialRecords records", ({ initialRecords, newRecords }) => { + let world: ReturnType; + let positions: Record; + let Position: Component<{ x: Type.Number; y: Type.Number }>; + + function setupStore(numRecords: number) { + world = createWorld(); + defineComponent(world, { name: Type.String }); + Position = defineComponent(world, { x: Type.Number, y: Type.Number }); + + // Initialize Position component with specified number of records + const initialPositions = generatePositions(numRecords); + for (const position of Object.values(initialPositions)) { + const entity = createEntity(world); + setComponent(Position, entity, position); + } + } + + bench( + "bench", + () => { + for (const position of Object.values(positions)) { + const entity = createEntity(world); + setComponent(Position, entity, position); + } + }, + { + setup: () => { + setupStore(initialRecords); + positions = generatePositions(newRecords); + }, + iterations: 3, + }, + ); +}); diff --git a/packages/stash/tsconfig.json b/packages/stash/tsconfig.json new file mode 100644 index 0000000000..039e0b4d16 --- /dev/null +++ b/packages/stash/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["src"] +} diff --git a/packages/stash/tsup.config.ts b/packages/stash/tsup.config.ts new file mode 100644 index 0000000000..709f21cd88 --- /dev/null +++ b/packages/stash/tsup.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: { + index: "src/exports/index.ts", + internal: "src/exports/internal.ts", + }, + target: "esnext", + format: ["esm"], + dts: !process.env.TSUP_SKIP_DTS, + sourcemap: true, + clean: true, + minify: true, +}); diff --git a/packages/stash/vitest.config.ts b/packages/stash/vitest.config.ts new file mode 100644 index 0000000000..b6a66f2a98 --- /dev/null +++ b/packages/stash/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globalSetup: "vitestSetup.ts", + }, +}); diff --git a/packages/stash/vitestSetup.ts b/packages/stash/vitestSetup.ts new file mode 100644 index 0000000000..035460b806 --- /dev/null +++ b/packages/stash/vitestSetup.ts @@ -0,0 +1 @@ +export { setup, teardown } from "@arktype/attest"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f37b8408cc..5237f0ff34 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -870,6 +870,52 @@ importers: specifier: ^6.7.0 version: 6.7.0(postcss@8.4.31)(typescript@5.4.2) + packages/stash: + dependencies: + '@arktype/util': + specifier: 0.0.40 + version: 0.0.40 + '@latticexyz/config': + specifier: workspace:* + version: link:../config + '@latticexyz/protocol-parser': + specifier: workspace:* + version: link:../protocol-parser + '@latticexyz/schema-type': + specifier: workspace:* + version: link:../schema-type + '@latticexyz/store': + specifier: workspace:* + version: link:../store + react: + specifier: ^18.2.0 + version: 18.2.0 + viem: + specifier: 2.9.20 + version: 2.9.20(bufferutil@4.0.8)(typescript@5.4.2)(utf-8-validate@5.0.10)(zod@3.23.8) + devDependencies: + '@arktype/attest': + specifier: 0.7.5 + version: 0.7.5(typescript@5.4.2) + '@testing-library/react': + specifier: ^16.0.0 + version: 16.0.0(@testing-library/dom@10.4.0)(@types/react-dom@18.2.7)(@types/react@18.2.22)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@testing-library/react-hooks': + specifier: ^8.0.1 + version: 8.0.1(@types/react@18.2.22)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@types/react': + specifier: 18.2.22 + version: 18.2.22 + react-dom: + specifier: ^18.2.0 + version: 18.2.0(react@18.2.0) + tsup: + specifier: ^6.7.0 + version: 6.7.0(postcss@8.4.31)(typescript@5.4.2) + vitest: + specifier: 0.34.6 + version: 0.34.6(jsdom@22.1.0(bufferutil@4.0.8)(utf-8-validate@5.0.10))(terser@5.31.6) + packages/store: dependencies: '@ark/util': @@ -1425,6 +1471,27 @@ packages: '@ark/util@0.2.2': resolution: {integrity: sha512-ryZ4+f3SlReQRH9nTFLK5EeU1Pan5ZfS+ACPSk0ir5uujJouFmvOdnkVfeAJAgeOb3kKmUM9kjelv1cwH2ScZg==} + '@arktype/attest@0.7.5': + resolution: {integrity: sha512-ZOF9uqLbvoVO6RHhlByJEBBj5qhWLpaCK/wWa5guD1OQR1/a7JZ9jCrDAcaASt6tYBA3dGhDerXhc7FcDWlRQw==} + hasBin: true + peerDependencies: + typescript: '*' + + '@arktype/fs@0.0.19': + resolution: {integrity: sha512-ZEiSc6DgJANSgMTee9lueumCH/3gIikJWT8wlN8CyVk/IDFKFbJb3/BHj906Aw+APvCc8oyvu+2Hanshkgh4Bg==} + + '@arktype/schema@0.1.2': + resolution: {integrity: sha512-ggvxs5P0toqd/4/XK76URQrtyOYpbYcLhirEZeTso6FxkloPa0lT+whPg7DNQj5qi2OQXLUHBYKMx9DOb13ViQ==} + + '@arktype/util@0.0.38': + resolution: {integrity: sha512-IvYMGnkUASJllRk3mdBVgckomKx2LNsDTrWCxz04EBK1OuU+4fJ/smSjxgZVWfopNXZds9sHNxZgTJOIw7GvJw==} + + '@arktype/util@0.0.40': + resolution: {integrity: sha512-dwC3xZh9Bz6LWSJq71AUoh06zB0qM65N4zS/NNogbumhbO55yot7yqDlv0qeBMNOWXj/gX7l7l58v0EqEaXN2w==} + + '@arktype/util@0.0.41': + resolution: {integrity: sha512-0YURzJ42v+lhlP1t5Dj90YezETRTCdFU0oM4xMVpYsmPx/DHJzr9n7AX1QPAlYWH4wY7hYY3gwai3O+8VntPgw==} + '@aws-crypto/ie11-detection@3.0.0': resolution: {integrity: sha512-341lBBkiY1DfDNKai/wXM3aujNBkXR7tq1URPQDL9wi3AUbI80NR74uF1TXHMm7po1AcnFk8iu2S2IeU/+/A+Q==} @@ -3266,12 +3333,19 @@ packages: cpu: [x64] os: [win32] + '@noble/curves@1.2.0': + resolution: {integrity: sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==} + '@noble/curves@1.4.0': resolution: {integrity: sha512-p+4cb332SFCrReJkCYe8Xzm0OWi4Jji5jVdIZRL/PmacmDkFNw6MrrV+gGpiPxLHbV+zKFRywUWbaseT+tZRXg==} '@noble/curves@1.4.2': resolution: {integrity: sha512-TavHr8qycMChk8UwMld0ZDRvatedkzWfH8IiaeGCfymOP5i0hSCozz9vHOL0nkwk7HRMlFnAiKpS2jrUmSybcw==} + '@noble/hashes@1.3.2': + resolution: {integrity: sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==} + engines: {node: '>= 16'} + '@noble/hashes@1.4.0': resolution: {integrity: sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==} engines: {node: '>= 16'} @@ -4151,9 +4225,15 @@ packages: '@scure/base@1.1.6': resolution: {integrity: sha512-ok9AWwhcgYuGG3Zfhyqg+zwl+Wn5uE+dwC0NV/2qQkx4dABbb/bx96vWu8NSj+BNjjSjno+JRYRjle1jV08k3g==} + '@scure/bip32@1.3.2': + resolution: {integrity: sha512-N1ZhksgwD3OBlwTv3R6KFEcPojl/W4ElJOeCZdi+vuI5QmTFwLq3OFf2zd2ROpKvxFdgZ6hUpb0dx9bVNEwYCA==} + '@scure/bip32@1.4.0': resolution: {integrity: sha512-sVUpc0Vq3tXCkDGYVWGIZTRfnvu8LoTDaev7vbwh0omSvVORONr960MQWdKqJDCReIEmTj3PAr73O3aoxz7OPg==} + '@scure/bip39@1.2.1': + resolution: {integrity: sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg==} + '@scure/bip39@1.3.0': resolution: {integrity: sha512-disdg7gHuTDZtY+ZdkmLpPCk7fxZSu3gBiEGuoC1XYxv9cGx3Z6cpTggCgW6odSOOIXCiDjuGejW+aJKCY/pIQ==} @@ -4450,6 +4530,10 @@ packages: resolution: {integrity: sha512-5Ly5TIRHnWH7vSDell9B/OVyV380qqIJVg7H7R7jU4fPEmOD4smqAX7VRflpYI09srWR8aj5OLD2Ccs1pI5mTg==} engines: {node: '>=12'} + '@testing-library/dom@10.4.0': + resolution: {integrity: sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==} + engines: {node: '>=18'} + '@testing-library/react-hooks@8.0.1': resolution: {integrity: sha512-Aqhl2IVmLt8IovEVarNDFuJDVWVvhnr9/GCU6UUnrYXwgDFF9h2L2o2P9KBni1AST5sT6riAyoukFLyjQUgD/g==} engines: {node: '>=12'} @@ -4466,6 +4550,21 @@ packages: react-test-renderer: optional: true + '@testing-library/react@16.0.0': + resolution: {integrity: sha512-guuxUKRWQ+FgNX0h0NS0FIq3Q3uLtWVpBzcLOggmfMoUpgBnzBzvLLd4fbm6yS8ydJd94cIfY4yP9qUQjM2KwQ==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 + '@types/react-dom': ^18.0.0 + react: ^18.0.0 + react-dom: ^18.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@tootallnate/once@2.0.0': resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==} engines: {node: '>= 10'} @@ -4490,6 +4589,9 @@ packages: '@types/accepts@1.3.7': resolution: {integrity: sha512-Pay9fq2lM2wXPWbteBsRAGiWH2hig4ZE2asK+mm7kUzlxRTfL961rj89I6zV/E3PcIkDqyuBEcMxFT7rccugeQ==} + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/babel__core@7.20.0': resolution: {integrity: sha512-+n8dL/9GWblDO0iU6eZAwEIJVr5DWigtle+Q6HLOrh/pdbXOhOtqzq8VPPE2zvNJzSKY4vH/z3iT3tn0A3ypiQ==} @@ -4753,6 +4855,9 @@ packages: resolution: {integrity: sha512-RnlSOPh14QbopGCApgkSx5UBgGda5MX1cHqp2fsqfiDyCwGL/m1jaeB9fzu7didVS81LQqGZZuxFBcg8YU8EVw==} hasBin: true + '@typescript/vfs@1.5.0': + resolution: {integrity: sha512-AJS307bPgbsZZ9ggCT3wwpg3VbTKMFNHfaY/uF0ahSkYYrPF2dSSKDNIDIQAHm9qJqbLvCsSJH7yN4Vs/CsMMg==} + '@typescript/vfs@1.6.0': resolution: {integrity: sha512-hvJUjNVeBMp77qPINuUvYXj4FyWeeMMKZkxEATEU3hqBAQ7qdTBCUFT7Sp0Zu0faeEtFf+ldXxMEDr/bk73ISg==} peerDependencies: @@ -4899,6 +5004,17 @@ packages: resolution: {integrity: sha512-dbaEZphdPje0ihqSdWg36Sb8S20TuqQomiz2593oIx+enQ9Q4vDZRjIzhnkWltGRKVKqC28kTribkgRLBexWVQ==} engines: {node: '>=6', npm: '>=3'} + abitype@1.0.0: + resolution: {integrity: sha512-NMeMah//6bJ56H5XRj8QCV4AwuW6hB6zqz2LnhhLdcWVQOsXki6/Pn3APeqxCma62nXIcmZWdu1DlHWS74umVQ==} + peerDependencies: + typescript: '>=5.0.4' + zod: ^3 >=3.22.0 + peerDependenciesMeta: + typescript: + optional: true + zod: + optional: true + abitype@1.0.5: resolution: {integrity: sha512-YzDhti7cjlfaBhHutMaboYB21Ha3rXR9QTkNJFzYC4kC8YclaiwPBBBJY8ejFdu2wnJeZCVZSMlQJ7fi8S6hsw==} peerDependencies: @@ -5087,9 +5203,15 @@ packages: aria-query@5.1.3: resolution: {integrity: sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==} + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + arktype@2.0.0-beta.6: resolution: {integrity: sha512-tbH5/h0z371sgrJIAhZhH2BcrErWv8uQIPVcLmknJ8ffov5/ZbMNufrQ3hG9avGKTcVnVmdQoPhl1WuKuagqXA==} + arktype@2.0.0-dev.11: + resolution: {integrity: sha512-k+WVQoHsHsTyTiVQkO201mxLQxyXHmy3buJW8TXLOkr4X2yOUCp0K1SBscuG9OEJoc8MjpvoIharjPHEkFI7kg==} + array-buffer-byte-length@1.0.1: resolution: {integrity: sha512-ahC5W1xgou+KTXix4sAO8Ki12Q+jf4i0+tmk3sC+zgcynshkHxzpXdImBehiUYKKKDwvfFiJl1tZt6ewscS1Mg==} engines: {node: '>= 0.4'} @@ -5890,6 +6012,10 @@ packages: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + destr@2.0.3: resolution: {integrity: sha512-2N3BOUU4gYMpTP24s5rF5iP7BDr7uNTCs4ozw3kf/eKfvWSIu93GEBi5m427YoyJoeOzQ5smuu4nNAPGb8idSQ==} @@ -5949,6 +6075,9 @@ packages: resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} engines: {node: '>=6.0.0'} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + domexception@4.0.0: resolution: {integrity: sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==} engines: {node: '>=12'} @@ -7335,6 +7464,11 @@ packages: resolution: {integrity: sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==} engines: {node: '>=0.10.0'} + isows@1.0.3: + resolution: {integrity: sha512-2cKei4vlmg2cxEjm3wVSqn8pcoRF/LX/wpifuuNquFO4SQmPwarClT+SUCA2lt+l581tTeZIPIZuIDo2jWN1fg==} + peerDependencies: + ws: '*' + isows@1.0.4: resolution: {integrity: sha512-hEzjY+x9u9hPmBom9IIAqdJCwNLax+xrPb51vEPpERoFlIxgmZcHzsT5jKG06nvInKOBGvReAVz80Umed5CczQ==} peerDependencies: @@ -7635,9 +7769,6 @@ packages: engines: {node: '>=6'} hasBin: true - jsonc-parser@3.2.0: - resolution: {integrity: sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==} - jsonfile@4.0.0: resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} @@ -7875,6 +8006,10 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + magic-string@0.30.5: resolution: {integrity: sha512-7xlpfBaQaP/T6Vh8MO/EqXSW5En6INHEvEXQiuff7Gku0PWjU3uf6w/j9o7O+SpB5fOAkrI5HeoNgwjEO0pFsA==} engines: {node: '>=12'} @@ -8112,9 +8247,6 @@ packages: engines: {node: '>=10'} hasBin: true - mlly@1.5.0: - resolution: {integrity: sha512-NPVQvAY1xr1QoVeG0cy8yUYC7FQcOx6evl/RjT1wL5FvzPnzOysoqB/jmx/DhssT2dYa8nxECLAaFI/+gVLhDQ==} - mlly@1.7.1: resolution: {integrity: sha512-rrVRZRELyQzrIUAVMHxP97kv+G786pHmOKzuFII8zDYahFBS7qnHh2AlYSl1GAHhaMPCz6/oHjVMcfFYgFYHgA==} @@ -8662,9 +8794,6 @@ packages: resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} engines: {node: '>=8'} - pkg-types@1.0.3: - resolution: {integrity: sha512-nN7pYi0AQqJnoLPC9eHFQ8AcyaixBUOwvqc5TDnIKCMEE6I0y8P7OKA7fPexsXGCGxQDl/cmrLAp26LhcwxZ4A==} - pkg-types@1.1.3: resolution: {integrity: sha512-+JrgthZG6m3ckicaOB74TwQ+tBWsFl3qVQg7mN8ulwSOElJ7gBhKzj2VkCPnZ4NlF6kEquYU+RIYNVAvzd54UA==} @@ -10143,9 +10272,6 @@ packages: engines: {node: '>=14.17'} hasBin: true - ufo@1.3.2: - resolution: {integrity: sha512-o+ORpgGwaYQXgqGDwd+hkS4PuZ3QnmqMMxRuajK/a38L6fTpcE5GPIfrf+L/KemFzfUpeUQc1rRS1iDBozvnFA==} - ufo@1.5.4: resolution: {integrity: sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ==} @@ -10359,6 +10485,14 @@ packages: typescript: optional: true + viem@2.9.20: + resolution: {integrity: sha512-PHb1MrBHMrSZ8Ayuk3Y/6wUTcMbzlACQaM6AJBSv9kRKX3xYSZ/kehi+gvS0swQJeAlTQ4eZM7jsHQJNAOarmg==} + peerDependencies: + typescript: '>=5.0.4' + peerDependenciesMeta: + typescript: + optional: true + vite-node@0.34.6: resolution: {integrity: sha512-nlBMJ9x6n7/Amaz6F3zJ97EBwR2FkzhBRxF5e+jE6LA3yi6Wtc2lyTij1OnDMIr34v5g/tVQtsVAzhT0jc5ygA==} engines: {node: '>=v14.18.0'} @@ -10767,6 +10901,29 @@ snapshots: '@ark/util@0.2.2': {} + '@arktype/attest@0.7.5(typescript@5.4.2)': + dependencies: + '@arktype/fs': 0.0.19 + '@arktype/util': 0.0.41 + '@typescript/analyze-trace': 0.10.1 + '@typescript/vfs': 1.5.0 + arktype: 2.0.0-dev.11 + typescript: 5.4.2 + transitivePeerDependencies: + - supports-color + + '@arktype/fs@0.0.19': {} + + '@arktype/schema@0.1.2': + dependencies: + '@arktype/util': 0.0.38 + + '@arktype/util@0.0.38': {} + + '@arktype/util@0.0.40': {} + + '@arktype/util@0.0.41': {} + '@aws-crypto/ie11-detection@3.0.0': dependencies: tslib: 1.14.1 @@ -13269,6 +13426,10 @@ snapshots: '@next/swc-win32-x64-msvc@14.2.5': optional: true + '@noble/curves@1.2.0': + dependencies: + '@noble/hashes': 1.3.2 + '@noble/curves@1.4.0': dependencies: '@noble/hashes': 1.4.0 @@ -13277,6 +13438,8 @@ snapshots: dependencies: '@noble/hashes': 1.4.0 + '@noble/hashes@1.3.2': {} + '@noble/hashes@1.4.0': {} '@nodelib/fs.scandir@2.1.5': @@ -14381,7 +14544,7 @@ snapshots: '@safe-global/safe-apps-sdk@9.1.0(bufferutil@4.0.8)(typescript@5.4.2)(utf-8-validate@5.0.10)(zod@3.23.8)': dependencies: '@safe-global/safe-gateway-typescript-sdk': 3.22.2 - viem: 2.19.8(bufferutil@4.0.8)(typescript@5.4.2)(utf-8-validate@5.0.10)(zod@3.23.8) + viem: 2.9.20(bufferutil@4.0.8)(typescript@5.4.2)(utf-8-validate@5.0.10)(zod@3.23.8) transitivePeerDependencies: - bufferutil - typescript @@ -14400,12 +14563,23 @@ snapshots: '@scure/base@1.1.6': {} + '@scure/bip32@1.3.2': + dependencies: + '@noble/curves': 1.2.0 + '@noble/hashes': 1.3.2 + '@scure/base': 1.1.6 + '@scure/bip32@1.4.0': dependencies: - '@noble/curves': 1.4.0 + '@noble/curves': 1.4.2 '@noble/hashes': 1.4.0 '@scure/base': 1.1.6 + '@scure/bip39@1.2.1': + dependencies: + '@noble/hashes': 1.3.2 + '@scure/base': 1.1.6 + '@scure/bip39@1.3.0': dependencies: '@noble/hashes': 1.4.0 @@ -14842,6 +15016,26 @@ snapshots: '@tanstack/table-core@8.20.1': {} + '@testing-library/dom@10.4.0': + dependencies: + '@babel/code-frame': 7.24.7 + '@babel/runtime': 7.25.0 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + chalk: 4.1.2 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + pretty-format: 27.5.1 + + '@testing-library/react-hooks@8.0.1(@types/react@18.2.22)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': + dependencies: + '@babel/runtime': 7.21.0 + react: 18.2.0 + react-error-boundary: 3.1.4(react@18.2.0) + optionalDependencies: + '@types/react': 18.2.22 + react-dom: 18.2.0(react@18.2.0) + '@testing-library/react-hooks@8.0.1(@types/react@18.2.22)(react-test-renderer@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: '@babel/runtime': 7.21.0 @@ -14851,6 +15045,16 @@ snapshots: '@types/react': 18.2.22 react-test-renderer: 18.2.0(react@18.2.0) + '@testing-library/react@16.0.0(@testing-library/dom@10.4.0)(@types/react-dom@18.2.7)(@types/react@18.2.22)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': + dependencies: + '@babel/runtime': 7.21.0 + '@testing-library/dom': 10.4.0 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + optionalDependencies: + '@types/react': 18.2.22 + '@types/react-dom': 18.2.7 + '@tootallnate/once@2.0.0': {} '@trivago/prettier-plugin-sort-imports@4.3.0(prettier@3.2.5)': @@ -14875,6 +15079,8 @@ snapshots: dependencies: '@types/node': 18.15.11 + '@types/aria-query@5.0.4': {} + '@types/babel__core@7.20.0': dependencies: '@babel/parser': 7.21.4 @@ -15205,6 +15411,12 @@ snapshots: treeify: 1.1.0 yargs: 16.2.0 + '@typescript/vfs@1.5.0': + dependencies: + debug: 4.3.4 + transitivePeerDependencies: + - supports-color + '@typescript/vfs@1.6.0(typescript@5.4.2)': dependencies: debug: 4.3.4 @@ -15251,7 +15463,7 @@ snapshots: dependencies: magic-string: 0.30.5 pathe: 1.1.2 - pretty-format: 29.5.0 + pretty-format: 29.7.0 '@vitest/spy@0.34.6': dependencies: @@ -15261,7 +15473,7 @@ snapshots: dependencies: diff-sequences: 29.6.3 loupe: 2.3.6 - pretty-format: 29.5.0 + pretty-format: 29.7.0 '@wagmi/connectors@5.1.7(@types/react@18.2.22)(@wagmi/core@2.13.4(@tanstack/query-core@5.52.0)(@types/react@18.2.22)(react@18.2.0)(typescript@5.4.2)(viem@2.19.8(bufferutil@4.0.8)(typescript@5.4.2)(utf-8-validate@5.0.10)(zod@3.23.8)))(bufferutil@4.0.8)(encoding@0.1.13)(react-dom@18.2.0(react@18.2.0))(react-native@0.75.2(@babel/core@7.21.4)(@babel/preset-env@7.25.3(@babel/core@7.21.4))(@types/react@18.2.22)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.2.0)(typescript@5.4.2)(utf-8-validate@5.0.10))(react@18.2.0)(rollup@3.21.8)(typescript@5.4.2)(utf-8-validate@5.0.10)(viem@2.19.8(bufferutil@4.0.8)(typescript@5.4.2)(utf-8-validate@5.0.10)(zod@3.23.8))(zod@3.23.8)': dependencies: @@ -15637,6 +15849,11 @@ snapshots: abind@1.0.5: {} + abitype@1.0.0(typescript@5.4.2)(zod@3.23.8): + optionalDependencies: + typescript: 5.4.2 + zod: 3.23.8 + abitype@1.0.5(typescript@5.4.2)(zod@3.23.8): optionalDependencies: typescript: 5.4.2 @@ -15790,11 +16007,20 @@ snapshots: dependencies: deep-equal: 2.2.3 + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + arktype@2.0.0-beta.6: dependencies: '@ark/schema': 0.3.3 '@ark/util': 0.2.2 + arktype@2.0.0-dev.11: + dependencies: + '@arktype/schema': 0.1.2 + '@arktype/util': 0.0.38 + array-buffer-byte-length@1.0.1: dependencies: call-bind: 1.0.7 @@ -16732,6 +16958,8 @@ snapshots: depd@2.0.0: {} + dequal@2.0.3: {} + destr@2.0.3: {} destroy@1.2.0: {} @@ -16770,6 +16998,8 @@ snapshots: dependencies: esutils: 2.0.3 + dom-accessibility-api@0.5.16: {} + domexception@4.0.0: dependencies: webidl-conversions: 7.0.0 @@ -18492,6 +18722,10 @@ snapshots: isobject@3.0.1: {} + isows@1.0.3(ws@8.13.0(bufferutil@4.0.8)(utf-8-validate@5.0.10)): + dependencies: + ws: 8.13.0(bufferutil@4.0.8)(utf-8-validate@5.0.10) + isows@1.0.4(ws@8.17.1(bufferutil@4.0.8)(utf-8-validate@5.0.10)): dependencies: ws: 8.17.1(bufferutil@4.0.8)(utf-8-validate@5.0.10) @@ -19103,8 +19337,6 @@ snapshots: json5@2.2.3: {} - jsonc-parser@3.2.0: {} - jsonfile@4.0.0: optionalDependencies: graceful-fs: 4.2.11 @@ -19420,6 +19652,8 @@ snapshots: dependencies: react: 18.2.0 + lz-string@1.5.0: {} + magic-string@0.30.5: dependencies: '@jridgewell/sourcemap-codec': 1.4.15 @@ -19768,13 +20002,6 @@ snapshots: mkdirp@1.0.4: {} - mlly@1.5.0: - dependencies: - acorn: 8.11.3 - pathe: 1.1.2 - pkg-types: 1.0.3 - ufo: 1.3.2 - mlly@1.7.1: dependencies: acorn: 8.11.3 @@ -20327,12 +20554,6 @@ snapshots: dependencies: find-up: 4.1.0 - pkg-types@1.0.3: - dependencies: - jsonc-parser: 3.2.0 - mlly: 1.5.0 - pathe: 1.1.2 - pkg-types@1.1.3: dependencies: confbox: 0.1.7 @@ -20407,7 +20628,7 @@ snapshots: postcss@8.4.31: dependencies: nanoid: 3.3.6 - picocolors: 1.0.0 + picocolors: 1.0.1 source-map-js: 1.0.2 postgres@3.3.5: {} @@ -21287,7 +21508,7 @@ snapshots: split2@3.2.2: dependencies: - readable-stream: 3.6.0 + readable-stream: 3.6.2 split2@4.2.0: {} @@ -21687,7 +21908,7 @@ snapshots: through2@4.0.2: dependencies: - readable-stream: 3.6.0 + readable-stream: 3.6.2 through@2.3.8: {} @@ -21954,8 +22175,6 @@ snapshots: typescript@5.4.2: {} - ufo@1.3.2: {} - ufo@1.5.4: {} uglify-js@3.17.4: @@ -22137,13 +22356,30 @@ snapshots: - utf-8-validate - zod + viem@2.9.20(bufferutil@4.0.8)(typescript@5.4.2)(utf-8-validate@5.0.10)(zod@3.23.8): + dependencies: + '@adraffy/ens-normalize': 1.10.0 + '@noble/curves': 1.2.0 + '@noble/hashes': 1.3.2 + '@scure/bip32': 1.3.2 + '@scure/bip39': 1.2.1 + abitype: 1.0.0(typescript@5.4.2)(zod@3.23.8) + isows: 1.0.3(ws@8.13.0(bufferutil@4.0.8)(utf-8-validate@5.0.10)) + ws: 8.13.0(bufferutil@4.0.8)(utf-8-validate@5.0.10) + optionalDependencies: + typescript: 5.4.2 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + - zod + vite-node@0.34.6(@types/node@18.15.11)(terser@5.31.6): dependencies: cac: 6.7.14 debug: 4.3.4 - mlly: 1.5.0 + mlly: 1.7.1 pathe: 1.1.2 - picocolors: 1.0.0 + picocolors: 1.0.1 vite: 4.3.6(@types/node@18.15.11)(terser@5.31.6) transitivePeerDependencies: - '@types/node' @@ -22192,7 +22428,7 @@ snapshots: local-pkg: 0.4.3 magic-string: 0.30.5 pathe: 1.1.2 - picocolors: 1.0.0 + picocolors: 1.0.1 std-env: 3.7.0 strip-literal: 1.3.0 tinybench: 2.6.0 @@ -22263,7 +22499,7 @@ snapshots: webauthn-p256@0.0.5: dependencies: - '@noble/curves': 1.4.0 + '@noble/curves': 1.4.2 '@noble/hashes': 1.4.0 webextension-polyfill@0.10.0: {} From 005e14bd5918fc5ae962798dc0855136210cdad5 Mon Sep 17 00:00:00 2001 From: alvrs Date: Thu, 15 Aug 2024 16:00:58 +0100 Subject: [PATCH 02/18] cleanup --- packages/stash/src/README.md | 24 -- packages/stash/src/actions/subscribeQuery.ts | 1 - packages/stash/src/createStash.ts | 2 +- packages/stash/src/exports/recs.ts | 1 - packages/stash/src/perf.test.ts | 432 ------------------- packages/stash/src/queryFragment.test.ts | 19 - packages/stash/src/queryFragments.ts | 26 +- packages/stash/src/recordEquals.ts | 26 -- packages/stash/src/write.bench.ts | 229 ---------- 9 files changed, 26 insertions(+), 734 deletions(-) delete mode 100644 packages/stash/src/README.md delete mode 100644 packages/stash/src/exports/recs.ts delete mode 100644 packages/stash/src/perf.test.ts delete mode 100644 packages/stash/src/queryFragment.test.ts delete mode 100644 packages/stash/src/recordEquals.ts delete mode 100644 packages/stash/src/write.bench.ts diff --git a/packages/stash/src/README.md b/packages/stash/src/README.md deleted file mode 100644 index 3fd7cc2fb5..0000000000 --- a/packages/stash/src/README.md +++ /dev/null @@ -1,24 +0,0 @@ -# TODOs - -- Set up performance benchmarks for setting records, reading records, running queries, updating records with subscribers -- Replace objects with Maps for performance ? (https://rehmat-sayany.medium.com/using-map-over-objects-in-javascript-a-performance-benchmark-ff2f351851ed) - - could be useful for TableRecords, Keys -- maybe add option to include records in the query result? -- Maybe turn `entityKey` into a tagged string? So we could make it the return type of `encodeKey`, - and allow us to use Symbol (to reduce memory overhead) or something else later without breaking change -- add more query fragments - ie GreaterThan, LessThan, Range, etc -- we might be able to enable different key shapes if we add something like a `keySelector` -- getKeySchema expects a full table as type, but only needs schema and key - -Ideas - -- Update streams: - - if each query added a new subscriber to the main state, each state update would trigger _every_ subscriber - - instead we could add a subscriber per table, which can be subscribed to again, so we only have to iterate through all tables once, - and then through all subscribers per table (only those who care about updates to this table) -- Query fragments: - - Instead of pre-defined query types (Has, HasValue, Not, NotValue), could we define fragments in a self-contained way, so - it's easy to add a new type of query fragment without changing the core code? - - The main complexity is the logic to initialize the initial set with the first query fragment, - but it's probably not that critical - we could just run the first fragment on all entities of the first table, - unless an initialSet is provided. diff --git a/packages/stash/src/actions/subscribeQuery.ts b/packages/stash/src/actions/subscribeQuery.ts index a3ceb532a7..0d49f12259 100644 --- a/packages/stash/src/actions/subscribeQuery.ts +++ b/packages/stash/src/actions/subscribeQuery.ts @@ -24,7 +24,6 @@ export type SubscribeQueryOptions = Co initialSubscribers?: QuerySubscriber[]; }; -// TODO: is it feasible to type the table updates based on the query? type QueryTableUpdates = { [namespace in getNamespaces]: { [table in getNamespaceTables]: TableUpdates>; diff --git a/packages/stash/src/createStash.ts b/packages/stash/src/createStash.ts index 75bd82325d..3d43e5c44b 100644 --- a/packages/stash/src/createStash.ts +++ b/packages/stash/src/createStash.ts @@ -8,7 +8,7 @@ export type Config = StoreConfig; export type CreateStoreResult = Stash & DefaultActions; /** - * Initializes a Zustand stash based on the provided table configs. + * Initializes a Stash based on the provided store config. */ export function createStash(storeConfig?: config): CreateStoreResult { const tableSubscribers: TableSubscribers = {}; diff --git a/packages/stash/src/exports/recs.ts b/packages/stash/src/exports/recs.ts deleted file mode 100644 index 02ed08b114..0000000000 --- a/packages/stash/src/exports/recs.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "../recs"; diff --git a/packages/stash/src/perf.test.ts b/packages/stash/src/perf.test.ts deleted file mode 100644 index 7a5f6efbc0..0000000000 --- a/packages/stash/src/perf.test.ts +++ /dev/null @@ -1,432 +0,0 @@ -import { describe, beforeEach, it } from "vitest"; -import { StoreApi, createStore as createZustandStore } from "zustand/vanilla"; -import { Component, Type, createEntity, createWorld, defineComponent, setComponent } from "@latticexyz/recs"; -import { mutative } from "zustand-mutative"; -import { defineStore } from "@latticexyz/store/config/v2"; -import { CreateStoreResult, createStash } from "./createStash"; - -export function printDuration(description: string, fn: () => unknown) { - const start = performance.now(); - fn(); - const end = performance.now(); - const duration = end - start; - console.log(description, duration); - return duration; -} - -type PositionSchema = { - x: number; - y: number; -}; - -type NameSchema = { - name: string; -}; - -type State = { - namespaces: { - app: { - Position: Record; - Name: Record; - }; - }; -}; - -type Actions = { - setPosition: (entity: string, position: PositionSchema) => void; -}; - -describe.skip("setting records in recs", () => { - let world: ReturnType; - let Position: Component<{ - x: Type.Number; - y: Type.Number; - }>; - - beforeEach(() => { - world = createWorld(); - defineComponent(world, { name: Type.String }); - Position = defineComponent(world, { x: Type.Number, y: Type.Number }); - }); - - it("[recs]: setting 10 records", () => { - printDuration("setting 10 records", () => { - for (let i = 0; i < 10; i++) { - const entity = createEntity(world); - setComponent(Position, entity, { x: i, y: i }); - } - }); - }); - - it("[recs]: setting 100 records", () => { - printDuration("setting 100 records", () => { - for (let i = 0; i < 100; i++) { - const entity = createEntity(world); - setComponent(Position, entity, { x: i, y: i }); - } - }); - }); - - it("[recs]: setting 1,000 records", () => { - printDuration("setting 1,000 records", () => { - for (let i = 0; i < 1_000; i++) { - const entity = createEntity(world); - setComponent(Position, entity, { x: i, y: i }); - } - }); - }); - - it("[recs]: setting 5,000 records", () => { - printDuration("setting 5,000 records", () => { - for (let i = 0; i < 5_000; i++) { - const entity = createEntity(world); - setComponent(Position, entity, { x: i, y: i }); - } - }); - }); - - it("[recs]: setting 10,000 records", () => { - printDuration("setting 10,000 records", () => { - for (let i = 0; i < 10_000; i++) { - const entity = createEntity(world); - setComponent(Position, entity, { x: i, y: i }); - } - }); - }); - - it("[recs]: setting 15,000 records", () => { - printDuration("setting 15,000 records", () => { - for (let i = 0; i < 15_000; i++) { - const entity = createEntity(world); - setComponent(Position, entity, { x: i, y: i }); - } - }); - }); - - it("[recs]: setting 20,000 records", () => { - printDuration("setting 20,000 records", () => { - for (let i = 0; i < 20_000; i++) { - const entity = createEntity(world); - setComponent(Position, entity, { x: i, y: i }); - } - }); - }); - - it("[recs]: setting 50,000 records", () => { - printDuration("setting 50,000 records", () => { - for (let i = 0; i < 50_000; i++) { - const entity = createEntity(world); - setComponent(Position, entity, { x: i, y: i }); - } - }); - }); - - it("[recs]: setting 100,000 records", () => { - printDuration("setting 100,000 records", () => { - for (let i = 0; i < 100_000; i++) { - const entity = createEntity(world); - setComponent(Position, entity, { x: i, y: i }); - } - }); - }); - - it("[recs]: setting 1,000,000 records", () => { - printDuration("setting 1,000,000 records", () => { - for (let i = 0; i < 1_000_000; i++) { - const entity = createEntity(world); - setComponent(Position, entity, { x: i, y: i }); - } - }); - }); -}); - -describe.skip("setting records in stash", () => { - let stash: CreateStoreResult; - const config = defineStore({ - tables: { - Position: { - schema: { player: "address", x: "uint32", y: "uint32" }, - key: ["player"], - }, - }, - }); - const Position = config.tables.Position; - - beforeEach(() => { - stash = createStash(config); - }); - - it("[stash]: setting 10 records", () => { - printDuration("setting 10 records", () => { - for (let i = 0; i < 10; i++) { - stash.setRecord({ table: Position, key: { player: `0x${i}` }, record: { x: i, y: i } }); - } - }); - }); - - it("[stash]: setting 100 records", () => { - printDuration("setting 100 records", () => { - for (let i = 0; i < 100; i++) { - stash.setRecord({ table: Position, key: { player: `0x${i}` }, record: { x: i, y: i } }); - } - }); - }); - - it("[stash]: setting 1,000 records", () => { - printDuration("setting 1,000 records", () => { - for (let i = 0; i < 1_000; i++) { - stash.setRecord({ table: Position, key: { player: `0x${i}` }, record: { x: i, y: i } }); - } - }); - }); - - it("[stash]: setting 5,000 records", () => { - printDuration("setting 5,000 records", () => { - for (let i = 0; i < 5_000; i++) { - stash.setRecord({ table: Position, key: { player: `0x${i}` }, record: { x: i, y: i } }); - } - }); - }); - - it("[stash]: setting 10,000 records", () => { - printDuration("setting 10,000 records", () => { - for (let i = 0; i < 10_000; i++) { - stash.setRecord({ table: Position, key: { player: `0x${i}` }, record: { x: i, y: i } }); - } - }); - }); - - it("[stash]: setting 15,000 records", () => { - printDuration("setting 15,000 records", () => { - for (let i = 0; i < 15_000; i++) { - stash.setRecord({ table: Position, key: { player: `0x${i}` }, record: { x: i, y: i } }); - } - }); - }); - - it("[stash]: setting 20,000 records", () => { - printDuration("setting 20,000 records", () => { - for (let i = 0; i < 20_000; i++) { - stash.setRecord({ table: Position, key: { player: `0x${i}` }, record: { x: i, y: i } }); - } - }); - }); - - it("[stash]: setting 50,000 records", () => { - printDuration("setting 50,000 records", () => { - for (let i = 0; i < 50_000; i++) { - stash.setRecord({ table: Position, key: { player: `0x${i}` }, record: { x: i, y: i } }); - } - }); - }); - - it("[stash]: setting 100,000 records", () => { - printDuration("setting 100,000 records", () => { - for (let i = 0; i < 100_000; i++) { - stash.setRecord({ table: Position, key: { player: `0x${i}` }, record: { x: i, y: i } }); - } - }); - }); - - it("[stash]: setting 1,000,000 records", () => { - printDuration("setting 1,000,000 records", () => { - for (let i = 0; i < 1_000_000; i++) { - stash.setRecord({ table: Position, key: { player: `0x${i}` }, record: { x: i, y: i } }); - } - }); - }); -}); - -describe.skip("setting records in zustand", () => { - let stash: StoreApi; - - beforeEach(() => { - stash = createZustandStore((set) => ({ - namespaces: { - app: { - Position: { - "0x": { - x: 0, - y: 0, - }, - }, - Name: { - "0x": { - name: "Some Name", - }, - }, - }, - }, - setPosition: (entity: string, position: PositionSchema) => - set((prev) => ({ - namespaces: { - app: { - ...prev.namespaces.app, - Position: { - ...prev.namespaces.app.Position, - [entity]: position, - }, - }, - }, - })), - })); - }); - - it("[zustand]: setting 10 records", () => { - printDuration("setting 10 records", () => { - for (let i = 0; i < 10; i++) { - stash.getState().setPosition(String(i), { x: i, y: i }); - } - }); - }); - - it("[zustand]: setting 100 records", () => { - printDuration("setting 100 records", () => { - for (let i = 0; i < 100; i++) { - stash.getState().setPosition(String(i), { x: i, y: i }); - } - }); - }); - - it("[zustand]: setting 1,000 records", () => { - printDuration("setting 1,000 records", () => { - for (let i = 0; i < 1_000; i++) { - stash.getState().setPosition(String(i), { x: i, y: i }); - } - }); - }); - - it("[zustand]: setting 5,000 records", () => { - printDuration("setting 5,000 records", () => { - for (let i = 0; i < 5_000; i++) { - stash.getState().setPosition(String(i), { x: i, y: i }); - } - }); - }); - - it("[zustand]: setting 10,000 records", () => { - printDuration("setting 10,000 records", () => { - for (let i = 0; i < 10_000; i++) { - stash.getState().setPosition(String(i), { x: i, y: i }); - } - }); - }); - - it("[zustand]: setting 15,000 records", () => { - printDuration("setting 15,000 records", () => { - for (let i = 0; i < 15_000; i++) { - stash.getState().setPosition(String(i), { x: i, y: i }); - } - }); - }); - - it("[zustand]: setting 20,000 records", () => { - printDuration("setting 20,000 records", () => { - for (let i = 0; i < 20_000; i++) { - stash.getState().setPosition(String(i), { x: i, y: i }); - } - }); - }); -}); - -describe.skip("setting records in zustand with mutative", () => { - let stash: StoreApi; - - beforeEach(() => { - stash = createZustandStore()( - mutative((set) => ({ - namespaces: { - app: { - Position: { - "0x": { - x: 0, - y: 0, - }, - }, - Name: { - "0x": { - name: "Some Name", - }, - }, - }, - }, - setPosition: (entity: string, position: PositionSchema) => - set((state) => { - state.namespaces.app.Position[entity] = position; - }), - })), - ); - }); - - it("[zustand mutative]: setting 10 records", () => { - printDuration("setting 10 records", () => { - for (let i = 0; i < 10; i++) { - stash.getState().setPosition(String(i), { x: i, y: i }); - } - }); - }); - - it("[zustand mutative]: setting 100 records", () => { - printDuration("setting 100 records", () => { - for (let i = 0; i < 100; i++) { - stash.getState().setPosition(String(i), { x: i, y: i }); - } - }); - }); - - it("[zustand mutative]: setting 1,000 records", () => { - printDuration("setting 1,000 records", () => { - for (let i = 0; i < 1_000; i++) { - stash.getState().setPosition(String(i), { x: i, y: i }); - } - }); - }); - - it("[zustand mutative]: setting 5,000 records", () => { - printDuration("setting 5,000 records", () => { - for (let i = 0; i < 5_000; i++) { - stash.getState().setPosition(String(i), { x: i, y: i }); - } - }); - }); - - it("[zustand mutative]: setting 10,000 records", () => { - printDuration("setting 10,000 records", () => { - for (let i = 0; i < 10_000; i++) { - stash.getState().setPosition(String(i), { x: i, y: i }); - } - }); - }); - - it("[zustand mutative]: setting 15,000 records", () => { - printDuration("setting 15,000 records", () => { - for (let i = 0; i < 15_000; i++) { - stash.getState().setPosition(String(i), { x: i, y: i }); - } - }); - }); - - it("[zustand mutative]: setting 20,000 records", () => { - printDuration("setting 20,000 records", () => { - for (let i = 0; i < 20_000; i++) { - stash.getState().setPosition(String(i), { x: i, y: i }); - } - }); - }); - - it("[zustand mutative]: setting 30,000 records", () => { - printDuration("setting 30,000 records", () => { - for (let i = 0; i < 30_000; i++) { - stash.getState().setPosition(String(i), { x: i, y: i }); - } - }); - }); - - it("[zustand mutative]: setting 40,000 records", () => { - printDuration("setting 40,000 records", () => { - for (let i = 0; i < 40_000; i++) { - stash.getState().setPosition(String(i), { x: i, y: i }); - } - }); - }); -}); diff --git a/packages/stash/src/queryFragment.test.ts b/packages/stash/src/queryFragment.test.ts deleted file mode 100644 index 7970519879..0000000000 --- a/packages/stash/src/queryFragment.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { describe, it } from "vitest"; - -describe("queryFragments", () => { - describe("In", () => { - it.todo("should match all records in the table"); - }); - - describe("NotIn", () => { - it.todo("should match all records not in the table"); - }); - - describe("MatchRecord", () => { - it.todo("should match all records matching the provided partial value"); - }); - - describe("NotMatchRecord", () => { - it.todo("should match all records not matching the provided partial value"); - }); -}); diff --git a/packages/stash/src/queryFragments.ts b/packages/stash/src/queryFragments.ts index 42b915da36..77a712a233 100644 --- a/packages/stash/src/queryFragments.ts +++ b/packages/stash/src/queryFragments.ts @@ -1,12 +1,36 @@ import { Table } from "@latticexyz/config"; import { Keys, Stash } from "./common"; import { TableRecord } from "./common"; -import { recordMatches } from "./recordEquals"; import { getRecords } from "./actions/getRecords"; import { getKeys } from "./actions/getKeys"; // TODO: add more query fragments - ie GreaterThan, LessThan, Range, etc +/** + * Compare two {@link TableRecord}s. + * `a` can be a partial record, in which case only the keys present in `a` are compared to the corresponding keys in `b`. + * + * @param a Partial {@link TableRecord} to compare to `b` + * @param b ${@link TableRecord} to compare `a` to. + * @returns True if `a` equals `b` in the keys present in a or neither `a` nor `b` are defined, else false. + * + * @example + * ``` + * recordMatches({ x: 1, y: 2 }, { x: 1, y: 3 }) // returns false because value of y doesn't match + * recordMatches({ x: 1 }, { x: 1, y: 3 }) // returns true because x is equal and y is not present in a + * ``` + */ +export function recordMatches(a?: Partial, b?: TableRecord) { + if (!a && !b) return true; + if (!a || !b) return false; + + for (const key of Object.keys(a)) { + if (a[key] !== b[key]) return false; + } + + return true; +} + export type QueryFragment
= { table: table; /** diff --git a/packages/stash/src/recordEquals.ts b/packages/stash/src/recordEquals.ts deleted file mode 100644 index f5c52b3622..0000000000 --- a/packages/stash/src/recordEquals.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { TableRecord } from "./common"; - -/** - * Compare two {@link TableRecord}s. - * `a` can be a partial record, in which case only the keys present in `a` are compared to the corresponding keys in `b`. - * - * @param a Partial {@link TableRecord} to compare to `b` - * @param b ${@link TableRecord} to compare `a` to. - * @returns True if `a` equals `b` in the keys present in a or neither `a` nor `b` are defined, else false. - * - * @example - * ``` - * recordMatches({ x: 1, y: 2 }, { x: 1, y: 3 }) // returns false because value of y doesn't match - * recordMatches({ x: 1 }, { x: 1, y: 3 }) // returns true because x is equal and y is not present in a - * ``` - */ -export function recordMatches(a?: Partial, b?: TableRecord) { - if (!a && !b) return true; - if (!a || !b) return false; - - for (const key of Object.keys(a)) { - if (a[key] !== b[key]) return false; - } - - return true; -} diff --git a/packages/stash/src/write.bench.ts b/packages/stash/src/write.bench.ts deleted file mode 100644 index ceb2b55bc9..0000000000 --- a/packages/stash/src/write.bench.ts +++ /dev/null @@ -1,229 +0,0 @@ -import { describe, bench } from "vitest"; -import { StoreApi, createStore } from "zustand/vanilla"; -import { Component, Type, createEntity, createWorld, defineComponent, setComponent } from "@latticexyz/recs"; -import { mutative } from "zustand-mutative"; - -export function printDuration(description: string, fn: () => unknown) { - const start = performance.now(); - fn(); - const end = performance.now(); - const duration = end - start; - console.log(description, duration); - return duration; -} - -type PositionSchema = { - x: number; - y: number; -}; - -type NameSchema = { - name: string; -}; - -type State = { - namespaces: { - app: { - Position: Record; - Name: Record; - }; - }; -}; - -type Actions = { - setPositions: (positions: { [entity: string]: PositionSchema }) => void; -}; - -let prefix = 0; -function generatePositions(numRecords: number): Record { - prefix++; - const positions: Record = {}; - for (let i = 0; i < numRecords; i++) { - positions[String(prefix) + "-" + String(i)] = { x: i, y: i }; - } - return positions; -} - -describe.skip.each([ - { initialRecords: 1_000, newRecords: 1 }, - { initialRecords: 1_000, newRecords: 100 }, - { initialRecords: 10_000, newRecords: 1 }, - { initialRecords: 10_000, newRecords: 100 }, - { initialRecords: 100_000, newRecords: 1 }, - { initialRecords: 100_000, newRecords: 100 }, - { initialRecords: 1_000_000, newRecords: 1 }, - { initialRecords: 1_000_000, newRecords: 100 }, - { initialRecords: 1_000_000, newRecords: 1_000 }, - { initialRecords: 1_000_000, newRecords: 10_000 }, - { initialRecords: 1_000_000, newRecords: 100_000 }, -])( - "[zustand]: setting $newRecords records in a stash with $initialRecords records", - ({ initialRecords, newRecords }) => { - let stash: StoreApi; - let positions: Record; - - function setupStore(numRecords: number) { - // Create stash - stash = createStore((set) => ({ - namespaces: { - app: { - Position: { - "0x": { - x: 0, - y: 0, - }, - }, - Name: { - "0x": { - name: "Some Name", - }, - }, - }, - }, - setPositions: (positions: { [entity: string]: PositionSchema }) => - set((prev) => ({ - namespaces: { - app: { - ...prev.namespaces.app, - Position: { - ...prev.namespaces.app.Position, - ...positions, - }, - }, - }, - })), - })); - - // Initialize stash with specified number of records - stash.getState().setPositions(generatePositions(numRecords)); - } - - bench( - "bench", - () => { - stash.getState().setPositions(positions); - }, - { - setup: () => { - setupStore(initialRecords); - positions = generatePositions(newRecords); - }, - iterations: 3, - }, - ); - }, -); - -describe.skip.each([ - { initialRecords: 1_000, newRecords: 1 }, - { initialRecords: 1_000, newRecords: 100 }, - { initialRecords: 10_000, newRecords: 1 }, - { initialRecords: 10_000, newRecords: 100 }, - { initialRecords: 100_000, newRecords: 1 }, - { initialRecords: 100_000, newRecords: 100 }, - { initialRecords: 1_000_000, newRecords: 1 }, - { initialRecords: 1_000_000, newRecords: 100 }, - { initialRecords: 1_000_000, newRecords: 1_000 }, - { initialRecords: 1_000_000, newRecords: 10_000 }, - { initialRecords: 1_000_000, newRecords: 100_000 }, -])( - "[zustand-mutative]: setting $newRecords records in a stash with $initialRecords records", - ({ initialRecords, newRecords }) => { - let stash: StoreApi; - let positions: Record; - - function setupStore(numRecords: number) { - // Create stash - stash = createStore()( - mutative((set) => ({ - namespaces: { - app: { - Position: { - "0x": { - x: 0, - y: 0, - }, - }, - Name: { - "0x": { - name: "Some Name", - }, - }, - }, - }, - setPositions: (positions: { [entity: string]: PositionSchema }) => - set((prev) => { - for (const [entity, position] of Object.entries(positions)) { - prev.namespaces.app.Position[entity] = position; - } - }), - })), - ); - - // Initialize stash with specified number of records - stash.getState().setPositions(generatePositions(numRecords)); - } - - bench( - "bench", - () => { - stash.getState().setPositions(positions); - }, - { - setup: () => { - setupStore(initialRecords); - positions = generatePositions(newRecords); - }, - iterations: 3, - }, - ); - }, -); - -describe.skip.each([ - { initialRecords: 1_000, newRecords: 1 }, - { initialRecords: 1_000, newRecords: 100 }, - { initialRecords: 10_000, newRecords: 1 }, - { initialRecords: 10_000, newRecords: 100 }, - { initialRecords: 100_000, newRecords: 1 }, - { initialRecords: 100_000, newRecords: 100 }, - { initialRecords: 1_000_000, newRecords: 1 }, - { initialRecords: 1_000_000, newRecords: 100 }, - { initialRecords: 1_000_000, newRecords: 1_000 }, - { initialRecords: 1_000_000, newRecords: 10_000 }, - // { initialRecords: 1_000_000, newRecords: 100_000 }, -])("[recs]: setting $newRecords records in a stash with $initialRecords records", ({ initialRecords, newRecords }) => { - let world: ReturnType; - let positions: Record; - let Position: Component<{ x: Type.Number; y: Type.Number }>; - - function setupStore(numRecords: number) { - world = createWorld(); - defineComponent(world, { name: Type.String }); - Position = defineComponent(world, { x: Type.Number, y: Type.Number }); - - // Initialize Position component with specified number of records - const initialPositions = generatePositions(numRecords); - for (const position of Object.values(initialPositions)) { - const entity = createEntity(world); - setComponent(Position, entity, position); - } - } - - bench( - "bench", - () => { - for (const position of Object.values(positions)) { - const entity = createEntity(world); - setComponent(Position, entity, position); - } - }, - { - setup: () => { - setupStore(initialRecords); - positions = generatePositions(newRecords); - }, - iterations: 3, - }, - ); -}); From f85bb4771c74736ed7d866d95ea4b053a9d8ff60 Mon Sep 17 00:00:00 2001 From: alvrs Date: Thu, 15 Aug 2024 16:01:46 +0100 Subject: [PATCH 03/18] package version --- packages/stash/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/stash/package.json b/packages/stash/package.json index f834197159..4801a07818 100644 --- a/packages/stash/package.json +++ b/packages/stash/package.json @@ -1,6 +1,6 @@ { "name": "@latticexyz/stash", - "version": "2.0.12", + "version": "2.1.0", "description": "High performance client store and query engine for MUD", "repository": { "type": "git", From fcb3a978012ab68952fd7736f45d763794c6b73a Mon Sep 17 00:00:00 2001 From: alvrs Date: Fri, 16 Aug 2024 14:11:44 +0100 Subject: [PATCH 04/18] rename to defaultActions --- packages/stash/src/createStash.ts | 2 +- .../src/decorators/{default.test.ts => defaultActions.test.ts} | 0 packages/stash/src/decorators/{default.ts => defaultActions.ts} | 0 3 files changed, 1 insertion(+), 1 deletion(-) rename packages/stash/src/decorators/{default.test.ts => defaultActions.test.ts} (100%) rename packages/stash/src/decorators/{default.ts => defaultActions.ts} (100%) diff --git a/packages/stash/src/createStash.ts b/packages/stash/src/createStash.ts index 3d43e5c44b..722c85a2ed 100644 --- a/packages/stash/src/createStash.ts +++ b/packages/stash/src/createStash.ts @@ -1,5 +1,5 @@ import { MutableState, Stash, StoreConfig, StoreSubscribers, TableSubscribers } from "./common"; -import { DefaultActions, defaultActions } from "./decorators/default"; +import { DefaultActions, defaultActions } from "./decorators/defaultActions"; import { extend } from "./actions/extend"; import { Table } from "@latticexyz/store/config/v2"; diff --git a/packages/stash/src/decorators/default.test.ts b/packages/stash/src/decorators/defaultActions.test.ts similarity index 100% rename from packages/stash/src/decorators/default.test.ts rename to packages/stash/src/decorators/defaultActions.test.ts diff --git a/packages/stash/src/decorators/default.ts b/packages/stash/src/decorators/defaultActions.ts similarity index 100% rename from packages/stash/src/decorators/default.ts rename to packages/stash/src/decorators/defaultActions.ts From ec871ed09be4816380045ca6d3b53f89fc17609b Mon Sep 17 00:00:00 2001 From: alvrs Date: Fri, 16 Aug 2024 14:16:11 +0100 Subject: [PATCH 05/18] add readonly to stash --- packages/stash/src/common.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/stash/src/common.ts b/packages/stash/src/common.ts index 048d0a822d..21de0dee56 100644 --- a/packages/stash/src/common.ts +++ b/packages/stash/src/common.ts @@ -153,15 +153,15 @@ export type Stash = { /** * Get a readonly reference to the current state */ - get: () => State; + readonly get: () => State; /** * Internal references for interacting with the state. * @internal * @deprecated Do not use this internal reference externally. */ - _: { - tableSubscribers: TableSubscribers; - storeSubscribers: StoreSubscribers; - state: MutableState; + readonly _: { + readonly tableSubscribers: TableSubscribers; + readonly storeSubscribers: StoreSubscribers; + readonly state: MutableState; }; }; From b8ca3b769870bf9deb93ff92d2050fcea926fc93 Mon Sep 17 00:00:00 2001 From: alvrs Date: Fri, 30 Aug 2024 12:15:04 +0100 Subject: [PATCH 06/18] rename query fragments --- packages/stash/src/actions/runQuery.test.ts | 12 ++++---- packages/stash/src/actions/runQuery.ts | 2 +- .../stash/src/actions/subscribeQuery.test.ts | 4 +-- packages/stash/src/actions/subscribeQuery.ts | 4 +-- packages/stash/src/queryFragments.ts | 28 +++++++++---------- 5 files changed, 24 insertions(+), 26 deletions(-) diff --git a/packages/stash/src/actions/runQuery.test.ts b/packages/stash/src/actions/runQuery.test.ts index 123d7ee795..681021e6b2 100644 --- a/packages/stash/src/actions/runQuery.test.ts +++ b/packages/stash/src/actions/runQuery.test.ts @@ -5,7 +5,7 @@ import { runQuery } from "./runQuery"; import { defineStore } from "@latticexyz/store"; import { Stash, StoreRecords, getQueryConfig } from "../common"; import { setRecord } from "./setRecord"; -import { In, MatchRecord, NotIn, NotMatchRecord } from "../queryFragments"; +import { In, Matches, NotIn, NotMatches } from "../queryFragments"; import { Hex } from "viem"; describe("runQuery", () => { @@ -79,7 +79,7 @@ describe("runQuery", () => { }); it("should return all keys that have Position.x = 4 and are included in Health", () => { - const result = runQuery({ stash, query: [MatchRecord(Position, { x: 4 }), In(Health)] }); + const result = runQuery({ stash, query: [Matches(Position, { x: 4 }), In(Health)] }); attest(result).snap({ keys: { "0x4": { player: "0x4" } } }); }); @@ -95,7 +95,7 @@ describe("runQuery", () => { }); it("should return all keys that don't include a gold item in the Inventory table", () => { - const result = runQuery({ stash, query: [NotMatchRecord(Inventory, { item: "0xgold" })] }); + const result = runQuery({ stash, query: [NotMatches(Inventory, { item: "0xgold" })] }); attest(result).snap({ keys: { "0x0|0xsilver": { player: "0x0", item: "0xsilver" }, @@ -108,9 +108,9 @@ describe("runQuery", () => { }); it("should throw an error when tables with different key schemas are mixed", () => { - attest(() => - runQuery({ stash, query: [In(Position), MatchRecord(Inventory, { item: "0xgold", amount: 2 })] }), - ).throws("All tables in a query must share the same key schema"); + attest(() => runQuery({ stash, query: [In(Position), Matches(Inventory, { item: "0xgold", amount: 2 })] })).throws( + "All tables in a query must share the same key schema", + ); }); it("should include all matching records from the tables if includeRecords is set", () => { diff --git a/packages/stash/src/actions/runQuery.ts b/packages/stash/src/actions/runQuery.ts index 46cf77039f..8948455ea1 100644 --- a/packages/stash/src/actions/runQuery.ts +++ b/packages/stash/src/actions/runQuery.ts @@ -62,7 +62,7 @@ export function runQuery({ for (const fragment of query) { // TODO: this might be more efficient if we would use a Map() instead of an object for (const encodedKey of Object.keys(keys)) { - if (!fragment.match(stash, encodedKey)) { + if (!fragment.pass(stash, encodedKey)) { delete keys[encodedKey]; } } diff --git a/packages/stash/src/actions/subscribeQuery.test.ts b/packages/stash/src/actions/subscribeQuery.test.ts index 80ea8ff12c..67edd9051a 100644 --- a/packages/stash/src/actions/subscribeQuery.test.ts +++ b/packages/stash/src/actions/subscribeQuery.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, it, vi, expect } from "vitest"; import { QueryUpdate, subscribeQuery } from "./subscribeQuery"; import { attest } from "@arktype/attest"; import { defineStore } from "@latticexyz/store"; -import { In, MatchRecord } from "../queryFragments"; +import { In, Matches } from "../queryFragments"; import { deleteRecord } from "./deleteRecord"; import { setRecord } from "./setRecord"; import { Stash } from "../common"; @@ -66,7 +66,7 @@ describe("defineQuery", () => { 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: [MatchRecord(Position, { x: 4 }), In(Health)] }); + const result = subscribeQuery({ stash, query: [Matches(Position, { x: 4 }), In(Health)] }); result.subscribe(subscriber); setRecord({ stash, table: Position, key: { player: "0x4" }, record: { y: 2 } }); diff --git a/packages/stash/src/actions/subscribeQuery.ts b/packages/stash/src/actions/subscribeQuery.ts index 0d49f12259..2edb4bbf86 100644 --- a/packages/stash/src/actions/subscribeQuery.ts +++ b/packages/stash/src/actions/subscribeQuery.ts @@ -94,7 +94,7 @@ export function subscribeQuery({ update.keys[key] = matching[key]; // 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.match(stash, key)); + const match = relevantFragments.every((f) => f.pass(stash, key)); if (match) { // If all relevant fragments still match, the key still matches the query. update.types[key] = "update"; @@ -105,7 +105,7 @@ export function subscribeQuery({ } } else { // If this key didn't match the query before, check all fragments - const match = query.every((f) => f.match(stash, key)); + const match = query.every((f) => f.pass(stash, key)); 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 }); diff --git a/packages/stash/src/queryFragments.ts b/packages/stash/src/queryFragments.ts index 77a712a233..50444d69ff 100644 --- a/packages/stash/src/queryFragments.ts +++ b/packages/stash/src/queryFragments.ts @@ -4,8 +4,6 @@ import { TableRecord } from "./common"; import { getRecords } from "./actions/getRecords"; import { getKeys } from "./actions/getKeys"; -// TODO: add more query fragments - ie GreaterThan, LessThan, Range, etc - /** * Compare two {@link TableRecord}s. * `a` can be a partial record, in which case only the keys present in `a` are compared to the corresponding keys in `b`. @@ -36,7 +34,7 @@ export type QueryFragment
= { /** * Checking an individual table row for whether it matches the query fragment */ - match: (stash: Stash, encodedKey: string) => boolean; + pass: (stash: Stash, encodedKey: string) => boolean; /** * The keys that should be included in the query result if this is the first fragment in the query. * This is to avoid having to iterate over each key in the first table if there is a more efficient @@ -50,9 +48,9 @@ export type QueryFragment
= { * RECS equivalent: Has(Component) */ export function In
(table: table): QueryFragment
{ - const match = (stash: Stash, encodedKey: string) => encodedKey in getRecords({ stash, table }); + const pass = (stash: Stash, encodedKey: string) => encodedKey in getRecords({ stash, table }); const getInitialKeys = (stash: Stash) => getKeys({ stash, table }); - return { table, match, getInitialKeys }; + return { table, pass, getInitialKeys }; } /** @@ -60,9 +58,9 @@ export function In
(table: table): QueryFragment
{ * RECS equivalent: Not(Component) */ export function NotIn
(table: table): QueryFragment
{ - const match = (stash: Stash, encodedKey: string) => !(encodedKey in getRecords({ stash, table })); + const pass = (stash: Stash, encodedKey: string) => !(encodedKey in getRecords({ stash, table })); const getInitialKeys = () => ({}); - return { table, match, getInitialKeys }; + return { table, pass, getInitialKeys }; } /** @@ -70,18 +68,18 @@ export function NotIn
(table: table): QueryFragment
{ * This works for both value and key, since both are part of the record. * RECS equivalent (only for value match): HasValue(Component, value) */ -export function MatchRecord
( +export function Matches
( table: table, partialRecord: Partial>, ): QueryFragment
{ - const match = (stash: Stash, encodedKey: string) => { + const pass = (stash: Stash, encodedKey: string) => { const record = getRecords({ stash, table })[encodedKey]; return recordMatches(partialRecord, record); }; // TODO: this is a very naive and inefficient implementation for large tables, can be optimized via indexer tables const getInitialKeys = (stash: Stash) => - Object.fromEntries(Object.entries(getKeys({ stash, table })).filter(([key]) => match(stash, key))); - return { table, match, getInitialKeys }; + Object.fromEntries(Object.entries(getKeys({ stash, table })).filter(([key]) => pass(stash, key))); + return { table, pass, getInitialKeys }; } /** @@ -91,16 +89,16 @@ export function MatchRecord
( * @param table * @param partialRecord */ -export function NotMatchRecord
( +export function NotMatches
( table: table, partialRecord: Partial>, ): QueryFragment
{ - const match = (stash: Stash, encodedKey: string) => { + const pass = (stash: Stash, encodedKey: string) => { const record = getRecords({ stash, table })[encodedKey]; return !recordMatches(partialRecord, record); }; // TODO: this is a very naive and inefficient implementation for large tables, can be optimized via indexer tables const getInitialKeys = (stash: Stash) => - Object.fromEntries(Object.entries(getKeys({ stash, table })).filter(([key]) => match(stash, key))); - return { table, match, getInitialKeys }; + Object.fromEntries(Object.entries(getKeys({ stash, table })).filter(([key]) => pass(stash, key))); + return { table, pass, getInitialKeys }; } From c1d751b6ebf63c85b6b100ef95c8705cf7ffcdd4 Mon Sep 17 00:00:00 2001 From: alvrs Date: Fri, 30 Aug 2024 12:36:07 +0100 Subject: [PATCH 07/18] Add Not query fragment --- packages/stash/src/actions/runQuery.test.ts | 6 +++--- packages/stash/src/apiEquality.test.ts | 2 +- packages/stash/src/boundTable.test.ts | 2 +- packages/stash/src/queryFragments.ts | 14 ++++++++++++++ 4 files changed, 19 insertions(+), 5 deletions(-) diff --git a/packages/stash/src/actions/runQuery.test.ts b/packages/stash/src/actions/runQuery.test.ts index 681021e6b2..9f9c143f73 100644 --- a/packages/stash/src/actions/runQuery.test.ts +++ b/packages/stash/src/actions/runQuery.test.ts @@ -5,7 +5,7 @@ import { runQuery } from "./runQuery"; import { defineStore } from "@latticexyz/store"; import { Stash, StoreRecords, getQueryConfig } from "../common"; import { setRecord } from "./setRecord"; -import { In, Matches, NotIn, NotMatches } from "../queryFragments"; +import { In, Matches, Not } from "../queryFragments"; import { Hex } from "viem"; describe("runQuery", () => { @@ -84,7 +84,7 @@ describe("runQuery", () => { }); it("should return all keys that are in Position but not Health", () => { - const result = runQuery({ stash, query: [In(Position), NotIn(Health)] }); + const result = runQuery({ stash, query: [In(Position), Not(In(Health))] }); attest(result).snap({ keys: { "0x0": { player: "0x0" }, @@ -95,7 +95,7 @@ describe("runQuery", () => { }); it("should return all keys that don't include a gold item in the Inventory table", () => { - const result = runQuery({ stash, query: [NotMatches(Inventory, { item: "0xgold" })] }); + const result = runQuery({ stash, query: [Not(Matches(Inventory, { item: "0xgold" }))] }); attest(result).snap({ keys: { "0x0|0xsilver": { player: "0x0", item: "0xsilver" }, diff --git a/packages/stash/src/apiEquality.test.ts b/packages/stash/src/apiEquality.test.ts index b63df25ba9..50b63d6fbf 100644 --- a/packages/stash/src/apiEquality.test.ts +++ b/packages/stash/src/apiEquality.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { createStash } from "./createStash"; import { attest } from "@ark/attest"; import { BoundTable } from "./actions/getTable"; -import { DefaultActions } from "./decorators/default"; +import { DefaultActions } from "./decorators/defaultActions"; import { defineTable } from "@latticexyz/store/config/v2"; describe("stash actions, bound table", () => { diff --git a/packages/stash/src/boundTable.test.ts b/packages/stash/src/boundTable.test.ts index ee70d60d29..0089c6f502 100644 --- a/packages/stash/src/boundTable.test.ts +++ b/packages/stash/src/boundTable.test.ts @@ -3,7 +3,7 @@ import { attest } from "@arktype/attest"; import { createStash } from "./createStash"; import { BoundTable } from "./actions/getTable"; import { Stash } from "./common"; -import { DefaultActions } from "./decorators/default"; +import { DefaultActions } from "./decorators/defaultActions"; import { defineTable } from "@latticexyz/store/config/v2"; describe("BoundTable", () => { diff --git a/packages/stash/src/queryFragments.ts b/packages/stash/src/queryFragments.ts index 50444d69ff..1133aaa6cc 100644 --- a/packages/stash/src/queryFragments.ts +++ b/packages/stash/src/queryFragments.ts @@ -102,3 +102,17 @@ export function NotMatches
( Object.fromEntries(Object.entries(getKeys({ stash, table })).filter(([key]) => pass(stash, key))); return { table, pass, getInitialKeys }; } + +/** + * Inverses a given query fragment (`In` becomes `NotIn`, `Matches` becomes `NotMatches` etc.) + * @param queryFragment + */ +export function Not
(queryFragment: QueryFragment
): QueryFragment
{ + const pass = (stash: Stash, encodedKey: string) => !queryFragment.pass(stash, encodedKey); + const getInitialKeys = (stash: Stash) => { + const allKeys = getKeys({ stash, table: queryFragment.table }); + const notInitialKeys = queryFragment.getInitialKeys(stash); + return Object.fromEntries(Object.entries(allKeys).filter(([key]) => !(key in notInitialKeys))); + }; + return { table: queryFragment.table, pass, getInitialKeys }; +} From 0277cf5d32067b73d880eb9649f1c18470357397 Mon Sep 17 00:00:00 2001 From: alvrs Date: Fri, 30 Aug 2024 12:53:17 +0100 Subject: [PATCH 08/18] fix tests --- packages/stash/src/actions/getConfig.test.ts | 2 +- packages/stash/src/actions/getTable.test.ts | 2 +- packages/stash/src/actions/registerTable.test.ts | 2 +- packages/stash/src/boundTable.test.ts | 2 +- packages/stash/src/createStash.test.ts | 6 +++--- packages/stash/src/decorators/defaultActions.test.ts | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/stash/src/actions/getConfig.test.ts b/packages/stash/src/actions/getConfig.test.ts index 01475d8734..e24f7eeb45 100644 --- a/packages/stash/src/actions/getConfig.test.ts +++ b/packages/stash/src/actions/getConfig.test.ts @@ -26,7 +26,7 @@ describe("getConfig", () => { deploy: _4, ...namespacedTable } = defineTable({ - namespace: "namespace", + namespaceLabel: "namespace", label: "test", schema: { field1: "address", field2: "string" }, key: ["field1"], diff --git a/packages/stash/src/actions/getTable.test.ts b/packages/stash/src/actions/getTable.test.ts index 4de80db169..3c894d93d8 100644 --- a/packages/stash/src/actions/getTable.test.ts +++ b/packages/stash/src/actions/getTable.test.ts @@ -11,7 +11,7 @@ describe("getTable", () => { stash: stash, table: defineTable({ label: "table1", - namespace: "namespace1", + namespaceLabel: "namespace1", schema: { field1: "uint32", field2: "address" }, key: ["field1"], }), diff --git a/packages/stash/src/actions/registerTable.test.ts b/packages/stash/src/actions/registerTable.test.ts index dcb7a2db6e..fcdb8daed8 100644 --- a/packages/stash/src/actions/registerTable.test.ts +++ b/packages/stash/src/actions/registerTable.test.ts @@ -11,7 +11,7 @@ describe("registerTable", () => { stash: stash, table: defineTable({ label: "table1", - namespace: "namespace1", + namespaceLabel: "namespace1", schema: { field1: "uint32", field2: "address" }, key: ["field1"], }), diff --git a/packages/stash/src/boundTable.test.ts b/packages/stash/src/boundTable.test.ts index 0089c6f502..cb05e33c14 100644 --- a/packages/stash/src/boundTable.test.ts +++ b/packages/stash/src/boundTable.test.ts @@ -9,7 +9,7 @@ import { defineTable } from "@latticexyz/store/config/v2"; describe("BoundTable", () => { const tableConfig = defineTable({ label: "table1", - namespace: "namespace1", + namespaceLabel: "namespace1", schema: { field1: "uint32", field2: "address" }, key: ["field1"], }); diff --git a/packages/stash/src/createStash.test.ts b/packages/stash/src/createStash.test.ts index 6486ea44ec..c89468ee8f 100644 --- a/packages/stash/src/createStash.test.ts +++ b/packages/stash/src/createStash.test.ts @@ -291,7 +291,7 @@ describe("createStash", () => { stash.registerTable({ table: defineTable({ - namespace: "namespace2", + namespaceLabel: "namespace2", label: "table2", schema: { field1: "uint256", value: "uint256" }, key: ["field1"], @@ -391,7 +391,7 @@ describe("createStash", () => { stash.registerTable({ table: defineTable({ label: "table1", - namespace: "namespace1", + namespaceLabel: "namespace1", schema: { field1: "uint32", field2: "address" }, key: ["field1"], }), @@ -399,7 +399,7 @@ describe("createStash", () => { stash.registerTable({ table: defineTable({ label: "table2", - namespace: "namespace2", + namespaceLabel: "namespace2", schema: { field1: "uint32", field2: "address" }, key: ["field1"], }), diff --git a/packages/stash/src/decorators/defaultActions.test.ts b/packages/stash/src/decorators/defaultActions.test.ts index d9c461a80c..ada2eef48c 100644 --- a/packages/stash/src/decorators/defaultActions.test.ts +++ b/packages/stash/src/decorators/defaultActions.test.ts @@ -116,7 +116,7 @@ describe("stash with default actions", () => { deploy: __, ...table } = defineTable({ - namespace: "namespace", + namespaceLabel: "namespace", label: "test", schema: { field1: "address", field2: "string" }, key: ["field1"], From 175ca92392bd9419ceaaf19d54e09dbfafce9134 Mon Sep 17 00:00:00 2001 From: alvrs Date: Fri, 30 Aug 2024 13:09:50 +0100 Subject: [PATCH 09/18] fix extend type --- packages/stash/src/actions/extend.test.ts | 7 ++ packages/stash/src/actions/extend.ts | 2 +- pnpm-lock.yaml | 102 +++++++++++++--------- 3 files changed, 68 insertions(+), 43 deletions(-) diff --git a/packages/stash/src/actions/extend.test.ts b/packages/stash/src/actions/extend.test.ts index 8eae889237..6fa6aa0cb2 100644 --- a/packages/stash/src/actions/extend.test.ts +++ b/packages/stash/src/actions/extend.test.ts @@ -12,4 +12,11 @@ describe("extend", () => { attest(); attest(Object.keys(extended)).equals([...Object.keys(stash), "additional"]); }); + + it("should allow overriding existing keys", () => { + const stash = createStash(); + const actions = { deleteKey: () => true }; + const extended = extend({ stash: stash, actions }); + attest<(typeof extended)["deleteKey"], (typeof actions)["deleteKey"]>(); + }); }); diff --git a/packages/stash/src/actions/extend.ts b/packages/stash/src/actions/extend.ts index 01742da7a9..eb717779d5 100644 --- a/packages/stash/src/actions/extend.ts +++ b/packages/stash/src/actions/extend.ts @@ -5,7 +5,7 @@ export type ExtendArgs = { actions: actions; }; -export type ExtendResult = stash & actions; +export type ExtendResult = Omit & actions; export function extend({ stash, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5237f0ff34..8f0f4a5a71 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,24 +4,6 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false -catalogs: - default: - '@ark/attest': - specifier: 0.12.1 - version: 0.12.1 - '@ark/util': - specifier: 0.2.2 - version: 0.2.2 - abitype: - specifier: 1.0.5 - version: 1.0.5 - arktype: - specifier: 2.0.0-beta.6 - version: 2.0.0-beta.6 - viem: - specifier: 2.19.8 - version: 2.19.8 - patchedDependencies: minimist@1.2.8: hash: tkbpkgnnti52zmnvbq3uwanedm @@ -115,7 +97,7 @@ importers: version: 6.7.0(postcss@8.4.31)(typescript@5.4.2) vitest: specifier: 0.34.6 - version: 0.34.6(jsdom@22.1.0(bufferutil@4.0.8)(utf-8-validate@5.0.10))(terser@5.31.6) + version: 0.34.6(jsdom@22.1.0)(terser@5.31.6) packages/block-logs-stream: dependencies: @@ -143,7 +125,7 @@ importers: version: 6.7.0(postcss@8.4.31)(typescript@5.4.2) vitest: specifier: 0.34.6 - version: 0.34.6(jsdom@22.1.0(bufferutil@4.0.8)(utf-8-validate@5.0.10))(terser@5.31.6) + version: 0.34.6(jsdom@22.1.0)(terser@5.31.6) packages/cli: dependencies: @@ -282,7 +264,7 @@ importers: version: 3.12.6 vitest: specifier: 0.34.6 - version: 0.34.6(jsdom@22.1.0(bufferutil@4.0.8)(utf-8-validate@5.0.10))(terser@5.31.6) + version: 0.34.6(jsdom@22.1.0)(terser@5.31.6) packages/common: dependencies: @@ -334,7 +316,7 @@ importers: version: 6.7.0(postcss@8.4.31)(typescript@5.4.2) vitest: specifier: 0.34.6 - version: 0.34.6(jsdom@22.1.0(bufferutil@4.0.8)(utf-8-validate@5.0.10))(terser@5.31.6) + version: 0.34.6(jsdom@22.1.0)(terser@5.31.6) packages/config: dependencies: @@ -448,7 +430,7 @@ importers: version: 6.7.0(postcss@8.4.23)(typescript@5.4.2) vitest: specifier: 0.34.6 - version: 0.34.6(jsdom@22.1.0(bufferutil@4.0.8)(utf-8-validate@5.0.10))(terser@5.31.6) + version: 0.34.6(jsdom@22.1.0)(terser@5.31.6) packages/explorer: dependencies: @@ -639,7 +621,7 @@ importers: version: 3.12.6 vitest: specifier: 0.34.6 - version: 0.34.6(jsdom@22.1.0(bufferutil@4.0.8)(utf-8-validate@5.0.10))(terser@5.31.6) + version: 0.34.6(jsdom@22.1.0)(terser@5.31.6) packages/gas-report: dependencies: @@ -685,7 +667,7 @@ importers: version: 6.7.0(postcss@8.4.31)(typescript@5.4.2) vitest: specifier: 0.34.6 - version: 0.34.6(jsdom@22.1.0(bufferutil@4.0.8)(utf-8-validate@5.0.10))(terser@5.31.6) + version: 0.34.6(jsdom@22.1.0)(terser@5.31.6) packages/protocol-parser: dependencies: @@ -710,7 +692,7 @@ importers: version: 6.7.0(postcss@8.4.31)(typescript@5.4.2) vitest: specifier: 0.34.6 - version: 0.34.6(jsdom@22.1.0(bufferutil@4.0.8)(utf-8-validate@5.0.10))(terser@5.31.6) + version: 0.34.6(jsdom@22.1.0)(terser@5.31.6) packages/query: dependencies: @@ -738,7 +720,7 @@ importers: version: 6.7.0(postcss@8.4.31)(typescript@5.4.2) vitest: specifier: 0.34.6 - version: 0.34.6(jsdom@22.1.0(bufferutil@4.0.8)(utf-8-validate@5.0.10))(terser@5.31.6) + version: 0.34.6(jsdom@22.1.0)(terser@5.31.6) packages/react: dependencies: @@ -790,7 +772,7 @@ importers: version: 4.3.6(@types/node@20.12.12)(terser@5.31.6) vitest: specifier: 0.34.6 - version: 0.34.6(jsdom@22.1.0(bufferutil@4.0.8)(utf-8-validate@5.0.10))(terser@5.31.6) + version: 0.34.6(jsdom@22.1.0)(terser@5.31.6) packages/recs: dependencies: @@ -815,10 +797,10 @@ importers: version: 8.3.4 jest: specifier: ^29.3.1 - version: 29.5.0(@types/node@18.15.11) + version: 29.5.0(@types/node@20.12.12) ts-jest: specifier: ^29.0.5 - version: 29.0.5(@babel/core@7.21.4)(@jest/types@29.6.3)(babel-jest@29.5.0(@babel/core@7.21.4))(jest@29.5.0(@types/node@18.15.11))(typescript@5.4.2) + version: 29.0.5(@babel/core@7.21.4)(@jest/types@29.6.3)(babel-jest@29.5.0(@babel/core@7.21.4))(jest@29.5.0(@types/node@20.12.12))(typescript@5.4.2) tsup: specifier: ^6.7.0 version: 6.7.0(postcss@8.4.31)(typescript@5.4.2) @@ -849,7 +831,7 @@ importers: version: 6.7.0(postcss@8.4.31)(typescript@5.4.2) vitest: specifier: 0.34.6 - version: 0.34.6(jsdom@22.1.0(bufferutil@4.0.8)(utf-8-validate@5.0.10))(terser@5.31.6) + version: 0.34.6(jsdom@22.1.0)(terser@5.31.6) packages/solhint-config-mud: devDependencies: @@ -975,7 +957,7 @@ importers: version: 3.12.6 vitest: specifier: 0.34.6 - version: 0.34.6(jsdom@22.1.0(bufferutil@4.0.8)(utf-8-validate@5.0.10))(terser@5.31.6) + version: 0.34.6(jsdom@22.1.0)(terser@5.31.6) packages/store-indexer: dependencies: @@ -1090,7 +1072,7 @@ importers: version: 3.12.6 vitest: specifier: 0.34.6 - version: 0.34.6(jsdom@22.1.0(bufferutil@4.0.8)(utf-8-validate@5.0.10))(terser@5.31.6) + version: 0.34.6(jsdom@22.1.0)(terser@5.31.6) packages/store-sync: dependencies: @@ -1187,7 +1169,7 @@ importers: version: 6.7.0(postcss@8.4.31)(typescript@5.4.2) vitest: specifier: 0.34.6 - version: 0.34.6(jsdom@22.1.0(bufferutil@4.0.8)(utf-8-validate@5.0.10))(terser@5.31.6) + version: 0.34.6(jsdom@22.1.0)(terser@5.31.6) packages/utils: dependencies: @@ -1206,10 +1188,10 @@ importers: version: 27.4.1 jest: specifier: ^29.3.1 - version: 29.5.0(@types/node@20.12.12) + version: 29.5.0(@types/node@18.15.11) ts-jest: specifier: ^29.0.5 - version: 29.0.5(@babel/core@7.21.4)(@jest/types@29.6.3)(babel-jest@29.5.0(@babel/core@7.21.4))(jest@29.5.0(@types/node@20.12.12))(typescript@5.4.2) + version: 29.0.5(@babel/core@7.21.4)(@jest/types@29.6.3)(babel-jest@29.5.0(@babel/core@7.21.4))(jest@29.5.0(@types/node@18.15.11))(typescript@5.4.2) tsup: specifier: ^6.7.0 version: 6.7.0(postcss@8.4.31)(typescript@5.4.2) @@ -1288,7 +1270,7 @@ importers: version: 3.12.6 vitest: specifier: 0.34.6 - version: 0.34.6(jsdom@22.1.0(bufferutil@4.0.8)(utf-8-validate@5.0.10))(terser@5.31.6) + version: 0.34.6(jsdom@22.1.0)(terser@5.31.6) packages/world-module-metadata: dependencies: @@ -1328,7 +1310,7 @@ importers: version: 3.12.6 vitest: specifier: 0.34.6 - version: 0.34.6(jsdom@22.1.0(bufferutil@4.0.8)(utf-8-validate@5.0.10))(terser@5.31.6) + version: 0.34.6(jsdom@22.1.0)(terser@5.31.6) packages/world-modules: dependencies: @@ -1389,7 +1371,7 @@ importers: version: 3.12.6 vitest: specifier: 0.34.6 - version: 0.34.6(jsdom@22.1.0(bufferutil@4.0.8)(utf-8-validate@5.0.10))(terser@5.31.6) + version: 0.34.6(jsdom@22.1.0)(terser@5.31.6) test/mock-game-contracts: devDependencies: @@ -11317,7 +11299,7 @@ snapshots: '@babel/code-frame@7.24.7': dependencies: '@babel/highlight': 7.24.7 - picocolors: 1.0.0 + picocolors: 1.0.1 '@babel/compat-data@7.21.4': {} @@ -11578,7 +11560,7 @@ snapshots: '@babel/helper-validator-identifier': 7.24.7 chalk: 2.4.2 js-tokens: 4.0.0 - picocolors: 1.0.0 + picocolors: 1.0.1 '@babel/parser@7.21.4': dependencies: @@ -19303,7 +19285,7 @@ snapshots: whatwg-encoding: 2.0.0 whatwg-mimetype: 3.0.0 whatwg-url: 12.0.1 - ws: 8.13.0(bufferutil@4.0.8)(utf-8-validate@5.0.10) + ws: 8.17.1(bufferutil@4.0.8)(utf-8-validate@5.0.10) xml-name-validator: 4.0.0 transitivePeerDependencies: - bufferutil @@ -22446,6 +22428,42 @@ snapshots: - supports-color - terser + vitest@0.34.6(jsdom@22.1.0)(terser@5.31.6): + dependencies: + '@types/chai': 4.3.5 + '@types/chai-subset': 1.3.3 + '@types/node': 18.15.11 + '@vitest/expect': 0.34.6 + '@vitest/runner': 0.34.6 + '@vitest/snapshot': 0.34.6 + '@vitest/spy': 0.34.6 + '@vitest/utils': 0.34.6 + acorn: 8.11.3 + acorn-walk: 8.3.2 + cac: 6.7.14 + chai: 4.4.1 + debug: 4.3.4 + local-pkg: 0.4.3 + magic-string: 0.30.5 + pathe: 1.1.2 + picocolors: 1.0.1 + std-env: 3.7.0 + strip-literal: 1.3.0 + tinybench: 2.6.0 + tinypool: 0.7.0 + vite: 4.3.6(@types/node@18.15.11)(terser@5.31.6) + vite-node: 0.34.6(@types/node@18.15.11)(terser@5.31.6) + why-is-node-running: 2.2.2 + optionalDependencies: + jsdom: 22.1.0(bufferutil@4.0.8)(utf-8-validate@5.0.10) + transitivePeerDependencies: + - less + - sass + - stylus + - sugarss + - supports-color + - terser + vlq@1.0.1: {} w3c-xmlserializer@4.0.0: From 62f9cf00f03c37f90257545f2f7ee5deeeac6001 Mon Sep 17 00:00:00 2001 From: alvrs Date: Fri, 30 Aug 2024 13:44:59 +0100 Subject: [PATCH 10/18] rename getConfig to getTableConfig --- packages/stash/src/actions/getTable.test.ts | 2 +- packages/stash/src/actions/getTable.ts | 6 +++--- .../{getConfig.test.ts => getTableConfig.test.ts} | 8 +++++--- .../src/actions/{getConfig.ts => getTableConfig.ts} | 6 +++--- packages/stash/src/actions/getTables.test.ts | 4 ++-- packages/stash/src/actions/getTables.ts | 6 +++--- packages/stash/src/actions/index.ts | 2 +- packages/stash/src/actions/runQuery.ts | 6 +++--- packages/stash/src/actions/subscribeQuery.ts | 4 ++-- packages/stash/src/common.ts | 12 ++++++------ packages/stash/src/decorators/defaultActions.test.ts | 6 +++--- packages/stash/src/decorators/defaultActions.ts | 8 ++++---- 12 files changed, 36 insertions(+), 34 deletions(-) rename packages/stash/src/actions/{getConfig.test.ts => getTableConfig.test.ts} (81%) rename packages/stash/src/actions/{getConfig.ts => getTableConfig.ts} (55%) diff --git a/packages/stash/src/actions/getTable.test.ts b/packages/stash/src/actions/getTable.test.ts index 3c894d93d8..c3fff9c034 100644 --- a/packages/stash/src/actions/getTable.test.ts +++ b/packages/stash/src/actions/getTable.test.ts @@ -131,7 +131,7 @@ describe("getTable", () => { const table = stash.getTable({ table: config, }); - attest>(); + attest>(); }); }); diff --git a/packages/stash/src/actions/getTable.ts b/packages/stash/src/actions/getTable.ts index 636d6b0552..9188756bd3 100644 --- a/packages/stash/src/actions/getTable.ts +++ b/packages/stash/src/actions/getTable.ts @@ -3,7 +3,7 @@ import { Stash } from "../common"; import { DecodeKeyArgs, DecodeKeyResult, decodeKey } from "./decodeKey"; import { DeleteRecordArgs, DeleteRecordResult, deleteRecord } from "./deleteRecord"; import { EncodeKeyArgs, EncodeKeyResult, encodeKey } from "./encodeKey"; -import { GetConfigResult, getConfig } from "./getConfig"; +import { GetTableConfigResult, getTableConfig } from "./getTableConfig"; import { GetKeysResult, getKeys } from "./getKeys"; import { GetRecordArgs, GetRecordResult, getRecord } from "./getRecord"; import { GetRecordsArgs, GetRecordsResult, getRecords } from "./getRecords"; @@ -28,7 +28,7 @@ export type BoundTable
= { decodeKey: (args: TableBoundDecodeKeyArgs
) => DecodeKeyResult
; deleteRecord: (args: TableBoundDeleteRecordArgs
) => DeleteRecordResult; encodeKey: (args: TableBoundEncodeKeyArgs
) => EncodeKeyResult; - getConfig: () => GetConfigResult
; + getTableConfig: () => GetTableConfigResult
; getKeys: () => GetKeysResult
; getRecord: (args: TableBoundGetRecordArgs
) => GetRecordResult
; getRecords: (args?: TableBoundGetRecordsArgs
) => GetRecordsResult
; @@ -55,7 +55,7 @@ export function getTable
({ stash, table }: GetTableArgs) => decodeKey({ stash, table, ...args }), deleteRecord: (args: TableBoundDeleteRecordArgs
) => deleteRecord({ stash, table, ...args }), encodeKey: (args: TableBoundEncodeKeyArgs
) => encodeKey({ table, ...args }), - getConfig: () => getConfig({ stash, table }) as table, + getTableConfig: () => getTableConfig({ stash, table }) as table, getKeys: () => getKeys({ stash, table }), getRecord: (args: TableBoundGetRecordArgs
) => getRecord({ stash, table, ...args }), getRecords: (args?: TableBoundGetRecordsArgs
) => getRecords({ stash, table, ...args }), diff --git a/packages/stash/src/actions/getConfig.test.ts b/packages/stash/src/actions/getTableConfig.test.ts similarity index 81% rename from packages/stash/src/actions/getConfig.test.ts rename to packages/stash/src/actions/getTableConfig.test.ts index e24f7eeb45..5de7e83bcf 100644 --- a/packages/stash/src/actions/getConfig.test.ts +++ b/packages/stash/src/actions/getTableConfig.test.ts @@ -2,7 +2,7 @@ import { defineTable } from "@latticexyz/store/config/v2"; import { describe, it } from "vitest"; import { createStash } from "../createStash"; import { attest } from "@ark/attest"; -import { getConfig } from "./getConfig"; +import { getTableConfig } from "./getTableConfig"; import { registerTable } from "./registerTable"; describe("getConfig", () => { @@ -36,7 +36,9 @@ describe("getConfig", () => { registerTable({ stash: stash, table: rootTable }); registerTable({ stash: stash, table: namespacedTable }); - attest(getConfig({ stash: stash, table: { label: "test" } })).equals(rootTable); - attest(getConfig({ stash: stash, table: { label: "test", namespaceLabel: "namespace" } })).equals(namespacedTable); + attest(getTableConfig({ stash: stash, table: { label: "test" } })).equals(rootTable); + attest(getTableConfig({ stash: stash, table: { label: "test", namespaceLabel: "namespace" } })).equals( + namespacedTable, + ); }); }); diff --git a/packages/stash/src/actions/getConfig.ts b/packages/stash/src/actions/getTableConfig.ts similarity index 55% rename from packages/stash/src/actions/getConfig.ts rename to packages/stash/src/actions/getTableConfig.ts index 11ce416266..9a01ac8e4b 100644 --- a/packages/stash/src/actions/getConfig.ts +++ b/packages/stash/src/actions/getTableConfig.ts @@ -1,14 +1,14 @@ import { Table } from "@latticexyz/config"; import { Stash } from "../common"; -export type GetConfigArgs = { +export type GetTableConfigArgs = { stash: Stash; table: { label: string; namespaceLabel?: string }; }; -export type GetConfigResult
= table; +export type GetTableConfigResult
= table; -export function getConfig({ stash, table }: GetConfigArgs): GetConfigResult
{ +export function getTableConfig({ stash, table }: GetTableConfigArgs): GetTableConfigResult
{ const { namespaceLabel, label } = table; return stash.get().config[namespaceLabel ?? ""][label]; } diff --git a/packages/stash/src/actions/getTables.test.ts b/packages/stash/src/actions/getTables.test.ts index e04f174992..f3a6ea75df 100644 --- a/packages/stash/src/actions/getTables.test.ts +++ b/packages/stash/src/actions/getTables.test.ts @@ -39,7 +39,7 @@ describe("getTables", () => { decodeKey: "Function(decodeKey)", deleteRecord: "Function(deleteRecord)", encodeKey: "Function(encodeKey)", - getConfig: "Function(getConfig)", + getTableConfig: "Function(getTableConfig)", getKeys: "Function(getKeys)", getRecord: "Function(getRecord)", getRecords: "Function(getRecords)", @@ -53,7 +53,7 @@ describe("getTables", () => { decodeKey: "Function(decodeKey1)", deleteRecord: "Function(deleteRecord1)", encodeKey: "Function(encodeKey1)", - getConfig: "Function(getConfig1)", + getTableConfig: "Function(getTableConfig1)", getKeys: "Function(getKeys1)", getRecord: "Function(getRecord1)", getRecords: "Function(getRecords1)", diff --git a/packages/stash/src/actions/getTables.ts b/packages/stash/src/actions/getTables.ts index f9f42b94f7..f226264bf3 100644 --- a/packages/stash/src/actions/getTables.ts +++ b/packages/stash/src/actions/getTables.ts @@ -1,15 +1,15 @@ -import { Stash, StoreConfig, getNamespaces, getTableConfig, getNamespaceTables } from "../common"; +import { Stash, StoreConfig, getNamespaces, getConfig, getNamespaceTables } from "../common"; import { BoundTable, getTable } from "./getTable"; type MutableBoundTables = { -readonly [namespace in getNamespaces]: { - -readonly [table in getNamespaceTables]: BoundTable>; + -readonly [table in getNamespaceTables]: BoundTable>; }; }; export type BoundTables = { [namespace in getNamespaces]: { - [table in getNamespaceTables]: BoundTable>; + [table in getNamespaceTables]: BoundTable>; }; }; diff --git a/packages/stash/src/actions/index.ts b/packages/stash/src/actions/index.ts index eb44c0514c..ebf2d6ed2a 100644 --- a/packages/stash/src/actions/index.ts +++ b/packages/stash/src/actions/index.ts @@ -2,7 +2,7 @@ export * from "./decodeKey"; export * from "./deleteRecord"; export * from "./encodeKey"; export * from "./extend"; -export * from "./getConfig"; +export * from "./getTableConfig"; export * from "./getKeys"; export * from "./getRecord"; export * from "./getRecords"; diff --git a/packages/stash/src/actions/runQuery.ts b/packages/stash/src/actions/runQuery.ts index 8948455ea1..4ea11068fb 100644 --- a/packages/stash/src/actions/runQuery.ts +++ b/packages/stash/src/actions/runQuery.ts @@ -8,7 +8,7 @@ import { CommonQueryResult, getQueryConfig, } from "../common"; -import { getConfig } from "./getConfig"; +import { getTableConfig } from "./getTableConfig"; import { getRecords } from "./getRecords"; export type RunQueryOptions = CommonQueryOptions & { @@ -41,11 +41,11 @@ export function runQuery({ }: RunQueryArgs): RunQueryResult { // Only allow fragments with matching table keys for now // TODO: we might be able to enable this if we add something like a `keySelector` - const expectedKeySchema = getKeySchema(getConfig({ stash, table: query[0].table })); + const expectedKeySchema = getKeySchema(getTableConfig({ stash, table: query[0].table })); for (const fragment of query) { if ( Object.values(expectedKeySchema).join("|") !== - Object.values(getKeySchema(getConfig({ stash, table: fragment.table }))).join("|") + Object.values(getKeySchema(getTableConfig({ stash, table: fragment.table }))).join("|") ) { throw new Error( "All tables in a query must share the same key schema. Found mismatch when comparing tables: " + diff --git a/packages/stash/src/actions/subscribeQuery.ts b/packages/stash/src/actions/subscribeQuery.ts index 2edb4bbf86..d20426e095 100644 --- a/packages/stash/src/actions/subscribeQuery.ts +++ b/packages/stash/src/actions/subscribeQuery.ts @@ -10,7 +10,7 @@ import { StoreConfig, getNamespaces, getNamespaceTables, - getTableConfig, + getConfig, getQueryConfig, } from "../common"; import { decodeKey } from "./decodeKey"; @@ -26,7 +26,7 @@ export type SubscribeQueryOptions = Co type QueryTableUpdates = { [namespace in getNamespaces]: { - [table in getNamespaceTables]: TableUpdates>; + [table in getNamespaceTables]: TableUpdates>; }; }; diff --git a/packages/stash/src/common.ts b/packages/stash/src/common.ts index 21de0dee56..fb28474766 100644 --- a/packages/stash/src/common.ts +++ b/packages/stash/src/common.ts @@ -19,7 +19,7 @@ export type getNamespaceTables< namespace extends keyof config["namespaces"], > = keyof config["namespaces"][namespace]["tables"]; -export type getTableConfig< +export type getConfig< config extends StoreConfig, namespace extends keyof config["namespaces"] | undefined, table extends keyof config["namespaces"][namespace extends undefined ? "" : namespace]["tables"], @@ -81,14 +81,14 @@ export type MutableTableRecords
= { [key: string]: export type StoreRecords = { readonly [namespace in getNamespaces]: { - readonly [table in getNamespaceTables]: TableRecords>; + readonly [table in getNamespaceTables]: TableRecords>; }; }; export type MutableStoreRecords = { -readonly [namespace in getNamespaces]: { -readonly [table in getNamespaceTables]: MutableTableRecords< - getTableConfig + getConfig >; }; }; @@ -96,7 +96,7 @@ export type MutableStoreRecords = { export type State = { readonly config: { readonly [namespace in getNamespaces]: { - readonly [table in getNamespaceTables]: getTableConfig; + readonly [table in getNamespaceTables]: getConfig; }; }; readonly records: StoreRecords; @@ -105,7 +105,7 @@ export type State = { export type MutableState = { config: { -readonly [namespace in getNamespaces]: { - -readonly [table in getNamespaceTables]: getTableConfig; + -readonly [table in getNamespaceTables]: getConfig; }; }; records: MutableStoreRecords; @@ -136,7 +136,7 @@ export type StoreUpdates = { }; records: { [namespace in getNamespaces]: { - [table in getNamespaceTables]: TableUpdates>; + [table in getNamespaceTables]: TableUpdates>; }; } & { [namespace: string]: { diff --git a/packages/stash/src/decorators/defaultActions.test.ts b/packages/stash/src/decorators/defaultActions.test.ts index ada2eef48c..1dee81f42c 100644 --- a/packages/stash/src/decorators/defaultActions.test.ts +++ b/packages/stash/src/decorators/defaultActions.test.ts @@ -125,7 +125,7 @@ describe("stash with default actions", () => { const stash = createStash(); stash.registerTable({ table }); - attest(stash.getConfig({ table: { label: "test", namespaceLabel: "namespace" } })).equals(table); + attest(stash.getTableConfig({ table: { label: "test", namespaceLabel: "namespace" } })).equals(table); }); }); @@ -299,7 +299,7 @@ describe("stash with default actions", () => { decodeKey: "Function(decodeKey)", deleteRecord: "Function(deleteRecord)", encodeKey: "Function(encodeKey)", - getConfig: "Function(getConfig)", + getTableConfig: "Function(getTableConfig)", getKeys: "Function(getKeys)", getRecord: "Function(getRecord)", getRecords: "Function(getRecords)", @@ -313,7 +313,7 @@ describe("stash with default actions", () => { decodeKey: "Function(decodeKey1)", deleteRecord: "Function(deleteRecord1)", encodeKey: "Function(encodeKey1)", - getConfig: "Function(getConfig1)", + getTableConfig: "Function(getTableConfig1)", getKeys: "Function(getKeys1)", getRecord: "Function(getRecord1)", getRecords: "Function(getRecords1)", diff --git a/packages/stash/src/decorators/defaultActions.ts b/packages/stash/src/decorators/defaultActions.ts index e7fa7b2245..70ae53f9a5 100644 --- a/packages/stash/src/decorators/defaultActions.ts +++ b/packages/stash/src/decorators/defaultActions.ts @@ -2,7 +2,7 @@ import { Query, Stash, StoreConfig } from "../common"; import { DecodeKeyArgs, DecodeKeyResult, decodeKey } from "../actions/decodeKey"; import { DeleteRecordArgs, DeleteRecordResult, deleteRecord } from "../actions/deleteRecord"; import { EncodeKeyArgs, EncodeKeyResult, encodeKey } from "../actions/encodeKey"; -import { GetConfigArgs, GetConfigResult, getConfig } from "../actions/getConfig"; +import { GetTableConfigArgs, GetTableConfigResult, getTableConfig } from "../actions/getTableConfig"; import { GetKeysArgs, GetKeysResult, getKeys } from "../actions/getKeys"; import { GetRecordArgs, GetRecordResult, getRecord } from "../actions/getRecord"; import { GetRecordsArgs, GetRecordsResult, getRecords } from "../actions/getRecords"; @@ -20,7 +20,7 @@ import { Table } from "@latticexyz/config"; export type StashBoundDecodeKeyArgs
= Omit, "stash">; export type StashBoundDeleteRecordArgs
= Omit, "stash">; export type StashBoundEncodeKeyArgs
= EncodeKeyArgs
; -export type StashBoundGetConfigArgs = Omit; +export type StashBoundGetTableConfigArgs = Omit; export type StashBoundGetKeysArgs
= Omit, "stash">; export type StashBoundGetRecordArgs
= Omit, "stash">; export type StashBoundGetRecordsArgs
= Omit, "stash">; @@ -43,7 +43,7 @@ export type DefaultActions = { decodeKey:
(args: StashBoundDecodeKeyArgs
) => DecodeKeyResult
; deleteRecord:
(args: StashBoundDeleteRecordArgs
) => DeleteRecordResult; encodeKey:
(args: StashBoundEncodeKeyArgs
) => EncodeKeyResult; - getConfig: (args: StashBoundGetConfigArgs) => GetConfigResult; + getTableConfig: (args: StashBoundGetTableConfigArgs) => GetTableConfigResult; getKeys:
(args: StashBoundGetKeysArgs
) => GetKeysResult
; getRecord:
(args: StashBoundGetRecordArgs
) => GetRecordResult
; getRecords:
(args: StashBoundGetRecordsArgs
) => GetRecordsResult
; @@ -65,7 +65,7 @@ export function defaultActions(stash: Stash) decodeKey:
(args: StashBoundDecodeKeyArgs
) => decodeKey({ stash, ...args }), deleteRecord:
(args: StashBoundDeleteRecordArgs
) => deleteRecord({ stash, ...args }), encodeKey:
(args: StashBoundEncodeKeyArgs
) => encodeKey(args), - getConfig: (args: StashBoundGetConfigArgs) => getConfig({ stash, ...args }), + getTableConfig: (args: StashBoundGetTableConfigArgs) => getTableConfig({ stash, ...args }), getKeys:
(args: StashBoundGetKeysArgs
) => getKeys({ stash, ...args }), getRecord:
(args: StashBoundGetRecordArgs
) => getRecord({ stash, ...args }), getRecords:
(args: StashBoundGetRecordsArgs
) => getRecords({ stash, ...args }), From dc8ad9058c6ee3f5a98b8ca867e5620642bcd71f Mon Sep 17 00:00:00 2001 From: alvrs Date: Fri, 30 Aug 2024 14:02:53 +0100 Subject: [PATCH 11/18] Add simple readme --- packages/stash/README.md | 78 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 77 insertions(+), 1 deletion(-) diff --git a/packages/stash/README.md b/packages/stash/README.md index 809d0e6255..8709392355 100644 --- a/packages/stash/README.md +++ b/packages/stash/README.md @@ -1,3 +1,79 @@ # Stash -High performance client store and query engine for MUD +Stash is a client state library optimized for the MUD data model. +It uses the MUD store config to define local tables, which support writing, reading and subscribing to table updates. +It comes with a query engine optimized for "ECS-style" queries (similar to `@latticexyz/recs`) but with native support for composite keys. + +## Getting started + +### Installation + +```bash +pnpm add @latticexyz/stash @latticexyz/store +``` + +### Example usage + +```ts +import { createStash } from "@latticexyz/stash"; +import { defineStore } from "@latticexyz/store"; + +// Define the store config +const config = defineStore( + tables: { + Position: { + schema: { + player: "address", + x: "int32", + y: "int32", + }, + key: ["player"], + }, + }, +); + +// Initialize stash +const stash = createStash(config); + +// Write to a table +const Position = config.tables; +const alice = "0xc0F21fa55169feF83aC5f059ad2432a16F06dD44"; +stash.setRecord({ + table: Position, + key: { + player: alice + }, + value: { + x: 1, + y: 2 + } +}); + +// Read from the table +const alicePosition = stash.getRecord({ table: Position, key: { player: alice }}); +// ^? { player: "0xc0F21fa55169feF83aC5f059ad2432a16F06dD44", x: 1, y: 2 } + +// Subscribe to table updates +stash.subscribeTable({ + table: Position, + subscriber: (update) => { + console.log("Position update", update); + } +}); + +// Query the table +const players = stash.runQuery({ + query: [Matches(Position, { x: 1 })], + options: { + includeRecords: true + } +}) + +// Subscribe to query updates +const query = stash.subscribeQuery({ + query: [Matches(Position, { x: 1 })] +}) +query.subscribe((update) => { + console.log("Query update", update); +}); +``` From c7bf34febd2ee0b536635fca17e3525fccfc78c4 Mon Sep 17 00:00:00 2001 From: alvarius Date: Fri, 30 Aug 2024 14:05:41 +0100 Subject: [PATCH 12/18] Create chatty-pigs-shake.md --- .changeset/chatty-pigs-shake.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .changeset/chatty-pigs-shake.md diff --git a/.changeset/chatty-pigs-shake.md b/.changeset/chatty-pigs-shake.md new file mode 100644 index 0000000000..62a3fa6ea9 --- /dev/null +++ b/.changeset/chatty-pigs-shake.md @@ -0,0 +1,9 @@ +--- +"@latticexyz/stash": patch +--- + +Adds `@latticexyz/stash`, a TypeScript client state library optimized for the MUD Store data model. +It uses the MUD store config to define local tables, which support writing, reading and subscribing to table updates. +It comes with a query engine optimized for "ECS-style" queries (similar to `@latticexyz/recs`) but with native support for composite keys. + +You can find usage examples in the [`@latticexyz/stash` README.md](https://github.com/latticexyz/mud/blob/main/packages/stash/README.md). From 82d4db2fe63a6c6a65355b7a36fa05247ae7c6b0 Mon Sep 17 00:00:00 2001 From: alvrs Date: Fri, 30 Aug 2024 14:10:30 +0100 Subject: [PATCH 13/18] replace rimraf with shx --- packages/stash/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/stash/package.json b/packages/stash/package.json index 4801a07818..ca08d0ba85 100644 --- a/packages/stash/package.json +++ b/packages/stash/package.json @@ -30,7 +30,7 @@ "scripts": { "bench": "tsx src/bench.ts", "build": "tsup", - "clean": "rimraf dist", + "clean": "shx rm -rf dist", "dev": "tsup --watch", "test": "vitest typecheck --run --passWithNoTests && vitest --run --passWithNoTests", "test:ci": "pnpm run test" From 698d34576d9427c758045c5280bf3717aebbbdd0 Mon Sep 17 00:00:00 2001 From: alvrs Date: Fri, 30 Aug 2024 14:12:51 +0100 Subject: [PATCH 14/18] add link to ECS explainer --- packages/stash/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/stash/README.md b/packages/stash/README.md index 8709392355..ade90560b0 100644 --- a/packages/stash/README.md +++ b/packages/stash/README.md @@ -2,7 +2,7 @@ Stash is a client state library optimized for the MUD data model. It uses the MUD store config to define local tables, which support writing, reading and subscribing to table updates. -It comes with a query engine optimized for "ECS-style" queries (similar to `@latticexyz/recs`) but with native support for composite keys. +It comes with a query engine optimized for ["ECS-style"](https://mud.dev/ecs) queries (similar to `@latticexyz/recs`) but with native support for composite keys. ## Getting started From 49888462982c826de2f6e0cdb0e5acc25e545a0e Mon Sep 17 00:00:00 2001 From: alvrs Date: Fri, 30 Aug 2024 14:14:14 +0100 Subject: [PATCH 15/18] properly deconstruct tables --- packages/stash/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/stash/README.md b/packages/stash/README.md index ade90560b0..843c74caf1 100644 --- a/packages/stash/README.md +++ b/packages/stash/README.md @@ -36,7 +36,7 @@ const config = defineStore( const stash = createStash(config); // Write to a table -const Position = config.tables; +const { Position } = config.tables; const alice = "0xc0F21fa55169feF83aC5f059ad2432a16F06dD44"; stash.setRecord({ table: Position, From 47c2c0c75baf043a6f5538cece00d3d5cdd279f0 Mon Sep 17 00:00:00 2001 From: alvarius Date: Fri, 30 Aug 2024 14:15:00 +0100 Subject: [PATCH 16/18] Update packages/stash/package.json Co-authored-by: Kevin Ingersoll --- packages/stash/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/stash/package.json b/packages/stash/package.json index ca08d0ba85..73bf26e698 100644 --- a/packages/stash/package.json +++ b/packages/stash/package.json @@ -1,4 +1,5 @@ { + "private": true, "name": "@latticexyz/stash", "version": "2.1.0", "description": "High performance client store and query engine for MUD", From 581ef8c82f92d3b1e926ecafcb7f63b62e05c9fb Mon Sep 17 00:00:00 2001 From: alvrs Date: Fri, 30 Aug 2024 14:19:32 +0100 Subject: [PATCH 17/18] sort package json --- packages/stash/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/stash/package.json b/packages/stash/package.json index 73bf26e698..fac7a4046a 100644 --- a/packages/stash/package.json +++ b/packages/stash/package.json @@ -1,7 +1,7 @@ { - "private": true, "name": "@latticexyz/stash", "version": "2.1.0", + "private": true, "description": "High performance client store and query engine for MUD", "repository": { "type": "git", From 3a5e0594e56fb1736e85cea5108c061860e98ce1 Mon Sep 17 00:00:00 2001 From: alvrs Date: Fri, 30 Aug 2024 14:20:57 +0100 Subject: [PATCH 18/18] rename arg in setRecord to value --- packages/stash/src/actions/decodeKey.test.ts | 2 +- .../stash/src/actions/deleteRecord.test.ts | 4 +-- packages/stash/src/actions/getKeys.test.ts | 4 +-- packages/stash/src/actions/getRecord.test.ts | 4 +-- packages/stash/src/actions/getRecords.test.ts | 4 +-- packages/stash/src/actions/getTable.test.ts | 22 ++++++++-------- packages/stash/src/actions/runQuery.test.ts | 6 ++--- packages/stash/src/actions/setRecord.test.ts | 10 +++---- packages/stash/src/actions/setRecord.ts | 6 ++--- .../stash/src/actions/subscribeQuery.test.ts | 12 ++++----- .../stash/src/actions/subscribeStore.test.ts | 6 ++--- .../stash/src/actions/subscribeTable.test.ts | 6 ++--- packages/stash/src/bench.ts | 6 ++--- packages/stash/src/boundTable.test.ts | 6 ++--- packages/stash/src/createStash.test.ts | 18 ++++++------- .../src/decorators/defaultActions.test.ts | 26 +++++++++---------- 16 files changed, 71 insertions(+), 71 deletions(-) diff --git a/packages/stash/src/actions/decodeKey.test.ts b/packages/stash/src/actions/decodeKey.test.ts index f8826ee2f5..d0a55909b7 100644 --- a/packages/stash/src/actions/decodeKey.test.ts +++ b/packages/stash/src/actions/decodeKey.test.ts @@ -20,7 +20,7 @@ describe("decodeKey", () => { const stash = createStash(config); const table = config.namespaces.namespace1.tables.table1; const key = { field2: 1, field3: 2n }; - setRecord({ stash, table, key, record: { field1: "hello" } }); + setRecord({ stash, table, key, value: { field1: "hello" } }); const encodedKey = encodeKey({ table, key }); attest(decodeKey({ stash, table, encodedKey })).equals({ field2: 1, field3: 2n }); diff --git a/packages/stash/src/actions/deleteRecord.test.ts b/packages/stash/src/actions/deleteRecord.test.ts index 921e6ccee2..b90a07a617 100644 --- a/packages/stash/src/actions/deleteRecord.test.ts +++ b/packages/stash/src/actions/deleteRecord.test.ts @@ -29,14 +29,14 @@ describe("deleteRecord", () => { stash, table, key: { field2: 1, field3: 2 }, - record: { field1: "hello" }, + value: { field1: "hello" }, }); setRecord({ stash, table, key: { field2: 3, field3: 1 }, - record: { field1: "world" }, + value: { field1: "world" }, }); deleteRecord({ diff --git a/packages/stash/src/actions/getKeys.test.ts b/packages/stash/src/actions/getKeys.test.ts index 7cc061f0cc..8415295b15 100644 --- a/packages/stash/src/actions/getKeys.test.ts +++ b/packages/stash/src/actions/getKeys.test.ts @@ -23,8 +23,8 @@ describe("getKeys", () => { const table = config.tables.test; const stash = createStash(config); - setRecord({ stash, table, key: { player: 1, match: 2 }, record: { x: 3, y: 4 } }); - setRecord({ stash, table, key: { player: 5, match: 6 }, record: { x: 7, y: 8 } }); + setRecord({ stash, table, key: { player: 1, match: 2 }, value: { x: 3, y: 4 } }); + setRecord({ stash, table, key: { player: 5, match: 6 }, value: { x: 7, y: 8 } }); attest<{ [encodedKey: string]: { player: number; match: number } }>(getKeys({ stash, table })).snap({ "1|2": { player: 1, match: 2 }, diff --git a/packages/stash/src/actions/getRecord.test.ts b/packages/stash/src/actions/getRecord.test.ts index f2871b000d..29a8a482b4 100644 --- a/packages/stash/src/actions/getRecord.test.ts +++ b/packages/stash/src/actions/getRecord.test.ts @@ -29,14 +29,14 @@ describe("getRecord", () => { stash, table, key: { field2: 1, field3: 2 }, - record: { field1: "hello" }, + value: { field1: "hello" }, }); setRecord({ stash, table, key: { field2: 2, field3: 1 }, - record: { field1: "world" }, + value: { field1: "world" }, }); attest( diff --git a/packages/stash/src/actions/getRecords.test.ts b/packages/stash/src/actions/getRecords.test.ts index dd3bc33c32..ff020ec345 100644 --- a/packages/stash/src/actions/getRecords.test.ts +++ b/packages/stash/src/actions/getRecords.test.ts @@ -23,8 +23,8 @@ describe("getRecords", () => { const table = config.tables.test; const stash = createStash(config); - setRecord({ stash, table, key: { player: 1, match: 2 }, record: { x: 3n, y: 4n } }); - setRecord({ stash, table, key: { player: 5, match: 6 }, record: { x: 7n, y: 8n } }); + setRecord({ stash, table, key: { player: 1, match: 2 }, value: { x: 3n, y: 4n } }); + setRecord({ stash, table, key: { player: 5, match: 6 }, value: { x: 7n, y: 8n } }); attest<{ [encodedKey: string]: { player: number; match: number; x: bigint; y: bigint } }>( getRecords({ stash, table }), diff --git a/packages/stash/src/actions/getTable.test.ts b/packages/stash/src/actions/getTable.test.ts index c3fff9c034..8a72daeded 100644 --- a/packages/stash/src/actions/getTable.test.ts +++ b/packages/stash/src/actions/getTable.test.ts @@ -52,7 +52,7 @@ describe("getTable", () => { }); const key = { field2: 1, field3: 2n }; - table.setRecord({ key, record: { field1: "hello" } }); + table.setRecord({ key, value: { field1: "hello" } }); const encodedKey = table.encodeKey({ key }); attest(table.decodeKey({ encodedKey })).equals({ field2: 1, field3: 2n }); @@ -151,8 +151,8 @@ describe("getTable", () => { }), }); - table.setRecord({ key: { player: 1, match: 2 }, record: { x: 3, y: 4 } }); - table.setRecord({ key: { player: 5, match: 6 }, record: { x: 7, y: 8 } }); + table.setRecord({ key: { player: 1, match: 2 }, value: { x: 3, y: 4 } }); + table.setRecord({ key: { player: 5, match: 6 }, value: { x: 7, y: 8 } }); attest<{ [encodedKey: string]: { player: number; match: number } }>(table.getKeys()).snap({ "1|2": { player: 1, match: 2 }, @@ -203,8 +203,8 @@ describe("getTable", () => { const stash = createStash(); const table = stash.getTable({ table: config }); - table.setRecord({ key: { player: 1, match: 2 }, record: { x: 3n, y: 4n } }); - table.setRecord({ key: { player: 5, match: 6 }, record: { x: 7n, y: 8n } }); + table.setRecord({ key: { player: 1, match: 2 }, value: { x: 3n, y: 4n } }); + table.setRecord({ key: { player: 5, match: 6 }, value: { x: 7n, y: 8n } }); attest<{ [encodedKey: string]: { player: number; match: number; x: bigint; y: bigint } }>( table.getRecords(), @@ -239,7 +239,7 @@ describe("getTable", () => { table.setRecord({ // @ts-expect-error Property 'field2' is missing in type '{ field3: number; }' key: { field3: 2 }, - record: { field1: "" }, + value: { field1: "" }, }), ) .throws("Provided key is missing field field2.") @@ -249,7 +249,7 @@ describe("getTable", () => { table.setRecord({ // @ts-expect-error Type 'string' is not assignable to type 'number'. key: { field2: 1, field3: "invalid" }, - record: { field1: "" }, + value: { field1: "" }, }), ).type.errors(`Type 'string' is not assignable to type 'number'.`); @@ -257,7 +257,7 @@ describe("getTable", () => { table.setRecord({ key: { field2: 1, field3: 2 }, // @ts-expect-error Type 'number' is not assignable to type 'string'. - record: { field1: 1 }, + value: { field1: 1 }, }), ).type.errors(`Type 'number' is not assignable to type 'string'.`); }); @@ -314,7 +314,7 @@ describe("getTable", () => { table1.subscribe({ subscriber }); - table1.setRecord({ key: { a: "0x00" }, record: { b: 1n, c: 2 } }); + table1.setRecord({ key: { a: "0x00" }, value: { b: 1n, c: 2 } }); expect(subscriber).toHaveBeenCalledTimes(1); expect(subscriber).toHaveBeenNthCalledWith(1, { @@ -325,10 +325,10 @@ describe("getTable", () => { }); // Expect unrelated updates to not notify subscribers - table2.setRecord({ key: { a: "0x01" }, record: { b: 1n, c: 2 } }); + table2.setRecord({ key: { a: "0x01" }, value: { b: 1n, c: 2 } }); expect(subscriber).toHaveBeenCalledTimes(1); - table1.setRecord({ key: { a: "0x00" }, record: { b: 1n, c: 3 } }); + table1.setRecord({ key: { a: "0x00" }, value: { b: 1n, c: 3 } }); expect(subscriber).toHaveBeenCalledTimes(2); expect(subscriber).toHaveBeenNthCalledWith(2, { diff --git a/packages/stash/src/actions/runQuery.test.ts b/packages/stash/src/actions/runQuery.test.ts index 9f9c143f73..6f72f03006 100644 --- a/packages/stash/src/actions/runQuery.test.ts +++ b/packages/stash/src/actions/runQuery.test.ts @@ -45,12 +45,12 @@ describe("runQuery", () => { const items = ["0xgold", "0xsilver"] as const; const num = 5; for (let i = 0; i < num; i++) { - setRecord({ stash, table: Position, key: { player: `0x${String(i)}` }, record: { x: i, y: num - i } }); + setRecord({ stash, table: Position, key: { player: `0x${String(i)}` }, value: { x: i, y: num - i } }); if (i > 2) { - setRecord({ stash, table: Health, key: { player: `0x${String(i)}` }, record: { health: i } }); + setRecord({ stash, table: Health, key: { player: `0x${String(i)}` }, value: { health: i } }); } for (const item of items) { - setRecord({ stash, table: Inventory, key: { player: `0x${String(i)}`, item }, record: { amount: i } }); + setRecord({ stash, table: Inventory, key: { player: `0x${String(i)}`, item }, value: { amount: i } }); } } }); diff --git a/packages/stash/src/actions/setRecord.test.ts b/packages/stash/src/actions/setRecord.test.ts index d0b020443b..7c9e9fef4a 100644 --- a/packages/stash/src/actions/setRecord.test.ts +++ b/packages/stash/src/actions/setRecord.test.ts @@ -27,14 +27,14 @@ describe("setRecord", () => { stash, table, key: { field2: 1, field3: 2 }, - record: { field1: "hello" }, + value: { field1: "hello" }, }); setRecord({ stash, table, key: { field2: 2, field3: 1 }, - record: { field1: "world" }, + value: { field1: "world" }, }); attest(stash.get().records).snap({ @@ -71,7 +71,7 @@ describe("setRecord", () => { table, // @ts-expect-error Property 'field2' is missing in type '{ field3: number; }' key: { field3: 2 }, - record: { field1: "" }, + value: { field1: "" }, }), ) .throws("Provided key is missing field field2.") @@ -83,7 +83,7 @@ describe("setRecord", () => { table, // @ts-expect-error Type 'string' is not assignable to type 'number'. key: { field2: 1, field3: "invalid" }, - record: { field1: "" }, + value: { field1: "" }, }), ).type.errors(`Type 'string' is not assignable to type 'number'.`); @@ -93,7 +93,7 @@ describe("setRecord", () => { table, key: { field2: 1, field3: 2 }, // @ts-expect-error Type 'number' is not assignable to type 'string'. - record: { field1: 1 }, + value: { field1: 1 }, }), ).type.errors(`Type 'number' is not assignable to type 'string'.`); }); diff --git a/packages/stash/src/actions/setRecord.ts b/packages/stash/src/actions/setRecord.ts index 6cbc45c600..12df6ce23c 100644 --- a/packages/stash/src/actions/setRecord.ts +++ b/packages/stash/src/actions/setRecord.ts @@ -6,18 +6,18 @@ export type SetRecordArgs
= { stash: Stash; table: table; key: Key
; - record: Partial>; + value: Partial>; }; export type SetRecordResult = void; -export function setRecord
({ stash, table, key, record }: SetRecordArgs
): SetRecordResult { +export function setRecord
({ stash, table, key, value }: SetRecordArgs
): SetRecordResult { setRecords({ stash, table, records: [ // Stored record should include key - { ...record, ...key }, + { ...value, ...key }, ], }); } diff --git a/packages/stash/src/actions/subscribeQuery.test.ts b/packages/stash/src/actions/subscribeQuery.test.ts index 67edd9051a..338f2cc175 100644 --- a/packages/stash/src/actions/subscribeQuery.test.ts +++ b/packages/stash/src/actions/subscribeQuery.test.ts @@ -37,12 +37,12 @@ describe("defineQuery", () => { const items = ["0xgold", "0xsilver"] as const; const num = 5; for (let i = 0; i < num; i++) { - setRecord({ stash, table: Position, key: { player: `0x${String(i)}` }, record: { x: i, y: num - i } }); + setRecord({ stash, table: Position, key: { player: `0x${String(i)}` }, value: { x: i, y: num - i } }); if (i > 2) { - setRecord({ stash, table: Health, key: { player: `0x${String(i)}` }, record: { health: i } }); + setRecord({ stash, table: Health, key: { player: `0x${String(i)}` }, value: { health: i } }); } for (const item of items) { - setRecord({ stash, table: Inventory, key: { player: `0x${String(i)}`, item }, record: { amount: i } }); + setRecord({ stash, table: Inventory, key: { player: `0x${String(i)}`, item }, value: { amount: i } }); } } }); @@ -54,7 +54,7 @@ describe("defineQuery", () => { "0x4": { player: "0x4" }, }); - setRecord({ stash, table: Health, key: { player: `0x2` }, record: { health: 2 } }); + setRecord({ stash, table: Health, key: { player: `0x2` }, value: { health: 2 } }); attest(result.keys).snap({ "0x2": { player: "0x2" }, @@ -69,7 +69,7 @@ describe("defineQuery", () => { const result = subscribeQuery({ stash, query: [Matches(Position, { x: 4 }), In(Health)] }); result.subscribe(subscriber); - setRecord({ stash, table: Position, key: { player: "0x4" }, record: { y: 2 } }); + setRecord({ stash, table: Position, key: { player: "0x4" }, value: { y: 2 } }); expect(subscriber).toBeCalledTimes(1); attest(lastUpdate).snap({ @@ -94,7 +94,7 @@ describe("defineQuery", () => { const result = subscribeQuery({ stash, query: [In(Position), In(Health)] }); result.subscribe(subscriber); - setRecord({ stash, table: Health, key: { player: `0x2` }, record: { health: 2 } }); + setRecord({ stash, table: Health, key: { player: `0x2` }, value: { health: 2 } }); expect(subscriber).toBeCalledTimes(1); attest(lastUpdate).snap({ diff --git a/packages/stash/src/actions/subscribeStore.test.ts b/packages/stash/src/actions/subscribeStore.test.ts index 11298f7562..c2aeffdb4e 100644 --- a/packages/stash/src/actions/subscribeStore.test.ts +++ b/packages/stash/src/actions/subscribeStore.test.ts @@ -32,7 +32,7 @@ describe("subscribeStore", () => { subscribeStore({ stash, subscriber }); - setRecord({ stash, table: config.tables.namespace1__table1, key: { a: "0x00" }, record: { b: 1n, c: 2 } }); + setRecord({ stash, table: config.tables.namespace1__table1, key: { a: "0x00" }, value: { b: 1n, c: 2 } }); expect(subscriber).toHaveBeenCalledTimes(1); expect(subscriber).toHaveBeenNthCalledWith(1, { @@ -49,7 +49,7 @@ describe("subscribeStore", () => { }, }); - setRecord({ stash, table: config.tables.namespace2__table2, key: { a: "0x01" }, record: { b: 1n, c: 2 } }); + setRecord({ stash, table: config.tables.namespace2__table2, key: { a: "0x01" }, value: { b: 1n, c: 2 } }); expect(subscriber).toHaveBeenCalledTimes(2); expect(subscriber).toHaveBeenNthCalledWith(2, { @@ -66,7 +66,7 @@ describe("subscribeStore", () => { }, }); - setRecord({ stash, table: config.tables.namespace2__table2, key: { a: "0x01" }, record: { b: 1n, c: 3 } }); + setRecord({ stash, table: config.tables.namespace2__table2, key: { a: "0x01" }, value: { b: 1n, c: 3 } }); expect(subscriber).toHaveBeenCalledTimes(3); expect(subscriber).toHaveBeenNthCalledWith(3, { diff --git a/packages/stash/src/actions/subscribeTable.test.ts b/packages/stash/src/actions/subscribeTable.test.ts index 72840b117a..a1e6ab1ca4 100644 --- a/packages/stash/src/actions/subscribeTable.test.ts +++ b/packages/stash/src/actions/subscribeTable.test.ts @@ -34,7 +34,7 @@ describe("subscribeTable", () => { subscribeTable({ stash, table: table1, subscriber }); - setRecord({ stash, table: table1, key: { a: "0x00" }, record: { b: 1n, c: 2 } }); + setRecord({ stash, table: table1, key: { a: "0x00" }, value: { b: 1n, c: 2 } }); expect(subscriber).toHaveBeenCalledTimes(1); expect(subscriber).toHaveBeenNthCalledWith(1, { @@ -45,10 +45,10 @@ describe("subscribeTable", () => { }); // Expect unrelated updates to not notify subscribers - setRecord({ stash, table: table2, key: { a: "0x01" }, record: { b: 1n, c: 2 } }); + setRecord({ stash, table: table2, key: { a: "0x01" }, value: { b: 1n, c: 2 } }); expect(subscriber).toHaveBeenCalledTimes(1); - setRecord({ stash, table: table1, key: { a: "0x00" }, record: { b: 1n, c: 3 } }); + setRecord({ stash, table: table1, key: { a: "0x00" }, value: { b: 1n, c: 3 } }); expect(subscriber).toHaveBeenCalledTimes(2); expect(subscriber).toHaveBeenNthCalledWith(2, { diff --git a/packages/stash/src/bench.ts b/packages/stash/src/bench.ts index 9238fe0125..704a2f1510 100644 --- a/packages/stash/src/bench.ts +++ b/packages/stash/src/bench.ts @@ -42,14 +42,14 @@ for (let i = 0; i < numItems; i++) { filledStore.setRecord({ table: config.tables.Position, key: { player: `0x${i}` }, - record: { x: i, y: i }, + value: { x: i, y: i }, }); } bench("setRecord", () => { filledStore.setRecord({ table: config.tables.Position, key: { player: `0x0` }, - record: { x: 1, y: 1 }, + value: { x: 1, y: 1 }, }); }).mark({ mean: [1.2, "us"], median: [1, "us"] }); @@ -58,7 +58,7 @@ bench("10x setRecord", () => { filledStore.setRecord({ table: config.tables.Position, key: { player: `0x${i}` }, - record: { x: i + 1, y: i + 1 }, + value: { x: i + 1, y: i + 1 }, }); } }).mark({ mean: [13, "us"], median: [12, "us"] }); diff --git a/packages/stash/src/boundTable.test.ts b/packages/stash/src/boundTable.test.ts index cb05e33c14..422f529641 100644 --- a/packages/stash/src/boundTable.test.ts +++ b/packages/stash/src/boundTable.test.ts @@ -24,7 +24,7 @@ describe("BoundTable", () => { describe("setRecord", () => { it("should set a record in the table", () => { - table.setRecord({ key: { field1: 1 }, record: { field2: "0x00" } }); + table.setRecord({ key: { field1: 1 }, value: { field2: "0x00" } }); attest(stash.get().records).snap({ namespace1: { table1: { "1": { field1: 1, field2: "0x00" } } } }); }); @@ -33,7 +33,7 @@ describe("BoundTable", () => { table.setRecord({ key: { field1: 1 }, // @ts-expect-error Type '"world"' is not assignable to type '`0x${string}`' - record: { field2: "world" }, + value: { field2: "world" }, }), ).type.errors("Type '\"world\"' is not assignable to type '`0x${string}`'"); }); @@ -41,7 +41,7 @@ describe("BoundTable", () => { describe("getRecord", () => { it("should get a record from the table", () => { - table.setRecord({ key: { field1: 2 }, record: { field2: "0x01" } }); + table.setRecord({ key: { field1: 2 }, value: { field2: "0x01" } }); attest(table.getRecord({ key: { field1: 2 } })).snap({ field1: 2, field2: "0x01" }); }); }); diff --git a/packages/stash/src/createStash.test.ts b/packages/stash/src/createStash.test.ts index c89468ee8f..b6c13544ae 100644 --- a/packages/stash/src/createStash.test.ts +++ b/packages/stash/src/createStash.test.ts @@ -57,7 +57,7 @@ describe("createStash", () => { stash.setRecord({ table: config.namespaces.namespace1.tables.table1, key: { field2: 1 }, - record: { field1: "hello" }, + value: { field1: "hello" }, }); attest>(stash); @@ -121,7 +121,7 @@ describe("createStash", () => { stash.setRecord({ table, key: { field2: 1, field3: 2 }, - record: { field1: "hello" }, + value: { field1: "hello" }, }); expect(listener).toHaveBeenNthCalledWith(1, { @@ -134,7 +134,7 @@ describe("createStash", () => { stash.setRecord({ table, key: { field2: 1, field3: 2 }, - record: { field1: "world" }, + value: { field1: "world" }, }); expect(listener).toHaveBeenNthCalledWith(2, { @@ -185,7 +185,7 @@ describe("createStash", () => { stash.setRecord({ table, key: { field2: 1, field3: 2 }, - record: { field1: "hello" }, + value: { field1: "hello" }, }); expect(subscriber).toHaveBeenNthCalledWith(1, { @@ -200,7 +200,7 @@ describe("createStash", () => { stash.setRecord({ table, key: { field2: 1, field3: 2 }, - record: { field1: "world" }, + value: { field1: "world" }, }); expect(subscriber).toBeCalledTimes(1); @@ -233,7 +233,7 @@ describe("createStash", () => { stash.setRecord({ table, key: { field2: 1, field3: 2 }, - record: { field1: "hello" }, + value: { field1: "hello" }, }); expect(subscriber).toHaveBeenNthCalledWith(1, { @@ -253,7 +253,7 @@ describe("createStash", () => { stash.setRecord({ table, key: { field2: 1, field3: 2 }, - record: { field1: "world" }, + value: { field1: "world" }, }); expect(subscriber).toHaveBeenNthCalledWith(2, { @@ -356,7 +356,7 @@ describe("createStash", () => { stash.setRecord({ table, key: { field2: 1, field3: 2 }, - record: { field1: "hello" }, + value: { field1: "hello" }, }); expect(subscriber).toHaveBeenNthCalledWith(1, { @@ -378,7 +378,7 @@ describe("createStash", () => { stash.setRecord({ table, key: { field2: 1, field3: 2 }, - record: { field1: "world" }, + value: { field1: "world" }, }); expect(subscriber).toBeCalledTimes(1); diff --git a/packages/stash/src/decorators/defaultActions.test.ts b/packages/stash/src/decorators/defaultActions.test.ts index 1dee81f42c..5a437855ba 100644 --- a/packages/stash/src/decorators/defaultActions.test.ts +++ b/packages/stash/src/decorators/defaultActions.test.ts @@ -23,7 +23,7 @@ describe("stash with default actions", () => { const stash = createStash(config); const table = config.namespaces.namespace1.tables.table1; const key = { field2: 1, field3: 2n }; - stash.setRecord({ table, key, record: { field1: "hello" } }); + stash.setRecord({ table, key, value: { field1: "hello" } }); const encodedKey = stash.encodeKey({ table, key }); attest(stash.decodeKey({ table, encodedKey })).equals({ field2: 1, field3: 2n }); @@ -147,8 +147,8 @@ describe("stash with default actions", () => { const table = config.tables.test; const stash = createStash(config); - stash.setRecord({ table, key: { player: 1, match: 2 }, record: { x: 3, y: 4 } }); - stash.setRecord({ table, key: { player: 5, match: 6 }, record: { x: 7, y: 8 } }); + stash.setRecord({ table, key: { player: 1, match: 2 }, value: { x: 3, y: 4 } }); + stash.setRecord({ table, key: { player: 5, match: 6 }, value: { x: 7, y: 8 } }); attest<{ [encodedKey: string]: { player: number; match: number } }>(stash.getKeys({ table })).snap({ "1|2": { player: 1, match: 2 }, @@ -180,7 +180,7 @@ describe("stash with default actions", () => { stash.setRecord({ table, key: { field2: 2, field3: 1 }, - record: { field1: "world" }, + value: { field1: "world" }, }); attest<{ field1: string; field2: number; field3: number }>( @@ -246,8 +246,8 @@ describe("stash with default actions", () => { const table = config.tables.test; const stash = createStash(config); - stash.setRecord({ table, key: { player: 1, match: 2 }, record: { x: 3n, y: 4n } }); - stash.setRecord({ table, key: { player: 5, match: 6 }, record: { x: 7n, y: 8n } }); + stash.setRecord({ table, key: { player: 1, match: 2 }, value: { x: 3n, y: 4n } }); + stash.setRecord({ table, key: { player: 5, match: 6 }, value: { x: 7n, y: 8n } }); attest<{ [encodedKey: string]: { player: number; match: number; x: bigint; y: bigint } }>( stash.getRecords({ table }), @@ -395,7 +395,7 @@ describe("stash with default actions", () => { table, // @ts-expect-error Property 'field2' is missing in type '{ field3: number; }' key: { field3: 2 }, - record: { field1: "" }, + value: { field1: "" }, }), ) .throws("Provided key is missing field field2.") @@ -406,7 +406,7 @@ describe("stash with default actions", () => { table, // @ts-expect-error Type 'string' is not assignable to type 'number'. key: { field2: 1, field3: "invalid" }, - record: { field1: "" }, + value: { field1: "" }, }), ).type.errors(`Type 'string' is not assignable to type 'number'.`); @@ -415,7 +415,7 @@ describe("stash with default actions", () => { table, key: { field2: 1, field3: 2 }, // @ts-expect-error Type 'number' is not assignable to type 'string'. - record: { field1: 1 }, + value: { field1: 1 }, }), ).type.errors(`Type 'number' is not assignable to type 'string'.`); }); @@ -480,7 +480,7 @@ describe("stash with default actions", () => { stash.subscribeStore({ subscriber }); - stash.setRecord({ table: config.tables.namespace1__table1, key: { a: "0x00" }, record: { b: 1n, c: 2 } }); + stash.setRecord({ table: config.tables.namespace1__table1, key: { a: "0x00" }, value: { b: 1n, c: 2 } }); expect(subscriber).toHaveBeenCalledTimes(1); expect(subscriber).toHaveBeenNthCalledWith(1, { @@ -529,7 +529,7 @@ describe("stash with default actions", () => { stash.subscribeTable({ table: table1, subscriber }); - stash.setRecord({ table: table1, key: { a: "0x00" }, record: { b: 1n, c: 2 } }); + stash.setRecord({ table: table1, key: { a: "0x00" }, value: { b: 1n, c: 2 } }); expect(subscriber).toHaveBeenCalledTimes(1); expect(subscriber).toHaveBeenNthCalledWith(1, { @@ -540,10 +540,10 @@ describe("stash with default actions", () => { }); // Expect unrelated updates to not notify subscribers - stash.setRecord({ table: table2, key: { a: "0x01" }, record: { b: 1n, c: 2 } }); + stash.setRecord({ table: table2, key: { a: "0x01" }, value: { b: 1n, c: 2 } }); expect(subscriber).toHaveBeenCalledTimes(1); - stash.setRecord({ table: table1, key: { a: "0x00" }, record: { b: 1n, c: 3 } }); + stash.setRecord({ table: table1, key: { a: "0x00" }, value: { b: 1n, c: 3 } }); expect(subscriber).toHaveBeenCalledTimes(2); expect(subscriber).toHaveBeenNthCalledWith(2, {