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). 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..843c74caf1 --- /dev/null +++ b/packages/stash/README.md @@ -0,0 +1,79 @@ +# Stash + +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"](https://mud.dev/ecs) 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); +}); +``` diff --git a/packages/stash/package.json b/packages/stash/package.json new file mode 100644 index 0000000000..fac7a4046a --- /dev/null +++ b/packages/stash/package.json @@ -0,0 +1,60 @@ +{ + "name": "@latticexyz/stash", + "version": "2.1.0", + "private": true, + "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": "shx rm -rf 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/actions/decodeKey.test.ts b/packages/stash/src/actions/decodeKey.test.ts new file mode 100644 index 0000000000..d0a55909b7 --- /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, 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/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..b90a07a617 --- /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 }, + value: { field1: "hello" }, + }); + + setRecord({ + stash, + table, + key: { field2: 3, field3: 1 }, + value: { 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..6fa6aa0cb2 --- /dev/null +++ b/packages/stash/src/actions/extend.test.ts @@ -0,0 +1,22 @@ +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"]); + }); + + 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 new file mode 100644 index 0000000000..eb717779d5 --- /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 = Omit & actions; + +export function extend({ + stash, + actions, +}: ExtendArgs): ExtendResult { + return { ...stash, ...actions }; +} diff --git a/packages/stash/src/actions/getKeys.test.ts b/packages/stash/src/actions/getKeys.test.ts new file mode 100644 index 0000000000..8415295b15 --- /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 }, 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 }, + "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..29a8a482b4 --- /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 }, + value: { field1: "hello" }, + }); + + setRecord({ + stash, + table, + key: { field2: 2, field3: 1 }, + value: { 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..ff020ec345 --- /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 }, 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 }), + ).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..8a72daeded --- /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", + namespaceLabel: "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, value: { 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 }, 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 }, + "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 }, 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(), + ).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 }, + value: { 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" }, + value: { 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'. + value: { 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" }, value: { 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" }, value: { b: 1n, c: 2 } }); + expect(subscriber).toHaveBeenCalledTimes(1); + + table1.setRecord({ key: { a: "0x00" }, value: { 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..9188756bd3 --- /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 { GetTableConfigResult, getTableConfig } from "./getTableConfig"; +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; + getTableConfig: () => GetTableConfigResult
; + 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 }), + getTableConfig: () => getTableConfig({ 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/getTableConfig.test.ts b/packages/stash/src/actions/getTableConfig.test.ts new file mode 100644 index 0000000000..5de7e83bcf --- /dev/null +++ b/packages/stash/src/actions/getTableConfig.test.ts @@ -0,0 +1,44 @@ +import { defineTable } from "@latticexyz/store/config/v2"; +import { describe, it } from "vitest"; +import { createStash } from "../createStash"; +import { attest } from "@ark/attest"; +import { getTableConfig } from "./getTableConfig"; +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({ + namespaceLabel: "namespace", + label: "test", + schema: { field1: "address", field2: "string" }, + key: ["field1"], + }); + + const stash = createStash(); + registerTable({ stash: stash, table: rootTable }); + registerTable({ stash: stash, table: 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/getTableConfig.ts b/packages/stash/src/actions/getTableConfig.ts new file mode 100644 index 0000000000..9a01ac8e4b --- /dev/null +++ b/packages/stash/src/actions/getTableConfig.ts @@ -0,0 +1,14 @@ +import { Table } from "@latticexyz/config"; +import { Stash } from "../common"; + +export type GetTableConfigArgs = { + stash: Stash; + table: { label: string; namespaceLabel?: string }; +}; + +export type GetTableConfigResult
= table; + +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 new file mode 100644 index 0000000000..f3a6ea75df --- /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)", + getTableConfig: "Function(getTableConfig)", + 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)", + getTableConfig: "Function(getTableConfig1)", + 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..f226264bf3 --- /dev/null +++ b/packages/stash/src/actions/getTables.ts @@ -0,0 +1,32 @@ +import { Stash, StoreConfig, getNamespaces, getConfig, 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..ebf2d6ed2a --- /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 "./getTableConfig"; +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..fcdb8daed8 --- /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", + namespaceLabel: "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..6f72f03006 --- /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, Matches, Not } 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)}` }, value: { x: i, y: num - i } }); + if (i > 2) { + 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 }, value: { 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: [Matches(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), Not(In(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: [Not(Matches(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), 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", () => { + 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..4ea11068fb --- /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 { getTableConfig } from "./getTableConfig"; +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(getTableConfig({ stash, table: query[0].table })); + for (const fragment of query) { + if ( + Object.values(expectedKeySchema).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: " + + 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.pass(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..7c9e9fef4a --- /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 }, + value: { field1: "hello" }, + }); + + setRecord({ + stash, + table, + key: { field2: 2, field3: 1 }, + value: { 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 }, + value: { 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" }, + value: { 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'. + 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 new file mode 100644 index 0000000000..12df6ce23c --- /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
; + value: Partial>; +}; + +export type SetRecordResult = void; + +export function setRecord
({ stash, table, key, value }: SetRecordArgs
): SetRecordResult { + setRecords({ + stash, + table, + records: [ + // Stored record should include key + { ...value, ...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..338f2cc175 --- /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, Matches } 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)}` }, value: { x: i, y: num - i } }); + if (i > 2) { + 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 }, value: { 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` }, value: { 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: [Matches(Position, { x: 4 }), In(Health)] }); + result.subscribe(subscriber); + + setRecord({ stash, table: Position, key: { player: "0x4" }, value: { 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` }, value: { 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..d20426e095 --- /dev/null +++ b/packages/stash/src/actions/subscribeQuery.ts @@ -0,0 +1,156 @@ +import { Table } from "@latticexyz/config"; +import { + TableUpdates, + Keys, + Unsubscribe, + Query, + Stash, + CommonQueryOptions, + CommonQueryResult, + StoreConfig, + getNamespaces, + getNamespaceTables, + getConfig, + 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[]; +}; + +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.pass(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.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 }); + 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..c2aeffdb4e --- /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" }, value: { 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" }, value: { 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" }, value: { 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..a1e6ab1ca4 --- /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" }, value: { 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" }, value: { b: 1n, c: 2 } }); + expect(subscriber).toHaveBeenCalledTimes(1); + + setRecord({ stash, table: table1, key: { a: "0x00" }, value: { 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..50b63d6fbf --- /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/defaultActions"; +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..704a2f1510 --- /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}` }, + value: { x: i, y: i }, + }); +} +bench("setRecord", () => { + filledStore.setRecord({ + table: config.tables.Position, + key: { player: `0x0` }, + value: { 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}` }, + 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 new file mode 100644 index 0000000000..422f529641 --- /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/defaultActions"; +import { defineTable } from "@latticexyz/store/config/v2"; + +describe("BoundTable", () => { + const tableConfig = defineTable({ + label: "table1", + namespaceLabel: "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 }, value: { 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}`' + value: { 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 }, value: { 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..fb28474766 --- /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 getConfig< + 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< + getConfig + >; + }; +}; + +export type State = { + readonly config: { + readonly [namespace in getNamespaces]: { + readonly [table in getNamespaceTables]: getConfig; + }; + }; + readonly records: StoreRecords; +}; + +export type MutableState = { + config: { + -readonly [namespace in getNamespaces]: { + -readonly [table in getNamespaceTables]: getConfig; + }; + }; + 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 + */ + readonly get: () => State; + /** + * Internal references for interacting with the state. + * @internal + * @deprecated Do not use this internal reference externally. + */ + readonly _: { + readonly tableSubscribers: TableSubscribers; + readonly storeSubscribers: StoreSubscribers; + readonly state: MutableState; + }; +}; diff --git a/packages/stash/src/createStash.test.ts b/packages/stash/src/createStash.test.ts new file mode 100644 index 0000000000..b6c13544ae --- /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 }, + value: { 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 }, + value: { 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 }, + value: { 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 }, + value: { 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 }, + value: { 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 }, + value: { 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 }, + value: { 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({ + namespaceLabel: "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 }, + value: { 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 }, + value: { 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", + namespaceLabel: "namespace1", + schema: { field1: "uint32", field2: "address" }, + key: ["field1"], + }), + }); + stash.registerTable({ + table: defineTable({ + label: "table2", + namespaceLabel: "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..722c85a2ed --- /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/defaultActions"; +import { extend } from "./actions/extend"; +import { Table } from "@latticexyz/store/config/v2"; + +export type Config = StoreConfig; + +export type CreateStoreResult = Stash & DefaultActions; + +/** + * Initializes a Stash based on the provided store config. + */ +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/defaultActions.test.ts b/packages/stash/src/decorators/defaultActions.test.ts new file mode 100644 index 0000000000..5a437855ba --- /dev/null +++ b/packages/stash/src/decorators/defaultActions.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, value: { 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({ + namespaceLabel: "namespace", + label: "test", + schema: { field1: "address", field2: "string" }, + key: ["field1"], + }); + + const stash = createStash(); + stash.registerTable({ table }); + + attest(stash.getTableConfig({ 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 }, 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 }, + "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 }, + value: { 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 }, 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 }), + ).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)", + getTableConfig: "Function(getTableConfig)", + 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)", + getTableConfig: "Function(getTableConfig1)", + 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 }, + value: { 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" }, + value: { 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'. + value: { 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" }, value: { 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" }, value: { 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" }, value: { b: 1n, c: 2 } }); + expect(subscriber).toHaveBeenCalledTimes(1); + + stash.setRecord({ table: table1, key: { a: "0x00" }, value: { 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/defaultActions.ts b/packages/stash/src/decorators/defaultActions.ts new file mode 100644 index 0000000000..70ae53f9a5 --- /dev/null +++ b/packages/stash/src/decorators/defaultActions.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 { 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"; +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 StashBoundGetTableConfigArgs = 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; + getTableConfig: (args: StashBoundGetTableConfigArgs) => GetTableConfigResult; + 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), + getTableConfig: (args: StashBoundGetTableConfigArgs) => getTableConfig({ 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/queryFragments.ts b/packages/stash/src/queryFragments.ts new file mode 100644 index 0000000000..1133aaa6cc --- /dev/null +++ b/packages/stash/src/queryFragments.ts @@ -0,0 +1,118 @@ +import { Table } from "@latticexyz/config"; +import { Keys, Stash } from "./common"; +import { TableRecord } from "./common"; +import { getRecords } from "./actions/getRecords"; +import { getKeys } from "./actions/getKeys"; + +/** + * 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; + /** + * Checking an individual table row for whether it matches the query fragment + */ + 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 + * 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 pass = (stash: Stash, encodedKey: string) => encodedKey in getRecords({ stash, table }); + const getInitialKeys = (stash: Stash) => getKeys({ stash, table }); + return { table, pass, getInitialKeys }; +} + +/** + * Matches all records that don't exist in the table. + * RECS equivalent: Not(Component) + */ +export function NotIn
(table: table): QueryFragment
{ + const pass = (stash: Stash, encodedKey: string) => !(encodedKey in getRecords({ stash, table })); + const getInitialKeys = () => ({}); + return { table, pass, 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 Matches
( + table: table, + partialRecord: Partial>, +): QueryFragment
{ + 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]) => pass(stash, key))); + return { table, pass, 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 NotMatches
( + table: table, + partialRecord: Partial>, +): QueryFragment
{ + 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]) => 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 }; +} 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..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: @@ -870,6 +852,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': @@ -929,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: @@ -1044,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: @@ -1141,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: @@ -1160,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) @@ -1242,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: @@ -1282,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: @@ -1343,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: @@ -1425,6 +1453,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 +3315,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 +4207,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 +4512,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 +4532,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 +4571,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 +4837,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 +4986,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 +5185,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 +5994,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 +6057,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 +7446,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 +7751,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 +7988,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 +8229,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 +8776,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 +10254,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 +10467,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 +10883,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 @@ -11160,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': {} @@ -11421,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: @@ -13269,6 +13408,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 +13420,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 +14526,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 +14545,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 +14998,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 +15027,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 +15061,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 +15393,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 +15445,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 +15455,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 +15831,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 +15989,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 +16940,8 @@ snapshots: depd@2.0.0: {} + dequal@2.0.3: {} + destr@2.0.3: {} destroy@1.2.0: {} @@ -16770,6 +16980,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 +18704,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) @@ -19069,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 @@ -19103,8 +19319,6 @@ snapshots: json5@2.2.3: {} - jsonc-parser@3.2.0: {} - jsonfile@4.0.0: optionalDependencies: graceful-fs: 4.2.11 @@ -19420,6 +19634,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 +19984,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 +20536,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 +20610,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 +21490,7 @@ snapshots: split2@3.2.2: dependencies: - readable-stream: 3.6.0 + readable-stream: 3.6.2 split2@4.2.0: {} @@ -21687,7 +21890,7 @@ snapshots: through2@4.0.2: dependencies: - readable-stream: 3.6.0 + readable-stream: 3.6.2 through@2.3.8: {} @@ -21954,8 +22157,6 @@ snapshots: typescript@5.4.2: {} - ufo@1.3.2: {} - ufo@1.5.4: {} uglify-js@3.17.4: @@ -22137,13 +22338,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 +22410,43 @@ 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 + 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 + + 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 @@ -22263,7 +22517,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: {}