diff --git a/src/modules/indexer/price-snapshot-writer.ts b/src/modules/indexer/price-snapshot-writer.ts new file mode 100644 index 00000000..3feabd37 --- /dev/null +++ b/src/modules/indexer/price-snapshot-writer.ts @@ -0,0 +1,82 @@ +// src/modules/indexer/price-snapshot-writer.ts +// Price snapshot writer that skips the write when the price is unchanged +// from the most recent stored snapshot for that creator (#763). +// +// Recording a snapshot whose price equals the previous one wastes storage +// and adds flat, redundant points to the price history chart. This writer +// compares the incoming price against the latest CreatorPriceHistory row +// and only inserts when the price actually moved. The first snapshot for a +// creator is always written because there is nothing to compare against. +// +// Note: the per-trade indexer path (upsertPriceSnapshot) intentionally keeps +// recording every trade, including unchanged prices, because the full-window +// trade TWAP depends on it (#880). Use this writer for periodic/sampled +// snapshots, not for the trade log. + +import { prisma } from '../../utils/prisma.utils'; +import { logger } from '../../utils/logger.utils'; + +export type PriceSnapshotWriteResult = + | { written: true; reason: 'first' | 'changed' } + | { written: false; reason: 'unchanged' }; + +export type PriceSnapshotWriterDeps = { + /** Returns the most recent stored snapshot for the creator, or null. */ + findLatest: (creatorId: string) => Promise<{ price: bigint } | null>; + /** Persists a new snapshot row for the creator. */ + create: (creatorId: string, price: bigint) => Promise; +}; + +const defaultDeps: PriceSnapshotWriterDeps = { + findLatest: (creatorId) => + prisma.creatorPriceHistory.findFirst({ + where: { creatorId }, + orderBy: { recordedAt: 'desc' }, + select: { price: true }, + }), + create: (creatorId, price) => + prisma.creatorPriceHistory.create({ + data: { creatorId, price }, + }), +}; + +/** + * Writes a price snapshot for a creator unless the price is unchanged from + * the most recent stored snapshot. + * + * - No previous snapshot: always written (`reason: 'first'`). + * - Price differs from the previous snapshot: written (`reason: 'changed'`). + * - Price equals the previous snapshot: skipped, no database write + * (`reason: 'unchanged'`). + */ +export async function writePriceSnapshot( + creatorId: string, + price: bigint, + deps?: Partial +): Promise { + const findLatest = deps?.findLatest ?? defaultDeps.findLatest; + const create = deps?.create ?? defaultDeps.create; + + const latest = await findLatest(creatorId); + + if (latest && latest.price === price) { + logger.debug( + { creator_id: creatorId, price: price.toString() }, + 'price-snapshot-writer: skipped (price unchanged)' + ); + return { written: false, reason: 'unchanged' }; + } + + await create(creatorId, price); + + const reason = latest ? 'changed' : 'first'; + logger.debug( + { + creator_id: creatorId, + new_price: price.toString(), + previous_price: latest ? latest.price.toString() : null, + }, + `price-snapshot-writer: written (${reason})` + ); + return { written: true, reason }; +} diff --git a/src/modules/indexer/price-snapshot-writer.unit.test.ts b/src/modules/indexer/price-snapshot-writer.unit.test.ts new file mode 100644 index 00000000..101ce69f --- /dev/null +++ b/src/modules/indexer/price-snapshot-writer.unit.test.ts @@ -0,0 +1,115 @@ +// src/modules/indexer/price-snapshot-writer.unit.test.ts +// Unit tests for #763: the price snapshot writer skips the write when the +// new price equals the most recent stored snapshot for that creator. +// +// Uses jest mocks; no database required. + +jest.mock('../../utils/prisma.utils', () => ({ + prisma: { + creatorPriceHistory: { + findFirst: jest.fn(), + create: jest.fn(), + }, + }, +})); + +jest.mock('../../utils/logger.utils', () => ({ + logger: { + debug: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + }, +})); + +import { prisma } from '../../utils/prisma.utils'; +import { writePriceSnapshot } from './price-snapshot-writer'; + +const mockPrisma = prisma as unknown as { + creatorPriceHistory: { + findFirst: jest.Mock; + create: jest.Mock; + }; +}; + +const CREATOR_ID = 'creator-763'; + +describe('#763 price snapshot writer', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockPrisma.creatorPriceHistory.findFirst.mockReset(); + mockPrisma.creatorPriceHistory.create.mockReset(); + mockPrisma.creatorPriceHistory.create.mockResolvedValue({ id: 'row' }); + }); + + it('reads the most recent snapshot for the creator', async () => { + mockPrisma.creatorPriceHistory.findFirst.mockResolvedValue(null); + + await writePriceSnapshot(CREATOR_ID, BigInt(1_000_000)); + + expect(mockPrisma.creatorPriceHistory.findFirst).toHaveBeenCalledWith({ + where: { creatorId: CREATOR_ID }, + orderBy: { recordedAt: 'desc' }, + select: { price: true }, + }); + }); + + it('skips the database write when the price is unchanged', async () => { + mockPrisma.creatorPriceHistory.findFirst.mockResolvedValue({ + price: BigInt(1_000_000), + }); + + const result = await writePriceSnapshot(CREATOR_ID, BigInt(1_000_000)); + + expect(mockPrisma.creatorPriceHistory.create).not.toHaveBeenCalled(); + expect(result).toEqual({ written: false, reason: 'unchanged' }); + }); + + it('writes when the price differs from the previous snapshot', async () => { + mockPrisma.creatorPriceHistory.findFirst.mockResolvedValue({ + price: BigInt(1_000_000), + }); + + const result = await writePriceSnapshot(CREATOR_ID, BigInt(1_100_000)); + + expect(mockPrisma.creatorPriceHistory.create).toHaveBeenCalledTimes(1); + expect(mockPrisma.creatorPriceHistory.create).toHaveBeenCalledWith({ + data: { creatorId: CREATOR_ID, price: BigInt(1_100_000) }, + }); + expect(result).toEqual({ written: true, reason: 'changed' }); + }); + + it('always writes the first snapshot for a creator', async () => { + mockPrisma.creatorPriceHistory.findFirst.mockResolvedValue(null); + + const result = await writePriceSnapshot(CREATOR_ID, BigInt(1_000_000)); + + expect(mockPrisma.creatorPriceHistory.create).toHaveBeenCalledTimes(1); + expect(mockPrisma.creatorPriceHistory.create).toHaveBeenCalledWith({ + data: { creatorId: CREATOR_ID, price: BigInt(1_000_000) }, + }); + expect(result).toEqual({ written: true, reason: 'first' }); + }); + + it('uses injected dependencies when provided', async () => { + const findLatest = jest.fn().mockResolvedValue({ price: BigInt(5) }); + const create = jest.fn().mockResolvedValue(undefined); + + const unchanged = await writePriceSnapshot(CREATOR_ID, BigInt(5), { + findLatest, + create, + }); + const changed = await writePriceSnapshot(CREATOR_ID, BigInt(6), { + findLatest, + create, + }); + + expect(findLatest).toHaveBeenCalledWith(CREATOR_ID); + expect(create).toHaveBeenCalledTimes(1); + expect(create).toHaveBeenCalledWith(CREATOR_ID, BigInt(6)); + expect(unchanged).toEqual({ written: false, reason: 'unchanged' }); + expect(changed).toEqual({ written: true, reason: 'changed' }); + expect(mockPrisma.creatorPriceHistory.findFirst).not.toHaveBeenCalled(); + expect(mockPrisma.creatorPriceHistory.create).not.toHaveBeenCalled(); + }); +});