@vantreeseba/drizzle-graphql 7.5.0 → 7.7.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.
@@ -272,6 +362,81 @@ const { schema } = buildSchema(db, {
272
362
 
273
363
  Return `undefined` for any table that should keep its default naming.
274
364
 
365
+ ## Soft delete
366
+
367
+ `softDelete` turns a table's `delete` mutation into an UPDATE that marks the row, and hides
368
+ marked rows from every read:
369
+
370
+ ```Typescript
371
+ const { schema } = buildSchema(db, {
372
+ softDelete: {
373
+ posts: 'deletedAt',
374
+ // The NOT NULL form has to spell out both values:
375
+ flags: { column: 'isArchived', deletedValue: true, restoredValue: false },
376
+ },
377
+ })
378
+ ```
379
+
380
+ Keyed by the **Drizzle schema key**, like `scope` and `exclude`. A function form is also
381
+ accepted, for a convention applied across the schema:
382
+
383
+ ```Typescript
384
+ softDelete: (table, tableName) => ('deletedAt' in getTableColumns(table) ? 'deletedAt' : undefined)
385
+ ```
386
+
387
+ What a declared table gets:
388
+
389
+ | Path | Effect |
390
+ | --- | --- |
391
+ | `deletePosts`, `deletePostsSingle` | UPDATE that writes the marker; returns the rows as they now stand |
392
+ | `restorePosts`, `restorePostsSingle` | the inverse — matches **only** marked rows and clears the marker |
393
+ | `posts`, `postsSingle`, cursor pages | marked rows are not returned |
394
+ | `postsAggregate`, `postsGroupBy` | counted over unmarked rows only |
395
+ | `user.posts`, `user.postsAggregate` | hidden on both the batched and the eager (`with:`) path |
396
+ | `updatePosts`, `updatePostsMany`, a second `deletePosts` | a marked row is simply not matched |
397
+ | nested `connect` / `disconnect` / `set` | a marked row cannot be attached or detached |
398
+ | `CreatePostsInput`, `UpdatePostsInput` | the marker column is **removed** — only the mutations write it |
399
+
400
+ Every read path also gains a `deleted` argument for the cases the default hides:
401
+
402
+ ```graphql
403
+ {
404
+ live: posts { id } # the default — EXCLUDE
405
+ trash: posts(deleted: ONLY) { id } # the trash view, no second field needed
406
+ all: posts(deleted: INCLUDE) { id }
407
+ }
408
+ ```
409
+
410
+ The marker column stays **readable and filterable** — it is only the write inputs that lose it,
411
+ the same trade `contextValues` makes.
412
+
413
+ Defaults come from the column's type: `new Date()` for a timestamp, an ISO string for text,
414
+ `Date.now()` for a number, `true` for a boolean. A NOT NULL column has no "absent" state, so
415
+ the predicate has to compare against a constant: any NOT NULL column other than a boolean must
416
+ give `deletedValue` (a constant, not a function) and `restoredValue`, or the build throws.
417
+
418
+ Three things it deliberately does not do:
419
+
420
+ - **It does not cascade.** Marking a post does not mark its comments. A comment whose post is
421
+ marked is still returned by `comments`; its `post` field resolves to `null`. Declare the
422
+ child table too if that is not what you want.
423
+ - **It does not free the row's unique keys.** A marked row still occupies its primary key and
424
+ every unique index, so inserting a new row with the same natural key fails the constraint.
425
+ A partial unique index (`WHERE deleted_at IS NULL`) is the usual answer.
426
+ - **It does not refuse.** Like `scope`, an unreachable row answers the same as a row that does
427
+ not exist.
428
+
429
+ The soft-delete predicate is ANDed **before** a `scope` predicate, both after the client's
430
+ `where` — so a scope still confines what `deleted: INCLUDE` can reach.
431
+
432
+ > **MySQL caveat.** `ON DUPLICATE KEY UPDATE` takes no predicate, so an upsert whose conflict
433
+ > target hits a marked row updates it — reviving it — rather than leaving it alone. On
434
+ > PostgreSQL and SQLite the conflict branch carries the predicate and updates nothing.
435
+
436
+ A table with no declaration keeps a real `DELETE`, gets no `restore` mutation and no `deleted`
437
+ argument, and a build with no `softDelete` at all emits exactly the SQL it did before.
438
+
439
+
275
440
  ## Scalars
276
441
 
277
442
  Columns whose values don't fit a built-in GraphQL scalar get a named custom scalar, so the
@@ -1547,7 +1712,8 @@ const meta = drizzleExtension(field)
1547
1712
  // table: 'Users', // the Drizzle schema key
1548
1713
  // kind: 'mutation', // 'query' | 'mutation' | 'relation' | 'aggregate' | 'type'
1549
1714
  // operation: 'update', // 'select' | 'insert' | 'update' | 'updateMany' | 'upsert'
1550
- // // | 'delete' | 'aggregate' | 'groupBy' | 'relation'
1715
+ // // | 'delete' | 'restore' | 'aggregate' | 'groupBy'
1716
+ // // | 'relation'
1551
1717
  // // | 'relationAggregate'
1552
1718
  // single: true, // one row, or a list
1553
1719
  // targetArg: 'where', // 'where' | 'values' | 'updates' — where the rows live
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.
@@ -272,6 +362,81 @@ const { schema } = buildSchema(db, {
272
362
 
273
363
  Return `undefined` for any table that should keep its default naming.
274
364
 
365
+ ## Soft delete
366
+
367
+ `softDelete` turns a table's `delete` mutation into an UPDATE that marks the row, and hides
368
+ marked rows from every read:
369
+
370
+ ```Typescript
371
+ const { schema } = buildSchema(db, {
372
+ softDelete: {
373
+ posts: 'deletedAt',
374
+ // The NOT NULL form has to spell out both values:
375
+ flags: { column: 'isArchived', deletedValue: true, restoredValue: false },
376
+ },
377
+ })
378
+ ```
379
+
380
+ Keyed by the **Drizzle schema key**, like `scope` and `exclude`. A function form is also
381
+ accepted, for a convention applied across the schema:
382
+
383
+ ```Typescript
384
+ softDelete: (table, tableName) => ('deletedAt' in getTableColumns(table) ? 'deletedAt' : undefined)
385
+ ```
386
+
387
+ What a declared table gets:
388
+
389
+ | Path | Effect |
390
+ | --- | --- |
391
+ | `deletePosts`, `deletePostsSingle` | UPDATE that writes the marker; returns the rows as they now stand |
392
+ | `restorePosts`, `restorePostsSingle` | the inverse — matches **only** marked rows and clears the marker |
393
+ | `posts`, `postsSingle`, cursor pages | marked rows are not returned |
394
+ | `postsAggregate`, `postsGroupBy` | counted over unmarked rows only |
395
+ | `user.posts`, `user.postsAggregate` | hidden on both the batched and the eager (`with:`) path |
396
+ | `updatePosts`, `updatePostsMany`, a second `deletePosts` | a marked row is simply not matched |
397
+ | nested `connect` / `disconnect` / `set` | a marked row cannot be attached or detached |
398
+ | `CreatePostsInput`, `UpdatePostsInput` | the marker column is **removed** — only the mutations write it |
399
+
400
+ Every read path also gains a `deleted` argument for the cases the default hides:
401
+
402
+ ```graphql
403
+ {
404
+ live: posts { id } # the default — EXCLUDE
405
+ trash: posts(deleted: ONLY) { id } # the trash view, no second field needed
406
+ all: posts(deleted: INCLUDE) { id }
407
+ }
408
+ ```
409
+
410
+ The marker column stays **readable and filterable** — it is only the write inputs that lose it,
411
+ the same trade `contextValues` makes.
412
+
413
+ Defaults come from the column's type: `new Date()` for a timestamp, an ISO string for text,
414
+ `Date.now()` for a number, `true` for a boolean. A NOT NULL column has no "absent" state, so
415
+ the predicate has to compare against a constant: any NOT NULL column other than a boolean must
416
+ give `deletedValue` (a constant, not a function) and `restoredValue`, or the build throws.
417
+
418
+ Three things it deliberately does not do:
419
+
420
+ - **It does not cascade.** Marking a post does not mark its comments. A comment whose post is
421
+ marked is still returned by `comments`; its `post` field resolves to `null`. Declare the
422
+ child table too if that is not what you want.
423
+ - **It does not free the row's unique keys.** A marked row still occupies its primary key and
424
+ every unique index, so inserting a new row with the same natural key fails the constraint.
425
+ A partial unique index (`WHERE deleted_at IS NULL`) is the usual answer.
426
+ - **It does not refuse.** Like `scope`, an unreachable row answers the same as a row that does
427
+ not exist.
428
+
429
+ The soft-delete predicate is ANDed **before** a `scope` predicate, both after the client's
430
+ `where` — so a scope still confines what `deleted: INCLUDE` can reach.
431
+
432
+ > **MySQL caveat.** `ON DUPLICATE KEY UPDATE` takes no predicate, so an upsert whose conflict
433
+ > target hits a marked row updates it — reviving it — rather than leaving it alone. On
434
+ > PostgreSQL and SQLite the conflict branch carries the predicate and updates nothing.
435
+
436
+ A table with no declaration keeps a real `DELETE`, gets no `restore` mutation and no `deleted`
437
+ argument, and a build with no `softDelete` at all emits exactly the SQL it did before.
438
+
439
+
275
440
  ## Scalars
276
441
 
277
442
  Columns whose values don't fit a built-in GraphQL scalar get a named custom scalar, so the
@@ -1547,7 +1712,8 @@ const meta = drizzleExtension(field)
1547
1712
  // table: 'Users', // the Drizzle schema key
1548
1713
  // kind: 'mutation', // 'query' | 'mutation' | 'relation' | 'aggregate' | 'type'
1549
1714
  // operation: 'update', // 'select' | 'insert' | 'update' | 'updateMany' | 'upsert'
1550
- // // | 'delete' | 'aggregate' | 'groupBy' | 'relation'
1715
+ // // | 'delete' | 'restore' | 'aggregate' | 'groupBy'
1716
+ // // | 'relation'
1551
1717
  // // | 'relationAggregate'
1552
1718
  // single: true, // one row, or a list
1553
1719
  // targetArg: 'where', // 'where' | 'values' | 'updates' — where the rows live