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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@
- Skills: keep global sorting across pagination on `/skills` (thanks @CodeBBakGoSu, #98).
- Skills: allow updating skill description/summary from frontmatter on subsequent publishes (#312) (thanks @ianalloway).
- Skills/Web: prevent filtered pagination dead-ends and loading-state flicker on `/skills`; move highlighted browse filtering into server list query (#339) (thanks @Marvae).
- Web: align `/skills` total count with public visibility and format header count (thanks @rknoche6, #76).
- Skills/Web: centralize public visibility checks and keep `globalStats` skill counts in sync incrementally; remove duplicate `/skills` default-sort fallback and share browse test mocks (thanks @rknoche6, #76).

## 0.6.1 - 2026-02-13

Expand Down
7 changes: 7 additions & 0 deletions convex/crons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,13 @@ crons.interval(
{},
)

crons.interval(
'global-stats-update',
{ minutes: 60 },
internal.statsMaintenance.updateGlobalStatsInternal,
{},
)

crons.interval('vt-pending-scans', { minutes: 5 }, internal.vt.pollPendingScans, { batchSize: 100 })

crons.interval('vt-cache-backfill', { minutes: 30 }, internal.vt.backfillActiveSkillsVTCache, {
Expand Down
153 changes: 153 additions & 0 deletions convex/lib/globalStats.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import type { Doc } from '../_generated/dataModel'
import type { MutationCtx, QueryCtx } from '../_generated/server'

export const GLOBAL_STATS_KEY = 'default'
const GLOBAL_STATS_PAGE_SIZE = 500

type SkillVisibilityFields = Pick<
Doc<'skills'>,
'softDeletedAt' | 'moderationStatus' | 'moderationFlags'
>

type DbCtx = Pick<MutationCtx | QueryCtx, 'db'>

export function isPublicSkillDoc(skill: SkillVisibilityFields | null | undefined) {
if (!skill || skill.softDeletedAt) return false
if (skill.moderationStatus && skill.moderationStatus !== 'active') return false
if (skill.moderationFlags?.includes('blocked.malware')) return false
return true
}

export function getPublicSkillVisibilityDelta(
before: SkillVisibilityFields | null | undefined,
after: SkillVisibilityFields | null | undefined,
) {
const beforePublic = isPublicSkillDoc(before)
const afterPublic = isPublicSkillDoc(after)
if (beforePublic === afterPublic) return 0
return afterPublic ? 1 : -1
}

function getErrorMessage(error: unknown) {
if (typeof error === 'string') return error
if (error && typeof error === 'object' && 'message' in error) {
const message = (error as { message?: unknown }).message
if (typeof message === 'string') return message
}
return ''
}

export function isGlobalStatsStorageNotReadyError(error: unknown) {
const message = getErrorMessage(error).toLowerCase()
if (!message) return false
const referencesGlobalStats = message.includes('globalstats') || message.includes('by_key')
if (!referencesGlobalStats) return false
return (
message.includes('table') ||
message.includes('index') ||
message.includes('schema') ||
message.includes('not found') ||
message.includes('does not exist') ||
message.includes('unknown')
)
}

export async function countPublicSkillsForGlobalStats(ctx: DbCtx) {
let count = 0
let cursor: string | null = null

while (true) {
const { page, isDone, continueCursor } = await ctx.db
.query('skills')
.withIndex('by_active_updated', (q) => q.eq('softDeletedAt', undefined))
.order('asc')
.paginate({ cursor, numItems: GLOBAL_STATS_PAGE_SIZE })

for (const skill of page) {
if (isPublicSkillDoc(skill)) {
count += 1
}
}

if (isDone) break
cursor = continueCursor
}

return count
}

export async function setGlobalPublicSkillsCount(
ctx: DbCtx,
count: number,
now = Date.now(),
) {
const normalizedCount = Math.max(0, Math.trunc(Number.isFinite(count) ? count : 0))
try {
const existing = await ctx.db
.query('globalStats')
.withIndex('by_key', (q) => q.eq('key', GLOBAL_STATS_KEY))
.unique()

if (existing) {
await ctx.db.patch(existing._id, { activeSkillsCount: normalizedCount, updatedAt: now })
} else {
await ctx.db.insert('globalStats', {
key: GLOBAL_STATS_KEY,
activeSkillsCount: normalizedCount,
updatedAt: now,
})
}
} catch (error) {
if (isGlobalStatsStorageNotReadyError(error)) return
throw error
}
}

export async function adjustGlobalPublicSkillsCount(
ctx: DbCtx,
delta: number,
now = Date.now(),
) {
const normalizedDelta = Math.trunc(Number.isFinite(delta) ? delta : 0)
if (normalizedDelta === 0) return

let existing:
| {
_id: Doc<'globalStats'>['_id']
activeSkillsCount: number
}
| null
| undefined
try {
existing = await ctx.db
.query('globalStats')
.withIndex('by_key', (q) => q.eq('key', GLOBAL_STATS_KEY))
.unique()
} catch (error) {
if (isGlobalStatsStorageNotReadyError(error)) return
throw error
}

if (!existing) {
// No baseline yet (e.g. fresh deploy). Initialize via full recount once.
const count = await countPublicSkillsForGlobalStats(ctx)
await setGlobalPublicSkillsCount(ctx, count, now)
return
}

const nextCount = Math.max(0, existing.activeSkillsCount + normalizedDelta)
await ctx.db.patch(existing._id, { activeSkillsCount: nextCount, updatedAt: now })
}

export async function readGlobalPublicSkillsCount(ctx: DbCtx) {
try {
const stats = await ctx.db
.query('globalStats')
.withIndex('by_key', (q) => q.eq('key', GLOBAL_STATS_KEY))
.unique()
return stats?.activeSkillsCount ?? null
} catch (error) {
if (isGlobalStatsStorageNotReadyError(error)) return null
throw error
}
}
5 changes: 2 additions & 3 deletions convex/lib/public.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Doc } from '../_generated/dataModel'
import { isPublicSkillDoc } from './globalStats'

export type PublicUser = Pick<
Doc<'users'>,
Expand Down Expand Up @@ -52,9 +53,7 @@ export function toPublicUser(user: Doc<'users'> | null | undefined): PublicUser
}

export function toPublicSkill(skill: Doc<'skills'> | null | undefined): PublicSkill | null {
if (!skill || skill.softDeletedAt) return null
if (skill.moderationStatus && skill.moderationStatus !== 'active') return null
if (skill.moderationFlags?.includes('blocked.malware')) return null
if (!isPublicSkillDoc(skill)) return null
const stats = {
downloads:
typeof skill.statsDownloads === 'number'
Expand Down
7 changes: 7 additions & 0 deletions convex/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,12 @@ const skillStatBackfillState = defineTable({
updatedAt: v.number(),
}).index('by_key', ['key'])

const globalStats = defineTable({
key: v.string(),
activeSkillsCount: v.number(),
updatedAt: v.number(),
}).index('by_key', ['key'])
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Global stats key allows duplicate rows

Medium Severity

globalStats uses a plain by_key index, but writes and reads assume a single row via .unique(). Concurrent initialization paths can insert multiple key: 'default' rows, after which .unique() can throw and break countPublicSkills and mutations that call adjustGlobalPublicSkillsCount.

Additional Locations (2)

Fix in Cursor Fix in Web


const skillStatEvents = defineTable({
skillId: v.id('skills'),
kind: v.union(
Expand Down Expand Up @@ -574,6 +580,7 @@ export default defineSchema({
skillDailyStats,
skillLeaderboards,
skillStatBackfillState,
globalStats,
skillStatEvents,
skillStatUpdateCursors,
comments,
Expand Down
106 changes: 106 additions & 0 deletions convex/skills.countPublicSkills.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { describe, expect, it, vi } from 'vitest'
import { countPublicSkills } from './skills'

type WrappedHandler<TArgs, TResult> = {
_handler: (ctx: unknown, args: TArgs) => Promise<TResult>
}

const countPublicSkillsHandler = (
countPublicSkills as unknown as WrappedHandler<Record<string, never>, number>
)._handler

function makeSkillsQuery(skills: Array<{ softDeletedAt?: number; moderationStatus?: string | null }>) {
return {
withIndex: (name: string) => {
if (name !== 'by_active_updated') throw new Error(`unexpected skills index ${name}`)
return {
order: (dir: string) => {
if (dir !== 'asc') throw new Error(`unexpected skills order ${dir}`)
return {
paginate: async () => ({
page: skills,
isDone: true,
continueCursor: null,
pageStatus: null,
splitCursor: null,
}),
}
},
}
},
}
}

describe('skills.countPublicSkills', () => {
it('returns precomputed global stats count when available', async () => {
const ctx = {
db: {
query: vi.fn((table: string) => {
if (table === 'globalStats') {
return {
withIndex: () => ({
unique: async () => ({ _id: 'globalStats:1', activeSkillsCount: 123 }),
}),
}
}
if (table === 'skills') {
return makeSkillsQuery([])
}
throw new Error(`unexpected table ${table}`)
}),
},
}

const result = await countPublicSkillsHandler(ctx, {})
expect(result).toBe(123)
})

it('falls back to live count when global stats row is missing', async () => {
const ctx = {
db: {
query: vi.fn((table: string) => {
if (table === 'globalStats') {
return {
withIndex: () => ({
unique: async () => null,
}),
}
}
if (table === 'skills') {
return makeSkillsQuery([
{ softDeletedAt: undefined, moderationStatus: 'active' },
{ softDeletedAt: undefined, moderationStatus: 'hidden' },
{ softDeletedAt: undefined, moderationStatus: 'active' },
])
}
throw new Error(`unexpected table ${table}`)
}),
},
}

const result = await countPublicSkillsHandler(ctx, {})
expect(result).toBe(2)
})

it('falls back to live count when globalStats table is unavailable', async () => {
const ctx = {
db: {
query: vi.fn((table: string) => {
if (table === 'globalStats') {
throw new Error('unexpected table globalStats')
}
if (table === 'skills') {
return makeSkillsQuery([
{ softDeletedAt: undefined, moderationStatus: 'active' },
{ softDeletedAt: undefined, moderationStatus: 'active' },
])
}
throw new Error(`unexpected table ${table}`)
}),
},
}

const result = await countPublicSkillsHandler(ctx, {})
expect(result).toBe(2)
})
})
25 changes: 25 additions & 0 deletions convex/skills.rateLimit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,21 @@ const clearOwnerSuspiciousFlagsHandler = (
clearOwnerSuspiciousFlagsInternal as unknown as WrappedHandler<Record<string, unknown>>
)._handler

function buildGlobalStatsQuery(table: string) {
if (table !== 'globalStats') return null
return {
withIndex: (name: string) => {
if (name !== 'by_key') throw new Error(`unexpected globalStats index ${name}`)
return {
unique: async () => ({
_id: 'globalStats:1',
activeSkillsCount: 100,
}),
}
},
}
}

function createPublishArgs(overrides?: Partial<Record<string, unknown>>) {
return {
userId: 'users:owner',
Expand Down Expand Up @@ -67,6 +82,8 @@ describe('skills anti-spam guards', () => {
deletedAt: undefined,
})),
query: vi.fn((table: string) => {
const globalStatsQuery = buildGlobalStatsQuery(table)
if (globalStatsQuery) return globalStatsQuery
if (table === 'skills') {
return {
withIndex: (name: string) => {
Expand Down Expand Up @@ -127,6 +144,8 @@ describe('skills anti-spam guards', () => {
return null
}),
query: vi.fn((table: string) => {
const globalStatsQuery = buildGlobalStatsQuery(table)
if (globalStatsQuery) return globalStatsQuery
if (table === 'skillVersions') {
return {
withIndex: () => ({
Expand Down Expand Up @@ -197,6 +216,8 @@ describe('skills anti-spam guards', () => {
return null
}),
query: vi.fn((table: string) => {
const globalStatsQuery = buildGlobalStatsQuery(table)
if (globalStatsQuery) return globalStatsQuery
if (table === 'skillVersions') {
return {
withIndex: () => ({
Expand Down Expand Up @@ -265,6 +286,8 @@ describe('skills anti-spam guards', () => {
return null
}),
query: vi.fn((table: string) => {
const globalStatsQuery = buildGlobalStatsQuery(table)
if (globalStatsQuery) return globalStatsQuery
if (table === 'skillVersions') {
return {
withIndex: () => ({
Expand Down Expand Up @@ -324,6 +347,8 @@ describe('skills anti-spam guards', () => {
return null
}),
query: vi.fn((table: string) => {
const globalStatsQuery = buildGlobalStatsQuery(table)
if (globalStatsQuery) return globalStatsQuery
if (table === 'skills') {
return {
withIndex: (name: string) => {
Expand Down
Loading