Skip to content

Raw SQL Boundary

All raw SQL in TomoriBot must live in the repository layer: src/utils/db/repositories/. Application code (commands, events, tools, context builders, caches) must call a repository method instead of writing sql\…`` directly. This keeps queries, their validation, and their cache invalidation in one place, and keeps the rest of the codebase decoupled from the schema.

This rule is enforced automatically — see Enforcement below.

A violation is any raw sql/tx tagged template literal outside the repository layer and outside the allow-listed paths:

// ❌ In a command / event / tool / util — NOT allowed
const [row] = await sql`SELECT * FROM users WHERE user_disc_id = ${id}`;
// ✅ The same query, moved into a repository method, called from anywhere
const user = await userRepository.loadByDiscordId(id);

The scanner detects:

  • sql\…`andawait sql`…`` template literals
  • Typed forms including nested generics, e.g. sql<Array<{ id: number }>>\…``
  • Transaction callbacks written as tx\…`insidesql.transaction(async (tx) => …)`

It deliberately does not flag:

  • SQL written inside // line comments or /* … */ block comments
  • Plain strings that merely contain SQL text (only tagged templates count)
  • Identifiers that merely end in sql/tx (e.g. mysql\``, someSql``)
  • sql.transaction(…) / sql.begin(…) orchestration itself (only the query literals inside count) — so a util may open a transaction and hand tx to a repository method without tripping the gate
  1. Pick the repository that owns the table (e.g. usersUserRepository, server_model_configsConfigRepository, personasPersonaRepository). The repositories are singletons exported from src/utils/db/repositories/index.ts.

  2. Add a method that holds the query. Reads return typed rows; writes own their cache invalidation (invalidate after a successful write, in the same code path — never before, never on failure):

    src/utils/db/repositories/UserRepository.ts
    async setSomething(userId: number, value: string): Promise<UserRow | null> {
    const user = await this.update(userId, { some_column: value });
    // update() already invalidates the user cache on success
    return user;
    }
  3. Call the method from the application code, deleting the inline SQL and its now-unused import { sql } from "@/utils/db/client":

    import { userRepository } from "@/utils/db/repositories";
    const user = await userRepository.setSomething(userId, value);
  4. Before inventing a new method, check whether one already exists — duplicated reference-count and lookup queries are a common source of accidental violations.

Two allow-lists in scripts/checks/lib/sqlAudit.ts let SQL live outside repositories/:

  • IGNORE_PATHS — paths skipped entirely. These are the legitimate homes of raw SQL plumbing: the repository layer itself, the DB client, migrations, the migration runner, DB init, and src/types/ (which may embed SQL only in doc comments / string types). You should rarely touch this.

  • EXEMPT_PATHS — individual files allowed to keep raw SQL, each paired with a justification. Hits on these are reported as EXEMPTIONS rather than violations. Current entries are observability/ status helpers, security primitives (crypto/key rotation), the logger, and the RAG service facade.

Prefer moving SQL into a repository. Only add an EXEMPT_PATHS entry when a repository genuinely does not fit (e.g. a security primitive that must own its own table access), and always include a clear reason string:

scripts/checks/lib/sqlAudit.ts
export const EXEMPT_PATHS = new Map<string, string>([
// …existing entries…
["src/utils/security/myPrimitive.ts", "security primitive; owns its own key table access"],
]);

The detector is shared by the CLI gate and a unit test, so they can never disagree:

  • scripts/checks/lib/sqlAudit.ts — the scanner (single source of truth).
  • bun run audit-sql — prints WRITES / READS / EXEMPTIONS and exits non-zero when any violation exists.
  • tests/unit/db/rawSqlBoundary.test.ts — a unit test asserting zero violations on the real tree, plus synthetic tests proving the detector has no false positives (comments/strings) or false negatives (real literals).

Both run as part of bun run vl: audit-sql is the “SQL Audit” item under Code Quality, and the unit test runs in the Unit Tests group.

Terminal window
bun run audit-sql # raw-SQL boundary report; non-zero on violations
bun run check # TypeScript strict mode (catches unused sql imports, etc.)
bun run lint # Biome formatting
bun run vl # full validation suite (includes both of the above)