@vantreeseba/drizzle-graphql 7.5.0 → 7.6.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
@@ -237,6 +237,96 @@ table becomes readable but not insertable — and warns on the console, since th
237
237
  often a mistake than a plan.
238
238
 
239
239
  > `exclude` is a static decision: the column is absent for every caller. Deciding per
240
+ > request *which fields* a caller may reach is a job for
241
+ > [`graphql-shield`](https://the-guild.dev/graphql/shield) or an equivalent layer above the
242
+ > schema. Deciding per request *which rows* a caller may reach is `scope`, below — that one a
243
+ > layer above cannot do, because a nested relation field never passes through a root resolver.
244
+
245
+ ## Row scoping and multi-tenancy
246
+
247
+ `scope` attaches a predicate, derived from the request context, to every read and write of a
248
+ table:
249
+
250
+ ```Typescript
251
+ import { and, eq } from 'drizzle-orm'
252
+
253
+ const { schema } = buildSchema(db, {
254
+ scope: {
255
+ posts: (ctx, table) => eq(table.orgId, ctx.orgId),
256
+ // A filter object works too — same shape as the generated `where` argument:
257
+ comments: (ctx) => ({ post: { orgId: { eq: ctx.orgId } } }),
258
+ },
259
+ })
260
+ ```
261
+
262
+ Keyed by the **Drizzle schema key**. `ctx` is your GraphQL context value; `table` is the table
263
+ the current statement runs against — for a relational read that is Drizzle's *aliased* copy, so
264
+ always build the predicate from that argument rather than from the imported table object.
265
+
266
+ The predicate is ANDed on **last**, after whatever the client sent, so a client filter can only
267
+ narrow the scope, never widen it. It reaches every path that touches the table:
268
+
269
+ | Path | Effect |
270
+ | --- | --- |
271
+ | `posts`, `postsSingle` | out-of-scope rows are not returned |
272
+ | `postsAggregate`, `postsGroupBy` | counted over in-scope rows only |
273
+ | `user.posts`, `user.postsAggregate` | scoped on both the batched and the eager (`with:`) path |
274
+ | cursor pages | the keyset page is taken within the scope |
275
+ | `updatePosts`, `updatePostsMany`, `deletePosts` | an out-of-scope row is simply not matched |
276
+ | `upsertPosts` (conflict branch) | a conflicting row outside the scope is left alone, not taken over |
277
+ | nested `connect` / `disconnect` / `set` | only in-scope rows can be attached or detached |
278
+
279
+ Two things it deliberately does not do:
280
+
281
+ - **It does not refuse.** A scoped `deletePosts(where: { id: { eq: 4 } })` on another
282
+ tenant's row returns `[]`, the same answer as a row that does not exist. Telling the caller
283
+ "that row exists but is not yours" is the leak the scope is there to close.
284
+ - **It does not apply to a plain insert.** There is no existing row to filter. `contextValues`
285
+ is the write-side half.
286
+
287
+ Returning `undefined` means *no restriction for this context*, so one hook can let an
288
+ administrator through:
289
+
290
+ ```Typescript
291
+ scope: {
292
+ posts: (ctx, table) => (ctx.isAdmin ? undefined : eq(table.orgId, ctx.orgId)),
293
+ }
294
+ ```
295
+
296
+ A table with no entry is unscoped, and a build with no `scope` at all emits exactly the SQL it
297
+ did before.
298
+
299
+ ### Columns the server owns
300
+
301
+ `contextValues` supplies a column's value from the request context instead of accepting it from
302
+ the client:
303
+
304
+ ```Typescript
305
+ const { schema } = buildSchema(db, {
306
+ contextValues: {
307
+ posts: {
308
+ orgId: (ctx) => ctx.orgId,
309
+ authorId: (ctx) => ctx.userId,
310
+ },
311
+ },
312
+ })
313
+ ```
314
+
315
+ - The column is **removed from `CreatePostsInput` and `UpdatePostsInput`**, so a client cannot
316
+ send it at all — the SDL itself says the column is server-owned. It stays readable on the
317
+ object type.
318
+ - Every insert stamps it, including rows created by a nested `create`.
319
+ - Updates never write it, so an update cannot hand a row to another owner.
320
+
321
+ Together the two close the loop: `contextValues` decides which tenant a new row belongs to, and
322
+ `scope` decides which rows a request can see and change afterwards.
323
+
324
+ > **MySQL caveat.** `ON DUPLICATE KEY UPDATE` takes no predicate, so a MySQL upsert's conflict
325
+ > branch cannot be scoped. Scope the table's `update` and rely on `contextValues` for the insert
326
+ > half.
327
+
328
+ Names that match nothing throw at build time, the same as `exclude` — a renamed column fails the
329
+ build instead of quietly ceasing to be scoped.
240
330
  > request who may see what is a job for [`graphql-shield`](https://the-guild.dev/graphql/shield)
241
331
  > or an equivalent layer above the schema, which has the request context this library
242
332
  > does not.
package/dist/README.md CHANGED
@@ -237,6 +237,96 @@ table becomes readable but not insertable — and warns on the console, since th
237
237
  often a mistake than a plan.
238
238
 
239
239
  > `exclude` is a static decision: the column is absent for every caller. Deciding per
240
+ > request *which fields* a caller may reach is a job for
241
+ > [`graphql-shield`](https://the-guild.dev/graphql/shield) or an equivalent layer above the
242
+ > schema. Deciding per request *which rows* a caller may reach is `scope`, below — that one a
243
+ > layer above cannot do, because a nested relation field never passes through a root resolver.
244
+
245
+ ## Row scoping and multi-tenancy
246
+
247
+ `scope` attaches a predicate, derived from the request context, to every read and write of a
248
+ table:
249
+
250
+ ```Typescript
251
+ import { and, eq } from 'drizzle-orm'
252
+
253
+ const { schema } = buildSchema(db, {
254
+ scope: {
255
+ posts: (ctx, table) => eq(table.orgId, ctx.orgId),
256
+ // A filter object works too — same shape as the generated `where` argument:
257
+ comments: (ctx) => ({ post: { orgId: { eq: ctx.orgId } } }),
258
+ },
259
+ })
260
+ ```
261
+
262
+ Keyed by the **Drizzle schema key**. `ctx` is your GraphQL context value; `table` is the table
263
+ the current statement runs against — for a relational read that is Drizzle's *aliased* copy, so
264
+ always build the predicate from that argument rather than from the imported table object.
265
+
266
+ The predicate is ANDed on **last**, after whatever the client sent, so a client filter can only
267
+ narrow the scope, never widen it. It reaches every path that touches the table:
268
+
269
+ | Path | Effect |
270
+ | --- | --- |
271
+ | `posts`, `postsSingle` | out-of-scope rows are not returned |
272
+ | `postsAggregate`, `postsGroupBy` | counted over in-scope rows only |
273
+ | `user.posts`, `user.postsAggregate` | scoped on both the batched and the eager (`with:`) path |
274
+ | cursor pages | the keyset page is taken within the scope |
275
+ | `updatePosts`, `updatePostsMany`, `deletePosts` | an out-of-scope row is simply not matched |
276
+ | `upsertPosts` (conflict branch) | a conflicting row outside the scope is left alone, not taken over |
277
+ | nested `connect` / `disconnect` / `set` | only in-scope rows can be attached or detached |
278
+
279
+ Two things it deliberately does not do:
280
+
281
+ - **It does not refuse.** A scoped `deletePosts(where: { id: { eq: 4 } })` on another
282
+ tenant's row returns `[]`, the same answer as a row that does not exist. Telling the caller
283
+ "that row exists but is not yours" is the leak the scope is there to close.
284
+ - **It does not apply to a plain insert.** There is no existing row to filter. `contextValues`
285
+ is the write-side half.
286
+
287
+ Returning `undefined` means *no restriction for this context*, so one hook can let an
288
+ administrator through:
289
+
290
+ ```Typescript
291
+ scope: {
292
+ posts: (ctx, table) => (ctx.isAdmin ? undefined : eq(table.orgId, ctx.orgId)),
293
+ }
294
+ ```
295
+
296
+ A table with no entry is unscoped, and a build with no `scope` at all emits exactly the SQL it
297
+ did before.
298
+
299
+ ### Columns the server owns
300
+
301
+ `contextValues` supplies a column's value from the request context instead of accepting it from
302
+ the client:
303
+
304
+ ```Typescript
305
+ const { schema } = buildSchema(db, {
306
+ contextValues: {
307
+ posts: {
308
+ orgId: (ctx) => ctx.orgId,
309
+ authorId: (ctx) => ctx.userId,
310
+ },
311
+ },
312
+ })
313
+ ```
314
+
315
+ - The column is **removed from `CreatePostsInput` and `UpdatePostsInput`**, so a client cannot
316
+ send it at all — the SDL itself says the column is server-owned. It stays readable on the
317
+ object type.
318
+ - Every insert stamps it, including rows created by a nested `create`.
319
+ - Updates never write it, so an update cannot hand a row to another owner.
320
+
321
+ Together the two close the loop: `contextValues` decides which tenant a new row belongs to, and
322
+ `scope` decides which rows a request can see and change afterwards.
323
+
324
+ > **MySQL caveat.** `ON DUPLICATE KEY UPDATE` takes no predicate, so a MySQL upsert's conflict
325
+ > branch cannot be scoped. Scope the table's `update` and rely on `contextValues` for the insert
326
+ > half.
327
+
328
+ Names that match nothing throw at build time, the same as `exclude` — a renamed column fails the
329
+ build instead of quietly ceasing to be scoped.
240
330
  > request who may see what is a job for [`graphql-shield`](https://the-guild.dev/graphql/shield)
241
331
  > or an equivalent layer above the schema, which has the request context this library
242
332
  > does not.