Advisors
Splinter-style lints that catch schema, performance, and security problems — many before you deploy.
Last updated:
Advisors are lints over your schema and functions, modelled on Supabase's
splinter. Each lint is a pure function
that emits zero or more findings, each tagged with a level
(ERROR/WARN/INFO), a category (SCHEMA/PERFORMANCE/SECURITY), and a
remediation.
What sets Lunora apart from a live-database-only advisor is that most lints are static: they run at codegen/build time against the declared schema and the discovered query/insert/middleware sites, so they catch a problem before it ships. A smaller set is runtime: it needs observed signal from a running shard (full-scan attribution, index hit counts, per-shard traffic, row samples).
Findings surface in the Studio's Advisors domain (Security, RLS, and Insights panels) and on the Home overview. Static findings ride along in the generated code; runtime findings are computed by the Studio backend from each shard's durable metrics.
Static lints
Run against the declared schema alone — deterministic, no running shard needed.
Correctness
| Lint | Level | Detects |
|---|---|---|
index_references_unknown_field | error | An index references a column that doesn't exist on the table. |
relation_references_unknown_table | error | A relation targets a table that doesn't exist. |
relation_references_unknown_field | error | A relation's key column doesn't exist — the join can never resolve. |
workflow_unknown_target | error | ctx.workflows.get("name") names a workflow that isn't declared. |
empty_index | warn | An index declared with no columns — it narrows nothing. |
circular_fk | warn | A cycle of one relations (A → B → C → A) — cascade/restrict hazards. |
nondeterministic_query_mutation | warn | A query/mutation calls Date.now/Math.random/fetch — non-deterministic reads break caching and replay. Use ctx.now for time; move randomness/network into an action. |
Performance
| Lint | Level | Detects |
|---|---|---|
unindexed_foreign_key | info | A foreign-key column with no index leading with it — reads on it full-scan. (Honours SQLite's leftmost-prefix rule; exempts FKs onto _id.) |
filter_without_index | warn | A query(...).filter(...) with no .withIndex() first — loads every row and filters in memory. |
duplicate_index | info | An index whose columns are a leading prefix of another — pure write/storage overhead. (Unique indexes exempt.) |
Security
| Lint | Level | Detects |
|---|---|---|
hardcoded_secret | error | A secret-shaped literal (sk_live…, AKIA…, a PEM header, a high-entropy 32+ char token) checked into lunora/ source — rotate it and move it to a binding/secret. |
sql_injection_risk | error | A ctx.sql tagged-template splices an unparameterized string expression into the query — pass request input through a bound placeholder instead. |
rls_uncovered_table | warn | A public procedure reads/writes a table that's RLS-protected elsewhere but has no .use(rls(...)) — an authorization bypass. |
mask_uncovered_pii_column | warn | A public procedure reads a table whose columns are masked elsewhere but has no .use(mask(...)) — PII returned in the clear. |
auth_api_call_without_headers | warn | A ctx.authApi.<method>(...) call omits headers, so better-auth skips session authorization and runs with full privilege. |
public_mutation_without_ratelimit | warn | A public mutation/action installs no rateLimit — abuse-sensitive names (login/signup/reset/magic/subscribe/contact) raise the urgency. Wrap with protectPublic. |
user_creating_mutation_without_captcha | warn | A public procedure writes a user/session/account table (or calls ctx.mail) with no CAPTCHA — add verifyTurnstile or protectPublic({ captcha }). |
public_arg_uses_any | warn | A public procedure accepts a v.any() argument — unvalidated input. Give it a concrete validator. |
admin_route_without_guard | warn | An httpRoute on an admin/privileged-looking path whose handler references no auth/admin guard. |
unbounded_string_arg | info | A public v.string() argument with no max-length bound — a storage/DoS abuse vector. Add a length .check. |
container_public_internet | info | A container with enableInternet unset (defaults on) — egress billed per GB, wider attack surface. |
Operational
| Lint | Level | Detects |
|---|---|---|
table_without_insert | info | No function inserts into the table — dead schema, or seeded out-of-band. |
workflow_unused | info | A declared workflow is never started — dead code (still billed), or external. |
container_oversized_instance | info | A container on a large instance type — provisioned memory/disk billed while running. |
Runtime lints
Need observed signal from a live shard, aggregated by the Studio backend.
| Lint | Level | Detects |
|---|---|---|
hot_shard | warn | One shard takes >50% of a shardBy(...) function's traffic while siblings idle (fires above a minimum request volume). |
index_utilization | info/warn | Two checks: a dead index with zero recorded reads (info), and a hot unindexed scan — a table full-scanned ≥25× with no index (warn). |
constraint_validator | warn | Sampled rows that violate a declared constraint — a dangling foreign key, a null in a non-optional column, or a duplicate on a unique index. (Bounded sample window.) |
Running advisors
You don't run advisors by hand in normal use — codegen runs the static set on
every lunora dev save and on deploy, and the Studio computes the runtime set
when you open the Advisors panels. The catalog is also available
programmatically:
import { runAdvisor, STATIC_LINTS, RUNTIME_LINTS, ALL_LINTS } from "@lunora/advisor";
const findings = runAdvisor(context, { source: "static" }); // or "runtime"Each finding carries a stable cacheKey so the Studio can dedup across runs and
let you dismiss a specific finding without silencing the whole rule.
See also
- Studio — the Security, RLS, and Insights panels.
- Data masking — what
mask_uncovered_pii_columnguards. - Workflows — what the
workflow_*lints guard.