Convex Doctor Skill
Run through these checks when writing or reviewing Convex backend and client code. Based on the convex-doctor CLI which scores projects 0-100 across 72 rules in 7 categories.
How to use this skill
- Before shipping backend changes, scan the affected files against the rules below.
- Flag violations by severity: error (must fix), warning (should fix), info (consider fixing).
- Suggest the fix inline when flagging.
- If the user has
convex-doctor installed, recommend running npx convex-doctor -v for a full report.
Security (13 rules, 1.5x weight)
| ID |
Severity |
Rule |
| missing-arg-validators |
error |
All query/mutation/action and internal variants must have args validators |
| missing-return-validators |
warning |
Public functions should have returns validators |
| missing-auth-check |
warning |
Public functions should call ctx.auth.getUserIdentity() |
| internal-api-misuse |
error |
Server-to-server calls must use internal.*, not api.* |
| hardcoded-secrets |
error |
No API keys, tokens, or secrets hardcoded in source |
| env-not-gitignored |
error |
.env.local must be in .gitignore |
| spoofable-access-control |
warning |
Do not trust client args like userId or role for access control |
| missing-table-id |
warning |
Use v.id("table") instead of v.string() for document references |
| missing-http-auth |
error |
HTTP action endpoints must include authentication |
| conditional-function-export |
error |
Do not conditionally export Convex functions based on environment |
| generic-mutation-args |
warning |
Do not use v.any() in public mutation args |
| overly-broad-patch |
warning |
Do not ctx.db.patch with spread args that bypass validation |
| http-missing-cors |
warning |
HTTP routes should include CORS headers |
Performance (13 rules, 1.2x weight)
| ID |
Severity |
Rule |
| unbounded-collect |
error |
.collect() without .take(n) limit |
| filter-without-index |
warning |
.filter() scanning entire tables instead of using .withIndex() |
| date-now-in-query |
error |
Date.now() in query functions breaks caching |
| loop-run-mutation |
error |
ctx.runMutation/ctx.runQuery inside loops (N+1) |
| sequential-run-calls |
warning |
Multiple sequential ctx.run* calls in an action |
| unnecessary-run-action |
warning |
ctx.runAction from within an action (same runtime) |
| helper-vs-run |
warning |
ctx.runQuery/ctx.runMutation inside a query or mutation |
| missing-index-on-foreign-key |
warning |
v.id("table") schema field without a corresponding index |
| action-from-client |
warning |
Client calling actions directly instead of mutations |
| collect-then-filter |
warning |
.collect() followed by JS .filter() instead of DB query filters |
| large-document-write |
info |
Inserting documents with 20+ fields |
| no-pagination-for-list |
warning |
Public query with .collect() returning unbounded results |
| missing-pagination-opts-validator |
warning |
.paginate(...) without paginationOptsValidator in args |
Correctness (20 rules, 1.5x weight)
| ID |
Severity |
Rule |
| unwaited-promise |
error |
ctx.db.insert, ctx.runMutation, etc. without await |
| old-function-syntax |
warning |
Legacy function registration syntax |
| db-in-action |
error |
Direct ctx.db.* calls inside actions |
| deprecated-api |
warning |
Deprecated APIs like v.bigint() |
| wrong-runtime-import |
warning |
Incompatible runtime imports |
| direct-function-ref |
warning |
Direct function refs instead of api.*/internal.* |
| missing-unique |
warning |
.first() where .unique() is appropriate |
| query-side-effect |
error |
Side effects (ctx.db.insert/patch/delete) inside queries |
| mutation-in-query |
error |
ctx.runMutation from within a query |
| cron-uses-public-api |
error |
Cron jobs referencing api.* instead of internal.* |
| node-query-mutation |
error |
Queries/mutations in "use node" files |
| scheduler-return-ignored |
info |
ctx.scheduler.runAfter return value not captured |
| non-deterministic-in-query |
warning |
Math.random(), new Date(), crypto in queries |
| replace-vs-patch |
info |
ctx.db.replace semantics reminder |
| generated-code-modified |
error |
Manual edits to _generated/ files |
| unsupported-validator-type |
error |
Unsupported validators (v.map(), v.set()) |
| query-delete-unsupported |
error |
.delete() on query chains |
| cron-helper-method-usage |
warning |
Deprecated crons.hourly/daily/weekly |
| cron-direct-function-reference |
error |
Direct function identifiers in cron methods |
| storage-get-metadata-deprecated |
warning |
Deprecated ctx.storage.getMetadata |
Schema (9 rules, 1.0x weight)
| ID |
Severity |
Rule |
| missing-schema |
warning |
No schema.ts in convex/ |
| deep-nesting |
warning |
Validators nested more than 3 levels deep |
| array-relationships |
warning |
v.array(v.id(...)) that may grow unbounded |
| redundant-index |
warning |
Index that is a prefix of another on the same table |
| too-many-indexes |
info |
Table with 8+ indexes |
| missing-search-index-filter |
info |
Search index without filterFields |
| optional-field-no-default-handling |
warning |
5+ optional fields without undefined handling |
| missing-index-for-query |
warning |
Query filters on a field with no matching index |
| index-name-includes-fields |
warning |
Index name does not include all indexed fields in order |
Architecture (8 rules, 0.8x weight)
| ID |
Severity |
Rule |
| large-handler |
warning |
Handler exceeding 50 lines |
| monolithic-file |
warning |
File with 10+ exported functions |
| duplicated-auth |
warning |
3+ inline auth checks in the same file |
| action-without-scheduling |
info |
Action that could use ctx.scheduler instead |
| no-convex-error |
info |
throw new Error(...) instead of throw new ConvexError(...) |
| mixed-function-types |
info |
File mixing public and internal exports |
| no-helper-functions |
info |
Multiple large handlers with no shared helpers |
| deep-function-chain |
warning |
Action with 5+ ctx.run* calls |
Configuration (5 rules, 1.0x weight)
| ID |
Severity |
Rule |
| missing-convex-json |
warning |
No convex.json in project root |
| missing-auth-config |
error |
Functions use ctx.auth but no auth.config.ts exists |
| missing-generated-code |
warning |
No _generated/ directory |
| outdated-node-version |
warning |
Node version in config is outdated |
| missing-tsconfig |
info |
No tsconfig.json in convex directory |
Client-Side (4 rules, 1.0x weight)
| ID |
Severity |
Rule |
| mutation-in-render |
error |
Mutation invocation during render |
| unhandled-loading-state |
warning |
useQuery result used without checking for undefined |
| action-instead-of-mutation |
info |
useAction where useMutation may suffice |
| missing-convex-provider |
info |
Convex hooks without ConvexProvider in component tree |
Scoring
Health score is 0-100. Each finding deducts points based on severity and category weight, with per-rule caps.
| Score |
Label |
Meaning |
| 85-100 |
Healthy |
Few or no issues |
| 70-84 |
Needs attention |
Some issues worth addressing |
| 50-69 |
Unhealthy |
Significant problems |
| 0-49 |
Critical |
Serious issues requiring immediate attention |
Common fix patterns
Replace .filter() with .withIndex()
// Bad
const docs = await ctx.db.query("tasks").filter((q) => q.eq(q.field("userId"), userId)).collect();
// Good (add index "by_userId" on ["userId"] in schema)
const docs = await ctx.db.query("tasks").withIndex("by_userId", (q) => q.eq("userId", userId)).collect();
Replace loop mutations with batch
// Bad
for (const id of ids) {
await ctx.runMutation(internal.tasks.complete, { taskId: id });
}
// Good: single mutation that handles the batch
const updates = ids.map((id) => ctx.db.patch(id, { completed: true }));
await Promise.all(updates);
Fix Date.now() in queries
// Bad (in a query)
const cutoff = Date.now() - 86400000;
// Good: pass timestamp as an argument from the caller
args: { cutoff: v.number() },
Fix unbounded collect
// Bad
const all = await ctx.db.query("messages").collect();
// Good: paginate or limit
const recent = await ctx.db.query("messages").order("desc").take(50);
CLI reference
npx convex-doctor # basic scan
npx convex-doctor -v # verbose with file paths and line numbers
npx convex-doctor --format json # JSON output for CI
npx convex-doctor --score # score only (prints a number)
npx convex-doctor --diff main # only files changed vs base branch
Source: https://github.com/nooesc/convex-doctor
1---2name: convex-doctor3description: Static analysis checklist for Convex backends covering 72 rules across security, performance, correctness, schema, architecture, configuration, and client-side patterns. Use when writing, reviewing, or auditing Convex code. Trigger on mentions of "convex-doctor", "health score", "static analysis", "anti-patterns", "audit convex", or before shipping backend changes.4---56# Convex Doctor Skill78Run through these checks when writing or reviewing Convex backend and client code. Based on the [convex-doctor](https://github.com/nooesc/convex-doctor) CLI which scores projects 0-100 across 72 rules in 7 categories.910## How to use this skill11121. Before shipping backend changes, scan the affected files against the rules below.132. Flag violations by severity: **error** (must fix), **warning** (should fix), **info** (consider fixing).143. Suggest the fix inline when flagging.154. If the user has `convex-doctor` installed, recommend running `npx convex-doctor -v` for a full report.1617## Security (13 rules, 1.5x weight)1819| ID | Severity | Rule |20|---|---|---|21| missing-arg-validators | error | All query/mutation/action and internal variants must have `args` validators |22| missing-return-validators | warning | Public functions should have `returns` validators |23| missing-auth-check | warning | Public functions should call `ctx.auth.getUserIdentity()` |24| internal-api-misuse | error | Server-to-server calls must use `internal.*`, not `api.*` |25| hardcoded-secrets | error | No API keys, tokens, or secrets hardcoded in source |26| env-not-gitignored | error | `.env.local` must be in `.gitignore` |27| spoofable-access-control | warning | Do not trust client args like `userId` or `role` for access control |28| missing-table-id | warning | Use `v.id("table")` instead of `v.string()` for document references |29| missing-http-auth | error | HTTP action endpoints must include authentication |30| conditional-function-export | error | Do not conditionally export Convex functions based on environment |31| generic-mutation-args | warning | Do not use `v.any()` in public mutation args |32| overly-broad-patch | warning | Do not `ctx.db.patch` with spread args that bypass validation |33| http-missing-cors | warning | HTTP routes should include CORS headers |3435## Performance (13 rules, 1.2x weight)3637| ID | Severity | Rule |38|---|---|---|39| unbounded-collect | error | `.collect()` without `.take(n)` limit |40| filter-without-index | warning | `.filter()` scanning entire tables instead of using `.withIndex()` |41| date-now-in-query | error | `Date.now()` in query functions breaks caching |42| loop-run-mutation | error | `ctx.runMutation`/`ctx.runQuery` inside loops (N+1) |43| sequential-run-calls | warning | Multiple sequential `ctx.run*` calls in an action |44| unnecessary-run-action | warning | `ctx.runAction` from within an action (same runtime) |45| helper-vs-run | warning | `ctx.runQuery`/`ctx.runMutation` inside a query or mutation |46| missing-index-on-foreign-key | warning | `v.id("table")` schema field without a corresponding index |47| action-from-client | warning | Client calling actions directly instead of mutations |48| collect-then-filter | warning | `.collect()` followed by JS `.filter()` instead of DB query filters |49| large-document-write | info | Inserting documents with 20+ fields |50| no-pagination-for-list | warning | Public query with `.collect()` returning unbounded results |51| missing-pagination-opts-validator | warning | `.paginate(...)` without `paginationOptsValidator` in args |5253## Correctness (20 rules, 1.5x weight)5455| ID | Severity | Rule |56|---|---|---|57| unwaited-promise | error | `ctx.db.insert`, `ctx.runMutation`, etc. without `await` |58| old-function-syntax | warning | Legacy function registration syntax |59| db-in-action | error | Direct `ctx.db.*` calls inside actions |60| deprecated-api | warning | Deprecated APIs like `v.bigint()` |61| wrong-runtime-import | warning | Incompatible runtime imports |62| direct-function-ref | warning | Direct function refs instead of `api.*`/`internal.*` |63| missing-unique | warning | `.first()` where `.unique()` is appropriate |64| query-side-effect | error | Side effects (`ctx.db.insert`/`patch`/`delete`) inside queries |65| mutation-in-query | error | `ctx.runMutation` from within a query |66| cron-uses-public-api | error | Cron jobs referencing `api.*` instead of `internal.*` |67| node-query-mutation | error | Queries/mutations in `"use node"` files |68| scheduler-return-ignored | info | `ctx.scheduler.runAfter` return value not captured |69| non-deterministic-in-query | warning | `Math.random()`, `new Date()`, `crypto` in queries |70| replace-vs-patch | info | `ctx.db.replace` semantics reminder |71| generated-code-modified | error | Manual edits to `_generated/` files |72| unsupported-validator-type | error | Unsupported validators (`v.map()`, `v.set()`) |73| query-delete-unsupported | error | `.delete()` on query chains |74| cron-helper-method-usage | warning | Deprecated `crons.hourly`/`daily`/`weekly` |75| cron-direct-function-reference | error | Direct function identifiers in cron methods |76| storage-get-metadata-deprecated | warning | Deprecated `ctx.storage.getMetadata` |7778## Schema (9 rules, 1.0x weight)7980| ID | Severity | Rule |81|---|---|---|82| missing-schema | warning | No `schema.ts` in `convex/` |83| deep-nesting | warning | Validators nested more than 3 levels deep |84| array-relationships | warning | `v.array(v.id(...))` that may grow unbounded |85| redundant-index | warning | Index that is a prefix of another on the same table |86| too-many-indexes | info | Table with 8+ indexes |87| missing-search-index-filter | info | Search index without `filterFields` |88| optional-field-no-default-handling | warning | 5+ optional fields without undefined handling |89| missing-index-for-query | warning | Query filters on a field with no matching index |90| index-name-includes-fields | warning | Index name does not include all indexed fields in order |9192## Architecture (8 rules, 0.8x weight)9394| ID | Severity | Rule |95|---|---|---|96| large-handler | warning | Handler exceeding 50 lines |97| monolithic-file | warning | File with 10+ exported functions |98| duplicated-auth | warning | 3+ inline auth checks in the same file |99| action-without-scheduling | info | Action that could use `ctx.scheduler` instead |100| no-convex-error | info | `throw new Error(...)` instead of `throw new ConvexError(...)` |101| mixed-function-types | info | File mixing public and internal exports |102| no-helper-functions | info | Multiple large handlers with no shared helpers |103| deep-function-chain | warning | Action with 5+ `ctx.run*` calls |104105## Configuration (5 rules, 1.0x weight)106107| ID | Severity | Rule |108|---|---|---|109| missing-convex-json | warning | No `convex.json` in project root |110| missing-auth-config | error | Functions use `ctx.auth` but no `auth.config.ts` exists |111| missing-generated-code | warning | No `_generated/` directory |112| outdated-node-version | warning | Node version in config is outdated |113| missing-tsconfig | info | No `tsconfig.json` in convex directory |114115## Client-Side (4 rules, 1.0x weight)116117| ID | Severity | Rule |118|---|---|---|119| mutation-in-render | error | Mutation invocation during render |120| unhandled-loading-state | warning | `useQuery` result used without checking for `undefined` |121| action-instead-of-mutation | info | `useAction` where `useMutation` may suffice |122| missing-convex-provider | info | Convex hooks without `ConvexProvider` in component tree |123124## Scoring125126Health score is 0-100. Each finding deducts points based on severity and category weight, with per-rule caps.127128| Score | Label | Meaning |129|---|---|---|130| 85-100 | Healthy | Few or no issues |131| 70-84 | Needs attention | Some issues worth addressing |132| 50-69 | Unhealthy | Significant problems |133| 0-49 | Critical | Serious issues requiring immediate attention |134135## Common fix patterns136137### Replace `.filter()` with `.withIndex()`138139```typescript140// Bad141const docs = await ctx.db.query("tasks").filter((q) => q.eq(q.field("userId"), userId)).collect();142143// Good (add index "by_userId" on ["userId"] in schema)144const docs = await ctx.db.query("tasks").withIndex("by_userId", (q) => q.eq("userId", userId)).collect();145```146147### Replace loop mutations with batch148149```typescript150// Bad151for (const id of ids) {152 await ctx.runMutation(internal.tasks.complete, { taskId: id });153}154155// Good: single mutation that handles the batch156const updates = ids.map((id) => ctx.db.patch(id, { completed: true }));157await Promise.all(updates);158```159160### Fix Date.now() in queries161162```typescript163// Bad (in a query)164const cutoff = Date.now() - 86400000;165166// Good: pass timestamp as an argument from the caller167args: { cutoff: v.number() },168```169170### Fix unbounded collect171172```typescript173// Bad174const all = await ctx.db.query("messages").collect();175176// Good: paginate or limit177const recent = await ctx.db.query("messages").order("desc").take(50);178```179180## CLI reference181182```bash183npx convex-doctor # basic scan184npx convex-doctor -v # verbose with file paths and line numbers185npx convex-doctor --format json # JSON output for CI186npx convex-doctor --score # score only (prints a number)187npx convex-doctor --diff main # only files changed vs base branch188```189190Source: https://github.com/nooesc/convex-doctor