mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-15 01:12:11 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89129a5d97 | ||
|
|
ecdfa16f79 | ||
|
|
ef77f759b5 |
@@ -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
|
||||
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Doc } from '../_generated/dataModel'
|
||||
import { isPublicSkillDoc } from './globalStats'
|
||||
|
||||
export type PublicUser = Pick<
|
||||
Doc<'users'>,
|
||||
@@ -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'
|
||||
|
||||
@@ -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'])
|
||||
|
||||
const skillStatEvents = defineTable({
|
||||
skillId: v.id('skills'),
|
||||
kind: v.union(
|
||||
@@ -574,6 +580,7 @@ export default defineSchema({
|
||||
skillDailyStats,
|
||||
skillLeaderboards,
|
||||
skillStatBackfillState,
|
||||
globalStats,
|
||||
skillStatEvents,
|
||||
skillStatUpdateCursors,
|
||||
comments,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
@@ -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) => {
|
||||
@@ -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: () => ({
|
||||
@@ -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: () => ({
|
||||
@@ -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: () => ({
|
||||
@@ -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) => {
|
||||
|
||||
+72
-14
@@ -24,6 +24,12 @@ import {
|
||||
canHealSkillOwnershipByGitHubProviderAccountId,
|
||||
getGitHubProviderAccountId,
|
||||
} from './lib/githubIdentity'
|
||||
import {
|
||||
adjustGlobalPublicSkillsCount,
|
||||
countPublicSkillsForGlobalStats,
|
||||
getPublicSkillVisibilityDelta,
|
||||
readGlobalPublicSkillsCount,
|
||||
} from './lib/globalStats'
|
||||
import { buildTrendingLeaderboard } from './lib/leaderboards'
|
||||
import { deriveModerationFlags } from './lib/moderation'
|
||||
import { toPublicSkill, toPublicUser } from './lib/public'
|
||||
@@ -117,6 +123,16 @@ function normalizeScannerSuspiciousReason(reason: string | undefined) {
|
||||
return `${reason.slice(0, -'.suspicious'.length)}.clean`
|
||||
}
|
||||
|
||||
async function adjustGlobalPublicCountForSkillChange(
|
||||
ctx: MutationCtx,
|
||||
previousSkill: Doc<'skills'> | null | undefined,
|
||||
nextSkill: Doc<'skills'> | null | undefined,
|
||||
) {
|
||||
const delta = getPublicSkillVisibilityDelta(previousSkill, nextSkill)
|
||||
if (delta === 0) return
|
||||
await adjustGlobalPublicSkillsCount(ctx, delta)
|
||||
}
|
||||
|
||||
async function getOwnerTrustSignals(
|
||||
ctx: QueryCtx | MutationCtx,
|
||||
owner: Doc<'users'>,
|
||||
@@ -219,7 +235,9 @@ async function hardDeleteSkillStep(
|
||||
if (Object.keys(patch).length) {
|
||||
patch.lastReviewedAt = now
|
||||
patch.updatedAt = now
|
||||
const nextSkill = { ...skill, ...patch }
|
||||
await ctx.db.patch(skill._id, patch)
|
||||
await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill)
|
||||
}
|
||||
|
||||
switch (phase) {
|
||||
@@ -887,7 +905,9 @@ export const clearOwnerSuspiciousFlagsInternal = internalMutation({
|
||||
patch.moderationStatus = 'active'
|
||||
}
|
||||
|
||||
const nextSkill = { ...skill, ...patch }
|
||||
await ctx.db.patch(skill._id, patch)
|
||||
await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill)
|
||||
updated += 1
|
||||
}
|
||||
|
||||
@@ -1462,7 +1482,9 @@ export const report = mutation({
|
||||
})
|
||||
}
|
||||
|
||||
const nextSkill = { ...skill, ...updates }
|
||||
await ctx.db.patch(skill._id, updates)
|
||||
await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill)
|
||||
|
||||
if (shouldAutoHide) {
|
||||
await setSkillEmbeddingsSoftDeleted(ctx, skill._id, true, now)
|
||||
@@ -1632,6 +1654,16 @@ function isCursorParseError(error: unknown) {
|
||||
return false
|
||||
}
|
||||
|
||||
export const countPublicSkills = query({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
const statsCount = await readGlobalPublicSkillsCount(ctx)
|
||||
if (typeof statsCount === 'number') return statsCount
|
||||
// Fallback for uninitialized/missing globalStats storage.
|
||||
return countPublicSkillsForGlobalStats(ctx)
|
||||
},
|
||||
})
|
||||
|
||||
function sortToIndex(
|
||||
sort: 'downloads' | 'stars' | 'installsCurrent' | 'installsAllTime',
|
||||
):
|
||||
@@ -2171,9 +2203,13 @@ export const getSkillsWithNullModerationStatusInternal = internalQuery({
|
||||
export const setSkillModerationStatusActiveInternal = internalMutation({
|
||||
args: { skillId: v.id('skills') },
|
||||
handler: async (ctx, args) => {
|
||||
await ctx.db.patch(args.skillId, {
|
||||
moderationStatus: 'active',
|
||||
})
|
||||
const skill = await ctx.db.get(args.skillId)
|
||||
if (!skill) return
|
||||
|
||||
const patch: Partial<Doc<'skills'>> = { moderationStatus: 'active' }
|
||||
const nextSkill = { ...skill, ...patch }
|
||||
await ctx.db.patch(args.skillId, patch)
|
||||
await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -2279,7 +2315,9 @@ export const applyBanToOwnedSkillsBatchInternal = internalMutation({
|
||||
hiddenCount += 1
|
||||
}
|
||||
|
||||
const nextSkill = { ...skill, ...patch }
|
||||
await ctx.db.patch(skill._id, patch)
|
||||
await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill)
|
||||
await setSkillEmbeddingsSoftDeleted(ctx, skill._id, true, args.bannedAt)
|
||||
}
|
||||
|
||||
@@ -2319,7 +2357,7 @@ export const restoreOwnedSkillsForUnbanBatchInternal = internalMutation({
|
||||
continue
|
||||
}
|
||||
|
||||
await ctx.db.patch(skill._id, {
|
||||
const patch: Partial<Doc<'skills'>> = {
|
||||
softDeletedAt: undefined,
|
||||
moderationStatus: 'active',
|
||||
moderationReason: 'restored.unban',
|
||||
@@ -2327,7 +2365,10 @@ export const restoreOwnedSkillsForUnbanBatchInternal = internalMutation({
|
||||
hiddenBy: undefined,
|
||||
lastReviewedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
}
|
||||
const nextSkill = { ...skill, ...patch }
|
||||
await ctx.db.patch(skill._id, patch)
|
||||
await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill)
|
||||
|
||||
await setSkillEmbeddingsSoftDeleted(ctx, skill._id, false, now)
|
||||
restoredCount += 1
|
||||
@@ -2595,7 +2636,7 @@ export const approveSkillByHashInternal = internalMutation({
|
||||
'Quality gate quarantine is still active. Manual moderation review required.')
|
||||
: undefined
|
||||
|
||||
await ctx.db.patch(skill._id, {
|
||||
const patch: Partial<Doc<'skills'>> = {
|
||||
moderationStatus: nextModerationStatus,
|
||||
moderationReason: nextModerationReason,
|
||||
moderationFlags: newFlags,
|
||||
@@ -2604,7 +2645,10 @@ export const approveSkillByHashInternal = internalMutation({
|
||||
hiddenBy: undefined,
|
||||
lastReviewedAt: nextModerationStatus === 'hidden' ? now : undefined,
|
||||
updatedAt: now,
|
||||
})
|
||||
}
|
||||
const nextSkill = { ...skill, ...patch }
|
||||
await ctx.db.patch(skill._id, patch)
|
||||
await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill)
|
||||
|
||||
// Auto-ban authors of malicious skills (skips moderators/admins)
|
||||
if (isMalicious && skill.ownerUserId) {
|
||||
@@ -2657,7 +2701,7 @@ export const escalateByVtInternal = internalMutation({
|
||||
newFlags = ['flagged.suspicious']
|
||||
}
|
||||
|
||||
const patch: Record<string, unknown> = {
|
||||
const patch: Partial<Doc<'skills'>> = {
|
||||
moderationFlags: newFlags.length ? newFlags : undefined,
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
@@ -2672,7 +2716,9 @@ export const escalateByVtInternal = internalMutation({
|
||||
patch.moderationStatus = 'hidden'
|
||||
}
|
||||
|
||||
const nextSkill = { ...skill, ...patch }
|
||||
await ctx.db.patch(skill._id, patch)
|
||||
await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill)
|
||||
|
||||
// Auto-ban authors of malicious skills
|
||||
if (isMalicious && skill.ownerUserId) {
|
||||
@@ -2962,14 +3008,17 @@ export const setSoftDeleted = mutation({
|
||||
if (!skill) throw new Error('Skill not found')
|
||||
|
||||
const now = Date.now()
|
||||
await ctx.db.patch(skill._id, {
|
||||
const patch: Partial<Doc<'skills'>> = {
|
||||
softDeletedAt: args.deleted ? now : undefined,
|
||||
moderationStatus: args.deleted ? 'hidden' : 'active',
|
||||
hiddenAt: args.deleted ? now : undefined,
|
||||
hiddenBy: args.deleted ? user._id : undefined,
|
||||
lastReviewedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
}
|
||||
const nextSkill = { ...skill, ...patch }
|
||||
await ctx.db.patch(skill._id, patch)
|
||||
await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill)
|
||||
|
||||
await setSkillEmbeddingsSoftDeleted(ctx, skill._id, args.deleted, now)
|
||||
|
||||
@@ -3513,6 +3562,9 @@ export const insertVersion = internalMutation({
|
||||
updatedAt: now,
|
||||
})
|
||||
skill = await ctx.db.get(skillId)
|
||||
if (skill) {
|
||||
await adjustGlobalPublicCountForSkillChange(ctx, null, skill)
|
||||
}
|
||||
}
|
||||
|
||||
if (!skill) throw new Error('Skill creation failed')
|
||||
@@ -3554,7 +3606,7 @@ export const insertVersion = internalMutation({
|
||||
files: args.files,
|
||||
})
|
||||
|
||||
await ctx.db.patch(skill._id, {
|
||||
const patch: Partial<Doc<'skills'>> = {
|
||||
displayName: args.displayName,
|
||||
summary: nextSummary ?? undefined,
|
||||
latestVersionId: versionId,
|
||||
@@ -3567,7 +3619,10 @@ export const insertVersion = internalMutation({
|
||||
quality: qualityRecord ?? skill.quality,
|
||||
moderationFlags: moderationFlags.length ? moderationFlags : undefined,
|
||||
updatedAt: now,
|
||||
})
|
||||
}
|
||||
const nextSkill = { ...skill, ...patch }
|
||||
await ctx.db.patch(skill._id, patch)
|
||||
await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill)
|
||||
|
||||
const badgeMap = await getSkillBadgeMap(ctx, skill._id)
|
||||
const isApproved = Boolean(badgeMap.redactionApproved)
|
||||
@@ -3632,14 +3687,17 @@ export const setSkillSoftDeletedInternal = internalMutation({
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
await ctx.db.patch(skill._id, {
|
||||
const patch: Partial<Doc<'skills'>> = {
|
||||
softDeletedAt: args.deleted ? now : undefined,
|
||||
moderationStatus: args.deleted ? 'hidden' : 'active',
|
||||
hiddenAt: args.deleted ? now : undefined,
|
||||
hiddenBy: args.deleted ? args.userId : undefined,
|
||||
lastReviewedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
}
|
||||
const nextSkill = { ...skill, ...patch }
|
||||
await ctx.db.patch(skill._id, patch)
|
||||
await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill)
|
||||
|
||||
await setSkillEmbeddingsSoftDeleted(ctx, skill._id, args.deleted, now)
|
||||
|
||||
|
||||
@@ -3,6 +3,10 @@ import { internal } from './_generated/api'
|
||||
import type { Doc } from './_generated/dataModel'
|
||||
import type { ActionCtx } from './_generated/server'
|
||||
import { internalAction, internalMutation, internalQuery } from './_generated/server'
|
||||
import {
|
||||
countPublicSkillsForGlobalStats,
|
||||
setGlobalPublicSkillsCount,
|
||||
} from './lib/globalStats'
|
||||
|
||||
const DEFAULT_BATCH_SIZE = 200
|
||||
const MAX_BATCH_SIZE = 1000
|
||||
@@ -299,3 +303,11 @@ export const runReconcileSkillStarCountsInternal = internalAction({
|
||||
function clampInt(value: number, min: number, max: number) {
|
||||
return Math.min(Math.max(value, min), max)
|
||||
}
|
||||
|
||||
export const updateGlobalStatsInternal = internalMutation({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
const count = await countPublicSkillsForGlobalStats(ctx)
|
||||
await setGlobalPublicSkillsCount(ctx, count)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { vi } from 'vitest'
|
||||
|
||||
export const convexReactMocks = {
|
||||
useAction: vi.fn(),
|
||||
useQuery: vi.fn(),
|
||||
usePaginatedQuery: vi.fn(),
|
||||
}
|
||||
|
||||
export function resetConvexReactMocks() {
|
||||
convexReactMocks.useAction.mockReset()
|
||||
convexReactMocks.useQuery.mockReset()
|
||||
convexReactMocks.usePaginatedQuery.mockReset()
|
||||
}
|
||||
|
||||
export function setupDefaultConvexReactMocks() {
|
||||
convexReactMocks.useAction.mockReturnValue(() => Promise.resolve([]))
|
||||
convexReactMocks.useQuery.mockReturnValue(null)
|
||||
}
|
||||
@@ -2,12 +2,15 @@
|
||||
import { act, render } from '@testing-library/react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
convexReactMocks,
|
||||
resetConvexReactMocks,
|
||||
setupDefaultConvexReactMocks,
|
||||
} from './helpers/convexReactMocks'
|
||||
|
||||
import { SkillsIndex } from '../routes/skills/index'
|
||||
|
||||
const navigateMock = vi.fn()
|
||||
const useActionMock = vi.fn()
|
||||
const usePaginatedQueryMock = vi.fn()
|
||||
let searchMock: Record<string, unknown> = {}
|
||||
|
||||
vi.mock('@tanstack/react-router', () => ({
|
||||
@@ -20,17 +23,17 @@ vi.mock('@tanstack/react-router', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('convex/react', () => ({
|
||||
useAction: (...args: unknown[]) => useActionMock(...args),
|
||||
usePaginatedQuery: (...args: unknown[]) => usePaginatedQueryMock(...args),
|
||||
useAction: (...args: unknown[]) => convexReactMocks.useAction(...args),
|
||||
useQuery: (...args: unknown[]) => convexReactMocks.useQuery(...args),
|
||||
usePaginatedQuery: (...args: unknown[]) => convexReactMocks.usePaginatedQuery(...args),
|
||||
}))
|
||||
|
||||
describe('SkillsIndex load-more observer', () => {
|
||||
beforeEach(() => {
|
||||
usePaginatedQueryMock.mockReset()
|
||||
useActionMock.mockReset()
|
||||
resetConvexReactMocks()
|
||||
navigateMock.mockReset()
|
||||
searchMock = {}
|
||||
useActionMock.mockReturnValue(() => Promise.resolve([]))
|
||||
setupDefaultConvexReactMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -39,7 +42,7 @@ describe('SkillsIndex load-more observer', () => {
|
||||
|
||||
it('triggers one request for repeated intersection callbacks', async () => {
|
||||
const loadMorePaginated = vi.fn()
|
||||
usePaginatedQueryMock.mockReturnValue({
|
||||
convexReactMocks.usePaginatedQuery.mockReturnValue({
|
||||
results: [makeListResult('skill-0', 'Skill 0')],
|
||||
status: 'CanLoadMore',
|
||||
loadMore: loadMorePaginated,
|
||||
|
||||
@@ -2,12 +2,15 @@
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
convexReactMocks,
|
||||
resetConvexReactMocks,
|
||||
setupDefaultConvexReactMocks,
|
||||
} from './helpers/convexReactMocks'
|
||||
|
||||
import { SkillsIndex } from '../routes/skills/index'
|
||||
|
||||
const navigateMock = vi.fn()
|
||||
const useActionMock = vi.fn()
|
||||
const usePaginatedQueryMock = vi.fn()
|
||||
let searchMock: Record<string, unknown> = {}
|
||||
|
||||
vi.mock('@tanstack/react-router', () => ({
|
||||
@@ -20,19 +23,19 @@ vi.mock('@tanstack/react-router', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('convex/react', () => ({
|
||||
useAction: (...args: unknown[]) => useActionMock(...args),
|
||||
usePaginatedQuery: (...args: unknown[]) => usePaginatedQueryMock(...args),
|
||||
useAction: (...args: unknown[]) => convexReactMocks.useAction(...args),
|
||||
useQuery: (...args: unknown[]) => convexReactMocks.useQuery(...args),
|
||||
usePaginatedQuery: (...args: unknown[]) => convexReactMocks.usePaginatedQuery(...args),
|
||||
}))
|
||||
|
||||
describe('SkillsIndex', () => {
|
||||
beforeEach(() => {
|
||||
usePaginatedQueryMock.mockReset()
|
||||
useActionMock.mockReset()
|
||||
resetConvexReactMocks()
|
||||
navigateMock.mockReset()
|
||||
searchMock = {}
|
||||
useActionMock.mockReturnValue(() => Promise.resolve([]))
|
||||
setupDefaultConvexReactMocks()
|
||||
// Default: return empty results with Exhausted status
|
||||
usePaginatedQueryMock.mockReturnValue({
|
||||
convexReactMocks.usePaginatedQuery.mockReturnValue({
|
||||
results: [],
|
||||
status: 'Exhausted',
|
||||
loadMore: vi.fn(),
|
||||
@@ -47,7 +50,7 @@ describe('SkillsIndex', () => {
|
||||
it('requests the first skills page', () => {
|
||||
render(<SkillsIndex />)
|
||||
// usePaginatedQuery should be called with the API endpoint and sort/dir args
|
||||
expect(usePaginatedQueryMock).toHaveBeenCalledWith(
|
||||
expect(convexReactMocks.usePaginatedQuery).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
{ sort: 'downloads', dir: 'desc', highlightedOnly: false, nonSuspiciousOnly: false },
|
||||
{ initialNumItems: 25 },
|
||||
@@ -61,7 +64,7 @@ describe('SkillsIndex', () => {
|
||||
|
||||
it('shows loading state instead of empty state when pagination is not exhausted', () => {
|
||||
// When status is not 'Exhausted', we should show loading, not "No skills match"
|
||||
usePaginatedQueryMock.mockReturnValue({
|
||||
convexReactMocks.usePaginatedQuery.mockReturnValue({
|
||||
results: [],
|
||||
status: 'CanLoadMore',
|
||||
loadMore: vi.fn(),
|
||||
@@ -72,7 +75,7 @@ describe('SkillsIndex', () => {
|
||||
})
|
||||
|
||||
it('keeps load-more reachable when results are empty but pagination can continue', () => {
|
||||
usePaginatedQueryMock.mockReturnValue({
|
||||
convexReactMocks.usePaginatedQuery.mockReturnValue({
|
||||
results: [],
|
||||
status: 'CanLoadMore',
|
||||
loadMore: vi.fn(),
|
||||
@@ -94,7 +97,7 @@ describe('SkillsIndex', () => {
|
||||
owner: null,
|
||||
ownerHandle: null,
|
||||
}
|
||||
usePaginatedQueryMock.mockReturnValue({
|
||||
convexReactMocks.usePaginatedQuery.mockReturnValue({
|
||||
results: [mockEntry],
|
||||
status: 'LoadingMore',
|
||||
loadMore: vi.fn(),
|
||||
@@ -106,7 +109,7 @@ describe('SkillsIndex', () => {
|
||||
|
||||
it('handles LoadingMore with empty results gracefully', () => {
|
||||
// Edge case: user changes filter while loading more, results become empty
|
||||
usePaginatedQueryMock.mockReturnValue({
|
||||
convexReactMocks.usePaginatedQuery.mockReturnValue({
|
||||
results: [],
|
||||
status: 'LoadingMore',
|
||||
loadMore: vi.fn(),
|
||||
@@ -124,9 +127,9 @@ describe('SkillsIndex', () => {
|
||||
// This tests the hasQuery condition in the empty state logic
|
||||
searchMock = { q: 'nonexistent-skill-xyz' }
|
||||
const actionFn = vi.fn().mockResolvedValue([])
|
||||
useActionMock.mockReturnValue(actionFn)
|
||||
convexReactMocks.useAction.mockReturnValue(actionFn)
|
||||
// Pagination is skipped in search mode, so status stays 'LoadingFirstPage'
|
||||
usePaginatedQueryMock.mockReturnValue({
|
||||
convexReactMocks.usePaginatedQuery.mockReturnValue({
|
||||
results: [],
|
||||
status: 'LoadingFirstPage',
|
||||
loadMore: vi.fn(),
|
||||
@@ -146,13 +149,13 @@ describe('SkillsIndex', () => {
|
||||
it('skips list query and calls search when query is set', async () => {
|
||||
searchMock = { q: 'remind' }
|
||||
const actionFn = vi.fn().mockResolvedValue([])
|
||||
useActionMock.mockReturnValue(actionFn)
|
||||
convexReactMocks.useAction.mockReturnValue(actionFn)
|
||||
vi.useFakeTimers()
|
||||
|
||||
render(<SkillsIndex />)
|
||||
|
||||
// usePaginatedQuery should be called with 'skip' when there's a search query
|
||||
expect(usePaginatedQueryMock).toHaveBeenCalledWith(expect.anything(), 'skip', {
|
||||
expect(convexReactMocks.usePaginatedQuery).toHaveBeenCalledWith(expect.anything(), 'skip', {
|
||||
initialNumItems: 25,
|
||||
})
|
||||
await act(async () => {
|
||||
@@ -182,7 +185,7 @@ describe('SkillsIndex', () => {
|
||||
.fn()
|
||||
.mockResolvedValueOnce(makeSearchResults(25))
|
||||
.mockResolvedValueOnce(makeSearchResults(50))
|
||||
useActionMock.mockReturnValue(actionFn)
|
||||
convexReactMocks.useAction.mockReturnValue(actionFn)
|
||||
vi.useFakeTimers()
|
||||
|
||||
render(<SkillsIndex />)
|
||||
@@ -213,7 +216,7 @@ describe('SkillsIndex', () => {
|
||||
makeSearchEntry({ slug: 'skill-b', displayName: 'Skill B', stars: 5, updatedAt: 200 }),
|
||||
makeSearchEntry({ slug: 'skill-c', displayName: 'Skill C', stars: 4, updatedAt: 999 }),
|
||||
])
|
||||
useActionMock.mockReturnValue(actionFn)
|
||||
convexReactMocks.useAction.mockReturnValue(actionFn)
|
||||
vi.useFakeTimers()
|
||||
|
||||
render(<SkillsIndex />)
|
||||
@@ -238,7 +241,7 @@ describe('SkillsIndex', () => {
|
||||
makeSearchResult('newer-low-score', 'Newer Low Score', 0.1, 2000),
|
||||
makeSearchResult('older-high-score', 'Older High Score', 0.9, 1000),
|
||||
])
|
||||
useActionMock.mockReturnValue(actionFn)
|
||||
convexReactMocks.useAction.mockReturnValue(actionFn)
|
||||
vi.useFakeTimers()
|
||||
|
||||
render(<SkillsIndex />)
|
||||
@@ -258,7 +261,7 @@ describe('SkillsIndex', () => {
|
||||
searchMock = { nonSuspicious: true }
|
||||
render(<SkillsIndex />)
|
||||
|
||||
expect(usePaginatedQueryMock).toHaveBeenCalledWith(
|
||||
expect(convexReactMocks.usePaginatedQuery).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
{ sort: 'downloads', dir: 'desc', highlightedOnly: false, nonSuspiciousOnly: true },
|
||||
{ initialNumItems: 25 },
|
||||
@@ -269,7 +272,7 @@ describe('SkillsIndex', () => {
|
||||
searchMock = { highlighted: true }
|
||||
render(<SkillsIndex />)
|
||||
|
||||
expect(usePaginatedQueryMock).toHaveBeenCalledWith(
|
||||
expect(convexReactMocks.usePaginatedQuery).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
{ sort: 'downloads', dir: 'desc', highlightedOnly: true, nonSuspiciousOnly: false },
|
||||
{ initialNumItems: 25 },
|
||||
|
||||
@@ -77,17 +77,6 @@ export function useSkillsBrowseModel({
|
||||
setQuery(search.q ?? '')
|
||||
}, [search.q])
|
||||
|
||||
useEffect(() => {
|
||||
if (hasQuery || search.sort) return
|
||||
void navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
sort: 'downloads',
|
||||
}),
|
||||
replace: true,
|
||||
})
|
||||
}, [hasQuery, navigate, search.sort])
|
||||
|
||||
useEffect(() => {
|
||||
if (search.focus === 'search' && searchInputRef.current) {
|
||||
searchInputRef.current.focus()
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||
import { useQuery } from 'convex/react'
|
||||
import { useRef } from 'react'
|
||||
import { api } from '../../../convex/_generated/api'
|
||||
import { parseSort } from './-params'
|
||||
import { SkillsResults } from './-SkillsResults'
|
||||
import { SkillsToolbar } from './-SkillsToolbar'
|
||||
@@ -49,6 +51,9 @@ export function SkillsIndex() {
|
||||
const navigate = Route.useNavigate()
|
||||
const search = Route.useSearch()
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
const totalSkills = useQuery(api.skills.countPublicSkills)
|
||||
const totalSkillsText =
|
||||
typeof totalSkills === 'number' ? totalSkills.toLocaleString('en-US') : null
|
||||
|
||||
const model = useSkillsBrowseModel({
|
||||
navigate,
|
||||
@@ -61,6 +66,7 @@ export function SkillsIndex() {
|
||||
<header className="skills-header-top">
|
||||
<h1 className="section-title" style={{ marginBottom: 8 }}>
|
||||
Skills
|
||||
{totalSkillsText && <span style={{ opacity: 0.55 }}>{` (${totalSkillsText})`}</span>}
|
||||
</h1>
|
||||
<p className="section-subtitle" style={{ marginBottom: 0 }}>
|
||||
{model.isLoadingSkills
|
||||
|
||||
Reference in New Issue
Block a user