From 0c6d6a878c9090f9eb7001edb0a575a8e67f33c1 Mon Sep 17 00:00:00 2001 From: Kevin Ingersoll Date: Thu, 14 Mar 2024 19:13:00 +0000 Subject: [PATCH] refactor(query,store-sync): rework query types, refactor query client (#2436) --- packages/query/src/api.ts | 64 +- packages/query/src/common.ts | 17 +- packages/query/src/findSubjects.ts | 35 +- packages/query/src/index.ts | 2 +- .../{matchesCondition.ts => matchRecords.ts} | 17 +- packages/store-sync/src/query-cache/common.ts | 10 +- .../src/query-cache/createStorageAdapter.ts | 40 +- .../store-sync/src/query-cache/createStore.ts | 17 +- .../store-sync/src/query-cache/getId.test.ts | 15 - packages/store-sync/src/query-cache/getId.ts | 10 - .../store-sync/src/query-cache/query.test.ts | 314 +++++++-- packages/store-sync/src/query-cache/query.ts | 26 +- .../src/query-cache/subjectsToWire.ts | 4 +- .../src/query-cache/subscribeToQuery.test.ts | 635 ++++++++++-------- .../src/query-cache/subscribeToQuery.ts | 133 ++-- 15 files changed, 860 insertions(+), 479 deletions(-) rename packages/query/src/{matchesCondition.ts => matchRecords.ts} (72%) delete mode 100644 packages/store-sync/src/query-cache/getId.test.ts delete mode 100644 packages/store-sync/src/query-cache/getId.ts diff --git a/packages/query/src/api.ts b/packages/query/src/api.ts index e75e762c2e..01818e866b 100644 --- a/packages/query/src/api.ts +++ b/packages/query/src/api.ts @@ -1,32 +1,41 @@ import { Hex } from "viem"; import { StaticPrimitiveType, DynamicPrimitiveType } from "@latticexyz/schema-type"; import { satisfy } from "@latticexyz/common/type-utils"; +import { SchemaToPrimitives } from "@latticexyz/store"; +import { Table } from "@latticexyz/store/config/v2"; /** * These types represent the "over the wire" protocol (i.e. JSON) for the query API. + * + * Currently always returns matching records for each subject. We may add separate endpoints and return types for just subjects later. */ -export type TableField = { +// TODO: decide if we want to support stronger types here (e.g. table generic that constrains subjects, records, etc.) +// TODO: decide if/how we want to add block number throughout (esp as it relates to instant sequencing) +// TODO: separate set of types for querying just + +export type QueryTable = { readonly tableId: Hex; readonly field: string; }; -export type TableSubject = { +export type QuerySubject = { readonly tableId: Hex; readonly subject: readonly string[]; }; -export type ConditionLiteral = boolean | number | bigint | string; +// TODO: should we exclude arrays? might be hard to support array comparisons in SQL +export type ConditionLiteral = StaticPrimitiveType | DynamicPrimitiveType; export type ComparisonCondition = { - readonly left: TableField; + readonly left: QueryTable; readonly op: "<" | "<=" | "=" | ">" | ">=" | "!="; - // TODO: add support for TableField + // TODO: add support for QueryTable readonly right: ConditionLiteral; }; export type InCondition = { - readonly left: TableField; + readonly left: QueryTable; readonly op: "in"; readonly right: readonly ConditionLiteral[]; }; @@ -34,15 +43,44 @@ export type InCondition = { export type QueryCondition = satisfy<{ readonly op: string }, ComparisonCondition | InCondition>; export type Query = { - readonly from: readonly TableSubject[]; - readonly except?: readonly TableSubject[]; + readonly from: readonly QuerySubject[]; + readonly except?: readonly QuerySubject[]; readonly where?: readonly QueryCondition[]; }; -export type QueryResultSubject = readonly (StaticPrimitiveType | DynamicPrimitiveType)[]; +export type PrimitiveType = StaticPrimitiveType | DynamicPrimitiveType; + +export type ResultRecord = { + readonly tableId: Hex; + readonly keyTuple: readonly Hex[]; + readonly primaryKey: readonly StaticPrimitiveType[]; + readonly fields: SchemaToPrimitives; +}; + +export type Subject = readonly PrimitiveType[]; + +export type SubjectRecords = { + readonly subject: Subject; + readonly records: readonly ResultRecord[]; +}; + +// TODO: consider flattening this to be more like `ResultRecord & { subject: Subject }` +export type SubjectRecord = { + readonly subject: Subject; + readonly record: ResultRecord; +}; + +// TODO: for change event, should this include previous record? +// TODO: use merge helper instead of `&` intersection? +export type SubjectEvent = SubjectRecord & { + /** + * `enter` = a new subject+record pair matched + * `exit` = a subject+record pair no longer matches + * `change` = the record oft he subject+record pair changed + */ + readonly type: "enter" | "exit" | "change"; +}; -export type QueryResult = { - subjects: readonly QueryResultSubject[]; - // TODO: matched records - // TODO: block number +export type Result = { + readonly subjects: readonly SubjectRecords[]; }; diff --git a/packages/query/src/common.ts b/packages/query/src/common.ts index b6843d6751..a3a14d8c50 100644 --- a/packages/query/src/common.ts +++ b/packages/query/src/common.ts @@ -1,10 +1,13 @@ -import { Table, Schema } from "@latticexyz/store/config/v2"; +import { StaticPrimitiveType } from "@latticexyz/schema-type"; +import { SchemaToPrimitives } from "@latticexyz/store"; +import { Table } from "@latticexyz/store/config/v2"; +import { Hex } from "viem"; -export type schemaAbiTypes = { - [key in keyof schema]: schema[key]["type"]; -}; - -export type TableRecord = { +export type TableRecord
= { readonly table: table; - readonly fields: schemaAbiTypes; + // TODO: refine to just static types + // TODO: add helper to extract primary key of primitive types from table primary key + field values + readonly primaryKey: readonly StaticPrimitiveType[]; + readonly keyTuple: readonly Hex[]; + readonly fields: SchemaToPrimitives; }; diff --git a/packages/query/src/findSubjects.ts b/packages/query/src/findSubjects.ts index 8af60c6687..40e2ec4543 100644 --- a/packages/query/src/findSubjects.ts +++ b/packages/query/src/findSubjects.ts @@ -1,8 +1,8 @@ import { encodeAbiParameters } from "viem"; import { Table } from "@latticexyz/store/config/v2"; import { groupBy, uniqueBy } from "@latticexyz/common/utils"; -import { Query, QueryResultSubject } from "./api"; -import { matchesCondition } from "./matchesCondition"; +import { Query, SubjectRecords } from "./api"; +import { matchRecords } from "./matchRecords"; import { TableRecord } from "./common"; // This assumes upstream has fully validated query @@ -14,16 +14,12 @@ export type FindSubjectsParameters
= { readonly query: Query; }; -export type FindSubjectsResult = { - readonly subjects: readonly QueryResultSubject[]; -}; - // TODO: make condition types smarter? so condition literal matches the field primitive type export function findSubjects
({ records: initialRecords, query, -}: FindSubjectsParameters
): FindSubjectsResult { +}: FindSubjectsParameters
): readonly SubjectRecords[] { const targetTables = Object.fromEntries( uniqueBy([...query.from, ...(query.except ?? [])], (subject) => subject.tableId).map((subject) => [ subject.tableId, @@ -64,9 +60,28 @@ export function findSubjects
({ const tableIds = new Set(records.map((record) => record.table.tableId)); return tableIds.size === fromTableIds.size; }) - .filter((match) => (query.where ? query.where.every((condition) => matchesCondition(condition, match)) : true)); + .map((match) => { + if (!query.where) return match; + + let records: readonly TableRecord
[] = match.records; + for (const condition of query.where) { + if (!records.length) break; + records = matchRecords(condition, records); + } + + return { ...match, records }; + }) + .filter((match) => match.records.length > 0); - const subjects = matchedSubjects.map((match) => match.subject); + const subjects = matchedSubjects.map((match) => ({ + subject: match.subject, + records: match.records.map((record) => ({ + tableId: record.table.tableId, + primaryKey: record.primaryKey, + keyTuple: record.keyTuple, + fields: record.fields, + })), + })); - return { subjects }; + return subjects; } diff --git a/packages/query/src/index.ts b/packages/query/src/index.ts index d27c330933..471b43d6d7 100644 --- a/packages/query/src/index.ts +++ b/packages/query/src/index.ts @@ -1,3 +1,3 @@ export * from "./api"; export * from "./findSubjects"; -export * from "./matchesCondition"; +export * from "./matchRecords"; diff --git a/packages/query/src/matchesCondition.ts b/packages/query/src/matchRecords.ts similarity index 72% rename from packages/query/src/matchesCondition.ts rename to packages/query/src/matchRecords.ts index f43b543178..15f3dfe2eb 100644 --- a/packages/query/src/matchesCondition.ts +++ b/packages/query/src/matchRecords.ts @@ -2,11 +2,6 @@ import { Table } from "@latticexyz/store/config/v2"; import { ComparisonCondition, ConditionLiteral, QueryCondition } from "./api"; import { TableRecord } from "./common"; -export type MatchedSubject
= { - readonly subject: readonly string[]; - readonly records: readonly TableRecord
[]; -}; - const comparisons = { "<": (left, right) => left < right, "<=": (left, right) => left <= right, @@ -16,12 +11,10 @@ const comparisons = { "!=": (left, right) => left !== right, } as const satisfies Record boolean>; -// TODO: adapt this to return matching records, not just a boolean - -export function matchesCondition
( +export function matchRecords
( condition: QueryCondition, - subject: MatchedSubject
, -): boolean { + records: readonly TableRecord
[], +): readonly TableRecord
[] { switch (condition.op) { case "<": case "<=": @@ -29,13 +22,13 @@ export function matchesCondition
( case ">": case ">=": case "!=": - return subject.records.some( + return records.filter( (record) => record.table.tableId === condition.left.tableId && comparisons[condition.op](record.fields[condition.left.field], condition.right), ); case "in": - return subject.records.some( + return records.filter( (record) => record.table.tableId === condition.left.tableId && condition.right.includes(record.fields[condition.left.field]), diff --git a/packages/store-sync/src/query-cache/common.ts b/packages/store-sync/src/query-cache/common.ts index 663b301fd6..b5bd29b12d 100644 --- a/packages/store-sync/src/query-cache/common.ts +++ b/packages/store-sync/src/query-cache/common.ts @@ -51,12 +51,4 @@ export type Query = { readonly where?: readonly queryConditions[]; }; -export type queryToResultSubject, tables extends Tables> = { - [table in keyof query["from"]]: table extends keyof tables - ? subjectSchemaToPrimitive>> - : never; -}[keyof query["from"]]; - -export type QueryResult, tables extends Tables = Tables> = { - readonly subjects: readonly queryToResultSubject[]; -}; +export type extractTables = T extends Query ? tables : never; diff --git a/packages/store-sync/src/query-cache/createStorageAdapter.ts b/packages/store-sync/src/query-cache/createStorageAdapter.ts index 2cfc8cc88a..d6b7f4e3ac 100644 --- a/packages/store-sync/src/query-cache/createStorageAdapter.ts +++ b/packages/store-sync/src/query-cache/createStorageAdapter.ts @@ -1,21 +1,27 @@ import { StorageAdapter } from "../common"; import { QueryCacheStore, RawTableRecord, TableRecord } from "./createStore"; import { hexToResource, resourceToLabel, spliceHex } from "@latticexyz/common"; -import { size } from "viem"; +import { Hex, concatHex, size } from "viem"; import { decodeKey, decodeValueArgs } from "@latticexyz/protocol-parser"; import { flattenSchema } from "../flattenSchema"; -import { getId } from "./getId"; import debug from "debug"; -import { KeySchema } from "@latticexyz/store"; import { Tables } from "./common"; -export type CreateStorageAdapterOptions = { - store: QueryCacheStore; +function getRecordId({ tableId, keyTuple }: { tableId: Hex; keyTuple: readonly Hex[] }): string { + return `${tableId}:${concatHex(keyTuple)}`; +} + +export type CreateStorageAdapterOptions = { + store: store; }; -export function createStorageAdapter({ +// TS isn't happy when we use the strongly typed store for the function definition so we +// overload the strongly typed variant here and allow the more generic version in the function. +export function createStorageAdapter({ store, -}: CreateStorageAdapterOptions): StorageAdapter { +}: CreateStorageAdapterOptions): StorageAdapter; + +export function createStorageAdapter({ store }: CreateStorageAdapterOptions>): StorageAdapter { return async function queryCacheStorageAdapter({ logs }) { const touchedIds = new Set(); @@ -35,7 +41,7 @@ export function createStorageAdapter({ continue; } - const id = getId(log.args); + const id = getRecordId(log.args); if (log.eventName === "Store_SetRecord") { // debug("setting record", { namespace: table.namespace, name: table.name, id, log }); @@ -103,19 +109,15 @@ export function createStorageAdapter({ const records: readonly TableRecord[] = [ ...previousRecords.filter((record) => !touchedIds.has(record.id)), ...Object.values(updatedRawRecords).map((rawRecord): TableRecord => { - // TODO: figure out how to define this without casting - const key = decodeKey(flattenSchema(rawRecord.table.keySchema as KeySchema), rawRecord.keyTuple) as TableRecord< - tables[keyof tables] - >["key"]; - - // TODO: figure out how to define this without casting - const value = decodeValueArgs(flattenSchema(rawRecord.table.valueSchema), rawRecord) as TableRecord< - tables[keyof tables] - >["value"]; + const key = decodeKey(flattenSchema(rawRecord.table.keySchema), rawRecord.keyTuple); + const value = decodeValueArgs(flattenSchema(rawRecord.table.valueSchema), rawRecord); return { table: rawRecord.table, id: rawRecord.id, + keyTuple: rawRecord.keyTuple, + // TODO: do something to make sure this stays ordered? + primaryKey: Object.values(key), key, value, fields: { ...key, ...value }, @@ -124,8 +126,8 @@ export function createStorageAdapter({ ]; store.setState({ - rawRecords: rawRecords as readonly RawTableRecord[], - records: records as readonly TableRecord[], + rawRecords, + records, }); }; } diff --git a/packages/store-sync/src/query-cache/createStore.ts b/packages/store-sync/src/query-cache/createStore.ts index fec94ead91..a7913faf99 100644 --- a/packages/store-sync/src/query-cache/createStore.ts +++ b/packages/store-sync/src/query-cache/createStore.ts @@ -1,7 +1,9 @@ import { StoreApi, UseBoundStore, create } from "zustand"; import { Table } from "@latticexyz/store/config/v2"; -import { Tables, schemaAbiTypes } from "./common"; +import { Tables } from "./common"; import { Hex } from "viem"; +import { StaticPrimitiveType } from "@latticexyz/schema-type"; +import { SchemaToPrimitives } from "@latticexyz/store"; export type RawTableRecord
= { readonly table: table; @@ -17,9 +19,11 @@ export type TableRecord
= { readonly table: table; /** @internal Internal unique ID */ readonly id: string; - readonly key: schemaAbiTypes; - readonly value: schemaAbiTypes; - readonly fields: schemaAbiTypes; + readonly keyTuple: readonly Hex[]; + readonly primaryKey: readonly StaticPrimitiveType[]; + readonly key: SchemaToPrimitives; + readonly value: SchemaToPrimitives; + readonly fields: SchemaToPrimitives; }; export type QueryCacheState = { @@ -28,7 +32,10 @@ export type QueryCacheState = { readonly records: readonly TableRecord[]; }; -export type QueryCacheStore = UseBoundStore>>; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type QueryCacheStore = UseBoundStore>>; + +export type extractTables = T extends QueryCacheStore ? tables : never; export type CreateStoreOptions = { tables: tables; diff --git a/packages/store-sync/src/query-cache/getId.test.ts b/packages/store-sync/src/query-cache/getId.test.ts deleted file mode 100644 index 2016d318a0..0000000000 --- a/packages/store-sync/src/query-cache/getId.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { getId } from "./getId"; - -describe("getId", () => { - it("should convert a store event log to a unique ID", async () => { - expect( - getId({ - tableId: "0x74626d756473746f72650000000000005461626c657300000000000000000000", - keyTuple: ["0x74626d756473746f72650000000000005461626c657300000000000000000000"], - }), - ).toMatchInlineSnapshot( - '"0x74626d756473746f72650000000000005461626c657300000000000000000000:0x74626d756473746f72650000000000005461626c657300000000000000000000"', - ); - }); -}); diff --git a/packages/store-sync/src/query-cache/getId.ts b/packages/store-sync/src/query-cache/getId.ts deleted file mode 100644 index f0b43d75ef..0000000000 --- a/packages/store-sync/src/query-cache/getId.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Hex, concatHex } from "viem"; - -type GetIdOptions = { - readonly tableId: Hex; - readonly keyTuple: readonly Hex[]; -}; - -export function getId({ tableId, keyTuple }: GetIdOptions): string { - return `${tableId}:${concatHex(keyTuple)}`; -} diff --git a/packages/store-sync/src/query-cache/query.test.ts b/packages/store-sync/src/query-cache/query.test.ts index 616a7049ae..e5c332eec0 100644 --- a/packages/store-sync/src/query-cache/query.test.ts +++ b/packages/store-sync/src/query-cache/query.test.ts @@ -21,18 +21,90 @@ describe("query", async () => { expect(result).toMatchInlineSnapshot(` { "subjects": [ - [ - "0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e", - ], - [ - "0x328809Bc894f92807417D2dAD6b7C998c1aFdac6", - ], - [ - "0x078cf0753dd50f7C56F20B3Ae02719EA199BE2eb", - ], - [ - "0xdBa86119a787422C593ceF119E40887f396024E2", - ], + { + "records": [ + { + "fields": { + "player": "0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e", + "x": 1, + "y": -1, + }, + "keyTuple": [ + "0x0000000000000000000000001d96f2f6bef1202e4ce1ff6dad0c2cb002861d3e", + ], + "primaryKey": [ + "0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e", + ], + "tableId": "0x74620000000000000000000000000000506f736974696f6e0000000000000000", + }, + ], + "subject": [ + "0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e", + ], + }, + { + "records": [ + { + "fields": { + "player": "0x328809Bc894f92807417D2dAD6b7C998c1aFdac6", + "x": 3, + "y": 5, + }, + "keyTuple": [ + "0x000000000000000000000000328809bc894f92807417d2dad6b7c998c1afdac6", + ], + "primaryKey": [ + "0x328809Bc894f92807417D2dAD6b7C998c1aFdac6", + ], + "tableId": "0x74620000000000000000000000000000506f736974696f6e0000000000000000", + }, + ], + "subject": [ + "0x328809Bc894f92807417D2dAD6b7C998c1aFdac6", + ], + }, + { + "records": [ + { + "fields": { + "player": "0x078cf0753dd50f7C56F20B3Ae02719EA199BE2eb", + "x": 3, + "y": 5, + }, + "keyTuple": [ + "0x000000000000000000000000078cf0753dd50f7c56f20b3ae02719ea199be2eb", + ], + "primaryKey": [ + "0x078cf0753dd50f7C56F20B3Ae02719EA199BE2eb", + ], + "tableId": "0x74620000000000000000000000000000506f736974696f6e0000000000000000", + }, + ], + "subject": [ + "0x078cf0753dd50f7C56F20B3Ae02719EA199BE2eb", + ], + }, + { + "records": [ + { + "fields": { + "player": "0xdBa86119a787422C593ceF119E40887f396024E2", + "x": 100, + "y": 100, + }, + "keyTuple": [ + "0x000000000000000000000000dba86119a787422c593cef119e40887f396024e2", + ], + "primaryKey": [ + "0xdBa86119a787422C593ceF119E40887f396024E2", + ], + "tableId": "0x74620000000000000000000000000000506f736974696f6e0000000000000000", + }, + ], + "subject": [ + "0xdBa86119a787422C593ceF119E40887f396024E2", + ], + }, ], } `); @@ -53,12 +125,48 @@ describe("query", async () => { expect(result).toMatchInlineSnapshot(` { "subjects": [ - [ - "0x328809Bc894f92807417D2dAD6b7C998c1aFdac6", - ], - [ - "0x078cf0753dd50f7C56F20B3Ae02719EA199BE2eb", - ], + { + "records": [ + { + "fields": { + "player": "0x328809Bc894f92807417D2dAD6b7C998c1aFdac6", + "x": 3, + "y": 5, + }, + "keyTuple": [ + "0x000000000000000000000000328809bc894f92807417d2dad6b7c998c1afdac6", + ], + "primaryKey": [ + "0x328809Bc894f92807417D2dAD6b7C998c1aFdac6", + ], + "tableId": "0x74620000000000000000000000000000506f736974696f6e0000000000000000", + }, + ], + "subject": [ + "0x328809Bc894f92807417D2dAD6b7C998c1aFdac6", + ], + }, + { + "records": [ + { + "fields": { + "player": "0x078cf0753dd50f7C56F20B3Ae02719EA199BE2eb", + "x": 3, + "y": 5, + }, + "keyTuple": [ + "0x000000000000000000000000078cf0753dd50f7c56f20b3ae02719ea199be2eb", + ], + "primaryKey": [ + "0x078cf0753dd50f7C56F20B3Ae02719EA199BE2eb", + ], + "tableId": "0x74620000000000000000000000000000506f736974696f6e0000000000000000", + }, + ], + "subject": [ + "0x078cf0753dd50f7C56F20B3Ae02719EA199BE2eb", + ], + }, ], } `); @@ -81,15 +189,69 @@ describe("query", async () => { expect(result).toMatchInlineSnapshot(` { "subjects": [ - [ - "0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e", - ], - [ - "0x328809Bc894f92807417D2dAD6b7C998c1aFdac6", - ], - [ - "0x078cf0753dd50f7C56F20B3Ae02719EA199BE2eb", - ], + { + "records": [ + { + "fields": { + "player": "0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e", + "x": 1, + "y": -1, + }, + "keyTuple": [ + "0x0000000000000000000000001d96f2f6bef1202e4ce1ff6dad0c2cb002861d3e", + ], + "primaryKey": [ + "0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e", + ], + "tableId": "0x74620000000000000000000000000000506f736974696f6e0000000000000000", + }, + ], + "subject": [ + "0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e", + ], + }, + { + "records": [ + { + "fields": { + "player": "0x328809Bc894f92807417D2dAD6b7C998c1aFdac6", + "x": 3, + "y": 5, + }, + "keyTuple": [ + "0x000000000000000000000000328809bc894f92807417d2dad6b7c998c1afdac6", + ], + "primaryKey": [ + "0x328809Bc894f92807417D2dAD6b7C998c1aFdac6", + ], + "tableId": "0x74620000000000000000000000000000506f736974696f6e0000000000000000", + }, + ], + "subject": [ + "0x328809Bc894f92807417D2dAD6b7C998c1aFdac6", + ], + }, + { + "records": [ + { + "fields": { + "player": "0x078cf0753dd50f7C56F20B3Ae02719EA199BE2eb", + "x": 3, + "y": 5, + }, + "keyTuple": [ + "0x000000000000000000000000078cf0753dd50f7c56f20b3ae02719ea199be2eb", + ], + "primaryKey": [ + "0x078cf0753dd50f7C56F20B3Ae02719EA199BE2eb", + ], + "tableId": "0x74620000000000000000000000000000506f736974696f6e0000000000000000", + }, + ], + "subject": [ + "0x078cf0753dd50f7C56F20B3Ae02719EA199BE2eb", + ], + }, ], } `); @@ -108,12 +270,46 @@ describe("query", async () => { expect(result).toMatchInlineSnapshot(` { "subjects": [ - [ - "0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e", - ], - [ - "0x328809Bc894f92807417D2dAD6b7C998c1aFdac6", - ], + { + "records": [ + { + "fields": { + "health": 5n, + "player": "0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e", + }, + "keyTuple": [ + "0x0000000000000000000000001d96f2f6bef1202e4ce1ff6dad0c2cb002861d3e", + ], + "primaryKey": [ + "0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e", + ], + "tableId": "0x746200000000000000000000000000004865616c746800000000000000000000", + }, + ], + "subject": [ + "0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e", + ], + }, + { + "records": [ + { + "fields": { + "health": 5n, + "player": "0x328809Bc894f92807417D2dAD6b7C998c1aFdac6", + }, + "keyTuple": [ + "0x000000000000000000000000328809bc894f92807417d2dad6b7c998c1afdac6", + ], + "primaryKey": [ + "0x328809Bc894f92807417D2dAD6b7C998c1aFdac6", + ], + "tableId": "0x746200000000000000000000000000004865616c746800000000000000000000", + }, + ], + "subject": [ + "0x328809Bc894f92807417D2dAD6b7C998c1aFdac6", + ], + }, ], } `); @@ -131,10 +327,30 @@ describe("query", async () => { expect(result).toMatchInlineSnapshot(` { "subjects": [ - [ - 3, - 5, - ], + { + "records": [ + { + "fields": { + "terrainType": 2, + "x": 3, + "y": 5, + }, + "keyTuple": [ + "0x0000000000000000000000000000000000000000000000000000000000000003", + "0x0000000000000000000000000000000000000000000000000000000000000005", + ], + "primaryKey": [ + 3, + 5, + ], + "tableId": "0x746200000000000000000000000000005465727261696e000000000000000000", + }, + ], + "subject": [ + 3, + 5, + ], + }, ], } `); @@ -154,9 +370,27 @@ describe("query", async () => { expect(result).toMatchInlineSnapshot(` { "subjects": [ - [ - "0xdBa86119a787422C593ceF119E40887f396024E2", - ], + { + "records": [ + { + "fields": { + "player": "0xdBa86119a787422C593ceF119E40887f396024E2", + "x": 100, + "y": 100, + }, + "keyTuple": [ + "0x000000000000000000000000dba86119a787422c593cef119e40887f396024e2", + ], + "primaryKey": [ + "0xdBa86119a787422C593ceF119E40887f396024E2", + ], + "tableId": "0x74620000000000000000000000000000506f736974696f6e0000000000000000", + }, + ], + "subject": [ + "0xdBa86119a787422C593ceF119E40887f396024E2", + ], + }, ], } `); diff --git a/packages/store-sync/src/query-cache/query.ts b/packages/store-sync/src/query-cache/query.ts index 3117b48626..243ad4aadf 100644 --- a/packages/store-sync/src/query-cache/query.ts +++ b/packages/store-sync/src/query-cache/query.ts @@ -1,24 +1,26 @@ -import { Query, QueryResult, Tables, queryToResultSubject } from "./common"; -import { evaluate } from "@latticexyz/common/type-utils"; -import { QueryCacheStore } from "./createStore"; -import { findSubjects } from "@latticexyz/query"; +import { Query } from "./common"; +import { QueryCacheStore, extractTables } from "./createStore"; +import { SubjectRecords, findSubjects } from "@latticexyz/query"; import { queryToWire } from "./queryToWire"; -// TODO: return matching records alongside subjects? because the record subset may be smaller than what querying for records with matching subjects +// TODO: take in query input type so we can narrow result types -export async function query< - store extends QueryCacheStore, - query extends Query, - tables extends Tables = store extends QueryCacheStore ? tables : Tables, ->(store: store, query: query): Promise>> { +export type QueryResult = { + subjects: readonly SubjectRecords[]; +}; + +export async function query>>( + store: store, + query: query, +): Promise { const { tables, records } = store.getState(); - const result = findSubjects({ + const subjects = findSubjects({ records, query: queryToWire(tables, query), }); return { - subjects: result.subjects as unknown as readonly queryToResultSubject[], + subjects, }; } diff --git a/packages/store-sync/src/query-cache/subjectsToWire.ts b/packages/store-sync/src/query-cache/subjectsToWire.ts index 8e48772d09..c076313e50 100644 --- a/packages/store-sync/src/query-cache/subjectsToWire.ts +++ b/packages/store-sync/src/query-cache/subjectsToWire.ts @@ -1,5 +1,5 @@ import { TableSubject, Tables } from "./common"; -import { TableSubject as WireTableSubject } from "@latticexyz/query"; +import { QuerySubject } from "@latticexyz/query"; // TODO: validate // - all subject types match @@ -10,7 +10,7 @@ export function subjectsToWire( subjects: { [tableName in keyof tables]?: TableSubject; }, -): readonly WireTableSubject[] { +): readonly QuerySubject[] { // TODO: validate `tables` contains all tables used `subjects` map // TODO: validate that subject field names exist in table schema return Object.entries(subjects).map(([tableName, subject]) => ({ diff --git a/packages/store-sync/src/query-cache/subscribeToQuery.test.ts b/packages/store-sync/src/query-cache/subscribeToQuery.test.ts index 53fa30f19d..016f308a50 100644 --- a/packages/store-sync/src/query-cache/subscribeToQuery.test.ts +++ b/packages/store-sync/src/query-cache/subscribeToQuery.test.ts @@ -1,6 +1,6 @@ import { beforeAll, describe, expect, it } from "vitest"; import { createHydratedStore } from "./test/createHydratedStore"; -import { QueryResultSubjectChange, subscribeToQuery } from "./subscribeToQuery"; +import { subscribeToQuery } from "./subscribeToQuery"; import { deployMockGame, worldAbi } from "../../test/mockGame"; import { writeContract } from "viem/actions"; import { Address, keccak256, parseEther, stringToHex } from "viem"; @@ -8,7 +8,7 @@ import { privateKeyToAccount } from "viem/accounts"; import { testClient } from "../../test/common"; import { combineLatest, filter, firstValueFrom, map, scan, shareReplay } from "rxjs"; import { waitForTransaction } from "./test/waitForTransaction"; -import { QueryResultSubject } from "@latticexyz/query"; +import { SubjectEvent } from "@latticexyz/query"; const henryAccount = privateKeyToAccount(keccak256(stringToHex("henry"))); @@ -22,7 +22,7 @@ describe("subscribeToQuery", async () => { it("can get players with a position", async () => { const { store, fetchLatestLogs } = await createHydratedStore(worldAddress); - const { subjects$, subjectChanges$ } = await subscribeToQuery(store, { + const { subjects$ } = subscribeToQuery(store, { from: { Position: ["player"], }, @@ -30,39 +30,91 @@ describe("subscribeToQuery", async () => { const latest$ = combineLatest({ subjects$: subjects$.pipe( - scan((values, value) => [...values, value], [] as readonly (readonly QueryResultSubject[])[]), - map((values) => ({ count: values.length, value: values.at(-1) })), - ), - subjectChanges$: subjectChanges$.pipe( - scan((values, value) => [...values, value], [] as readonly (readonly QueryResultSubjectChange[])[]), + scan((values, value) => [...values, value], [] as readonly (readonly SubjectEvent[])[]), map((values) => ({ count: values.length, value: values.at(-1) })), ), }).pipe(shareReplay(1)); expect(await firstValueFrom(latest$)).toMatchInlineSnapshot(` { - "subjectChanges$": { + "subjects$": { "count": 1, "value": [ { + "record": { + "fields": { + "player": "0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e", + "x": 1, + "y": -1, + }, + "keyTuple": [ + "0x0000000000000000000000001d96f2f6bef1202e4ce1ff6dad0c2cb002861d3e", + ], + "primaryKey": [ + "0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e", + ], + "tableId": "0x74620000000000000000000000000000506f736974696f6e0000000000000000", + }, "subject": [ "0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e", ], "type": "enter", }, { + "record": { + "fields": { + "player": "0x328809Bc894f92807417D2dAD6b7C998c1aFdac6", + "x": 3, + "y": 5, + }, + "keyTuple": [ + "0x000000000000000000000000328809bc894f92807417d2dad6b7c998c1afdac6", + ], + "primaryKey": [ + "0x328809Bc894f92807417D2dAD6b7C998c1aFdac6", + ], + "tableId": "0x74620000000000000000000000000000506f736974696f6e0000000000000000", + }, "subject": [ "0x328809Bc894f92807417D2dAD6b7C998c1aFdac6", ], "type": "enter", }, { + "record": { + "fields": { + "player": "0x078cf0753dd50f7C56F20B3Ae02719EA199BE2eb", + "x": 3, + "y": 5, + }, + "keyTuple": [ + "0x000000000000000000000000078cf0753dd50f7c56f20b3ae02719ea199be2eb", + ], + "primaryKey": [ + "0x078cf0753dd50f7C56F20B3Ae02719EA199BE2eb", + ], + "tableId": "0x74620000000000000000000000000000506f736974696f6e0000000000000000", + }, "subject": [ "0x078cf0753dd50f7C56F20B3Ae02719EA199BE2eb", ], "type": "enter", }, { + "record": { + "fields": { + "player": "0xdBa86119a787422C593ceF119E40887f396024E2", + "x": 100, + "y": 100, + }, + "keyTuple": [ + "0x000000000000000000000000dba86119a787422c593cef119e40887f396024e2", + ], + "primaryKey": [ + "0xdBa86119a787422C593ceF119E40887f396024E2", + ], + "tableId": "0x74620000000000000000000000000000506f736974696f6e0000000000000000", + }, "subject": [ "0xdBa86119a787422C593ceF119E40887f396024E2", ], @@ -70,23 +122,6 @@ describe("subscribeToQuery", async () => { }, ], }, - "subjects$": { - "count": 1, - "value": [ - [ - "0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e", - ], - [ - "0x328809Bc894f92807417D2dAD6b7C998c1aFdac6", - ], - [ - "0x078cf0753dd50f7C56F20B3Ae02719EA199BE2eb", - ], - [ - "0xdBa86119a787422C593ceF119E40887f396024E2", - ], - ], - }, } `); @@ -104,10 +139,24 @@ describe("subscribeToQuery", async () => { expect(await firstValueFrom(latest$)).toMatchInlineSnapshot(` { - "subjectChanges$": { + "subjects$": { "count": 2, "value": [ { + "record": { + "fields": { + "player": "0x5f2cC8fb10299751348e1b10f5F1Ba47820B1cB8", + "x": 1, + "y": 2, + }, + "keyTuple": [ + "0x0000000000000000000000005f2cc8fb10299751348e1b10f5f1ba47820b1cb8", + ], + "primaryKey": [ + "0x5f2cC8fb10299751348e1b10f5F1Ba47820B1cB8", + ], + "tableId": "0x74620000000000000000000000000000506f736974696f6e0000000000000000", + }, "subject": [ "0x5f2cC8fb10299751348e1b10f5F1Ba47820B1cB8", ], @@ -115,26 +164,6 @@ describe("subscribeToQuery", async () => { }, ], }, - "subjects$": { - "count": 2, - "value": [ - [ - "0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e", - ], - [ - "0x328809Bc894f92807417D2dAD6b7C998c1aFdac6", - ], - [ - "0x078cf0753dd50f7C56F20B3Ae02719EA199BE2eb", - ], - [ - "0xdBa86119a787422C593ceF119E40887f396024E2", - ], - [ - "0x5f2cC8fb10299751348e1b10f5F1Ba47820B1cB8", - ], - ], - }, } `); }); @@ -142,7 +171,7 @@ describe("subscribeToQuery", async () => { it("can get players at position (3, 5)", async () => { const { store, fetchLatestLogs } = await createHydratedStore(worldAddress); - const { subjects$, subjectChanges$ } = await subscribeToQuery(store, { + const { subjects$ } = subscribeToQuery(store, { from: { Position: ["player"], }, @@ -154,27 +183,51 @@ describe("subscribeToQuery", async () => { const latest$ = combineLatest({ subjects$: subjects$.pipe( - scan((values, value) => [...values, value], [] as readonly (readonly QueryResultSubject[])[]), - map((values) => ({ count: values.length, value: values.at(-1) })), - ), - subjectChanges$: subjectChanges$.pipe( - scan((values, value) => [...values, value], [] as readonly (readonly QueryResultSubjectChange[])[]), + scan((values, value) => [...values, value], [] as readonly (readonly SubjectEvent[])[]), map((values) => ({ count: values.length, value: values.at(-1) })), ), }).pipe(shareReplay(1)); expect(await firstValueFrom(latest$)).toMatchInlineSnapshot(` { - "subjectChanges$": { + "subjects$": { "count": 1, "value": [ { + "record": { + "fields": { + "player": "0x328809Bc894f92807417D2dAD6b7C998c1aFdac6", + "x": 3, + "y": 5, + }, + "keyTuple": [ + "0x000000000000000000000000328809bc894f92807417d2dad6b7c998c1afdac6", + ], + "primaryKey": [ + "0x328809Bc894f92807417D2dAD6b7C998c1aFdac6", + ], + "tableId": "0x74620000000000000000000000000000506f736974696f6e0000000000000000", + }, "subject": [ "0x328809Bc894f92807417D2dAD6b7C998c1aFdac6", ], "type": "enter", }, { + "record": { + "fields": { + "player": "0x078cf0753dd50f7C56F20B3Ae02719EA199BE2eb", + "x": 3, + "y": 5, + }, + "keyTuple": [ + "0x000000000000000000000000078cf0753dd50f7c56f20b3ae02719ea199be2eb", + ], + "primaryKey": [ + "0x078cf0753dd50f7C56F20B3Ae02719EA199BE2eb", + ], + "tableId": "0x74620000000000000000000000000000506f736974696f6e0000000000000000", + }, "subject": [ "0x078cf0753dd50f7C56F20B3Ae02719EA199BE2eb", ], @@ -182,17 +235,6 @@ describe("subscribeToQuery", async () => { }, ], }, - "subjects$": { - "count": 1, - "value": [ - [ - "0x328809Bc894f92807417D2dAD6b7C998c1aFdac6", - ], - [ - "0x078cf0753dd50f7C56F20B3Ae02719EA199BE2eb", - ], - ], - }, } `); @@ -210,10 +252,24 @@ describe("subscribeToQuery", async () => { expect(await firstValueFrom(latest$)).toMatchInlineSnapshot(` { - "subjectChanges$": { + "subjects$": { "count": 2, "value": [ { + "record": { + "fields": { + "player": "0x5f2cC8fb10299751348e1b10f5F1Ba47820B1cB8", + "x": 3, + "y": 5, + }, + "keyTuple": [ + "0x0000000000000000000000005f2cc8fb10299751348e1b10f5f1ba47820b1cb8", + ], + "primaryKey": [ + "0x5f2cC8fb10299751348e1b10f5F1Ba47820B1cB8", + ], + "tableId": "0x74620000000000000000000000000000506f736974696f6e0000000000000000", + }, "subject": [ "0x5f2cC8fb10299751348e1b10f5F1Ba47820B1cB8", ], @@ -221,20 +277,6 @@ describe("subscribeToQuery", async () => { }, ], }, - "subjects$": { - "count": 2, - "value": [ - [ - "0x328809Bc894f92807417D2dAD6b7C998c1aFdac6", - ], - [ - "0x078cf0753dd50f7C56F20B3Ae02719EA199BE2eb", - ], - [ - "0x5f2cC8fb10299751348e1b10f5F1Ba47820B1cB8", - ], - ], - }, } `); @@ -252,10 +294,24 @@ describe("subscribeToQuery", async () => { expect(await firstValueFrom(latest$)).toMatchInlineSnapshot(` { - "subjectChanges$": { + "subjects$": { "count": 3, "value": [ { + "record": { + "fields": { + "player": "0x5f2cC8fb10299751348e1b10f5F1Ba47820B1cB8", + "x": 3, + "y": 5, + }, + "keyTuple": [ + "0x0000000000000000000000005f2cc8fb10299751348e1b10f5f1ba47820b1cb8", + ], + "primaryKey": [ + "0x5f2cC8fb10299751348e1b10f5F1Ba47820B1cB8", + ], + "tableId": "0x74620000000000000000000000000000506f736974696f6e0000000000000000", + }, "subject": [ "0x5f2cC8fb10299751348e1b10f5F1Ba47820B1cB8", ], @@ -263,17 +319,6 @@ describe("subscribeToQuery", async () => { }, ], }, - "subjects$": { - "count": 3, - "value": [ - [ - "0x328809Bc894f92807417D2dAD6b7C998c1aFdac6", - ], - [ - "0x078cf0753dd50f7C56F20B3Ae02719EA199BE2eb", - ], - ], - }, } `); }); @@ -281,7 +326,7 @@ describe("subscribeToQuery", async () => { it("can get players within the bounds of (-5, -5) and (5, 5)", async () => { const { store, fetchLatestLogs } = await createHydratedStore(worldAddress); - const { subjects$, subjectChanges$ } = await subscribeToQuery(store, { + const { subjects$ } = subscribeToQuery(store, { from: { Position: ["player"], }, @@ -295,33 +340,71 @@ describe("subscribeToQuery", async () => { const latest$ = combineLatest({ subjects$: subjects$.pipe( - scan((values, value) => [...values, value], [] as readonly (readonly QueryResultSubject[])[]), - map((values) => ({ count: values.length, value: values.at(-1) })), - ), - subjectChanges$: subjectChanges$.pipe( - scan((values, value) => [...values, value], [] as readonly (readonly QueryResultSubjectChange[])[]), + scan((values, value) => [...values, value], [] as readonly (readonly SubjectEvent[])[]), map((values) => ({ count: values.length, value: values.at(-1) })), ), }).pipe(shareReplay(1)); expect(await firstValueFrom(latest$)).toMatchInlineSnapshot(` { - "subjectChanges$": { + "subjects$": { "count": 1, "value": [ { + "record": { + "fields": { + "player": "0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e", + "x": 1, + "y": -1, + }, + "keyTuple": [ + "0x0000000000000000000000001d96f2f6bef1202e4ce1ff6dad0c2cb002861d3e", + ], + "primaryKey": [ + "0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e", + ], + "tableId": "0x74620000000000000000000000000000506f736974696f6e0000000000000000", + }, "subject": [ "0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e", ], "type": "enter", }, { + "record": { + "fields": { + "player": "0x328809Bc894f92807417D2dAD6b7C998c1aFdac6", + "x": 3, + "y": 5, + }, + "keyTuple": [ + "0x000000000000000000000000328809bc894f92807417d2dad6b7c998c1afdac6", + ], + "primaryKey": [ + "0x328809Bc894f92807417D2dAD6b7C998c1aFdac6", + ], + "tableId": "0x74620000000000000000000000000000506f736974696f6e0000000000000000", + }, "subject": [ "0x328809Bc894f92807417D2dAD6b7C998c1aFdac6", ], "type": "enter", }, { + "record": { + "fields": { + "player": "0x078cf0753dd50f7C56F20B3Ae02719EA199BE2eb", + "x": 3, + "y": 5, + }, + "keyTuple": [ + "0x000000000000000000000000078cf0753dd50f7c56f20b3ae02719ea199be2eb", + ], + "primaryKey": [ + "0x078cf0753dd50f7C56F20B3Ae02719EA199BE2eb", + ], + "tableId": "0x74620000000000000000000000000000506f736974696f6e0000000000000000", + }, "subject": [ "0x078cf0753dd50f7C56F20B3Ae02719EA199BE2eb", ], @@ -329,20 +412,6 @@ describe("subscribeToQuery", async () => { }, ], }, - "subjects$": { - "count": 1, - "value": [ - [ - "0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e", - ], - [ - "0x328809Bc894f92807417D2dAD6b7C998c1aFdac6", - ], - [ - "0x078cf0753dd50f7C56F20B3Ae02719EA199BE2eb", - ], - ], - }, } `); @@ -360,10 +429,24 @@ describe("subscribeToQuery", async () => { expect(await firstValueFrom(latest$)).toMatchInlineSnapshot(` { - "subjectChanges$": { + "subjects$": { "count": 2, "value": [ { + "record": { + "fields": { + "player": "0x5f2cC8fb10299751348e1b10f5F1Ba47820B1cB8", + "x": 3, + "y": 5, + }, + "keyTuple": [ + "0x0000000000000000000000005f2cc8fb10299751348e1b10f5f1ba47820b1cb8", + ], + "primaryKey": [ + "0x5f2cC8fb10299751348e1b10f5F1Ba47820B1cB8", + ], + "tableId": "0x74620000000000000000000000000000506f736974696f6e0000000000000000", + }, "subject": [ "0x5f2cC8fb10299751348e1b10f5F1Ba47820B1cB8", ], @@ -371,23 +454,6 @@ describe("subscribeToQuery", async () => { }, ], }, - "subjects$": { - "count": 2, - "value": [ - [ - "0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e", - ], - [ - "0x328809Bc894f92807417D2dAD6b7C998c1aFdac6", - ], - [ - "0x078cf0753dd50f7C56F20B3Ae02719EA199BE2eb", - ], - [ - "0x5f2cC8fb10299751348e1b10f5F1Ba47820B1cB8", - ], - ], - }, } `); @@ -405,10 +471,24 @@ describe("subscribeToQuery", async () => { expect(await firstValueFrom(latest$)).toMatchInlineSnapshot(` { - "subjectChanges$": { + "subjects$": { "count": 3, "value": [ { + "record": { + "fields": { + "player": "0x5f2cC8fb10299751348e1b10f5F1Ba47820B1cB8", + "x": 3, + "y": 5, + }, + "keyTuple": [ + "0x0000000000000000000000005f2cc8fb10299751348e1b10f5f1ba47820b1cb8", + ], + "primaryKey": [ + "0x5f2cC8fb10299751348e1b10f5F1Ba47820B1cB8", + ], + "tableId": "0x74620000000000000000000000000000506f736974696f6e0000000000000000", + }, "subject": [ "0x5f2cC8fb10299751348e1b10f5F1Ba47820B1cB8", ], @@ -416,20 +496,6 @@ describe("subscribeToQuery", async () => { }, ], }, - "subjects$": { - "count": 3, - "value": [ - [ - "0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e", - ], - [ - "0x328809Bc894f92807417D2dAD6b7C998c1aFdac6", - ], - [ - "0x078cf0753dd50f7C56F20B3Ae02719EA199BE2eb", - ], - ], - }, } `); }); @@ -437,7 +503,7 @@ describe("subscribeToQuery", async () => { it("can get players that are still alive", async () => { const { store } = await createHydratedStore(worldAddress); - const { subjects$, subjectChanges$ } = await subscribeToQuery(store, { + const { subjects$ } = subscribeToQuery(store, { from: { Position: ["player"], Health: ["player"], @@ -447,27 +513,49 @@ describe("subscribeToQuery", async () => { const latest$ = combineLatest({ subjects$: subjects$.pipe( - scan((values, value) => [...values, value], [] as readonly (readonly QueryResultSubject[])[]), - map((values) => ({ count: values.length, value: values.at(-1) })), - ), - subjectChanges$: subjectChanges$.pipe( - scan((values, value) => [...values, value], [] as readonly (readonly QueryResultSubjectChange[])[]), + scan((values, value) => [...values, value], [] as readonly (readonly SubjectEvent[])[]), map((values) => ({ count: values.length, value: values.at(-1) })), ), }).pipe(shareReplay(1)); expect(await firstValueFrom(latest$)).toMatchInlineSnapshot(` { - "subjectChanges$": { + "subjects$": { "count": 1, "value": [ { + "record": { + "fields": { + "health": 5n, + "player": "0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e", + }, + "keyTuple": [ + "0x0000000000000000000000001d96f2f6bef1202e4ce1ff6dad0c2cb002861d3e", + ], + "primaryKey": [ + "0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e", + ], + "tableId": "0x746200000000000000000000000000004865616c746800000000000000000000", + }, "subject": [ "0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e", ], "type": "enter", }, { + "record": { + "fields": { + "health": 5n, + "player": "0x328809Bc894f92807417D2dAD6b7C998c1aFdac6", + }, + "keyTuple": [ + "0x000000000000000000000000328809bc894f92807417d2dad6b7c998c1afdac6", + ], + "primaryKey": [ + "0x328809Bc894f92807417D2dAD6b7C998c1aFdac6", + ], + "tableId": "0x746200000000000000000000000000004865616c746800000000000000000000", + }, "subject": [ "0x328809Bc894f92807417D2dAD6b7C998c1aFdac6", ], @@ -475,17 +563,6 @@ describe("subscribeToQuery", async () => { }, ], }, - "subjects$": { - "count": 1, - "value": [ - [ - "0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e", - ], - [ - "0x328809Bc894f92807417D2dAD6b7C998c1aFdac6", - ], - ], - }, } `); }); @@ -493,7 +570,7 @@ describe("subscribeToQuery", async () => { it("can get all players in grassland", async () => { const { store } = await createHydratedStore(worldAddress); - const { subjects$, subjectChanges$ } = await subscribeToQuery(store, { + const { subjects$ } = subscribeToQuery(store, { from: { Terrain: ["x", "y"], }, @@ -502,21 +579,33 @@ describe("subscribeToQuery", async () => { const latest$ = combineLatest({ subjects$: subjects$.pipe( - scan((values, value) => [...values, value], [] as readonly (readonly QueryResultSubject[])[]), - map((values) => ({ count: values.length, value: values.at(-1) })), - ), - subjectChanges$: subjectChanges$.pipe( - scan((values, value) => [...values, value], [] as readonly (readonly QueryResultSubjectChange[])[]), + scan((values, value) => [...values, value], [] as readonly (readonly SubjectEvent[])[]), map((values) => ({ count: values.length, value: values.at(-1) })), ), }).pipe(shareReplay(1)); expect(await firstValueFrom(latest$)).toMatchInlineSnapshot(` { - "subjectChanges$": { + "subjects$": { "count": 1, "value": [ { + "record": { + "fields": { + "terrainType": 2, + "x": 3, + "y": 5, + }, + "keyTuple": [ + "0x0000000000000000000000000000000000000000000000000000000000000003", + "0x0000000000000000000000000000000000000000000000000000000000000005", + ], + "primaryKey": [ + 3, + 5, + ], + "tableId": "0x746200000000000000000000000000005465727261696e000000000000000000", + }, "subject": [ 3, 5, @@ -525,15 +614,6 @@ describe("subscribeToQuery", async () => { }, ], }, - "subjects$": { - "count": 1, - "value": [ - [ - 3, - 5, - ], - ], - }, } `); }); @@ -541,7 +621,7 @@ describe("subscribeToQuery", async () => { it("can get all players without health (e.g. spectator)", async () => { const { store } = await createHydratedStore(worldAddress); - const { subjects$, subjectChanges$ } = await subscribeToQuery(store, { + const { subjects$ } = subscribeToQuery(store, { from: { Position: ["player"], }, @@ -552,21 +632,31 @@ describe("subscribeToQuery", async () => { const latest$ = combineLatest({ subjects$: subjects$.pipe( - scan((values, value) => [...values, value], [] as readonly (readonly QueryResultSubject[])[]), - map((values) => ({ count: values.length, value: values.at(-1) })), - ), - subjectChanges$: subjectChanges$.pipe( - scan((values, value) => [...values, value], [] as readonly (readonly QueryResultSubjectChange[])[]), + scan((values, value) => [...values, value], [] as readonly (readonly SubjectEvent[])[]), map((values) => ({ count: values.length, value: values.at(-1) })), ), }).pipe(shareReplay(1)); expect(await firstValueFrom(latest$)).toMatchInlineSnapshot(` { - "subjectChanges$": { + "subjects$": { "count": 1, "value": [ { + "record": { + "fields": { + "player": "0xdBa86119a787422C593ceF119E40887f396024E2", + "x": 100, + "y": 100, + }, + "keyTuple": [ + "0x000000000000000000000000dba86119a787422c593cef119e40887f396024e2", + ], + "primaryKey": [ + "0xdBa86119a787422C593ceF119E40887f396024E2", + ], + "tableId": "0x74620000000000000000000000000000506f736974696f6e0000000000000000", + }, "subject": [ "0xdBa86119a787422C593ceF119E40887f396024E2", ], @@ -574,14 +664,6 @@ describe("subscribeToQuery", async () => { }, ], }, - "subjects$": { - "count": 1, - "value": [ - [ - "0xdBa86119a787422C593ceF119E40887f396024E2", - ], - ], - }, } `); }); @@ -589,7 +671,7 @@ describe("subscribeToQuery", async () => { it("emits new subjects when initial matching set is empty", async () => { const { store, fetchLatestLogs } = await createHydratedStore(worldAddress); - const { subjects$, subjectChanges$ } = await subscribeToQuery(store, { + const { subjects$ } = subscribeToQuery(store, { from: { Position: ["player"], }, @@ -601,21 +683,13 @@ describe("subscribeToQuery", async () => { const latest$ = combineLatest({ subjects$: subjects$.pipe( - scan((values, value) => [...values, value], [] as readonly (readonly QueryResultSubject[])[]), - map((values) => ({ count: values.length, value: values.at(-1) })), - ), - subjectChanges$: subjectChanges$.pipe( - scan((values, value) => [...values, value], [] as readonly (readonly QueryResultSubjectChange[])[]), + scan((values, value) => [...values, value], [] as readonly (readonly SubjectEvent[])[]), map((values) => ({ count: values.length, value: values.at(-1) })), ), }).pipe(shareReplay(1)); expect(await firstValueFrom(latest$)).toMatchInlineSnapshot(` { - "subjectChanges$": { - "count": 1, - "value": [], - }, "subjects$": { "count": 1, "value": [], @@ -637,10 +711,24 @@ describe("subscribeToQuery", async () => { expect(await firstValueFrom(latest$)).toMatchInlineSnapshot(` { - "subjectChanges$": { + "subjects$": { "count": 2, "value": [ { + "record": { + "fields": { + "player": "0x5f2cC8fb10299751348e1b10f5F1Ba47820B1cB8", + "x": 999, + "y": 999, + }, + "keyTuple": [ + "0x0000000000000000000000005f2cc8fb10299751348e1b10f5f1ba47820b1cb8", + ], + "primaryKey": [ + "0x5f2cC8fb10299751348e1b10f5F1Ba47820B1cB8", + ], + "tableId": "0x74620000000000000000000000000000506f736974696f6e0000000000000000", + }, "subject": [ "0x5f2cC8fb10299751348e1b10f5F1Ba47820B1cB8", ], @@ -648,14 +736,6 @@ describe("subscribeToQuery", async () => { }, ], }, - "subjects$": { - "count": 2, - "value": [ - [ - "0x5f2cC8fb10299751348e1b10f5F1Ba47820B1cB8", - ], - ], - }, } `); }); @@ -663,7 +743,7 @@ describe("subscribeToQuery", async () => { it("emits changed subjects when subscribing some time after initial query", async () => { const { store, fetchLatestLogs } = await createHydratedStore(worldAddress); - const { subjects, subjects$, subjectChanges$ } = await subscribeToQuery(store, { + const { subjects, subjects$ } = subscribeToQuery(store, { from: { Position: ["player"], }, @@ -673,14 +753,50 @@ describe("subscribeToQuery", async () => { ], }); - expect(subjects).toMatchInlineSnapshot(` + expect(await subjects).toMatchInlineSnapshot(` [ - [ - "0x328809Bc894f92807417D2dAD6b7C998c1aFdac6", - ], - [ - "0x078cf0753dd50f7C56F20B3Ae02719EA199BE2eb", - ], + { + "records": [ + { + "fields": { + "player": "0x328809Bc894f92807417D2dAD6b7C998c1aFdac6", + "x": 3, + "y": 5, + }, + "keyTuple": [ + "0x000000000000000000000000328809bc894f92807417d2dad6b7c998c1afdac6", + ], + "primaryKey": [ + "0x328809Bc894f92807417D2dAD6b7C998c1aFdac6", + ], + "tableId": "0x74620000000000000000000000000000506f736974696f6e0000000000000000", + }, + ], + "subject": [ + "0x328809Bc894f92807417D2dAD6b7C998c1aFdac6", + ], + }, + { + "records": [ + { + "fields": { + "player": "0x078cf0753dd50f7C56F20B3Ae02719EA199BE2eb", + "x": 3, + "y": 5, + }, + "keyTuple": [ + "0x000000000000000000000000078cf0753dd50f7c56f20b3ae02719ea199be2eb", + ], + "primaryKey": [ + "0x078cf0753dd50f7C56F20B3Ae02719EA199BE2eb", + ], + "tableId": "0x74620000000000000000000000000000506f736974696f6e0000000000000000", + }, + ], + "subject": [ + "0x078cf0753dd50f7C56F20B3Ae02719EA199BE2eb", + ], + }, ] `); @@ -698,26 +814,32 @@ describe("subscribeToQuery", async () => { const latest$ = combineLatest({ subjects$: subjects$.pipe( - scan((values, value) => [...values, value], [] as readonly (readonly QueryResultSubject[])[]), - map((values) => ({ count: values.length, value: values.at(-1) })), - ), - subjectChanges$: subjectChanges$.pipe( - scan((values, value) => [...values, value], [] as readonly (readonly QueryResultSubjectChange[])[]), + scan((values, value) => [...values, value], [] as readonly (readonly SubjectEvent[])[]), map((values) => ({ count: values.length, value: values.at(-1) })), ), }).pipe(shareReplay(1)); // we expect two emissions for by this point: initial subjects + subjects changed since starting the subscriptions - expect( - await firstValueFrom( - latest$.pipe(filter((latest) => latest.subjects$.count === 2 && latest.subjectChanges$.count === 2)), - ), - ).toMatchInlineSnapshot(` + expect(await firstValueFrom(latest$.pipe(filter((latest) => latest.subjects$.count === 2)))).toMatchInlineSnapshot(` { - "subjectChanges$": { + "subjects$": { "count": 2, "value": [ { + "record": { + "fields": { + "player": "0x5f2cC8fb10299751348e1b10f5F1Ba47820B1cB8", + "x": 3, + "y": 5, + }, + "keyTuple": [ + "0x0000000000000000000000005f2cc8fb10299751348e1b10f5f1ba47820b1cb8", + ], + "primaryKey": [ + "0x5f2cC8fb10299751348e1b10f5F1Ba47820B1cB8", + ], + "tableId": "0x74620000000000000000000000000000506f736974696f6e0000000000000000", + }, "subject": [ "0x5f2cC8fb10299751348e1b10f5F1Ba47820B1cB8", ], @@ -725,20 +847,6 @@ describe("subscribeToQuery", async () => { }, ], }, - "subjects$": { - "count": 2, - "value": [ - [ - "0x328809Bc894f92807417D2dAD6b7C998c1aFdac6", - ], - [ - "0x078cf0753dd50f7C56F20B3Ae02719EA199BE2eb", - ], - [ - "0x5f2cC8fb10299751348e1b10f5F1Ba47820B1cB8", - ], - ], - }, } `); @@ -754,16 +862,26 @@ describe("subscribeToQuery", async () => { ); await fetchLatestLogs(); - expect( - await firstValueFrom( - latest$.pipe(filter((latest) => latest.subjects$.count === 3 && latest.subjectChanges$.count === 3)), - ), - ).toMatchInlineSnapshot(` + expect(await firstValueFrom(latest$.pipe(filter((latest) => latest.subjects$.count === 3)))).toMatchInlineSnapshot(` { - "subjectChanges$": { + "subjects$": { "count": 3, "value": [ { + "record": { + "fields": { + "player": "0x5f2cC8fb10299751348e1b10f5F1Ba47820B1cB8", + "x": 3, + "y": 5, + }, + "keyTuple": [ + "0x0000000000000000000000005f2cc8fb10299751348e1b10f5f1ba47820b1cb8", + ], + "primaryKey": [ + "0x5f2cC8fb10299751348e1b10f5F1Ba47820B1cB8", + ], + "tableId": "0x74620000000000000000000000000000506f736974696f6e0000000000000000", + }, "subject": [ "0x5f2cC8fb10299751348e1b10f5F1Ba47820B1cB8", ], @@ -771,17 +889,6 @@ describe("subscribeToQuery", async () => { }, ], }, - "subjects$": { - "count": 3, - "value": [ - [ - "0x328809Bc894f92807417D2dAD6b7C998c1aFdac6", - ], - [ - "0x078cf0753dd50f7C56F20B3Ae02719EA199BE2eb", - ], - ], - }, } `); }); diff --git a/packages/store-sync/src/query-cache/subscribeToQuery.ts b/packages/store-sync/src/query-cache/subscribeToQuery.ts index 4611f80e86..7fe1832ad8 100644 --- a/packages/store-sync/src/query-cache/subscribeToQuery.ts +++ b/packages/store-sync/src/query-cache/subscribeToQuery.ts @@ -1,105 +1,118 @@ -import { Observable, distinctUntilChanged, map, scan } from "rxjs"; -import isEqual from "fast-deep-equal"; -import { QueryResultSubject, findSubjects } from "@latticexyz/query"; -import { Query, Tables } from "./common"; -import { QueryCacheStore } from "./createStore"; +import { Observable } from "rxjs"; +import { SubjectEvent, SubjectRecord, SubjectRecords, findSubjects } from "@latticexyz/query"; +import { Query } from "./common"; +import { QueryCacheStore, extractTables } from "./createStore"; import { queryToWire } from "./queryToWire"; -export type QueryResultSubjectChange = { - // TODO: naming - // is enter/exit better than add/remove? what about enter/exit vs entered/exited? in/out? - readonly type: "enter" | "exit"; - readonly subject: QueryResultSubject; -}; +function getId({ subject, record }: SubjectRecord): string { + // TODO: memoize + return JSON.stringify([subject, record.primaryKey]); +} + +function flattenSubjectRecords(subjects: readonly SubjectRecords[]): readonly SubjectRecord[] { + return subjects.flatMap((subject) => + subject.records.map((record) => ({ + subject: subject.subject, + record, + })), + ); +} + +function subjectEvents(prev: readonly SubjectRecord[], next: readonly SubjectRecord[]): readonly SubjectEvent[] { + const prevSet = new Set(prev.map((record) => getId(record))); + const nextSet = new Set(next.map((record) => getId(record))); + + const enters = next.filter((record) => !prevSet.has(getId(record))); + const exits = prev.filter((record) => !nextSet.has(getId(record))); + const changes = next.filter((nextRecord) => { + const prevRecord = prev.find((record) => getId(record) === getId(nextRecord)); + // TODO: improve this so we're not dependent on field order + return prevRecord && JSON.stringify(prevRecord.record.fields) !== JSON.stringify(nextRecord.record.fields); + }); + + return [ + ...enters.map((record) => ({ ...record, type: "enter" as const })), + ...exits.map((record) => ({ ...record, type: "exit" as const })), + ...changes.map((record) => ({ ...record, type: "change" as const })), + ]; +} // TODO: decide if this whole thing is returned in a promise or just `subjects` // TODO: return matching records alongside subjects? because the record subset may be smaller than what querying for records with matching subjects -// eslint-disable-next-line @typescript-eslint/no-unused-vars -type SubscribeToQueryResult = { +// TODO: stronger types +export type SubscribeToQueryResult = { /** * Set of initial matching subjects for query. */ - subjects: readonly QueryResultSubject[]; - /** - * Stream of matching subjects for query. First emission is the same as `subjects`. - */ - subjects$: Observable; + subjects: Promise; /** * Stream of subject changes for query. * First emission will be an `enter` for each item in `subjects`, or an empty array if no matches. - * Each emission after that will only be the subjects that have changed (have entered or exited the result set). + * Each emission after that will only be the subjects that have changed (entered/exited the result set, or the underlying record changed). */ - subjectChanges$: Observable; + subjects$: Observable; }; -export async function subscribeToQuery< - store extends QueryCacheStore, - query extends Query, - tables extends Tables = store extends QueryCacheStore ? tables : Tables, ->(store: store, query: query): Promise { - const { tables, records: initialRecords } = store.getState(); +export function subscribeToQuery>>( + store: store, + query: query, +): SubscribeToQueryResult { + const { tables, records: initialTableRecords } = store.getState(); const wireQuery = queryToWire(tables, query); const initialSubjects = findSubjects({ - records: Object.values(initialRecords), + records: Object.values(initialTableRecords), query: wireQuery, - }).subjects; + }); - function createSubjectStream(): Observable { - return new Observable(function subscribe(subscriber) { + function createSubjectStream(): Observable { + return new Observable(function subscribe(subscriber) { // return initial results immediately - subscriber.next(initialSubjects); + const initialRecords: readonly SubjectRecord[] = flattenSubjectRecords(initialSubjects); + subscriber.next(subjectEvents([], initialRecords)); + + let previousRecords = initialRecords; // if records have changed between query and subscription, reevaluate - const { records } = store.getState(); - if (records !== initialRecords) { - subscriber.next( + const { records: tableRecords } = store.getState(); + if (tableRecords !== initialTableRecords) { + const nextSubjectRecords = flattenSubjectRecords( findSubjects({ - records: Object.values(records), + records: Object.values(tableRecords), query: wireQuery, - }).subjects, + }), ); + const events = subjectEvents(previousRecords, nextSubjectRecords); + if (events.length) { + subscriber.next(events); + previousRecords = nextSubjectRecords; + } } // then listen for changes to records and reevaluate const unsub = store.subscribe((state, prevState) => { if (state.records !== prevState.records) { - subscriber.next( + const nextSubjectRecords = flattenSubjectRecords( findSubjects({ records: Object.values(state.records), query: wireQuery, - }).subjects, + }), ); + const events = subjectEvents(previousRecords, nextSubjectRecords); + if (events.length) { + subscriber.next(events); + previousRecords = nextSubjectRecords; + } } }); return () => void unsub(); - }).pipe(distinctUntilChanged(isEqual)); + }); } const subjects$ = createSubjectStream(); - const subjectChanges$ = createSubjectStream().pipe( - scan( - (acc, curr) => ({ prev: acc.curr, curr }), - { prev: [], curr: [] }, - ), - map(({ prev, curr }) => { - const prevSet = new Set(prev.map((subject) => JSON.stringify(subject))); - const currSet = new Set(curr.map((subject) => JSON.stringify(subject))); - - const enter = curr.filter((subject) => !prevSet.has(JSON.stringify(subject))); - const exit = prev.filter((subject) => !currSet.has(JSON.stringify(subject))); - - return [ - ...enter.map((subject) => ({ type: "enter" as const, subject })), - ...exit.map((subject) => ({ type: "exit" as const, subject })), - ]; - }), - ); - return { - subjects: initialSubjects, + subjects: new Promise((resolve) => resolve(initialSubjects)), subjects$, - subjectChanges$, }; }