@vantreeseba/drizzle-graphql 7.0.0 → 7.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -101,6 +101,7 @@ const { schema } = buildSchema(db, {
101
101
  distinct: false, // the `distinct` argument on list queries
102
102
  insert: false, // create<Table> / create<Table>Single mutations
103
103
  update: false, // update<Table> / update<Table>Single mutations
104
+ updateMany: false, // update<Table>Many batch mutation (needs `update`)
104
105
  delete: false, // delete<Table> / delete<Table>Single mutations
105
106
  upsert: true, // upsert<Table> / upsert<Table>Single mutations (off by default)
106
107
  nestedWrites: true, // relation fields on the create/update inputs (off by default)
@@ -123,6 +124,38 @@ const { schema } = buildSchema(db, {
123
124
  `mutations: false`
124
125
  - List and single queries are always generated, so `Query` is never empty
125
126
 
127
+ ## Naming
128
+
129
+ Type and field names are derived from the Drizzle schema keys. Three options change them.
130
+
131
+ `prefixes` and `suffixes` rename operations without touching type names:
132
+
133
+ ```Typescript
134
+ const { schema } = buildSchema(db, {
135
+ prefixes: { insert: 'add', update: 'edit', delete: 'remove' }, // default: create / update / delete
136
+ suffixes: { list: 'All', single: '' }, // default: '' / 'Single'
137
+ })
138
+ ```
139
+
140
+ The list and single suffixes cannot be identical unless a `typeNameMapper` is also given —
141
+ `users` and `usersSingle` would otherwise collapse onto one field name, and `buildSchema`
142
+ throws rather than generating a schema that silently drops one of them.
143
+
144
+ `typeNameMapper` renames the table itself, which is what a plural-keyed schema usually wants:
145
+ `export const users = pgTable('users', …)` otherwise produces `type Users` and a
146
+ `usersSingle` query.
147
+
148
+ ```Typescript
149
+ import pluralize from 'pluralize'
150
+
151
+ const { schema } = buildSchema(db, {
152
+ typeNameMapper: (table) => ({ singular: pluralize.singular(table), plural: table }),
153
+ })
154
+ // type User; queries `users` / `user`; mutations `createUsers` / `createUser`
155
+ ```
156
+
157
+ Return `undefined` for any table that should keep its default naming.
158
+
126
159
  ## Scalars
127
160
 
128
161
  Columns whose values don't fit a built-in GraphQL scalar get a named custom scalar, so the
@@ -162,6 +195,25 @@ import { GraphQLBigIntString, GraphQLDate, GraphQLDateTime, GraphQLJSON, GraphQL
162
195
  `GraphQLBigIntString` is the `BigInt` scalar — named for what it does rather than what it is
163
196
  called in SDL, to avoid clashing with the language's own `BigInt`.
164
197
 
198
+ ### Overriding a column's scalar
199
+
200
+ Built-in detection is a default, not a ceiling. `scalars` overrides named columns, and
201
+ `mapColumnType` applies a rule across every column at build time — the tool for a convention
202
+ ("every `numeric` column is `Money`") or for a custom column type detection does not know:
203
+
204
+ ```Typescript
205
+ const { schema } = buildSchema(db, {
206
+ scalars: {
207
+ users: { email: EmailAddressScalar }, // by table key, then column key
208
+ },
209
+ mapColumnType: (column) => (column.columnType === 'PgNumeric' ? MoneyScalar : undefined),
210
+ })
211
+ ```
212
+
213
+ Either may return a single scalar, or an `{ output, input }` pair when the read and write
214
+ types differ. `mapColumnType` returning `undefined` keeps the default, and a column matched
215
+ by both is decided by `scalars` alone.
216
+
165
217
  ## Enum types
166
218
 
167
219
  A column declared with a database enum becomes a GraphQL enum named after **the enum**, not
@@ -798,6 +850,51 @@ that out at the type level, `features: { requireWhere: true }` makes `where` non
798
850
  `update<Table>` / `delete<Table>` and rejects a `where` with no filters, exactly like the
799
851
  Single variants. It defaults to `false` for backwards compatibility.
800
852
 
853
+ ## Bounding page size
854
+
855
+ Nothing stops a client asking a list query for every row it has. `limits` sets a page size for
856
+ a request that names none, a ceiling for one that does, or both:
857
+
858
+ ```ts
859
+ buildSchema(db, {
860
+ limits: {
861
+ defaultLimit: 25,
862
+ maxLimit: 100,
863
+ tables: {
864
+ Posts: { defaultLimit: 50 },
865
+ AuditLog: { maxLimit: 10, clampToMax: true },
866
+ },
867
+ },
868
+ });
869
+ ```
870
+
871
+ | Key | Effect |
872
+ | --- | --- |
873
+ | `defaultLimit` | The `limit` applied when the request passes none |
874
+ | `maxLimit` | The largest `limit` a request may ask for |
875
+ | `clampToMax` | `false` (default) rejects a `limit` above `maxLimit`; `true` reduces it |
876
+ | `tables` | Per-table overrides, keyed by the table's key in your Drizzle schema |
877
+
878
+ A per-table policy overrides the global one key by key, so `AuditLog` above still inherits
879
+ `defaultLimit: 25`. Everything is optional; a build with no `limits` behaves exactly as before.
880
+
881
+ Two details worth stating plainly:
882
+
883
+ - **`maxLimit` alone also bounds a request that passes no `limit`.** No limit means every
884
+ row, which is above any maximum, so such a request runs at `maxLimit`.
885
+ - **A rejection is the default because truncation lies.** Silently cutting a page down to
886
+ `maxLimit` tells a paginating client it has reached the end when it has not. `clampToMax:
887
+ true` opts into that when you would rather never fail a query.
888
+
889
+ The policy applies to root list queries and to to-many relation fields, on both the eager and
890
+ the lazy relation paths. A relation takes the policy of the table it *reads*, not its parent's:
891
+ with `tables: { Posts: { maxLimit: 10 } }`, `users { posts(limit: 50) }` is rejected. Cursor
892
+ pagination inherits it — a page defaults to `defaultLimit` and the `cursor` values stay usable.
893
+
894
+ What it does not touch: `<tableName>Single` queries, which are already one row; aggregate,
895
+ group-by and `<relation>Aggregate` fields, which count over the whole table rather than
896
+ returning rows; and to-one relation fields, which take no `limit` at all.
897
+
801
898
  ## Query cost
802
899
 
803
900
  Generated fields carry a `complexity` hint in their GraphQL extensions, ready for
@@ -815,6 +912,10 @@ fields it mentions:
815
912
  So `users(limit: 20) { id posts(limit: 5) { id } }` costs `20 * (1 + 5 * 1)` = 120, while the
816
913
  same query without limits costs `10 * (1 + 10 * 1)` = 110.
817
914
 
915
+ A [limit policy](#bounding-page-size) feeds into the hint: a field with no `limit` is priced at
916
+ its `defaultLimit` rather than `defaultListSize`, and nothing is priced above `maxLimit`, so the
917
+ cost matches the rows the query can actually pull.
918
+
818
919
  The hints do nothing until you install a complexity rule, so they are generated by default:
819
920
 
820
921
  ```ts
@@ -840,9 +941,108 @@ buildSchema(db, { complexity: false });
840
941
 
841
942
  Cost is not a depth bound. A cyclic relation graph (`user -> posts -> author -> posts -> …`)
842
943
  lets a client nest as deep as it likes, and a deep query over cheap fields can stay under any
843
- complexity ceiling. Put a depth limit in front of a publicly exposed schema as well — e.g.
844
- [`graphql-depth-limit`](https://github.com/stems/graphql-depth-limit) — or set
845
- `relationsDepthLimit: 0` to generate no relation fields at all.
944
+ complexity ceiling. Set `relationsDepthLimit: 0` to generate no relation fields at all, or —
945
+ more usefully — bound depth at the edge, along with the rest of the document-shape defences
946
+ below.
947
+
948
+ ### Hardening a public endpoint
949
+
950
+ A generated schema is wide by construction: every table is reachable, every relation is
951
+ traversable, and the relation graph is usually cyclic. The `complexity` hints price that
952
+ surface, but only for the shapes the generator knows about, and nothing in this library
953
+ inspects the incoming document. For a schema exposed to untrusted clients, put
954
+ [GraphQL Armor](https://github.com/Escape-Technologies/graphql-armor) in front of it — it
955
+ bundles the document-level defences this library deliberately does not generate:
956
+
957
+ | Protection | What it bounds |
958
+ | --- | --- |
959
+ | `maxDepth` | nesting through a cyclic relation graph |
960
+ | `maxAliases` | the same expensive list requested many times under many aliases |
961
+ | `maxDirectives` | directive-count amplification |
962
+ | `maxTokens` | parser blow-up, rejected before validation runs |
963
+ | `costLimit` | a static per-document cost ceiling |
964
+ | `blockFieldSuggestion` | `Did you mean "keyHash"?` leaking field names on a typo |
965
+
966
+ ```ts
967
+ import { EnvelopArmor } from '@escape.tech/graphql-armor';
968
+ import { createYoga } from 'graphql-yoga';
969
+
970
+ const { schema } = buildSchema(db);
971
+ const armor = new EnvelopArmor({
972
+ maxDepth: { n: 8 }, // roughly three relation hops into the generated graph
973
+ maxAliases: { n: 15 },
974
+ costLimit: { maxCost: 5000 },
975
+ });
976
+
977
+ const yoga = createYoga({ schema, plugins: armor.protect().plugins });
978
+ ```
979
+
980
+ `ApolloArmor` is the Apollo Server equivalent — its `protect()` returns `plugins` plus
981
+ `validationRules`.
982
+
983
+ Armor's `costLimit` is its own document-shape estimate and does not read the `complexity`
984
+ extensions above; the two compose, if you want a cheap static ceiling alongside row-count
985
+ aware pricing. Neither one bounds an individual `limit` argument, so a single unbounded list
986
+ query is still a full table scan — cap `limit` yourself if that matters.
987
+
988
+ ## Authorization
989
+
990
+ `buildSchema` is handed a Drizzle instance, not a request. It has no notion of who is asking,
991
+ so it generates no authorization: every table it knows about is queryable and writable by
992
+ anyone who can reach the endpoint. Auth belongs to a layer above the schema, where the
993
+ request context actually lives.
994
+
995
+ - **[graphql-shield](https://github.com/maticzav/graphql-shield)** — permission rules
996
+ mapped onto fields and applied with
997
+ [`graphql-middleware`](https://github.com/maticzav/graphql-middleware). Rules attach to
998
+ root fields *and* to fields of a generated object type, which is what makes it an answer
999
+ to "this column must never leave the server" and not just "this query is blocked":
1000
+
1001
+ ```ts
1002
+ import { applyMiddleware } from 'graphql-middleware';
1003
+ import { allow, deny, rule, shield } from 'graphql-shield';
1004
+
1005
+ const isAuthenticated = rule()((parent, args, ctx) => Boolean(ctx.userId));
1006
+
1007
+ const { schema } = buildSchema(db);
1008
+ const guarded = applyMiddleware(
1009
+ schema,
1010
+ shield(
1011
+ {
1012
+ Query: { users: isAuthenticated, usersSingle: isAuthenticated },
1013
+ Mutation: { '*': isAuthenticated },
1014
+ ApiKeys: { keyHash: deny }, // field stays in the schema, never resolves
1015
+ },
1016
+ { fallbackRule: allow },
1017
+ ),
1018
+ );
1019
+ ```
1020
+
1021
+ - **[CASL](https://casl.js.org)** (`@casl/ability`) — one ability definition, conditions and
1022
+ field lists included (`can('read', 'Posts', ['title', 'body'])`), shared between the
1023
+ GraphQL layer and the rest of the app instead of a permission model that exists only in
1024
+ the API. CASL is not GraphQL middleware itself: wire it in through the shield rules above
1025
+ or through your own resolver wrapper, and let the ability answer the questions.
1026
+
1027
+ What neither can do is **narrow** a query. They permit or deny a field; they do not append
1028
+ `WHERE user_id = …` to the SQL a generated resolver runs, and a permitted `users` query still
1029
+ returns every tenant's rows. Until row scoping is a first-class option here, the ways to get
1030
+ it are, in increasing order of effort:
1031
+
1032
+ - supply the tenant predicate as an ordinary `where` argument from a trusted gateway, and
1033
+ deny the raw generated fields to everyone else;
1034
+ - build the schema by hand from `entities`, exposing only the generated resolvers you have
1035
+ wrapped;
1036
+ - put the scoped tables behind database row-level security and give the driver the
1037
+ request's identity, so the narrowing happens below Drizzle entirely.
1038
+
1039
+ None of this removes anything from introspection either — `deny` hides the data, not the
1040
+ shape. If the schema must not so much as mention a table, keep that table out of the Drizzle
1041
+ instance you pass to `buildSchema` (a second instance over a narrowed schema object is
1042
+ cheap).
1043
+
1044
+ Nor is it a row bound: a complexity ceiling prices the `limit` a client asks for, it does not
1045
+ cap it. Use [`limits`](#bounding-page-size) for that.
846
1046
 
847
1047
  ## Error handling
848
1048
 
@@ -889,10 +1089,44 @@ want to fall back to it explicitly.
889
1089
 
890
1090
  ## Transactions
891
1091
 
892
- Each resolver runs its statements on the database the schema was built from. A request that
893
- fires several mutations therefore commits each one separately, and a failure halfway leaves
894
- the earlier ones in place. To run a whole request as one unit, open a transaction yourself
895
- and put it on the GraphQL context under the exported `drizzleExecutorKey`:
1092
+ Each resolver runs its statements on the database the schema was built from, so by default a
1093
+ request that fires several mutations commits each one separately and a failure halfway leaves
1094
+ the earlier ones in place. There are two ways to make a request atomic.
1095
+
1096
+ ### Letting the library open one
1097
+
1098
+ `transactions: 'auto'` opens `db.transaction()` once per request whose document selects more
1099
+ than one root mutation field, and runs every mutation field — plus the reads nested under
1100
+ them — inside it:
1101
+
1102
+ ```Typescript
1103
+ const { schema } = buildSchema(db, { transactions: 'auto' })
1104
+
1105
+ // or, to change the safety timeout:
1106
+ const { schema } = buildSchema(db, { transactions: { mode: 'auto', timeoutMs: 10_000 } })
1107
+ ```
1108
+
1109
+ The transaction commits when the last mutation field completes and rolls back if any of them
1110
+ fails; mutation fields after a failure fail fast instead of running against a rolled-back
1111
+ transaction. A document with one mutation field or none opens nothing, so ordinary requests
1112
+ are untouched.
1113
+
1114
+ - The driver must support `db.transaction()` — `neon-http` does not. For SQLite only an
1115
+ asynchronous driver can hold a transaction open across resolvers, and `buildSchema`
1116
+ throws for a synchronous one rather than failing at request time
1117
+ - The GraphQL context value must be a fresh object per request (every mainstream server
1118
+ does this) — it keys the per-request transaction state
1119
+ - `timeoutMs` (default `30000`) rolls back an abandoned transaction, e.g. when the host
1120
+ server stopped calling resolvers because of a non-null completion error, instead of
1121
+ leaking the connection
1122
+ - A document that mixes in mutation fields this build did not generate is left alone: their
1123
+ completion cannot be tracked, so no transaction is opened
1124
+ - It never nests — an executor supplied on the context wins, as below
1125
+
1126
+ ### Supplying your own executor
1127
+
1128
+ To control the boundary yourself, open a transaction and put it on the GraphQL context under
1129
+ the exported `drizzleExecutorKey`:
896
1130
 
897
1131
  ```Typescript
898
1132
  import { buildSchema, drizzleExecutorKey } from 'drizzle-graphql'
@@ -1018,3 +1252,24 @@ Opting a relation out does **not** remove its field — it keeps resolving lazil
1018
1252
  request-scoped batch loader, so the field still works even before you override it. Table
1019
1253
  and relation names are the Drizzle schema keys (e.g. `Users`, `posts`), matching the keys
1020
1254
  of `entities.fieldResolvers`.
1255
+
1256
+ ## Configuration reference
1257
+
1258
+ Every key of the second argument to `buildSchema(db, config?)`. Each links to the section
1259
+ that explains it, where there is one.
1260
+
1261
+ | Key | Default | What it does |
1262
+ | --- | --- | --- |
1263
+ | `mutations` | `true` | `false` omits the `Mutation` type entirely |
1264
+ | `features` | see [Choosing what gets generated](#choosing-what-gets-generated) | which operations are generated, per operation |
1265
+ | `relationsDepthLimit` | unlimited | how deep relation fields are generated; `0` gives a flat, columns-only schema |
1266
+ | `prefixes` / `suffixes` / `typeNameMapper` | see [Naming](#naming) | operation and type names |
1267
+ | `scalars` / `mapColumnType` | built-in detection | [override a column's GraphQL type](#overriding-a-columns-scalar), by name or by rule |
1268
+ | `enumNameMapper` | shared `<EnumName>Enum` per database enum | [names the enum a column maps to](#enum-types) |
1269
+ | `describeColumn` / `describeTable` / `describeRelation` | none | [GraphQL descriptions](#documenting-the-schema) |
1270
+ | `deprecateColumn` | none | `@deprecated` on a column's output and optional input fields |
1271
+ | `conflictDoNothing` | `false` | PostgreSQL only — `ON CONFLICT DO NOTHING` on inserts, which makes `create<Table>Single` nullable |
1272
+ | `complexity` | on | [cost hints](#query-cost) in field extensions; `false` turns them off |
1273
+ | `transactions` | `'none'` | `'auto'` wraps a multi-mutation request in one [transaction](#transactions) |
1274
+ | `onError` | replaces driver errors | [error handling](#error-handling) hook — log, or surface a different error |
1275
+ | `eagerLoadRelations` | `true` | [which relations are pre-fetched](#overriding-a-relations-resolver-without-overfetching) with the parent query |
package/dist/README.md CHANGED
@@ -101,6 +101,7 @@ const { schema } = buildSchema(db, {
101
101
  distinct: false, // the `distinct` argument on list queries
102
102
  insert: false, // create<Table> / create<Table>Single mutations
103
103
  update: false, // update<Table> / update<Table>Single mutations
104
+ updateMany: false, // update<Table>Many batch mutation (needs `update`)
104
105
  delete: false, // delete<Table> / delete<Table>Single mutations
105
106
  upsert: true, // upsert<Table> / upsert<Table>Single mutations (off by default)
106
107
  nestedWrites: true, // relation fields on the create/update inputs (off by default)
@@ -123,6 +124,38 @@ const { schema } = buildSchema(db, {
123
124
  `mutations: false`
124
125
  - List and single queries are always generated, so `Query` is never empty
125
126
 
127
+ ## Naming
128
+
129
+ Type and field names are derived from the Drizzle schema keys. Three options change them.
130
+
131
+ `prefixes` and `suffixes` rename operations without touching type names:
132
+
133
+ ```Typescript
134
+ const { schema } = buildSchema(db, {
135
+ prefixes: { insert: 'add', update: 'edit', delete: 'remove' }, // default: create / update / delete
136
+ suffixes: { list: 'All', single: '' }, // default: '' / 'Single'
137
+ })
138
+ ```
139
+
140
+ The list and single suffixes cannot be identical unless a `typeNameMapper` is also given —
141
+ `users` and `usersSingle` would otherwise collapse onto one field name, and `buildSchema`
142
+ throws rather than generating a schema that silently drops one of them.
143
+
144
+ `typeNameMapper` renames the table itself, which is what a plural-keyed schema usually wants:
145
+ `export const users = pgTable('users', …)` otherwise produces `type Users` and a
146
+ `usersSingle` query.
147
+
148
+ ```Typescript
149
+ import pluralize from 'pluralize'
150
+
151
+ const { schema } = buildSchema(db, {
152
+ typeNameMapper: (table) => ({ singular: pluralize.singular(table), plural: table }),
153
+ })
154
+ // type User; queries `users` / `user`; mutations `createUsers` / `createUser`
155
+ ```
156
+
157
+ Return `undefined` for any table that should keep its default naming.
158
+
126
159
  ## Scalars
127
160
 
128
161
  Columns whose values don't fit a built-in GraphQL scalar get a named custom scalar, so the
@@ -162,6 +195,25 @@ import { GraphQLBigIntString, GraphQLDate, GraphQLDateTime, GraphQLJSON, GraphQL
162
195
  `GraphQLBigIntString` is the `BigInt` scalar — named for what it does rather than what it is
163
196
  called in SDL, to avoid clashing with the language's own `BigInt`.
164
197
 
198
+ ### Overriding a column's scalar
199
+
200
+ Built-in detection is a default, not a ceiling. `scalars` overrides named columns, and
201
+ `mapColumnType` applies a rule across every column at build time — the tool for a convention
202
+ ("every `numeric` column is `Money`") or for a custom column type detection does not know:
203
+
204
+ ```Typescript
205
+ const { schema } = buildSchema(db, {
206
+ scalars: {
207
+ users: { email: EmailAddressScalar }, // by table key, then column key
208
+ },
209
+ mapColumnType: (column) => (column.columnType === 'PgNumeric' ? MoneyScalar : undefined),
210
+ })
211
+ ```
212
+
213
+ Either may return a single scalar, or an `{ output, input }` pair when the read and write
214
+ types differ. `mapColumnType` returning `undefined` keeps the default, and a column matched
215
+ by both is decided by `scalars` alone.
216
+
165
217
  ## Enum types
166
218
 
167
219
  A column declared with a database enum becomes a GraphQL enum named after **the enum**, not
@@ -798,6 +850,51 @@ that out at the type level, `features: { requireWhere: true }` makes `where` non
798
850
  `update<Table>` / `delete<Table>` and rejects a `where` with no filters, exactly like the
799
851
  Single variants. It defaults to `false` for backwards compatibility.
800
852
 
853
+ ## Bounding page size
854
+
855
+ Nothing stops a client asking a list query for every row it has. `limits` sets a page size for
856
+ a request that names none, a ceiling for one that does, or both:
857
+
858
+ ```ts
859
+ buildSchema(db, {
860
+ limits: {
861
+ defaultLimit: 25,
862
+ maxLimit: 100,
863
+ tables: {
864
+ Posts: { defaultLimit: 50 },
865
+ AuditLog: { maxLimit: 10, clampToMax: true },
866
+ },
867
+ },
868
+ });
869
+ ```
870
+
871
+ | Key | Effect |
872
+ | --- | --- |
873
+ | `defaultLimit` | The `limit` applied when the request passes none |
874
+ | `maxLimit` | The largest `limit` a request may ask for |
875
+ | `clampToMax` | `false` (default) rejects a `limit` above `maxLimit`; `true` reduces it |
876
+ | `tables` | Per-table overrides, keyed by the table's key in your Drizzle schema |
877
+
878
+ A per-table policy overrides the global one key by key, so `AuditLog` above still inherits
879
+ `defaultLimit: 25`. Everything is optional; a build with no `limits` behaves exactly as before.
880
+
881
+ Two details worth stating plainly:
882
+
883
+ - **`maxLimit` alone also bounds a request that passes no `limit`.** No limit means every
884
+ row, which is above any maximum, so such a request runs at `maxLimit`.
885
+ - **A rejection is the default because truncation lies.** Silently cutting a page down to
886
+ `maxLimit` tells a paginating client it has reached the end when it has not. `clampToMax:
887
+ true` opts into that when you would rather never fail a query.
888
+
889
+ The policy applies to root list queries and to to-many relation fields, on both the eager and
890
+ the lazy relation paths. A relation takes the policy of the table it *reads*, not its parent's:
891
+ with `tables: { Posts: { maxLimit: 10 } }`, `users { posts(limit: 50) }` is rejected. Cursor
892
+ pagination inherits it — a page defaults to `defaultLimit` and the `cursor` values stay usable.
893
+
894
+ What it does not touch: `<tableName>Single` queries, which are already one row; aggregate,
895
+ group-by and `<relation>Aggregate` fields, which count over the whole table rather than
896
+ returning rows; and to-one relation fields, which take no `limit` at all.
897
+
801
898
  ## Query cost
802
899
 
803
900
  Generated fields carry a `complexity` hint in their GraphQL extensions, ready for
@@ -815,6 +912,10 @@ fields it mentions:
815
912
  So `users(limit: 20) { id posts(limit: 5) { id } }` costs `20 * (1 + 5 * 1)` = 120, while the
816
913
  same query without limits costs `10 * (1 + 10 * 1)` = 110.
817
914
 
915
+ A [limit policy](#bounding-page-size) feeds into the hint: a field with no `limit` is priced at
916
+ its `defaultLimit` rather than `defaultListSize`, and nothing is priced above `maxLimit`, so the
917
+ cost matches the rows the query can actually pull.
918
+
818
919
  The hints do nothing until you install a complexity rule, so they are generated by default:
819
920
 
820
921
  ```ts
@@ -840,9 +941,108 @@ buildSchema(db, { complexity: false });
840
941
 
841
942
  Cost is not a depth bound. A cyclic relation graph (`user -> posts -> author -> posts -> …`)
842
943
  lets a client nest as deep as it likes, and a deep query over cheap fields can stay under any
843
- complexity ceiling. Put a depth limit in front of a publicly exposed schema as well — e.g.
844
- [`graphql-depth-limit`](https://github.com/stems/graphql-depth-limit) — or set
845
- `relationsDepthLimit: 0` to generate no relation fields at all.
944
+ complexity ceiling. Set `relationsDepthLimit: 0` to generate no relation fields at all, or —
945
+ more usefully — bound depth at the edge, along with the rest of the document-shape defences
946
+ below.
947
+
948
+ ### Hardening a public endpoint
949
+
950
+ A generated schema is wide by construction: every table is reachable, every relation is
951
+ traversable, and the relation graph is usually cyclic. The `complexity` hints price that
952
+ surface, but only for the shapes the generator knows about, and nothing in this library
953
+ inspects the incoming document. For a schema exposed to untrusted clients, put
954
+ [GraphQL Armor](https://github.com/Escape-Technologies/graphql-armor) in front of it — it
955
+ bundles the document-level defences this library deliberately does not generate:
956
+
957
+ | Protection | What it bounds |
958
+ | --- | --- |
959
+ | `maxDepth` | nesting through a cyclic relation graph |
960
+ | `maxAliases` | the same expensive list requested many times under many aliases |
961
+ | `maxDirectives` | directive-count amplification |
962
+ | `maxTokens` | parser blow-up, rejected before validation runs |
963
+ | `costLimit` | a static per-document cost ceiling |
964
+ | `blockFieldSuggestion` | `Did you mean "keyHash"?` leaking field names on a typo |
965
+
966
+ ```ts
967
+ import { EnvelopArmor } from '@escape.tech/graphql-armor';
968
+ import { createYoga } from 'graphql-yoga';
969
+
970
+ const { schema } = buildSchema(db);
971
+ const armor = new EnvelopArmor({
972
+ maxDepth: { n: 8 }, // roughly three relation hops into the generated graph
973
+ maxAliases: { n: 15 },
974
+ costLimit: { maxCost: 5000 },
975
+ });
976
+
977
+ const yoga = createYoga({ schema, plugins: armor.protect().plugins });
978
+ ```
979
+
980
+ `ApolloArmor` is the Apollo Server equivalent — its `protect()` returns `plugins` plus
981
+ `validationRules`.
982
+
983
+ Armor's `costLimit` is its own document-shape estimate and does not read the `complexity`
984
+ extensions above; the two compose, if you want a cheap static ceiling alongside row-count
985
+ aware pricing. Neither one bounds an individual `limit` argument, so a single unbounded list
986
+ query is still a full table scan — cap `limit` yourself if that matters.
987
+
988
+ ## Authorization
989
+
990
+ `buildSchema` is handed a Drizzle instance, not a request. It has no notion of who is asking,
991
+ so it generates no authorization: every table it knows about is queryable and writable by
992
+ anyone who can reach the endpoint. Auth belongs to a layer above the schema, where the
993
+ request context actually lives.
994
+
995
+ - **[graphql-shield](https://github.com/maticzav/graphql-shield)** — permission rules
996
+ mapped onto fields and applied with
997
+ [`graphql-middleware`](https://github.com/maticzav/graphql-middleware). Rules attach to
998
+ root fields *and* to fields of a generated object type, which is what makes it an answer
999
+ to "this column must never leave the server" and not just "this query is blocked":
1000
+
1001
+ ```ts
1002
+ import { applyMiddleware } from 'graphql-middleware';
1003
+ import { allow, deny, rule, shield } from 'graphql-shield';
1004
+
1005
+ const isAuthenticated = rule()((parent, args, ctx) => Boolean(ctx.userId));
1006
+
1007
+ const { schema } = buildSchema(db);
1008
+ const guarded = applyMiddleware(
1009
+ schema,
1010
+ shield(
1011
+ {
1012
+ Query: { users: isAuthenticated, usersSingle: isAuthenticated },
1013
+ Mutation: { '*': isAuthenticated },
1014
+ ApiKeys: { keyHash: deny }, // field stays in the schema, never resolves
1015
+ },
1016
+ { fallbackRule: allow },
1017
+ ),
1018
+ );
1019
+ ```
1020
+
1021
+ - **[CASL](https://casl.js.org)** (`@casl/ability`) — one ability definition, conditions and
1022
+ field lists included (`can('read', 'Posts', ['title', 'body'])`), shared between the
1023
+ GraphQL layer and the rest of the app instead of a permission model that exists only in
1024
+ the API. CASL is not GraphQL middleware itself: wire it in through the shield rules above
1025
+ or through your own resolver wrapper, and let the ability answer the questions.
1026
+
1027
+ What neither can do is **narrow** a query. They permit or deny a field; they do not append
1028
+ `WHERE user_id = …` to the SQL a generated resolver runs, and a permitted `users` query still
1029
+ returns every tenant's rows. Until row scoping is a first-class option here, the ways to get
1030
+ it are, in increasing order of effort:
1031
+
1032
+ - supply the tenant predicate as an ordinary `where` argument from a trusted gateway, and
1033
+ deny the raw generated fields to everyone else;
1034
+ - build the schema by hand from `entities`, exposing only the generated resolvers you have
1035
+ wrapped;
1036
+ - put the scoped tables behind database row-level security and give the driver the
1037
+ request's identity, so the narrowing happens below Drizzle entirely.
1038
+
1039
+ None of this removes anything from introspection either — `deny` hides the data, not the
1040
+ shape. If the schema must not so much as mention a table, keep that table out of the Drizzle
1041
+ instance you pass to `buildSchema` (a second instance over a narrowed schema object is
1042
+ cheap).
1043
+
1044
+ Nor is it a row bound: a complexity ceiling prices the `limit` a client asks for, it does not
1045
+ cap it. Use [`limits`](#bounding-page-size) for that.
846
1046
 
847
1047
  ## Error handling
848
1048
 
@@ -889,10 +1089,44 @@ want to fall back to it explicitly.
889
1089
 
890
1090
  ## Transactions
891
1091
 
892
- Each resolver runs its statements on the database the schema was built from. A request that
893
- fires several mutations therefore commits each one separately, and a failure halfway leaves
894
- the earlier ones in place. To run a whole request as one unit, open a transaction yourself
895
- and put it on the GraphQL context under the exported `drizzleExecutorKey`:
1092
+ Each resolver runs its statements on the database the schema was built from, so by default a
1093
+ request that fires several mutations commits each one separately and a failure halfway leaves
1094
+ the earlier ones in place. There are two ways to make a request atomic.
1095
+
1096
+ ### Letting the library open one
1097
+
1098
+ `transactions: 'auto'` opens `db.transaction()` once per request whose document selects more
1099
+ than one root mutation field, and runs every mutation field — plus the reads nested under
1100
+ them — inside it:
1101
+
1102
+ ```Typescript
1103
+ const { schema } = buildSchema(db, { transactions: 'auto' })
1104
+
1105
+ // or, to change the safety timeout:
1106
+ const { schema } = buildSchema(db, { transactions: { mode: 'auto', timeoutMs: 10_000 } })
1107
+ ```
1108
+
1109
+ The transaction commits when the last mutation field completes and rolls back if any of them
1110
+ fails; mutation fields after a failure fail fast instead of running against a rolled-back
1111
+ transaction. A document with one mutation field or none opens nothing, so ordinary requests
1112
+ are untouched.
1113
+
1114
+ - The driver must support `db.transaction()` — `neon-http` does not. For SQLite only an
1115
+ asynchronous driver can hold a transaction open across resolvers, and `buildSchema`
1116
+ throws for a synchronous one rather than failing at request time
1117
+ - The GraphQL context value must be a fresh object per request (every mainstream server
1118
+ does this) — it keys the per-request transaction state
1119
+ - `timeoutMs` (default `30000`) rolls back an abandoned transaction, e.g. when the host
1120
+ server stopped calling resolvers because of a non-null completion error, instead of
1121
+ leaking the connection
1122
+ - A document that mixes in mutation fields this build did not generate is left alone: their
1123
+ completion cannot be tracked, so no transaction is opened
1124
+ - It never nests — an executor supplied on the context wins, as below
1125
+
1126
+ ### Supplying your own executor
1127
+
1128
+ To control the boundary yourself, open a transaction and put it on the GraphQL context under
1129
+ the exported `drizzleExecutorKey`:
896
1130
 
897
1131
  ```Typescript
898
1132
  import { buildSchema, drizzleExecutorKey } from 'drizzle-graphql'
@@ -1018,3 +1252,24 @@ Opting a relation out does **not** remove its field — it keeps resolving lazil
1018
1252
  request-scoped batch loader, so the field still works even before you override it. Table
1019
1253
  and relation names are the Drizzle schema keys (e.g. `Users`, `posts`), matching the keys
1020
1254
  of `entities.fieldResolvers`.
1255
+
1256
+ ## Configuration reference
1257
+
1258
+ Every key of the second argument to `buildSchema(db, config?)`. Each links to the section
1259
+ that explains it, where there is one.
1260
+
1261
+ | Key | Default | What it does |
1262
+ | --- | --- | --- |
1263
+ | `mutations` | `true` | `false` omits the `Mutation` type entirely |
1264
+ | `features` | see [Choosing what gets generated](#choosing-what-gets-generated) | which operations are generated, per operation |
1265
+ | `relationsDepthLimit` | unlimited | how deep relation fields are generated; `0` gives a flat, columns-only schema |
1266
+ | `prefixes` / `suffixes` / `typeNameMapper` | see [Naming](#naming) | operation and type names |
1267
+ | `scalars` / `mapColumnType` | built-in detection | [override a column's GraphQL type](#overriding-a-columns-scalar), by name or by rule |
1268
+ | `enumNameMapper` | shared `<EnumName>Enum` per database enum | [names the enum a column maps to](#enum-types) |
1269
+ | `describeColumn` / `describeTable` / `describeRelation` | none | [GraphQL descriptions](#documenting-the-schema) |
1270
+ | `deprecateColumn` | none | `@deprecated` on a column's output and optional input fields |
1271
+ | `conflictDoNothing` | `false` | PostgreSQL only — `ON CONFLICT DO NOTHING` on inserts, which makes `create<Table>Single` nullable |
1272
+ | `complexity` | on | [cost hints](#query-cost) in field extensions; `false` turns them off |
1273
+ | `transactions` | `'none'` | `'auto'` wraps a multi-mutation request in one [transaction](#transactions) |
1274
+ | `onError` | replaces driver errors | [error handling](#error-handling) hook — log, or surface a different error |
1275
+ | `eagerLoadRelations` | `true` | [which relations are pre-fetched](#overriding-a-relations-resolver-without-overfetching) with the parent query |