diff --git a/convex/commentModeration.ts b/convex/commentModeration.ts index 5d9488e3c..7c4b02a44 100644 --- a/convex/commentModeration.ts +++ b/convex/commentModeration.ts @@ -4,6 +4,7 @@ import type { Id } from "./_generated/dataModel"; import type { ActionCtx, MutationCtx } from "./_generated/server"; import { action, internalAction, internalMutation, internalQuery } from "./functions"; import { assertRole, requireUserFromAction } from "./lib/access"; +import { clampInt } from "./lib/math"; import { buildCommentScamBanReason, isCertainScam, @@ -474,6 +475,3 @@ export const scheduleCommentScamModeration: ReturnType = action({ }, }); -function clampInt(value: number, min: number, max: number) { - return Math.min(Math.max(Math.trunc(value), min), max); -} diff --git a/convex/githubBackups.ts b/convex/githubBackups.ts index 2902b6542..c59ad9e59 100644 --- a/convex/githubBackups.ts +++ b/convex/githubBackups.ts @@ -3,6 +3,7 @@ import { internal } from "./_generated/api"; import type { Doc, Id } from "./_generated/dataModel"; import { action, internalMutation, internalQuery } from "./functions"; import { assertRole, requireUserFromAction } from "./lib/access"; +import { clampInt } from "./lib/math"; const DEFAULT_BATCH_SIZE = 50; const MAX_BATCH_SIZE = 200; @@ -186,6 +187,3 @@ export const syncGitHubBackups: ReturnType = action({ }, }); -function clampInt(value: number, min: number, max: number) { - return Math.max(min, Math.min(max, Math.floor(value))); -} diff --git a/convex/githubBackupsNode.ts b/convex/githubBackupsNode.ts index 046073efc..a62582015 100644 --- a/convex/githubBackupsNode.ts +++ b/convex/githubBackupsNode.ts @@ -5,6 +5,7 @@ import { internal } from "./_generated/api"; import type { Doc } from "./_generated/dataModel"; import type { ActionCtx } from "./_generated/server"; import { internalAction } from "./functions"; +import { clampInt } from "./lib/math"; import { backupSkillToGitHub, deleteGitHubSkillBackup, @@ -317,6 +318,3 @@ export const deleteGitHubBackupForSlugInternal = internalAction({ }, }); -function clampInt(value: number, min: number, max: number) { - return Math.max(min, Math.min(max, Math.floor(value))); -} diff --git a/convex/githubSoulBackups.ts b/convex/githubSoulBackups.ts index bc07ff27b..3d3f7f20c 100644 --- a/convex/githubSoulBackups.ts +++ b/convex/githubSoulBackups.ts @@ -3,6 +3,7 @@ import { internal } from "./_generated/api"; import type { Doc, Id } from "./_generated/dataModel"; import { action, internalMutation, internalQuery } from "./functions"; import { assertRole, requireUserFromAction } from "./lib/access"; +import { clampInt } from "./lib/math"; const DEFAULT_BATCH_SIZE = 50; const MAX_BATCH_SIZE = 200; @@ -165,6 +166,3 @@ export const syncGitHubSoulBackups: ReturnType = action({ }, }); -function clampInt(value: number, min: number, max: number) { - return Math.max(min, Math.min(max, Math.floor(value))); -} diff --git a/convex/githubSoulBackupsNode.ts b/convex/githubSoulBackupsNode.ts index d63e17eba..c68fa5a98 100644 --- a/convex/githubSoulBackupsNode.ts +++ b/convex/githubSoulBackupsNode.ts @@ -5,6 +5,7 @@ import { internal } from "./_generated/api"; import type { Doc } from "./_generated/dataModel"; import type { ActionCtx } from "./_generated/server"; import { internalAction } from "./functions"; +import { clampInt } from "./lib/math"; import { backupSoulToGitHub, fetchGitHubSoulMeta, @@ -181,6 +182,3 @@ export const syncGitHubSoulBackupsInternal = internalAction({ handler: syncGitHubSoulBackupsInternalHandler, }); -function clampInt(value: number, min: number, max: number) { - return Math.max(min, Math.min(max, Math.floor(value))); -} diff --git a/convex/leaderboards.ts b/convex/leaderboards.ts index 63b4d88b0..c06d3da3f 100644 --- a/convex/leaderboards.ts +++ b/convex/leaderboards.ts @@ -1,6 +1,7 @@ import { v } from "convex/values"; import { internal } from "./_generated/api"; import { internalAction, internalMutation, internalQuery } from "./functions"; +import { clampInt } from "./lib/math"; import { buildTrendingEntriesFromDailyRows, getTrendingRange, @@ -136,6 +137,3 @@ export const rebuildTrendingLeaderboardInternal = internalMutation({ }, }); -function clampInt(value: number, min: number, max: number) { - return Math.min(Math.max(value, min), max); -} diff --git a/convex/lib/math.test.ts b/convex/lib/math.test.ts new file mode 100644 index 000000000..77e998fdb --- /dev/null +++ b/convex/lib/math.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { clampInt } from "./math"; + +describe("clampInt", () => { + it("clamps within range", () => { + expect(clampInt(5, 1, 10)).toBe(5); + }); + + it("clamps below min", () => { + expect(clampInt(-3, 1, 10)).toBe(1); + }); + + it("clamps above max", () => { + expect(clampInt(20, 1, 10)).toBe(10); + }); + + it("truncates toward zero", () => { + expect(clampInt(3.9, 1, 10)).toBe(3); + expect(clampInt(-2.9, -5, 5)).toBe(-2); + }); + + it("returns min for NaN", () => { + expect(clampInt(NaN, 1, 10)).toBe(1); + }); + + it("returns min for Infinity", () => { + expect(clampInt(Infinity, 1, 10)).toBe(1); + expect(clampInt(-Infinity, 1, 10)).toBe(1); + }); + + it("handles exact boundaries", () => { + expect(clampInt(1, 1, 10)).toBe(1); + expect(clampInt(10, 1, 10)).toBe(10); + }); +}); diff --git a/convex/lib/math.ts b/convex/lib/math.ts new file mode 100644 index 000000000..ba56b2c4b --- /dev/null +++ b/convex/lib/math.ts @@ -0,0 +1,11 @@ +/** + * Clamp a numeric value to an integer within [min, max]. + * + * Truncates toward zero (like `Math.trunc`), handles NaN / ±Infinity by + * falling back to `min`, and clamps the result to the given range. + */ +export function clampInt(value: number, min: number, max: number): number { + const truncated = Math.trunc(value); + if (!Number.isFinite(truncated)) return min; + return Math.min(max, Math.max(min, truncated)); +} diff --git a/convex/maintenance.ts b/convex/maintenance.ts index 89670c2ab..95d4a8a1a 100644 --- a/convex/maintenance.ts +++ b/convex/maintenance.ts @@ -4,6 +4,7 @@ import type { Doc, Id } from "./_generated/dataModel"; import type { ActionCtx } from "./_generated/server"; import { action, internalAction, internalMutation, internalQuery } from "./functions"; import { assertRole, requireUserFromAction } from "./lib/access"; +import { clampInt } from "./lib/math"; import { buildSkillSummaryBackfillPatch, type ParsedSkillData } from "./lib/skillBackfill"; import { computeQualitySignals, @@ -2066,8 +2067,3 @@ export const backfillDigestIsSuspicious = internalMutation({ }, }); -function clampInt(value: number, min: number, max: number) { - const rounded = Math.trunc(value); - if (!Number.isFinite(rounded)) return min; - return Math.min(max, Math.max(min, rounded)); -} diff --git a/convex/skills.ts b/convex/skills.ts index 951fdda3c..6c1a9a90a 100644 --- a/convex/skills.ts +++ b/convex/skills.ts @@ -14,6 +14,7 @@ import { query, } from "./functions"; import { assertAdmin, assertModerator, requireUser, requireUserFromAction } from "./lib/access"; +import { clampInt } from "./lib/math"; import { getSkillBadgeMap, getSkillBadgeMaps, isSkillHighlighted } from "./lib/badges"; import { scheduleNextBatchIfNeeded } from "./lib/batching"; import { generateChangelogPreview as buildChangelogPreview } from "./lib/changelog"; @@ -6340,10 +6341,6 @@ export const setSkillSoftDeletedInternal = internalMutation({ }, }); -function clampInt(value: number, min: number, max: number) { - const rounded = Number.isFinite(value) ? Math.round(value) : min; - return Math.min(max, Math.max(min, rounded)); -} async function findCanonicalSkillForFingerprint( ctx: { db: MutationCtx["db"] }, diff --git a/convex/souls.ts b/convex/souls.ts index c72867223..3abc5ea60 100644 --- a/convex/souls.ts +++ b/convex/souls.ts @@ -8,6 +8,7 @@ import { embeddingVisibilityFor } from "./lib/embeddingVisibility"; import { toPublicSoul, toPublicUser } from "./lib/public"; import { getFrontmatterValue, hashSkillFiles } from "./lib/skills"; import { generateSoulChangelogPreview } from "./lib/soulChangelog"; +import { clampInt } from "./lib/math"; import { fetchText, type PublishResult, publishSoulVersionForUser } from "./lib/soulPublish"; export { publishSoulVersionForUser } from "./lib/soulPublish"; @@ -671,7 +672,3 @@ export const setSoulSoftDeletedInternal = internalMutation({ }, }); -function clampInt(value: number, min: number, max: number) { - const rounded = Number.isFinite(value) ? Math.round(value) : min; - return Math.min(max, Math.max(min, rounded)); -} diff --git a/convex/statsMaintenance.ts b/convex/statsMaintenance.ts index 9ba1761d1..60abd6fd2 100644 --- a/convex/statsMaintenance.ts +++ b/convex/statsMaintenance.ts @@ -3,6 +3,7 @@ import { internal } from "./_generated/api"; import type { Doc } from "./_generated/dataModel"; import type { ActionCtx } from "./_generated/server"; import { internalAction, internalMutation, internalQuery } from "./functions"; +import { clampInt } from "./lib/math"; import { countPublicSkillsForGlobalStats, isPublicSkillDoc, @@ -301,9 +302,6 @@ export const runReconcileSkillStarCountsInternal = internalAction({ }, }); -function clampInt(value: number, min: number, max: number) { - return Math.min(Math.max(value, min), max); -} /** * Count a page of skillSearchDigest docs and return the partial public count. diff --git a/convex/users.ts b/convex/users.ts index 571b4d760..d3195892d 100644 --- a/convex/users.ts +++ b/convex/users.ts @@ -12,6 +12,7 @@ import { getUserByHandleOrPersonalPublisher, } from "./lib/publishers"; import { toPublicUser } from "./lib/public"; +import { clampInt } from "./lib/math"; import { getLatestActiveReservedHandle, isHandleReservedForAnotherUser, @@ -424,9 +425,6 @@ async function queryUsersForAdminList( }; } -function clampInt(value: number, min: number, max: number) { - return Math.min(Math.max(Math.trunc(value), min), max); -} export const getByHandle = query({ args: { handle: v.string() },