Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions Backend/src/stats/cache/stats-cache.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { StatsCacheService } from './stats-cache.service';

describe('StatsCacheService', () => {
let config: { get: jest.Mock };
let cache: StatsCacheService;

beforeEach(() => {
config = { get: jest.fn().mockReturnValue(30_000) };
cache = new StatsCacheService(config as any);
jest.useFakeTimers().setSystemTime(new Date('2026-01-01T00:00:00.000Z'));
});

afterEach(() => {
jest.useRealTimers();
});

it('returns the cached value without re-invoking fn within the TTL', async () => {
const fn = jest.fn().mockResolvedValue('value');

await cache.getOrSet('key', fn);
await cache.getOrSet('key', fn);

expect(fn).toHaveBeenCalledTimes(1);
});

it('re-invokes fn once the TTL has expired', async () => {
const fn = jest.fn().mockResolvedValueOnce('first').mockResolvedValueOnce('second');

await cache.getOrSet('key', fn, 1_000);
jest.advanceTimersByTime(1_001);
const second = await cache.getOrSet('key', fn, 1_000);

expect(fn).toHaveBeenCalledTimes(2);
expect(second).toBe('second');
});

it('clear() forces the next call to re-invoke fn', async () => {
const fn = jest.fn().mockResolvedValue('value');

await cache.getOrSet('key', fn);
cache.clear();
await cache.getOrSet('key', fn);

expect(fn).toHaveBeenCalledTimes(2);
});
});
37 changes: 37 additions & 0 deletions Backend/src/stats/cache/stats-cache.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';

interface CacheEntry {
value: unknown;
expiresAt: number;
}

/**
* Small in-memory TTL cache for aggregate stats queries. Not distributed —
* fine for a single backend instance; a shared cache (Redis) would be a
* follow-up if this ever runs behind more than one instance.
*/
@Injectable()
export class StatsCacheService {
private readonly store = new Map<string, CacheEntry>();
private readonly defaultTtlMs: number;

constructor(private readonly config: ConfigService) {
this.defaultTtlMs = this.config.get<number>('STATS_CACHE_TTL_MS', 30_000);
}

async getOrSet<T>(key: string, fn: () => Promise<T>, ttlMs?: number): Promise<T> {
const cached = this.store.get(key);
if (cached && cached.expiresAt > Date.now()) {
return cached.value as T;
}

const value = await fn();
this.store.set(key, { value, expiresAt: Date.now() + (ttlMs ?? this.defaultTtlMs) });
return value;
}

clear(): void {
this.store.clear();
}
}
31 changes: 31 additions & 0 deletions Backend/src/stats/dto/range-query.dto.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { BadRequestException } from '@nestjs/common';
import { RangeQueryDto } from './range-query.dto';

describe('RangeQueryDto.resolveRange', () => {
it('defaults to the last 7 days when from/to are omitted', () => {
const { from, to } = RangeQueryDto.resolveRange({});
const days = (to.getTime() - from.getTime()) / (24 * 60 * 60 * 1000);
expect(days).toBeCloseTo(7, 1);
});

it('rejects a range wider than 90 days', () => {
expect(() =>
RangeQueryDto.resolveRange({ from: '2020-01-01T00:00:00.000Z', to: '2020-06-01T00:00:00.000Z' }),
).toThrow(BadRequestException);
});

it('rejects from >= to', () => {
expect(() =>
RangeQueryDto.resolveRange({ from: '2026-01-02T00:00:00.000Z', to: '2026-01-01T00:00:00.000Z' }),
).toThrow(BadRequestException);
});

it('accepts an explicit valid range', () => {
const { from, to } = RangeQueryDto.resolveRange({
from: '2026-01-01T00:00:00.000Z',
to: '2026-01-02T00:00:00.000Z',
});
expect(from.toISOString()).toBe('2026-01-01T00:00:00.000Z');
expect(to.toISOString()).toBe('2026-01-02T00:00:00.000Z');
});
});
45 changes: 45 additions & 0 deletions Backend/src/stats/dto/range-query.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsIn, IsISO8601, IsOptional } from 'class-validator';
import { BadRequestException } from '@nestjs/common';

export const RANGE_BUCKETS = ['hour', 'day', 'week'] as const;
export type RangeBucket = (typeof RANGE_BUCKETS)[number];

const DEFAULT_RANGE_DAYS = 7;
const MAX_RANGE_DAYS = 90;
const DAY_MS = 24 * 60 * 60 * 1000;

export class RangeQueryDto {
@ApiPropertyOptional({ description: 'Range start (ISO 8601). Defaults to 7 days before `to`.' })
@IsOptional()
@IsISO8601()
from?: string;

@ApiPropertyOptional({ description: 'Range end (ISO 8601). Defaults to now.' })
@IsOptional()
@IsISO8601()
to?: string;

@ApiPropertyOptional({ description: 'Bucket granularity', enum: RANGE_BUCKETS, default: 'day' })
@IsOptional()
@IsIn(RANGE_BUCKETS)
bucket?: RangeBucket = 'day';

/**
* Resolves `from`/`to` into concrete `Date`s, applying the "last 7
* days" default and rejecting a range wider than `MAX_RANGE_DAYS`.
*/
static resolveRange(dto: Pick<RangeQueryDto, 'from' | 'to'>): { from: Date; to: Date } {
const to = dto.to ? new Date(dto.to) : new Date();
const from = dto.from ? new Date(dto.from) : new Date(to.getTime() - DEFAULT_RANGE_DAYS * DAY_MS);

if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime()) || from >= to) {
throw new BadRequestException('`from` must be a valid ISO date before `to`');
}
if (to.getTime() - from.getTime() > MAX_RANGE_DAYS * DAY_MS) {
throw new BadRequestException(`Range too large: maximum is ${MAX_RANGE_DAYS} days`);
}

return { from, to };
}
}
10 changes: 10 additions & 0 deletions Backend/src/stats/stats.controller.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Controller, Get } from '@nestjs/common';
import { Controller, Get, Query } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { SkipThrottle } from '@nestjs/throttler';
Expand All @@ -12,6 +13,15 @@ import { QueryStatsEventsDto } from './dto/query-stats-events.dto';
export class StatsController {
constructor(private readonly statsService: StatsService) {}

@Get('overview')
@SkipThrottle()
@ApiOperation({
summary: 'Platform totals: total/active/expired/hidden/signed/anonymous/reports',
description:
'"active" means the gist is not hidden, not expired (expires_at > now), and is_active.',
})
getOverview() {
return this.statsService.getOverview();
@Get('timeseries/gists')
@SkipThrottle()
@ApiOperation({ summary: 'Gists created over time, zero-filled per bucket' })
Expand Down
5 changes: 5 additions & 0 deletions Backend/src/stats/stats.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ import { Module } from '@nestjs/common';

import { StatsService } from './stats.service';
import { StatsController } from './stats.controller';
import { StatsCacheService } from './cache/stats-cache.service';

@Module({
controllers: [StatsController],
providers: [StatsService, StatsCacheService],

@Module({
controllers: [StatsController],
Expand Down
51 changes: 51 additions & 0 deletions Backend/src/stats/stats.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
import { StatsService } from './stats.service';
import { StatsCacheService } from './cache/stats-cache.service';

describe('StatsService.getOverview', () => {
let dataSource: { query: jest.Mock };
let cache: StatsCacheService;
import { BadRequestException } from '@nestjs/common';
import { StatsService } from './stats.service';

Expand All @@ -7,6 +13,51 @@ describe('StatsService', () => {

beforeEach(() => {
dataSource = { query: jest.fn() };
// Real cache with a throwaway ConfigService so getOverview's single
// query is actually exercised, not short-circuited by a mock cache.
cache = new StatsCacheService({ get: jest.fn().mockReturnValue(30_000) } as any);
service = new StatsService(dataSource as any, cache);
});

it('maps each FILTER-clause counter from the aggregate row', async () => {
dataSource.query.mockResolvedValue([
{
total: '10',
active: '6',
expired: '2',
hidden: '1',
unique_authors: '4',
signed: '5',
anonymous: '5',
api_submitted: '3',
indexed_only: '7',
total_reports: '9',
},
]);

const result = await service.getOverview();

expect(result).toEqual({
total: 10,
active: 6,
expired: 2,
hidden: 1,
uniqueAuthors: 4,
signed: 5,
anonymous: 5,
apiSubmitted: 3,
indexedOnly: 7,
totalReports: 9,
});
});

it('caches the result so a second call does not re-query', async () => {
dataSource.query.mockResolvedValue([{}]);

await service.getOverview();
await service.getOverview();

expect(dataSource.query).toHaveBeenCalledTimes(1);
service = new StatsService(dataSource as any);
});

Expand Down
56 changes: 56 additions & 0 deletions Backend/src/stats/stats.service.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,20 @@
import { Injectable } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';

import { StatsCacheService } from './cache/stats-cache.service';

interface OverviewRow {
total: string;
active: string;
expired: string;
hidden: string;
unique_authors: string;
signed: string;
anonymous: string;
api_submitted: string;
indexed_only: string;
total_reports: string;
import { BadRequestException, Injectable } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
Expand Down Expand Up @@ -26,6 +43,45 @@ export interface RecentEvent {

@Injectable()
export class StatsService {
constructor(
@InjectDataSource() private readonly dataSource: DataSource,
private readonly cache: StatsCacheService,
) {}

/**
* #1238 — platform totals in one aggregate query. "Active" means not
* hidden, not expired, and `is_active`.
*/
async getOverview() {
return this.cache.getOrSet('stats:overview', async () => {
const [row] = await this.dataSource.query<OverviewRow[]>(`
SELECT
COUNT(*) AS total,
COUNT(*) FILTER (WHERE NOT hidden AND is_active AND expires_at > NOW()) AS active,
COUNT(*) FILTER (WHERE expires_at <= NOW()) AS expired,
COUNT(*) FILTER (WHERE hidden) AS hidden,
COUNT(DISTINCT author_address) AS unique_authors,
COUNT(*) FILTER (WHERE author_address IS NOT NULL) AS signed,
COUNT(*) FILTER (WHERE author_address IS NULL) AS anonymous,
COUNT(*) FILTER (WHERE tx_hash IS NOT NULL) AS api_submitted,
COUNT(*) FILTER (WHERE tx_hash IS NULL) AS indexed_only,
COALESCE(SUM(report_count), 0) AS total_reports
FROM gists
`);

return {
total: Number(row?.total ?? 0),
active: Number(row?.active ?? 0),
expired: Number(row?.expired ?? 0),
hidden: Number(row?.hidden ?? 0),
uniqueAuthors: Number(row?.unique_authors ?? 0),
signed: Number(row?.signed ?? 0),
anonymous: Number(row?.anonymous ?? 0),
apiSubmitted: Number(row?.api_submitted ?? 0),
indexedOnly: Number(row?.indexed_only ?? 0),
totalReports: Number(row?.total_reports ?? 0),
};
});
constructor(@InjectDataSource() private readonly dataSource: DataSource) {}

/**
Expand Down