Skip to content

Doctor ​

Report what DRZL cannot type or enforce in your schema, and what to do about each one.

Usage:

bash
pnpm dlx @drzl/cli doctor [schema] [--json] [--strict] [--constraints [--sql]] [--policies] [-c drzl.config.ts]
bash
npx @drzl/cli doctor [schema] [--json] [--strict] [--constraints [--sql]] [--policies] [-c drzl.config.ts]
bash
yarn dlx @drzl/cli doctor [schema] [--json] [--strict] [--constraints [--sql]] [--policies] [-c drzl.config.ts]
bash
bunx @drzl/cli doctor [schema] [--json] [--strict] [--constraints [--sql]] [--policies] [-c drzl.config.ts]

With no schema argument it reads the schema path out of your drzl.config.*, so a project that already has one needs only drzl doctor. A config without a schema key resolves through your drizzle-kit config, exactly as generate does.

Why this is not analyze ​

drzl analyze prints the whole Analysis as JSON. That is a description of your schema, and reading trouble out of it means knowing which fields mean trouble.

doctor is the other thing: the list of what will silently not work. Both failure modes it reports produce a generated file that exists, compiles and validates nothing:

  • a column DRZL cannot type gets a validator that accepts any value
  • a CHECK constraint DRZL will not translate is simply absent from the output

Example ​

bash
npx @drzl/cli doctor src/db/schema.ts
DRZL doctor  src/db/schema.ts
postgres, 1 table, 11 columns, 12 CHECK constraints

Columns DRZL cannot type  (2)
  These get a validator that accepts any value.

  - Column "balance" on table "accounts" has no known type (SQL type numeric(12,2)), so its
    validator will accept any value.
  - Column "credit" on table "accounts" has no known type (SQL type numeric(12,2)), so its
    validator will accept any value.
    A customType has no runtime shape to read. Declare it with .$type<T>() and turn on
    typedColumns to give the validator the type.

CHECK constraints DRZL does not enforce  (9)
  Your database still enforces these. Nothing DRZL generates does.

  - CHECK "age_or" on "accounts" is not translated: contains OR. Expression: age >= 18 OR age <=
    65
  - CHECK "email_re" on "accounts" is not translated: not a single comparison this version
    understands. Expression: email ~ '^[a-z]+$'
    Only constraints whose meaning is unambiguous are translated, because a validator enforcing
    a guess rejects rows the database accepts. Your database still enforces this one; nothing
    DRZL emits does.

  - CHECK "tags_scalar" on "accounts" compares an array column "tags" against a scalar literal,
    which does not describe it, so it is not translated. Expression: tags = '{}'
    On an array column only cardinality(col) is read, since it is the one comparison that is
    about the array rather than about an element.

11 findings in src/db/schema.ts. None of these stop DRZL generating; they are what it will not
check for you.

A healthy schema says so, and says what was looked at, so a clean run cannot be confused with a command that failed to run:

DRZL doctor  src/db/schema.ts
postgres, 3 tables, 6 columns, 0 CHECK constraints

Nothing to report.
  Every column has a type DRZL can describe.
  Every CHECK constraint is translated into the generated validators.
  Every table has a primary key the generators can use.

What it reports ​

SectionWhat it means
Columns DRZL cannot typeThe emitted validator accepts any value for this column
CHECK constraints DRZL does not enforceThe constraint is in your schema and in your database, and in no schema DRZL emits
Primary keys the generators cannot usegetById, update and delete are keyed on one column
Other findingsEverything else the analyzer said while reading the schema

Four CHECK cases are distinguished, because they have different fixes:

  • Not translated. The shared parser refused the expression and says why: contains OR, right side is not a literal, and so on. See the skip list in Generators → Zod; the same parser serves all five validation generators.
  • Names a column the table does not have. Usually a typo, or a constraint that spans two tables.
  • Compares an array or structured column against a scalar literal. tags = '{}' on a text[] says nothing usable about the array, so it is skipped rather than guessed at. On an array column only cardinality(col) is read.
  • Counts a column whose count JavaScript cannot take the way the database did.octet_length(bin) <= 8 on a MySQL varbinary(8): the value arrives as a string produced by a lossy decode, so neither its characters nor their UTF-8 re-encoding is the number the server took. The same expression on a text or a bytea column is enforced, and is not listed.

A constraint DRZL does translate is not listed. age >= 18 folds into .gte(18) and start_date < end_date becomes an object-level refinement, and listing those would bury the ones that matter.

Other reports ​

Two flags replace the findings above with a different report over the same analysis. They are separate reports rather than extra sections, and passing both is an error rather than a run that silently picks one.

--constraints ​

What the database enforces that the generated schemas do not, and the reverse.

bash
npx @drzl/cli doctor src/db/schema.ts --constraints

The half worth your attention is the second one, because it can lose data. A Drizzle text(name, { enum: [...] }) column is a plain text column: the generated schema refuses anything outside the set, and a migration, a psql session or any other client writes past it. A native pgEnum carries the enum's type name as its SQL type and is enforced by the database, so the two are told apart and only the first is reported.

Add --sql to emit the closing statements alone, with no prose and no colour, for redirecting into a migration:

bash
npx @drzl/cli doctor src/db/schema.ts --constraints --sql > migrations/0002_checks.sql

SQLite gets no statement: ALTER TABLE ... ADD CONSTRAINT is a syntax error there, so the gap carries the reason instead. Neither does unknown, because emitting DDL for a database nobody has named is the kind of guess that ends up in a migration.

Under --strict only the schema-only side counts. A primary key or a foreign key that no per-row validator can check is not a defect anyone can fix.

--policies ​

The row-level security policies each table carries, and what they refuse.

bash
npx @drzl/cli doctor src/db/schema.ts --policies
DRZL row-level security  src/db/schema.ts
postgres, 2 tables under RLS, 2 policies

These tables refuse the operation your generated code performs
  A table under row-level security permits only what a policy grants. The generated service
  still compiles and its return type still promises rows.

  - audit_log everything
      row-level security is on and no permissive policy grants anything, so every read returns
      zero rows and every write is refused, for every role but the table's owner and any role
      with BYPASSRLS
      Close it: declare a policy, or drop the row-level security on this table

  - posts insert
      row-level security is on and no permissive policy grants INSERT, so every insert is
      refused for every role but the table's owner and any role with BYPASSRLS
      Close it: the policy "anyone_inserts" names INSERT but grants nothing; give it a WITH
      CHECK expression

A table with row-level security on and nothing granting a command does not half-work: the read returns zero rows and the write raises new row violates row-level security policy. The generated service over it still compiles and its return type still promises rows, which is why this is worth a report of its own.

Three readings this deliberately does not make, each settled by running it against Postgres rather than by reading the declaration:

  • A table with policies and no .enableRLS() is not unprotected. Declaring any policy makes drizzle-kit emit ALTER TABLE ... ENABLE ROW LEVEL SECURITY on its own, so the flag says nothing about the running database and a report keyed on it would tell you your rules were inert while Postgres enforced them.
  • A write policy with no WITH CHECK is not a hole. A lone FOR INSERT policy carrying no WITH CHECK refuses every insert. It is reported as a door that is shut, not one left open.
  • FOR UPDATE and FOR ALL with a USING and no WITH CHECK are not defects at all. Both fall back to the USING expression for the new row, so the most ordinary policy anybody writes is not flagged.

The report ends with the fact no schema change fixes: no generator emits policy awareness. A generated read path describes rows the caller may not be allowed to see, and a reader of the emitted types will believe otherwise. That is listed rather than counted as a finding, so --strict cannot fail a pipeline nobody can make pass.

Whether a policy applies to the role your application connects as is the one question the report cannot answer for you, so every policy's TO is printed.

Non-Postgres dialects have no row-level security to declare, and their tables are absent from this report rather than listed as having it switched off.

Exit codes ​

CodeWhen
0The schema was read. Findings may have been reported.
1The schema could not be read at all: the file is missing, or importing it threw.
2Findings were reported and --strict was passed.

Zero by default is deliberate. A schema carrying a customType, or a CHECK this parser will not guess at, is normal and usable, and a doctor that failed every pipeline reading one would be switched off within a week. --strict is how you opt into a gate:

yaml
- run: npx @drzl/cli doctor --strict

This differs from analyze, which exits 2 on an error-level issue, because there error means "the JSON you asked for is not there". doctor always has a report to print.

--json ​

bash
npx @drzl/cli doctor src/db/schema.ts --json
json
{
  "command": "doctor",
  "exitCode": 0,
  "schema": "src/db/schema.ts",
  "dialect": "postgres",
  "ok": false,
  "counts": { "tables": 2, "columns": 5, "checks": 0, "findings": 2 },
  "findings": [
    {
      "kind": "partial-primary-key",
      "level": "warn",
      "table": "composite",
      "message": "Table \"composite\" has a composite primary key (a, b). ...",
      "hint": "Treat the generated service as a starting point for this table and widen the key by hand."
    }
  ]
}

ok here means "there is nothing to report about your schema", which it has always meant. It is not a statement about whether the run worked: a report full of findings is a perfectly successful doctor. The run's own answer is exitCode, and that is why the shared envelope defines no ok of its own. See Output & exit codes.

kind is a stable identifier, so a CI step can count one category without matching on prose:

bash
npx @drzl/cli doctor --json \
  | node -e "const r=JSON.parse(require('fs').readFileSync(0,'utf8'));
             const n=r.findings.filter(f=>f.kind==='unknown-column').length;
             if (n) { console.error(n+' untypeable column(s)'); process.exit(1); }"

Values are unknown-column, check-declined, check-unknown-column, check-not-scalar, check-uncountable, no-primary-key, partial-primary-key and analyzer.

Runnable config ​

doctor needs no config of its own. It reads the schema path out of the one you already have:

ts
// drzl.config.ts
export default {
  schema: 'src/db/schema.ts',
  outDir: 'src/api',
  analyzer: { includeRelations: true, validateConstraints: true },
  generators: [{ kind: 'zod', path: 'src/validators/zod', typedColumns: true }],
} as const;
bash
npx @drzl/cli doctor

typedColumns above is the fix doctor names for an untypeable column: it does not make the validator check the value, which nothing can do for a customType, but it recovers the declared TypeScript type so the call site is still narrowed. See Generators → Zod.

See also: Analyze · Explain · Generate · Output & exit codes

doctor reports the findings across every table and says nothing about a table that is fine. explain is the other direction: everything about one table, including the constraints it does enforce and the facts it read off each column, with the same silent failures called out at the bottom. Reach for doctor when the question is "what will not work in this schema", and for explain when it is "what did DRZL make of this table".

Need a custom template or integration? DM @omardulaimidev on X.