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

LintLevelDetects
index_references_unknown_fielderrorAn index references a column that doesn't exist on the table.
relation_references_unknown_tableerrorA relation targets a table that doesn't exist.
relation_references_unknown_fielderrorA relation's key column doesn't exist — the join can never resolve.
workflow_unknown_targeterrorctx.workflows.get("name") names a workflow that isn't declared.
empty_indexwarnAn index declared with no columns — it narrows nothing.
circular_fkwarnA cycle of one relations (A → B → C → A) — cascade/restrict hazards.
nondeterministic_query_mutationwarnA 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

LintLevelDetects
unindexed_foreign_keyinfoA 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_indexwarnA query(...).filter(...) with no .withIndex() first — loads every row and filters in memory.
duplicate_indexinfoAn index whose columns are a leading prefix of another — pure write/storage overhead. (Unique indexes exempt.)

Security

LintLevelDetects
hardcoded_secreterrorA 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_riskerrorA ctx.sql tagged-template splices an unparameterized string expression into the query — pass request input through a bound placeholder instead.
rls_uncovered_tablewarnA public procedure reads/writes a table that's RLS-protected elsewhere but has no .use(rls(...)) — an authorization bypass.
mask_uncovered_pii_columnwarnA public procedure reads a table whose columns are masked elsewhere but has no .use(mask(...)) — PII returned in the clear.
auth_api_call_without_headerswarnA ctx.authApi.<method>(...) call omits headers, so better-auth skips session authorization and runs with full privilege.
public_mutation_without_ratelimitwarnA 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_captchawarnA public procedure writes a user/session/account table (or calls ctx.mail) with no CAPTCHA — add verifyTurnstile or protectPublic({ captcha }).
public_arg_uses_anywarnA public procedure accepts a v.any() argument — unvalidated input. Give it a concrete validator.
admin_route_without_guardwarnAn httpRoute on an admin/privileged-looking path whose handler references no auth/admin guard.
unbounded_string_arginfoA public v.string() argument with no max-length bound — a storage/DoS abuse vector. Add a length .check.
container_public_internetinfoA container with enableInternet unset (defaults on) — egress billed per GB, wider attack surface.

Operational

LintLevelDetects
table_without_insertinfoNo function inserts into the table — dead schema, or seeded out-of-band.
workflow_unusedinfoA declared workflow is never started — dead code (still billed), or external.
container_oversized_instanceinfoA 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.

LintLevelDetects
hot_shardwarnOne shard takes >50% of a shardBy(...) function's traffic while siblings idle (fires above a minimum request volume).
index_utilizationinfo/warnTwo checks: a dead index with zero recorded reads (info), and a hot unindexed scan — a table full-scanned ≥25× with no index (warn).
constraint_validatorwarnSampled 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_column guards.
  • Workflows — what the workflow_* lints guard.