Skip to content

Commit

Permalink
feat(stash): add stash, the new MUD client state library (#3040)
Browse files Browse the repository at this point in the history
Co-authored-by: Kevin Ingersoll <kingersoll@gmail.com>
  • Loading branch information
alvrs and holic authored Aug 30, 2024
1 parent 0763959 commit 0a20d81
Show file tree
Hide file tree
Showing 55 changed files with 4,287 additions and 82 deletions.
9 changes: 9 additions & 0 deletions .changeset/chatty-pigs-shake.md
Original file line number Diff line number Diff line change
@@ -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).
1 change: 1 addition & 0 deletions packages/stash/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# @latticexyz/stash
79 changes: 79 additions & 0 deletions packages/stash/README.md
Original file line number Diff line number Diff line change
@@ -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);
});
```
60 changes: 60 additions & 0 deletions packages/stash/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
28 changes: 28 additions & 0 deletions packages/stash/src/actions/decodeKey.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof key>(decodeKey({ stash, table, encodedKey })).equals({ field2: 1, field3: 2n });
});
});
22 changes: 22 additions & 0 deletions packages/stash/src/actions/decodeKey.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { Table } from "@latticexyz/config";
import { Key, Stash } from "../common";

export type DecodeKeyArgs<table extends Table = Table> = {
stash: Stash;
table: table;
encodedKey: string;
};

export type DecodeKeyResult<table extends Table = Table> = Key<table>;

export function decodeKey<table extends Table>({
stash,
table,
encodedKey,
}: DecodeKeyArgs<table>): DecodeKeyResult<table> {
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;
}
98 changes: 98 additions & 0 deletions packages/stash/src/actions/deleteRecord.test.ts
Original file line number Diff line number Diff line change
@@ -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'`);
});
});
37 changes: 37 additions & 0 deletions packages/stash/src/actions/deleteRecord.ts
Original file line number Diff line number Diff line change
@@ -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<table extends Table = Table> = {
stash: Stash;
table: table;
key: Key<table>;
};

export type DeleteRecordResult = void;

export function deleteRecord<table extends Table>({ stash, table, key }: DeleteRecordArgs<table>): 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));
}
46 changes: 46 additions & 0 deletions packages/stash/src/actions/encodeKey.test.ts
Original file line number Diff line number Diff line change
@@ -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'.`);
});
});
Loading

0 comments on commit 0a20d81

Please sign in to comment.