diff --git a/.changeset/witty-moons-care.md b/.changeset/witty-moons-care.md new file mode 100644 index 00000000..14e91817 --- /dev/null +++ b/.changeset/witty-moons-care.md @@ -0,0 +1,15 @@ +--- +"@dojoengine/sdk": patch +"@dojoengine/core": patch +"@dojoengine/create-burner": patch +"@dojoengine/create-dojo": patch +"@dojoengine/predeployed-connector": patch +"@dojoengine/react": patch +"@dojoengine/state": patch +"@dojoengine/torii-client": patch +"@dojoengine/torii-wasm": patch +"@dojoengine/utils": patch +"@dojoengine/utils-wasm": patch +--- + +Added experimental ToriiQueryBuilder and ClauseBuilder to be closer to how we should query ECS through torii diff --git a/examples/example-vite-experimental-sdk/.gitignore b/examples/example-vite-experimental-sdk/.gitignore new file mode 100644 index 00000000..a547bf36 --- /dev/null +++ b/examples/example-vite-experimental-sdk/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/examples/example-vite-experimental-sdk/dojoConfig.ts b/examples/example-vite-experimental-sdk/dojoConfig.ts new file mode 100644 index 00000000..e45ee85a --- /dev/null +++ b/examples/example-vite-experimental-sdk/dojoConfig.ts @@ -0,0 +1,7 @@ +import { createDojoConfig } from "@dojoengine/core"; + +import manifest from "../../worlds/dojo-starter/manifest_dev.json"; + +export const dojoConfig = createDojoConfig({ + manifest, +}); diff --git a/examples/example-vite-experimental-sdk/index.html b/examples/example-vite-experimental-sdk/index.html new file mode 100644 index 00000000..95ad17d2 --- /dev/null +++ b/examples/example-vite-experimental-sdk/index.html @@ -0,0 +1,16 @@ + + + + + + + + Vite + TS + + + + + diff --git a/examples/example-vite-experimental-sdk/package.json b/examples/example-vite-experimental-sdk/package.json new file mode 100644 index 00000000..a0ca075f --- /dev/null +++ b/examples/example-vite-experimental-sdk/package.json @@ -0,0 +1,23 @@ +{ + "name": "example-vite-experimental-sdk", + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@dojoengine/core": "workspace:*", + "@dojoengine/sdk": "workspace:*", + "highlight.js": "^11.11.1", + "starknet": "catalog:" + }, + "devDependencies": { + "@types/highlight.js": "^10.1.0", + "typescript": "~5.6.3", + "vite": "^6.0.7", + "vite-plugin-top-level-await": "^1.4.4", + "vite-plugin-wasm": "^3.4.1" + } +} diff --git a/examples/example-vite-experimental-sdk/public/vite.svg b/examples/example-vite-experimental-sdk/public/vite.svg new file mode 100644 index 00000000..e7b8dfb1 --- /dev/null +++ b/examples/example-vite-experimental-sdk/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/example-vite-experimental-sdk/src/main.ts b/examples/example-vite-experimental-sdk/src/main.ts new file mode 100644 index 00000000..86b99261 --- /dev/null +++ b/examples/example-vite-experimental-sdk/src/main.ts @@ -0,0 +1,63 @@ +import "./style.css"; +import { init } from "@dojoengine/sdk/experimental"; +import { ModelsMapping } from "./typescript/models.gen"; +import { dojoConfig } from "../dojoConfig.ts"; +import { ThemeManager } from "./theme-manager"; +import { UpdateManager } from "./update-manager"; +import { ClauseBuilder, ToriiQueryBuilder } from "@dojoengine/sdk"; + +async function main() { + const um = new UpdateManager(); + new ThemeManager(); + + const sdk = await init({ + client: { + rpcUrl: dojoConfig.rpcUrl, + toriiUrl: dojoConfig.toriiUrl, + relayUrl: dojoConfig.relayUrl, + worldAddress: dojoConfig.manifest.world.address, + }, + domain: { + name: "WORLD_NAME", + version: "1.0", + chainId: "KATANA", + revision: "1", + }, + }); + const entities = await sdk.getEntities( + new ToriiQueryBuilder() + .withClause(new ClauseBuilder().keys([], [undefined]).build()) + .addOrderBy(ModelsMapping.Moves, "remaining", "Asc") + .build() + ); + console.log("entities", entities); + + const events = await sdk.getEvents( + new ToriiQueryBuilder() + .withClause(new ClauseBuilder().keys([], [undefined]).build()) + .build(), + true + ); + console.log("events", events); + + const [initialEntities, freeSubscription] = await sdk.subscribeEntities( + new ToriiQueryBuilder() + .withClause(new ClauseBuilder().keys([], [undefined]).build()) + .addOrderBy(ModelsMapping.Moves, "remaining", "Asc") + .includeHashedKeys() + .build(), + ({ data, error }: { data: any; error: Error }) => { + if (data) { + console.log(data); + } + if (error) { + console.log(error); + } + } + ); + console.log(initialEntities, freeSubscription); + + um.displayUpdate("fetch", initialEntities); +} + +main().catch(console.error); diff --git a/examples/example-vite-experimental-sdk/src/style.css b/examples/example-vite-experimental-sdk/src/style.css new file mode 100644 index 00000000..4a626d2b --- /dev/null +++ b/examples/example-vite-experimental-sdk/src/style.css @@ -0,0 +1,87 @@ +:root { + font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +a { + font-weight: 500; + color: #646cff; + text-decoration: inherit; +} +a:hover { + color: #535bf2; +} + +h1 { + font-size: 3.2em; + line-height: 1.1; +} + +#updates { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; +} + +.logo { + height: 6em; + padding: 1.5em; + will-change: filter; + transition: filter 300ms; +} +.logo:hover { + filter: drop-shadow(0 0 2em #646cffaa); +} +.logo.vanilla:hover { + filter: drop-shadow(0 0 2em #3178c6aa); +} + +.card { + padding: 2em; +} + +.read-the-docs { + color: #888; +} + +button { + border-radius: 8px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: #1a1a1a; + cursor: pointer; + transition: border-color 0.25s; +} +button:hover { + border-color: #646cff; +} +button:focus, +button:focus-visible { + outline: 4px auto -webkit-focus-ring-color; +} + +@media (prefers-color-scheme: light) { + :root { + color: #213547; + background-color: #ffffff; + } + a:hover { + color: #747bff; + } + button { + background-color: #f9f9f9; + } +} diff --git a/examples/example-vite-experimental-sdk/src/theme-manager.ts b/examples/example-vite-experimental-sdk/src/theme-manager.ts new file mode 100644 index 00000000..59188ac0 --- /dev/null +++ b/examples/example-vite-experimental-sdk/src/theme-manager.ts @@ -0,0 +1,40 @@ +/** + * A simple theme manager for the playground, + * using highlight.js. + */ +export class ThemeManager { + currentTheme: number; + themes: string[]; + constructor() { + this.themes = [ + "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.8.0/styles/atom-one-dark.min.css", + "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.8.0/styles/atom-one-light.min.css", + ]; + this.currentTheme = 0; + this.setupToggleButton(); + } + + /** + * Setups a button to toggle the theme. + * The button is positioned at the top right of the screen. + */ + setupToggleButton() { + const button = document.createElement("button"); + button.id = "themeToggle"; + button.textContent = "Toggle Theme"; + button.style.position = "fixed"; + button.style.top = "10px"; + button.style.right = "10px"; + button.style.padding = "5px"; + + button.onclick = () => this.toggleTheme(); + document.body.appendChild(button); + } + + toggleTheme() { + this.currentTheme = (this.currentTheme + 1) % this.themes.length; + ( + document.querySelector('link[rel="stylesheet"]')! as HTMLLinkElement + ).href = this.themes[this.currentTheme]; + } +} diff --git a/examples/example-vite-experimental-sdk/src/typescript/contracts.gen.ts b/examples/example-vite-experimental-sdk/src/typescript/contracts.gen.ts new file mode 100644 index 00000000..5fc335f9 --- /dev/null +++ b/examples/example-vite-experimental-sdk/src/typescript/contracts.gen.ts @@ -0,0 +1,60 @@ +import { DojoProvider, DojoCall } from "@dojoengine/core"; +import { Account, AccountInterface, CairoCustomEnum } from "starknet"; + +export function setupWorld(provider: DojoProvider) { + const build_actions_move_calldata = ( + direction: CairoCustomEnum + ): DojoCall => { + return { + contractName: "actions", + entrypoint: "move", + calldata: [direction], + }; + }; + + const actions_move = async ( + snAccount: Account | AccountInterface, + direction: CairoCustomEnum + ) => { + try { + return await provider.execute( + snAccount, + build_actions_move_calldata(direction), + "dojo_starter" + ); + } catch (error) { + console.error(error); + throw error; + } + }; + + const build_actions_spawn_calldata = (): DojoCall => { + return { + contractName: "actions", + entrypoint: "spawn", + calldata: [], + }; + }; + + const actions_spawn = async (snAccount: Account | AccountInterface) => { + try { + return await provider.execute( + snAccount, + build_actions_spawn_calldata(), + "dojo_starter" + ); + } catch (error) { + console.error(error); + throw error; + } + }; + + return { + actions: { + move: actions_move, + buildMoveCalldata: build_actions_move_calldata, + spawn: actions_spawn, + buildSpawnCalldata: build_actions_spawn_calldata, + }, + }; +} diff --git a/examples/example-vite-experimental-sdk/src/typescript/models.gen.ts b/examples/example-vite-experimental-sdk/src/typescript/models.gen.ts new file mode 100644 index 00000000..497a5988 --- /dev/null +++ b/examples/example-vite-experimental-sdk/src/typescript/models.gen.ts @@ -0,0 +1,172 @@ +import type { SchemaType as ISchemaType } from "@dojoengine/sdk"; + +import { + CairoCustomEnum, + CairoOption, + CairoOptionVariant, + BigNumberish, +} from "starknet"; + +type WithFieldOrder = T & { fieldOrder: string[] }; + +// Type definition for `dojo_starter::models::DirectionsAvailable` struct +export interface DirectionsAvailable { + player: string; + directions: Array; +} + +// Type definition for `dojo_starter::models::DirectionsAvailableValue` struct +export interface DirectionsAvailableValue { + directions: Array; +} + +// Type definition for `dojo_starter::models::Moves` struct +export interface Moves { + player: string; + remaining: BigNumberish; + last_direction: CairoOption; + can_move: boolean; +} + +// Type definition for `dojo_starter::models::MovesValue` struct +export interface MovesValue { + remaining: BigNumberish; + last_direction: CairoOption; + can_move: boolean; +} + +// Type definition for `dojo_starter::models::Position` struct +export interface Position { + player: string; + vec: Vec2; +} + +// Type definition for `dojo_starter::models::PositionValue` struct +export interface PositionValue { + vec: Vec2; +} + +// Type definition for `dojo_starter::models::Vec2` struct +export interface Vec2 { + x: BigNumberish; + y: BigNumberish; +} + +// Type definition for `dojo_starter::systems::actions::actions::Moved` struct +export interface Moved { + player: string; + direction: DirectionEnum; +} + +// Type definition for `dojo_starter::systems::actions::actions::MovedValue` struct +export interface MovedValue { + direction: DirectionEnum; +} + +// Type definition for `dojo_starter::models::Direction` enum +export type Direction = { + Left: string; + Right: string; + Up: string; + Down: string; +}; +export type DirectionEnum = CairoCustomEnum; + +export interface SchemaType extends ISchemaType { + dojo_starter: { + DirectionsAvailable: WithFieldOrder; + DirectionsAvailableValue: WithFieldOrder; + Moves: WithFieldOrder; + MovesValue: WithFieldOrder; + Position: WithFieldOrder; + PositionValue: WithFieldOrder; + Vec2: WithFieldOrder; + Moved: WithFieldOrder; + MovedValue: WithFieldOrder; + }; +} +export const schema: SchemaType = { + dojo_starter: { + DirectionsAvailable: { + fieldOrder: ["player", "directions"], + player: "", + directions: [ + new CairoCustomEnum({ + Left: "", + Right: undefined, + Up: undefined, + Down: undefined, + }), + ], + }, + DirectionsAvailableValue: { + fieldOrder: ["directions"], + directions: [ + new CairoCustomEnum({ + Left: "", + Right: undefined, + Up: undefined, + Down: undefined, + }), + ], + }, + Moves: { + fieldOrder: ["player", "remaining", "last_direction", "can_move"], + player: "", + remaining: 0, + last_direction: new CairoOption(CairoOptionVariant.None), + can_move: false, + }, + MovesValue: { + fieldOrder: ["remaining", "last_direction", "can_move"], + remaining: 0, + last_direction: new CairoOption(CairoOptionVariant.None), + can_move: false, + }, + Position: { + fieldOrder: ["player", "vec"], + player: "", + vec: { x: 0, y: 0 }, + }, + PositionValue: { + fieldOrder: ["vec"], + vec: { x: 0, y: 0 }, + }, + Vec2: { + fieldOrder: ["x", "y"], + x: 0, + y: 0, + }, + Moved: { + fieldOrder: ["player", "direction"], + player: "", + direction: new CairoCustomEnum({ + Left: "", + Right: undefined, + Up: undefined, + Down: undefined, + }), + }, + MovedValue: { + fieldOrder: ["direction"], + direction: new CairoCustomEnum({ + Left: "", + Right: undefined, + Up: undefined, + Down: undefined, + }), + }, + }, +}; +export enum ModelsMapping { + Direction = "dojo_starter-Direction", + DirectionsAvailable = "dojo_starter-DirectionsAvailable", + DirectionsAvailableValue = "dojo_starter-DirectionsAvailableValue", + Moves = "dojo_starter-Moves", + MovesValue = "dojo_starter-MovesValue", + Position = "dojo_starter-Position", + PositionValue = "dojo_starter-PositionValue", + Vec2 = "dojo_starter-Vec2", + Moved = "dojo_starter-Moved", + MovedValue = "dojo_starter-MovedValue", +} diff --git a/examples/example-vite-experimental-sdk/src/update-manager.ts b/examples/example-vite-experimental-sdk/src/update-manager.ts new file mode 100644 index 00000000..55011d07 --- /dev/null +++ b/examples/example-vite-experimental-sdk/src/update-manager.ts @@ -0,0 +1,86 @@ +import hljs from "highlight.js"; + +/** + * Manages the display and interaction of updates in the playground. + */ +export class UpdateManager { + container: HTMLDivElement; + constructor() { + // Create the main container for updates + this.container = document.createElement("div"); + this.container.id = "updates"; + this.container.style.cssText = ` + height: 80vh; + overflow-y: auto; + margin-top: 5vh; + padding: 10px; + `; + document.body.appendChild(this.container); + } + + /** + * Displays an update in the updates div. + * + * The `updates` div must be defined in the HTML file. + * + * @param {Object} update - The update to display as a JSON object. + */ + displayUpdate(title: string, update: any) { + const updatesDiv = document.getElementById("updates")!; + const updateContainer = document.createElement("div"); + updateContainer.style.position = "relative"; + + const titleElement = document.createElement("div"); + titleElement.textContent = title; + titleElement.style.cssText = ` + padding: 8px 12px; + border-top: 1px solid #ddd; + color: #666; + font-family: monospace; + font-size: 0.9em; + `; + + const updateElement = document.createElement("pre"); + updateElement.style.margin = "8px"; + updateElement.style.padding = "12px"; + updateElement.style.backgroundColor = "#f5f5f5"; + updateElement.style.borderRadius = "4px"; + updateElement.style.fontFamily = "monospace"; + updateElement.style.fontSize = "10px"; + updateElement.innerHTML = `${JSON.stringify(update, null, 2)}`; + hljs.highlightElement(updateElement.firstChild! as HTMLElement); + + const copyButton = document.createElement("button"); + copyButton.textContent = "copy"; + copyButton.style.cssText = ` + position: absolute; + top: 5px; + right: 5px; + padding: 2px 4px; + border: none; + border-radius: 4px; + background: #e0e0e0; + cursor: pointer; + `; + + copyButton.onclick = async () => { + try { + await navigator.clipboard.writeText( + JSON.stringify(update, null, 2) + ); + copyButton.textContent = "✓"; + setTimeout(() => (copyButton.textContent = "copy"), 1000); + } catch (err) { + console.error("Failed to copy:", err); + copyButton.textContent = "❌"; + setTimeout(() => (copyButton.textContent = "copy"), 1000); + } + }; + + updateContainer.appendChild(titleElement); + updateContainer.appendChild(updateElement); + updateContainer.appendChild(copyButton); + updatesDiv.appendChild(updateContainer); + updatesDiv.scrollTop = updatesDiv.scrollHeight; + } +} diff --git a/examples/example-vite-experimental-sdk/src/vite-env.d.ts b/examples/example-vite-experimental-sdk/src/vite-env.d.ts new file mode 100644 index 00000000..11f02fe2 --- /dev/null +++ b/examples/example-vite-experimental-sdk/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/example-vite-experimental-sdk/tsconfig.json b/examples/example-vite-experimental-sdk/tsconfig.json new file mode 100644 index 00000000..1a33e88c --- /dev/null +++ b/examples/example-vite-experimental-sdk/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2023"], + "skipLibCheck": true, + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src", "vite.config.ts"] +} diff --git a/examples/example-vite-experimental-sdk/vite.config.ts b/examples/example-vite-experimental-sdk/vite.config.ts new file mode 100644 index 00000000..9ec33a86 --- /dev/null +++ b/examples/example-vite-experimental-sdk/vite.config.ts @@ -0,0 +1,17 @@ +import path from "path"; +import { defineConfig } from "vite"; +import wasm from "vite-plugin-wasm"; +import topLevelAwait from "vite-plugin-top-level-await"; + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [wasm(), topLevelAwait()], + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, + build: { + target: "ES2022", + }, +}); diff --git a/examples/example-vite-react-sdk/README.md b/examples/example-vite-react-sdk/README.md index 44f2acc0..544eeee4 100644 --- a/examples/example-vite-react-sdk/README.md +++ b/examples/example-vite-react-sdk/README.md @@ -6,45 +6,3 @@ Currently, two official plugins are available: - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh - -## Expanding the ESLint configuration - -If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: - -- Configure the top-level `parserOptions` property like this: - -```js -export default tseslint.config({ - languageOptions: { - // other options... - parserOptions: { - project: ["./tsconfig.node.json", "./tsconfig.app.json"], - tsconfigRootDir: import.meta.dirname, - }, - }, -}); -``` - -- Replace `tseslint.configs.recommended` to `tseslint.configs.recommendedTypeChecked` or `tseslint.configs.strictTypeChecked` -- Optionally add `...tseslint.configs.stylisticTypeChecked` -- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and update the config: - -```js -// eslint.config.js -import react from "eslint-plugin-react"; - -export default tseslint.config({ - // Set the react version - settings: { react: { version: "18.3" } }, - plugins: { - // Add the react plugin - react, - }, - rules: { - // other rules... - // Enable its recommended rules - ...react.configs.recommended.rules, - ...react.configs["jsx-runtime"].rules, - }, -}); -``` diff --git a/examples/example-vite-react-sdk/src/App.tsx b/examples/example-vite-react-sdk/src/App.tsx index 80487a4f..a3c284c7 100644 --- a/examples/example-vite-react-sdk/src/App.tsx +++ b/examples/example-vite-react-sdk/src/App.tsx @@ -31,6 +31,26 @@ function App() { return BigInt(0); }, [account]); + // This is experimental feature. + // Use those queries if you want to be closer to how you should query your ecs system with torii + // useEffect(() => { + // async function fetchToriiClause() { + // const res = await sdk.client.getEntities( + // new ToriiQueryBuilder() + // .withClause( + // new ClauseBuilder() + // .keys([], [undefined], "VariableLen") + // .build() + // ) + // .withLimit(2) + // .addOrderBy(ModelsMapping.Moves, "remaining", "Desc") + // .build() + // ); + // return res; + // } + // fetchToriiClause().then(console.log); + // }); + useEffect(() => { let unsubscribe: (() => void) | undefined; @@ -248,7 +268,7 @@ function App() { className="text-gray-300" > - {entityId} + {addAddressPadding(entityId)} {position?.player ?? "N/A"} diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 26d2b4b5..445a242a 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -26,6 +26,11 @@ "types": "./dist/src/sql.d.ts", "import": "./dist/src/sql.js", "require": "./dist/src/sql.cjs" + }, + "./experimental": { + "types": "./dist/src/experimental.d.ts", + "import": "./dist/src/experimental.js", + "require": "./dist/src/experimental.cjs" } }, "type": "module", diff --git a/packages/sdk/src/__tests__/clauseBuilder.test.ts b/packages/sdk/src/__tests__/clauseBuilder.test.ts new file mode 100644 index 00000000..5c3afb16 --- /dev/null +++ b/packages/sdk/src/__tests__/clauseBuilder.test.ts @@ -0,0 +1,211 @@ +import { describe, expect, it } from "vitest"; +import { ClauseBuilder } from "../clauseBuilder"; +import { + ComparisonOperator, + LogicalOperator, + PatternMatching, +} from "@dojoengine/torii-client"; +import { SchemaType } from "../types"; + +// Test models interface +interface TestModels extends SchemaType { + dojo_starter: { + Moves: { + fieldOrder: string[]; + remaining: number; + player: string; + }; + Position: { + fieldOrder: string[]; + x: number; + y: number; + }; + GameState: { + fieldOrder: string[]; + active: boolean; + score: number; + gameId: string; + }; + }; +} + +describe("ClauseBuilder", () => { + describe("whereKeys", () => { + it("should create a Keys clause with default pattern matching", () => { + const builder = new ClauseBuilder(); + const clause = builder + .keys(["dojo_starter-Moves"], ["player1"]) + .build(); + + expect(clause).toEqual({ + Keys: { + keys: ["player1"], + pattern_matching: "VariableLen" as PatternMatching, + models: ["dojo_starter-Moves"], + }, + }); + }); + + it("should create a Keys clause with custom pattern matching", () => { + const builder = new ClauseBuilder(); + const clause = builder + .keys(["dojo_starter-Moves"], ["player1"], "VariableLen") + .build(); + + expect(clause).toEqual({ + Keys: { + keys: ["player1"], + pattern_matching: "VariableLen" as PatternMatching, + models: ["dojo_starter-Moves"], + }, + }); + }); + }); + + describe("where", () => { + it("should create a Member clause with number value", () => { + const builder = new ClauseBuilder(); + const clause = builder + .where("dojo_starter-Moves", "remaining", "Gt", 10) + .build(); + + expect(clause).toEqual({ + Member: { + model: "dojo_starter-Moves", + member: "remaining", + operator: "Gt" as ComparisonOperator, + value: { Primitive: { U32: 10 } }, + }, + }); + }); + + it("should create a Member clause with string value", () => { + const builder = new ClauseBuilder(); + const clause = builder + .where("dojo_starter-Moves", "player", "Eq", "player1") + .build(); + + expect(clause).toEqual({ + Member: { + model: "dojo_starter-Moves", + member: "player", + operator: "Eq" as ComparisonOperator, + value: { String: "player1" }, + }, + }); + }); + }); + + describe("compose", () => { + it("should create a composite OR then AND clause", () => { + const clause = new ClauseBuilder() + .compose() + .or([ + new ClauseBuilder().where( + "dojo_starter-Position", + "x", + "Gt", + 0 + ), + new ClauseBuilder().where( + "dojo_starter-Position", + "y", + "Gt", + 0 + ), + ]) + .and([ + new ClauseBuilder().where( + "dojo_starter-GameState", + "active", + "Eq", + true + ), + ]) + .build(); + + expect(clause).toEqual({ + Composite: { + operator: "And", + clauses: [ + { + Member: { + model: "dojo_starter-GameState", + member: "active", + operator: "Eq" as ComparisonOperator, + value: { Primitive: { Bool: true } }, + }, + }, + { + Composite: { + operator: "Or", + clauses: [ + { + Member: { + model: "dojo_starter-Position", + member: "x", + operator: + "Gt" as ComparisonOperator, + value: { Primitive: { U32: 0 } }, + }, + }, + { + Member: { + model: "dojo_starter-Position", + member: "y", + operator: + "Gt" as ComparisonOperator, + value: { Primitive: { U32: 0 } }, + }, + }, + ], + }, + }, + ], + }, + }); + }); + + it("should handle single composite operation", () => { + const builder = new ClauseBuilder(); + const clauseA = new ClauseBuilder().where( + "dojo_starter-Position", + "x", + "Gt", + 0 + ); + const clauseB = new ClauseBuilder().where( + "dojo_starter-Position", + "y", + "Gt", + 0 + ); + + const clause = builder.compose().or([clauseA, clauseB]).build(); + + expect(clause).toEqual({ + Composite: { + operator: "Or", + clauses: [ + { + Member: { + model: "dojo_starter-Position", + member: "x", + operator: "Gt" as ComparisonOperator, + value: { Primitive: { U32: 0 } }, + }, + }, + { + Member: { + model: "dojo_starter-Position", + member: "y", + operator: "Gt" as ComparisonOperator, + value: { Primitive: { U32: 0 } }, + }, + }, + ], + }, + }); + }); + }); +}); diff --git a/packages/sdk/src/__tests__/parseEntities.test.ts b/packages/sdk/src/__tests__/parseEntities.test.ts index 72333a36..2b003a58 100644 --- a/packages/sdk/src/__tests__/parseEntities.test.ts +++ b/packages/sdk/src/__tests__/parseEntities.test.ts @@ -140,50 +140,52 @@ describe("parseEntities", () => { const result = parseEntities(mockEntities); - expect(result).toEqual([ - { - entityId: - "0x14c362c17947ef1d40152d6e3bedd859c98bebfad41f75ef3f26798556a4c85", - models: { - dojo_starter: { - Position: { - player: "0x7f7e355d3ae20c34de26c21b46612622f4e4012e7debc10f0300cf193a46366", - vec: { - x: 6, - y: 10, + expect(result).toEqual({ + "0x14c362c17947ef1d40152d6e3bedd859c98bebfad41f75ef3f26798556a4c85": + { + entityId: + "0x14c362c17947ef1d40152d6e3bedd859c98bebfad41f75ef3f26798556a4c85", + models: { + dojo_starter: { + Position: { + player: "0x7f7e355d3ae20c34de26c21b46612622f4e4012e7debc10f0300cf193a46366", + vec: { + x: 6, + y: 10, + }, + }, + Moves: { + last_direction: "Left", + remaining: 98, + can_move: true, + player: "0x7f7e355d3ae20c34de26c21b46612622f4e4012e7debc10f0300cf193a46366", }, - }, - Moves: { - last_direction: "Left", - remaining: 98, - can_move: true, - player: "0x7f7e355d3ae20c34de26c21b46612622f4e4012e7debc10f0300cf193a46366", }, }, }, - }, - { - entityId: - "0x144c128b8ead7d0da39c6a150abbfdd38f572ba9418d3e36929eb6107b4ce4d", - models: { - dojo_starter: { - Moves: { - last_direction: "Left", - remaining: 99, - can_move: true, - player: "0x70c774f8d061323ada4e4924c12c894f39b5874b71147af254b3efae07e68c0", - }, - Position: { - player: "0x70c774f8d061323ada4e4924c12c894f39b5874b71147af254b3efae07e68c0", - vec: { - x: 6, - y: 10, + "0x144c128b8ead7d0da39c6a150abbfdd38f572ba9418d3e36929eb6107b4ce4d": + { + entityId: + "0x144c128b8ead7d0da39c6a150abbfdd38f572ba9418d3e36929eb6107b4ce4d", + models: { + dojo_starter: { + Moves: { + last_direction: "Left", + remaining: 99, + can_move: true, + player: "0x70c774f8d061323ada4e4924c12c894f39b5874b71147af254b3efae07e68c0", + }, + Position: { + player: "0x70c774f8d061323ada4e4924c12c894f39b5874b71147af254b3efae07e68c0", + vec: { + x: 6, + y: 10, + }, }, }, }, }, - }, - ]); + }); }); it("should parse Options", () => { @@ -223,9 +225,11 @@ describe("parseEntities", () => { const res = parseEntities(toriiResult); const expected = new CairoOption(CairoOptionVariant.Some, 1734537235); // @ts-ignore can be undefined - expect(res[0].models.onchain_dash.CallerCounter.timestamp).toEqual( - expected - ); + expect( + res[ + "0x43ebbfee0476dcc36cae36dfa9b47935cc20c36cb4dc7d014076e5f875cf164" + ].models.onchain_dash.CallerCounter.timestamp + ).toEqual(expected); }); it("should parse complex enums", () => { const toriiResult: torii.Entities = { @@ -278,7 +282,11 @@ describe("parseEntities", () => { const res = parseEntities(toriiResult); const expected = new CairoCustomEnum({ Predefined: "Dojo" }); // @ts-ignore can be undefined - expect(res[0].models.onchain_dash.Theme.value).toEqual(expected); + expect( + res[ + "0x5248d30cafd7af5e7f9255ed9bef2bd7aa0f191669a4c1e3a03b8c64ea5a9d8" + ].models.onchain_dash.Theme.value + ).toEqual(expected); }); it("should parse enum with nested struct", () => { @@ -336,6 +344,10 @@ describe("parseEntities", () => { }, }); // @ts-ignore can be undefined - expect(res[0].models.onchain_dash.Theme.value).toEqual(expected); + expect( + res[ + "0x5248d30cafd7af5e7f9255ed9bef2bd7aa0f191669a4c1e3a03b8c64ea5a9d8" + ].models.onchain_dash.Theme.value + ).toEqual(expected); }); }); diff --git a/packages/sdk/src/__tests__/toriiQueryBuilder.test.ts b/packages/sdk/src/__tests__/toriiQueryBuilder.test.ts new file mode 100644 index 00000000..c8d43f7c --- /dev/null +++ b/packages/sdk/src/__tests__/toriiQueryBuilder.test.ts @@ -0,0 +1,185 @@ +import { describe, expect, it } from "vitest"; +import { ToriiQueryBuilder } from "../toriiQueryBuilder"; +import { Clause, OrderBy } from "@dojoengine/torii-client"; +import { SchemaType } from "../types"; +import { ClauseBuilder } from "../clauseBuilder"; + +interface TestModels extends SchemaType { + dojo_starter: { + Moves: { + fieldOrder: string[]; + remaining: number; + player: string; + }; + Position: { + fieldOrder: string[]; + x: number; + y: number; + }; + }; +} + +describe("ToriiQueryBuilder", () => { + describe("basic query building", () => { + it("override default options", () => { + const builder = new ToriiQueryBuilder({ limit: 25 }); + const query = builder.build(); + + expect(query).toEqual({ + limit: 25, + offset: 0, + clause: undefined, + dont_include_hashed_keys: true, + order_by: [], + entity_models: [], + entity_updated_after: 0, + }); + }); + + it("should create a query with default values", () => { + const builder = new ToriiQueryBuilder(); + const query = builder.build(); + + expect(query).toEqual({ + limit: 100, + offset: 0, + clause: undefined, + dont_include_hashed_keys: true, + order_by: [], + entity_models: [], + entity_updated_after: 0, + }); + }); + + it("should set limit and offset", () => { + const builder = new ToriiQueryBuilder(); + const query = builder.withLimit(10).withOffset(20).build(); + + expect(query.limit).toBe(10); + expect(query.offset).toBe(20); + }); + + it("should set clause", () => { + const mockClause: Clause = { + Member: { + model: "dojo_starter-Position", + member: "x", + operator: "Gt", + value: { Primitive: { U32: 10 } }, + }, + }; + const clause = new ClauseBuilder().where( + "dojo_starter-Position", + "x", + "Gt", + 10 + ); + + const builder = new ToriiQueryBuilder(); + const query = builder.withClause(clause.build()).build(); + + expect(query.clause).toEqual(mockClause); + }); + }); + + describe("order by handling", () => { + it("should add a single order by", () => { + const builder = new ToriiQueryBuilder(); + const query = builder + .addOrderBy("dojo_starter", "x", "Asc") + .build(); + + expect(query.order_by).toEqual([ + { model: "dojo_starter", member: "x", direction: "Asc" }, + ]); + }); + + it("should set multiple order by clauses", () => { + const orderBy: OrderBy[] = [ + { model: "dojo_starter", member: "x", direction: "Asc" }, + { model: "dojo_starter", member: "y", direction: "Desc" }, + ]; + + const builder = new ToriiQueryBuilder(); + const query = builder.withOrderBy(orderBy).build(); + + expect(query.order_by).toEqual(orderBy); + }); + }); + + describe("entity models handling", () => { + it("should add a single entity model", () => { + const builder = new ToriiQueryBuilder(); + const query = builder.addEntityModel("dojo_starter").build(); + + expect(query.entity_models).toEqual(["dojo_starter"]); + }); + + it("should set multiple entity models", () => { + const builder = new ToriiQueryBuilder(); + const query = builder.withEntityModels(["dojo_starter"]).build(); + + expect(query.entity_models).toEqual(["dojo_starter"]); + }); + }); + + describe("timestamp and hashed keys handling", () => { + it("should set updated after timestamp", () => { + const timestamp = Date.now(); + const builder = new ToriiQueryBuilder(); + const query = builder.updatedAfter(timestamp).build(); + + expect(query.entity_updated_after).toBe(timestamp); + }); + + it("should handle hashed keys inclusion", () => { + const builder = new ToriiQueryBuilder(); + const query = builder.includeHashedKeys().build(); + + expect(query.dont_include_hashed_keys).toBe(false); + }); + }); + + describe("static methods", () => { + it("should create a paginated query", () => { + const query = ToriiQueryBuilder.withPagination( + 2, + 25 + ).build(); + + expect(query.limit).toBe(25); + expect(query.offset).toBe(50); + }); + }); + + describe("chaining", () => { + it("should support method chaining", () => { + const timestamp = Date.now(); + const builder = new ToriiQueryBuilder(); + const query = builder + .withLimit(10) + .withOffset(20) + .addEntityModel("dojo_starter-Position") + .addOrderBy("dojo_starter-Position", "x", "Asc") + .includeHashedKeys() + .updatedAfter(timestamp) + .build(); + + expect(query).toEqual({ + limit: 10, + offset: 20, + clause: undefined, + dont_include_hashed_keys: false, + order_by: [ + { + model: "dojo_starter-Position", + member: "x", + direction: "Asc", + }, + ], + entity_models: ["dojo_starter-Position"], + entity_updated_after: timestamp, + }); + }); + }); +}); diff --git a/packages/sdk/src/clauseBuilder.ts b/packages/sdk/src/clauseBuilder.ts new file mode 100644 index 00000000..e882da78 --- /dev/null +++ b/packages/sdk/src/clauseBuilder.ts @@ -0,0 +1,163 @@ +import { + Clause, + ComparisonOperator, + MemberValue, + PatternMatching, +} from "@dojoengine/torii-client"; + +import { convertToPrimitive } from "./convertToMemberValue"; +import { SchemaType } from "./types"; + +// Helper types for nested model structure +type ModelPath = K extends string + ? T[K] extends Record + ? { + [SubK in keyof T[K]]: `${K}-${SubK & string}`; + }[keyof T[K]] + : never + : never; + +type GetModelType< + T, + Path extends string, +> = Path extends `${infer Namespace}-${infer Model}` + ? Namespace extends keyof T + ? Model extends keyof T[Namespace] + ? T[Namespace][Model] + : never + : never + : never; + +export class ClauseBuilder { + private clause: Clause; + + constructor() { + // @ts-expect-error It's ok if it's not assignable here. + this.clause = {}; + } + + /** + * Create a clause based on entity keys + */ + keys( + models: ModelPath[], + keys: (string | undefined)[], + pattern: PatternMatching = "VariableLen" + ): ClauseBuilder { + this.clause = { + Keys: { + keys: keys.length === 0 ? [undefined] : keys, + pattern_matching: pattern, + models, + }, + }; + return this; + } + + /** + * Create a member clause for comparing values + */ + where< + Path extends ModelPath, + M extends keyof GetModelType, + >( + model: Path, + member: M & string, + operator: ComparisonOperator, + value: GetModelType[M] | GetModelType[M][] + ): ClauseBuilder { + const memberValue: MemberValue = Array.isArray(value) + ? { List: value.map(convertToPrimitive) } + : convertToPrimitive(value); + + this.clause = { + Member: { + model, + member, + operator, + value: memberValue, + }, + }; + return this; + } + + /** + * Start a composite clause chain + */ + compose(): CompositeBuilder { + return new CompositeBuilder(); + } + /** + * Build the final clause + */ + build(): Clause { + if (Object.keys(this.clause).length === 0) { + throw new Error("You cannot build an empty Clause"); + } + + return this.clause; + } +} + +class CompositeBuilder>> { + private orClauses: Clause[] = []; + private andClauses: Clause[] = []; + + or(clauses: ClauseBuilder[]): CompositeBuilder { + this.orClauses = clauses.map((c) => c.build()); + return this; + } + + and(clauses: ClauseBuilder[]): CompositeBuilder { + this.andClauses = clauses.map((c) => c.build()); + return this; + } + + build(): Clause { + if (this.orClauses.length === 0 && this.andClauses.length === 0) { + throw new Error( + "ComposeClause is empty. Add .or([clause]) or .and([clause])" + ); + } + + // If we only have OR clauses + if (this.orClauses && this.andClauses.length === 0) { + return { + Composite: { + operator: "Or", + clauses: this.orClauses, + }, + }; + } + + // If we only have AND clauses + if (this.andClauses && this.orClauses.length === 0) { + return { + Composite: { + operator: "And", + clauses: this.andClauses, + }, + }; + } + + // If we have both OR and AND clauses + if (this.andClauses && this.orClauses) { + return { + Composite: { + operator: "And", + clauses: [ + ...this.andClauses, + { + Composite: { + operator: "Or", + clauses: this.orClauses, + }, + }, + ], + }, + }; + } + + throw new Error("CompositeClause is not properly build"); + } +} diff --git a/packages/sdk/src/convertQuerytoClause.ts b/packages/sdk/src/convertQuerytoClause.ts index 649901c5..211e7492 100644 --- a/packages/sdk/src/convertQuerytoClause.ts +++ b/packages/sdk/src/convertQuerytoClause.ts @@ -3,6 +3,7 @@ import * as torii from "@dojoengine/torii-client"; import { QueryType, SchemaType, SubscriptionQueryType } from "./types"; import { convertQueryToEntityKeyClauses } from "./convertQueryToEntityKeyClauses"; +import { convertToPrimitive } from "./convertToMemberValue"; /** * Converts a query object into a Torii clause. @@ -275,34 +276,6 @@ function buildWhereClause( return undefined; } -/** - * Converts a value to a Torii primitive type. - * - * @param {any} value - The value to convert. - * @returns {torii.MemberValue} - The converted primitive value. - * @throws {Error} - If the value type is unsupported. - */ -function convertToPrimitive(value: any): torii.MemberValue { - if (typeof value === "number") { - return { Primitive: { U32: value } }; - } else if (typeof value === "boolean") { - return { Primitive: { Bool: value } }; - } else if (typeof value === "bigint") { - return { - Primitive: { - Felt252: torii.cairoShortStringToFelt(value.toString()), - }, - }; - } else if (typeof value === "string") { - return { String: value }; - } else if (Array.isArray(value)) { - return { List: value.map((item) => convertToPrimitive(item)) }; - } - - // Add more type conversions as needed - throw new Error(`Unsupported primitive type: ${typeof value}`); -} - /** * Converts a query operator to a Torii comparison operator. * diff --git a/packages/sdk/src/convertToMemberValue.ts b/packages/sdk/src/convertToMemberValue.ts new file mode 100644 index 00000000..6fefe342 --- /dev/null +++ b/packages/sdk/src/convertToMemberValue.ts @@ -0,0 +1,29 @@ +import * as torii from "@dojoengine/torii-client"; + +/** + * Converts a value to a Torii primitive type. + * + * @param {any} value - The value to convert. + * @returns {torii.MemberValue} - The converted primitive value. + * @throws {Error} - If the value type is unsupported. + */ +export function convertToPrimitive(value: any): torii.MemberValue { + if (typeof value === "number") { + return { Primitive: { U32: value } }; + } else if (typeof value === "boolean") { + return { Primitive: { Bool: value } }; + } else if (typeof value === "bigint") { + return { + Primitive: { + Felt252: torii.cairoShortStringToFelt(value.toString()), + }, + }; + } else if (typeof value === "string") { + return { String: value }; + } else if (Array.isArray(value)) { + return { List: value.map((item) => convertToPrimitive(item)) }; + } + + // Add more type conversions as needed + throw new Error(`Unsupported primitive type: ${typeof value}`); +} diff --git a/packages/sdk/src/experimental/__tests__/convertClauseToEntityKeysClause.test.ts b/packages/sdk/src/experimental/__tests__/convertClauseToEntityKeysClause.test.ts new file mode 100644 index 00000000..353fc1f1 --- /dev/null +++ b/packages/sdk/src/experimental/__tests__/convertClauseToEntityKeysClause.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { intoEntityKeysClause } from "../convertClauseToEntityKeysClause"; +import { ClauseBuilder } from "../../clauseBuilder"; + +describe("convertClauseToEntityKeysClause", () => { + it("return empty array", () => { + expect(intoEntityKeysClause(undefined)).toEqual([]); + }); + + it("returns Keys if KeysClause", () => { + const clause = new ClauseBuilder().keys([], []).build(); + expect(intoEntityKeysClause(clause)).toEqual([ + { + Keys: { + keys: [undefined], + pattern_matching: "VariableLen", + models: [], + }, + }, + ]); + }); + + it("HashedKeys has priority over keys", () => { + const clause = new ClauseBuilder() + .keys(["dojo_starter-Position"], ["0x123"]) + .build(); + expect( + intoEntityKeysClause(clause, [ + { + entityId: "0xentityHashedKey", + models: { dojo_starter: { Position: { some: "data" } } }, + }, + ]) + ).toEqual([ + { + HashedKeys: ["0xentityHashedKey"], + }, + ]); + }); + + it("CompositeClauses cannot be converted to EntityKeysClause", () => { + const clause = new ClauseBuilder() + .compose() + .or([ + new ClauseBuilder().keys(["dojo_starter-Position"], ["0x123"]), + new ClauseBuilder().keys(["dojo_starter-Position"], ["0x456"]), + ]) + .build(); + + expect(() => intoEntityKeysClause(clause, [])).toThrowError( + /cannot use CompositeClause \| MemberClause/ + ); + }); + + it("MemberClause cannot be converted to EntityKeysClause", () => { + const clause = new ClauseBuilder() + .where("dojo_starter-Position", "x", "Gt", 5) + .build(); + + expect(() => intoEntityKeysClause(clause, [])).toThrowError( + /cannot use CompositeClause \| MemberClause/ + ); + }); +}); diff --git a/packages/sdk/src/experimental/convertClauseToEntityKeysClause.ts b/packages/sdk/src/experimental/convertClauseToEntityKeysClause.ts new file mode 100644 index 00000000..43d54722 --- /dev/null +++ b/packages/sdk/src/experimental/convertClauseToEntityKeysClause.ts @@ -0,0 +1,34 @@ +import { EntityKeysClause, Clause } from "@dojoengine/torii-client"; +import { SchemaType, StandardizedQueryResult } from "../types"; + +export function intoEntityKeysClause( + clause: Clause | undefined, + initialData: StandardizedQueryResult = [] +): EntityKeysClause[] { + // We want to send over placeholder but this case is very unlikely to happen + if (!clause) { + return []; + } + // if we have initial wih query.dont_include_hash_keys = false + // we can move forward to query those hashed keys directly + if (initialData && initialData.length > 0) { + return [{ HashedKeys: initialData.map((e) => e.entityId) }]; + } + + if (Object.hasOwn(clause, "Keys")) { + return [clause as unknown as EntityKeysClause]; + } + + // We want to avoid those kind of weird cases where we are guessing what data should be retrieved + if ( + (Object.hasOwn(clause, "Member") || + Object.hasOwn(clause, "Composite")) && + initialData.length === 0 + ) { + throw new Error( + "You cannot use CompositeClause | MemberClause to subscribe to entity updates, include initial data with hashed keys" + ); + } + + return []; +} diff --git a/packages/sdk/src/experimental/index.ts b/packages/sdk/src/experimental/index.ts new file mode 100644 index 00000000..3082ee15 --- /dev/null +++ b/packages/sdk/src/experimental/index.ts @@ -0,0 +1,64 @@ +import * as torii from "@dojoengine/torii-client"; +import { SchemaType, SDKConfig } from "../types"; +import { parseEntities } from "../parseEntities"; +import { parseHistoricalEvents } from "../parseHistoricalEvents"; +import { intoEntityKeysClause } from "./convertClauseToEntityKeysClause"; + +export async function init(options: SDKConfig) { + const client = await torii.createClient(options.client); + + return { + getEntities: async (query: torii.Query) => { + return parseEntities(await client.getEntities(query)); + }, + getEvents: async (query: torii.Query, historical: boolean = false) => { + const events = await client.getEventMessages(query, historical); + return historical + ? parseHistoricalEvents(events) + : parseEntities(events); + }, + subscribeEntities: async (query: torii.Query, callback: Function) => { + if ( + query.dont_include_hashed_keys && + query.clause && + !Object.hasOwn(query.clause, "Keys") + ) { + throw new Error( + "For subscription, you need to include entity ids" + ); + } + const entities = parseEntities(await client.getEntities(query)); + return [ + entities, + client.onEntityUpdated( + intoEntityKeysClause(query.clause, entities), + callback + ), + ]; + }, + subscribeEvents: async ( + query: torii.Query, + callback: Function, + historical: boolean = false + ) => { + if ( + query.dont_include_hashed_keys && + query.clause && + !Object.hasOwn(query.clause, "Keys") + ) { + throw new Error( + "For subscription, you need to include entity ids" + ); + } + const events = parseEntities(await client.getEntities(query)); + return [ + events, + client.onEventMessageUpdated( + intoEntityKeysClause(query.clause, events), + historical, + callback + ), + ]; + }, + }; +} diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index ee09d120..804b7e53 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -9,6 +9,8 @@ import { SchemaType, SDK, SDKConfig, UnionOfModelData } from "./types"; export * from "./types"; export * from "./queryBuilder"; +export * from "./clauseBuilder"; +export * from "./toriiQueryBuilder"; /** * Creates a new Torii client instance. diff --git a/packages/sdk/src/parseEntities.ts b/packages/sdk/src/parseEntities.ts index afeb78b5..9ae4e745 100644 --- a/packages/sdk/src/parseEntities.ts +++ b/packages/sdk/src/parseEntities.ts @@ -3,25 +3,15 @@ import * as torii from "@dojoengine/torii-client"; import { ParsedEntity, SchemaType, StandardizedQueryResult } from "./types"; import { CairoCustomEnum, CairoOption, CairoOptionVariant } from "starknet"; -/** - * Parses a collection of entities into a standardized query result format. - * - * @template T - The schema type. - * @param {torii.Entities} entities - The collection of entities to parse. - * @param {{ logging?: boolean }} [options] - Optional settings for logging. - * @returns {StandardizedQueryResult} - The parsed entities in a standardized query result format. - * - * @example - * const parsedResult = parseEntities(entities, { logging: true }); - * console.log(parsedResult); - */ export function parseEntities( entities: torii.Entities, options?: { logging?: boolean } ): StandardizedQueryResult { - const result: StandardizedQueryResult = []; + // @ts-ignore + const result: StandardizedQueryResult = entities; + const entityIds = Object.keys(entities); - for (const entityId in entities) { + entityIds.forEach((entityId) => { const entityData = entities[entityId]; const parsedEntity: ParsedEntity = { entityId, @@ -50,12 +40,13 @@ export function parseEntities( ); } - result.push(parsedEntity); + result[entityId] = parsedEntity; if (options?.logging) { console.log(`Parsed entity:`, parsedEntity); } - } + return parsedEntity; + }); if (options?.logging) { console.log("Parsed result:", result); diff --git a/packages/sdk/src/toriiQueryBuilder.ts b/packages/sdk/src/toriiQueryBuilder.ts new file mode 100644 index 00000000..45356edd --- /dev/null +++ b/packages/sdk/src/toriiQueryBuilder.ts @@ -0,0 +1,122 @@ +import { Clause, OrderBy, Query } from "@dojoengine/torii-client"; +import { SchemaType } from "./types"; + +const defaultToriiOptions = () => ({ + limit: 100, // default limit + offset: 0, + clause: undefined, + dont_include_hashed_keys: true, + order_by: [], + entity_models: [], + entity_updated_after: 0, +}); + +type ToriiQueryBuilderOptions = Omit, "clause">; + +export class ToriiQueryBuilder { + private query: Query; + + constructor(options?: ToriiQueryBuilderOptions) { + this.query = { ...defaultToriiOptions(), ...options }; + } + + /** + * Set the maximum number of results to return + */ + withLimit(limit: number): ToriiQueryBuilder { + this.query.limit = limit; + return this; + } + + /** + * Set the offset for pagination + */ + withOffset(offset: number): ToriiQueryBuilder { + this.query.offset = offset; + return this; + } + + /** + * Add the clause to filter results + */ + withClause(clause: Clause): ToriiQueryBuilder { + this.query.clause = clause; + return this; + } + + /** + * Set whether to include hashed keys in the response + * HashedKeys represent internal torii entity id. + */ + includeHashedKeys(): ToriiQueryBuilder { + this.query.dont_include_hashed_keys = false; + return this; + } + + /** + * Add a single order by clause + */ + addOrderBy( + model: keyof T & string, + member: string, + direction: "Asc" | "Desc" + ): ToriiQueryBuilder { + this.query.order_by.push({ + model, + member, + direction, + }); + return this; + } + + /** + * Add multiple order by clauses at once + */ + withOrderBy(orderBy: OrderBy[]): ToriiQueryBuilder { + this.query.order_by = orderBy; + return this; + } + + /** + * Add a single entity model to filter + */ + addEntityModel(model: keyof T & string): ToriiQueryBuilder { + this.query.entity_models.push(model); + return this; + } + + /** + * Set multiple entity models at once + */ + withEntityModels(models: (keyof T & string)[]): ToriiQueryBuilder { + this.query.entity_models = models; + return this; + } + + /** + * Set the minimum timestamp for entity updates + */ + updatedAfter(timestamp: number): ToriiQueryBuilder { + this.query.entity_updated_after = timestamp; + return this; + } + + /** + * Build the final query + */ + build(): Query { + return this.query; + } + + /** + * Create a new builder instance with pagination settings + */ + static withPagination>>( + page: number, + pageSize: number + ): ToriiQueryBuilder { + return new ToriiQueryBuilder() + .withLimit(pageSize) + .withOffset(page * pageSize); + } +} diff --git a/packages/sdk/tsup.config.ts b/packages/sdk/tsup.config.ts index 05edf348..fb6b3bb6 100644 --- a/packages/sdk/tsup.config.ts +++ b/packages/sdk/tsup.config.ts @@ -9,5 +9,6 @@ export default defineConfig({ "src/state": "src/state/index.ts", "src/react": "src/react/index.ts", "src/sql": "src/sql/index.ts", + "src/experimental": "src/experimental/index.ts", }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c4da2105..65a89c9e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -132,7 +132,7 @@ importers: version: 3.0.11 bun-types: specifier: latest - version: 1.1.43 + version: 1.1.45 graphql: specifier: ^16.9.0 version: 16.10.0 @@ -186,6 +186,37 @@ importers: specifier: ^3.3.0 version: 3.4.1(vite@5.4.11(@types/node@22.10.6)(terser@5.37.0)) + examples/example-vite-experimental-sdk: + dependencies: + '@dojoengine/core': + specifier: workspace:* + version: link:../../packages/core + '@dojoengine/sdk': + specifier: workspace:* + version: link:../../packages/sdk + highlight.js: + specifier: ^11.11.1 + version: 11.11.1 + starknet: + specifier: 'catalog:' + version: 6.21.0(encoding@0.1.13) + devDependencies: + '@types/highlight.js': + specifier: ^10.1.0 + version: 10.1.0 + typescript: + specifier: ~5.6.3 + version: 5.6.3 + vite: + specifier: ^6.0.7 + version: 6.0.7(@types/node@22.10.6)(jiti@2.4.2)(terser@5.37.0)(tsx@4.19.2)(yaml@2.7.0) + vite-plugin-top-level-await: + specifier: ^1.4.4 + version: 1.4.4(@swc/helpers@0.5.5)(rollup@4.30.1)(vite@6.0.7(@types/node@22.10.6)(jiti@2.4.2)(terser@5.37.0)(tsx@4.19.2)(yaml@2.7.0)) + vite-plugin-wasm: + specifier: ^3.4.1 + version: 3.4.1(vite@6.0.7(@types/node@22.10.6)(jiti@2.4.2)(terser@5.37.0)(tsx@4.19.2)(yaml@2.7.0)) + examples/example-vite-kitchen-sink: dependencies: '@cartridge/connector': @@ -843,7 +874,7 @@ importers: version: 2.1.1 drizzle-orm: specifier: ^0.38.3 - version: 0.38.3(@libsql/client@0.14.0)(@types/react@18.3.18)(bun-types@1.1.43)(react@18.3.1) + version: 0.38.3(@libsql/client@0.14.0)(@types/react@18.3.18)(bun-types@1.1.45)(react@18.3.1) lucide-react: specifier: ^0.469.0 version: 0.469.0(react@18.3.1) @@ -6294,6 +6325,10 @@ packages: '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + '@types/highlight.js@10.1.0': + resolution: {integrity: sha512-77hF2dGBsOgnvZll1vymYiNUtqJ8cJfXPD6GG/2M0aLRc29PkvB7Au6sIDjIEFcSICBhCh2+Pyq6WSRS7LUm6A==} + deprecated: This is a stub types definition. highlight.js provides its own type definitions, so you do not need this installed. + '@types/http-errors@2.0.4': resolution: {integrity: sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==} @@ -7292,8 +7327,8 @@ packages: buffer@6.0.3: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} - bun-types@1.1.43: - resolution: {integrity: sha512-W0wCtVH+bwFp7p3Zgs03CqxEDmXxEvmmUM/FBKgWIv9T8gyeotvIjIbHzuDScc2DphhRNtr7hJLCR5PspYL5qw==} + bun-types@1.1.45: + resolution: {integrity: sha512-8NT3BYwkyO8nzTG1k+q86VEvucw7s5W1fjRIGs0Y6/XNbTZn+mHEU39LFnuDLj4UmGCMpWCQtXUhLd6cko49Ww==} bundle-require@5.1.0: resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} @@ -9249,6 +9284,10 @@ packages: header-case@2.0.4: resolution: {integrity: sha512-H/vuk5TEEVZwrR0lp2zed9OCo1uAILMlx0JEMgC26rzyJJ3N1v6XkwHHXJQdR2doSjcGPM6OKPYoJgf0plJ11Q==} + highlight.js@11.11.1: + resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} + engines: {node: '>=12.0.0'} + hls.js@1.5.18: resolution: {integrity: sha512-znxR+2jecWluu/0KOBqUcvVyAB5tLff10vjMGrpAlz1eFY+ZhF1bY3r82V+Bk7WJdk03iTjtja9KFFz5BrqjSA==} @@ -13009,6 +13048,11 @@ packages: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <5.8.0' + typescript@5.6.3: + resolution: {integrity: sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==} + engines: {node: '>=14.17'} + hasBin: true + typescript@5.7.2: resolution: {integrity: sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==} engines: {node: '>=14.17'} @@ -19741,6 +19785,10 @@ snapshots: dependencies: '@types/unist': 3.0.3 + '@types/highlight.js@10.1.0': + dependencies: + highlight.js: 11.11.1 + '@types/http-errors@2.0.4': {} '@types/istanbul-lib-coverage@2.0.6': {} @@ -21028,7 +21076,7 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 - bun-types@1.1.43: + bun-types@1.1.45: dependencies: '@types/node': 20.12.14 '@types/ws': 8.5.13 @@ -21904,11 +21952,11 @@ snapshots: transitivePeerDependencies: - supports-color - drizzle-orm@0.38.3(@libsql/client@0.14.0)(@types/react@18.3.18)(bun-types@1.1.43)(react@18.3.1): + drizzle-orm@0.38.3(@libsql/client@0.14.0)(@types/react@18.3.18)(bun-types@1.1.45)(react@18.3.1): optionalDependencies: '@libsql/client': 0.14.0 '@types/react': 18.3.18 - bun-types: 1.1.43 + bun-types: 1.1.45 react: 18.3.1 dset@3.1.4: {} @@ -23420,6 +23468,8 @@ snapshots: capital-case: 1.0.4 tslib: 2.8.1 + highlight.js@11.11.1: {} + hls.js@1.5.18: {} hmac-drbg@1.0.1: @@ -26808,8 +26858,8 @@ snapshots: picocolors: 1.1.1 sade: 1.8.1 svelte: 4.2.19 - svelte-preprocess: 5.1.4(@babel/core@7.26.0)(postcss-load-config@4.0.2(postcss@8.5.1))(postcss@8.5.1)(svelte@4.2.19)(typescript@5.7.2) - typescript: 5.7.2 + svelte-preprocess: 5.1.4(@babel/core@7.26.0)(postcss-load-config@4.0.2(postcss@8.5.1))(postcss@8.5.1)(svelte@4.2.19)(typescript@5.7.3) + typescript: 5.7.3 transitivePeerDependencies: - '@babel/core' - coffeescript @@ -26825,7 +26875,7 @@ snapshots: dependencies: svelte: 4.2.19 - svelte-preprocess@5.1.4(@babel/core@7.26.0)(postcss-load-config@4.0.2(postcss@8.5.1))(postcss@8.5.1)(svelte@4.2.19)(typescript@5.7.2): + svelte-preprocess@5.1.4(@babel/core@7.26.0)(postcss-load-config@4.0.2(postcss@8.5.1))(postcss@8.5.1)(svelte@4.2.19)(typescript@5.7.3): dependencies: '@types/pug': 2.0.10 detect-indent: 6.1.0 @@ -26837,7 +26887,7 @@ snapshots: '@babel/core': 7.26.0 postcss: 8.5.1 postcss-load-config: 4.0.2(postcss@8.5.1) - typescript: 5.7.2 + typescript: 5.7.3 svelte@4.2.19: dependencies: @@ -27332,6 +27382,8 @@ snapshots: transitivePeerDependencies: - supports-color + typescript@5.6.3: {} + typescript@5.7.2: {} typescript@5.7.3: {} diff --git a/scripts/build-examples.sh b/scripts/build-examples.sh index 497a8ec2..db4b7019 100755 --- a/scripts/build-examples.sh +++ b/scripts/build-examples.sh @@ -15,6 +15,7 @@ examples=( "examples/example-vue-app-recs" "examples/example-vite-svelte-recs" "examples/example-vite-react-sql" + "examples/example-vite-experimental-sdk" ) # Iterate over each example directory and run the build command @@ -24,4 +25,3 @@ for example in "${examples[@]}"; do pnpm run build cd ../../ done -