diff --git a/apps/s03-indexer/src/queryResultShapes.ts b/apps/s03-indexer/src/queryResultShapes.ts new file mode 100644 index 0000000..51169f1 --- /dev/null +++ b/apps/s03-indexer/src/queryResultShapes.ts @@ -0,0 +1,170 @@ +export type QueryEntityName = "markets" | "orders" | "positions"; + +export type FieldShape = + | "string" + | "number" + | "boolean" + | "date" + | "nullableString" + | "nullableNumber" + | "nullableBoolean" + | "nullableDate" + | NestedShape; + +export type NestedShape = { + fields: Record; +}; + +export const frontendQueryResultShapes: Record = { + markets: { + fields: { + id: "string", + key: "string", + name: "nullableString", + status: "string", + createdBy: "nullableString", + createdLedger: "number", + createdTimestamp: "date", + createdTransactionHash: "string", + marketToken: tokenSummaryShape(), + indexToken: tokenSummaryShape(), + longToken: tokenSummaryShape(), + shortToken: tokenSummaryShape(), + }, + }, + orders: { + fields: { + id: "string", + key: "string", + account: "string", + orderType: "string", + status: "string", + isLong: "nullableBoolean", + sizeDeltaUsd: "nullableString", + collateralDeltaAmount: "nullableString", + triggerPrice: "nullableString", + acceptablePrice: "nullableString", + createdTimestamp: "nullableDate", + updatedTimestamp: "nullableDate", + frozenTimestamp: "nullableDate", + frozenTransactionHash: "nullableString", + executedTimestamp: "nullableDate", + executedTransactionHash: "nullableString", + cancelledTimestamp: "nullableDate", + cancelledTransactionHash: "nullableString", + cancellationReason: "nullableString", + market: marketSummaryShape(), + collateralToken: tokenSummaryWithDecimalsShape(), + }, + }, + positions: { + fields: { + id: "string", + key: "string", + account: "string", + isLong: "boolean", + status: "string", + sizeUsd: "nullableString", + collateralAmount: "nullableString", + averagePrice: "nullableString", + entryFundingRate: "nullableString", + reserveAmount: "nullableString", + realizedPnlUsd: "nullableString", + realizedPnlAmount: "nullableString", + openedLedger: "nullableNumber", + openedTimestamp: "nullableDate", + updatedTimestamp: "nullableDate", + closedTimestamp: "nullableDate", + market: { + fields: { + ...marketSummaryShape().fields, + indexToken: tokenSummaryShape(), + longToken: tokenSummaryShape(), + shortToken: tokenSummaryShape(), + }, + }, + collateralToken: tokenSummaryWithDecimalsShape(), + }, + }, +}; + +export function assertFrontendQueryResultShape( + entityName: QueryEntityName, + node: Record, +): void { + assertShape(entityName, frontendQueryResultShapes[entityName], node); +} + +function assertShape(path: string, shape: NestedShape, value: unknown): void { + if (!isRecord(value)) { + throw new Error(`${path} must be an object`); + } + + const expectedKeys = Object.keys(shape.fields).sort(); + const actualKeys = Object.keys(value).sort(); + if (JSON.stringify(actualKeys) !== JSON.stringify(expectedKeys)) { + throw new Error( + `${path} fields changed. Expected ${expectedKeys.join(", ")}; received ${actualKeys.join(", ")}`, + ); + } + + for (const [field, fieldShape] of Object.entries(shape.fields)) { + assertField(`${path}.${field}`, fieldShape, value[field]); + } +} + +function assertField(path: string, shape: FieldShape, value: unknown): void { + if (typeof shape === "object") { + assertShape(path, shape, value); + return; + } + + if (shape.startsWith("nullable") && value === null) { + return; + } + + const expectedType = shape.replace("nullable", "").toLowerCase(); + if (expectedType === "date") { + if (!(value instanceof Date)) { + throw new Error(`${path} must be a Date or null`); + } + return; + } + + if (typeof value !== expectedType) { + throw new Error(`${path} must be ${shape}`); + } +} + +function tokenSummaryShape(): NestedShape { + return { + fields: { + address: "string", + symbol: "nullableString", + }, + }; +} + +function tokenSummaryWithDecimalsShape(): NestedShape { + return { + fields: { + address: "string", + decimals: "nullableNumber", + symbol: "nullableString", + }, + }; +} + +function marketSummaryShape(): NestedShape { + return { + fields: { + id: "string", + key: "string", + name: "nullableString", + }, + }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/apps/s03-indexer/tests/query-result-shapes.test.ts b/apps/s03-indexer/tests/query-result-shapes.test.ts new file mode 100644 index 0000000..15363df --- /dev/null +++ b/apps/s03-indexer/tests/query-result-shapes.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, test } from "bun:test"; +import { assertFrontendQueryResultShape } from "../src/queryResultShapes"; + +const account = "GB6YQTHA5COVDNKLV6B4ISXYE6A2ZY5ENBZJTQVI7RI4KOCC6JFZ6C7E"; +const timestamp = new Date("2026-06-24T12:00:00Z"); + +const fixtures = seedQueryFixtures(); + +describe("frontend indexer query result shapes", () => { + test("markets query keeps field names and types stable", () => { + const [market] = queryMarkets(fixtures); + + assertFrontendQueryResultShape("markets", market); + expect(Object.keys(market).sort()).toEqual([ + "createdBy", + "createdLedger", + "createdTimestamp", + "createdTransactionHash", + "id", + "indexToken", + "key", + "longToken", + "marketToken", + "name", + "shortToken", + "status", + ]); + }); + + test("positions query keeps frontend-consumed field names and types stable", () => { + const [position] = queryPositionsByAccount(fixtures, account); + + assertFrontendQueryResultShape("positions", position); + expect(position.market).toEqual({ + id: "market:BTC-USD", + key: "BTC-USD", + name: "BTC/USD", + indexToken: { address: "CBTCINDEX", symbol: "BTC" }, + longToken: { address: "CLONGTOKEN", symbol: "BTC" }, + shortToken: { address: "CSHORTTOKEN", symbol: "USDC" }, + }); + expect(typeof position.isLong).toBe("boolean"); + expect(typeof position.sizeUsd).toBe("string"); + }); + + test("orders query keeps frontend-consumed field names and types stable", () => { + const [order] = queryOrdersByAccount(fixtures, account); + + assertFrontendQueryResultShape("orders", order); + expect(order.market).toEqual({ + id: "market:BTC-USD", + key: "BTC-USD", + name: "BTC/USD", + }); + expect(typeof order.orderType).toBe("string"); + expect(typeof order.isLong).toBe("boolean"); + expect(order.cancelledTimestamp).toBeNull(); + }); +}); + +type QueryFixtures = ReturnType; + +function seedQueryFixtures() { + const market = { + id: "market:BTC-USD", + key: "BTC-USD", + name: "BTC/USD", + status: "ACTIVE", + createdBy: account, + createdLedger: 12345, + createdTimestamp: timestamp, + createdTransactionHash: "tx-market", + marketToken: { address: "CMARKETTOKEN", symbol: "BTC-USD" }, + indexToken: { address: "CBTCINDEX", symbol: "BTC" }, + longToken: { address: "CLONGTOKEN", symbol: "BTC" }, + shortToken: { address: "CSHORTTOKEN", symbol: "USDC" }, + }; + + const collateralToken = { + address: "CSHORTTOKEN", + symbol: "USDC", + decimals: 7, + }; + + return { + markets: [market], + positions: [ + { + id: "position:pos-1", + key: "pos-1", + account, + isLong: true, + status: "open", + sizeUsd: "500000000000000000000000000000000", + collateralAmount: "1000000000", + averagePrice: "50000", + entryFundingRate: "0", + reserveAmount: "250000000", + realizedPnlUsd: "0", + realizedPnlAmount: "0", + openedLedger: 12346, + openedTimestamp: timestamp, + updatedTimestamp: timestamp, + closedTimestamp: null, + market: { + id: market.id, + key: market.key, + name: market.name, + indexToken: market.indexToken, + longToken: market.longToken, + shortToken: market.shortToken, + }, + collateralToken, + }, + ], + orders: [ + { + id: "order:ord-1", + key: "ord-1", + account, + orderType: "MarketIncrease", + status: "created", + isLong: true, + sizeDeltaUsd: "100000000000000000000000000000000", + collateralDeltaAmount: "250000000", + triggerPrice: null, + acceptablePrice: "51000", + createdTimestamp: timestamp, + updatedTimestamp: timestamp, + frozenTimestamp: null, + frozenTransactionHash: null, + executedTimestamp: null, + executedTransactionHash: null, + cancelledTimestamp: null, + cancelledTransactionHash: null, + cancellationReason: null, + market: { + id: market.id, + key: market.key, + name: market.name, + }, + collateralToken, + }, + ], + }; +} + +function queryMarkets(store: QueryFixtures): Array> { + return store.markets; +} + +function queryPositionsByAccount( + store: QueryFixtures, + accountId: string, +): Array> { + return store.positions.filter((position) => position.account === accountId); +} + +function queryOrdersByAccount( + store: QueryFixtures, + accountId: string, +): Array> { + return store.orders.filter((order) => order.account === accountId); +}