@vantreeseba/drizzle-graphql 7.6.0 → 7.8.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
@@ -362,6 +362,81 @@ const { schema } = buildSchema(db, {
362
362
 
363
363
  Return `undefined` for any table that should keep its default naming.
364
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
+
365
440
  ## Scalars
366
441
 
367
442
  Columns whose values don't fit a built-in GraphQL scalar get a named custom scalar, so the
@@ -1055,12 +1130,46 @@ mutation {
1055
1130
  `author: { create: … }` available, `authorId` is one of two ways to supply it
1056
1131
  - Nesting is one level deep: the row a nested `create` inserts takes columns only, and the
1057
1132
  join column is left off, since the write it is part of sets it
1058
- - Many-to-many (`.through()`) relations are not written through
1133
+ - Many-to-many (`.through()`) relations are written through their junction table — see
1134
+ below
1059
1135
  - The whole tree runs in one transaction — a savepoint when the request already carries one
1060
1136
  — so a failure anywhere leaves nothing behind
1061
1137
  - A `set` that carries only relation fields is a valid update: it changes what the row is
1062
1138
  attached to without rewriting the row
1063
1139
 
1140
+ ### Many-to-many relations
1141
+
1142
+ A relation declared with `.through()` is written by inserting and deleting rows of its
1143
+ junction table, so nothing about the two rows being linked changes:
1144
+
1145
+ ```graphql
1146
+ mutation {
1147
+ updatePostsSingle(
1148
+ where: { id: { eq: 1 } }
1149
+ set: { tags: { set: [{ name: { inArray: ["news", "release"] } }] } }
1150
+ ) {
1151
+ id
1152
+ }
1153
+ }
1154
+ ```
1155
+
1156
+ - It offers `connect` on the create input, and `connect`, `disconnect` and `set` on the
1157
+ update input. All three take the target's `Filters`, exactly as on a foreign-key relation
1158
+ - `disconnect` and `set` are offered unconditionally here: unlinking deletes a junction row
1159
+ rather than writing `NULL` into a column that might be `NOT NULL`
1160
+ - `connect` links every row its filters match and leaves already-linked rows alone, so
1161
+ re-connecting is a no-op rather than a unique-constraint error
1162
+ - `disconnect` deletes only the links, never the rows they pointed at
1163
+ - `set: []` unlinks everything and links nothing back, which is how a many-to-many relation
1164
+ is cleared
1165
+ - There is no `create`: inserting a row *and* linking it is a write to two tables, and the
1166
+ link is what the relation field is for. Create the row with its own mutation, then
1167
+ `connect` it
1168
+ - A relation is left out when its junction carries a further `NOT NULL` column with no
1169
+ default — a filter alone could not supply a value for it — and when it is declared as a
1170
+ to-one, whose "exactly one" guarantee lives in the junction's constraints rather than in
1171
+ the relation
1172
+
1064
1173
  Dialect support: **PostgreSQL**, and **SQLite** on an asynchronous driver (libsql, D1). A
1065
1174
  nested write reads back the key of the row it just wrote, which MySQL has no `RETURNING` for,
1066
1175
  and interleaves awaited statements inside a transaction, which a synchronous SQLite driver
@@ -1480,6 +1589,81 @@ example a pooled connection bound to a tenant, or a logging proxy around `db`. B
1480
1589
  key is created with `Symbol.for`, the ESM and CJS builds of this package agree on it when
1481
1590
  both end up loaded in one process.
1482
1591
 
1592
+ Or let the library open one per request: `transactions: 'auto'` wraps a document that fires
1593
+ more than one generated mutation in a single transaction, committed when the last one
1594
+ completes and rolled back if any of them fails.
1595
+
1596
+ ```Typescript
1597
+ buildSchema(db, { transactions: 'auto' })
1598
+ ```
1599
+
1600
+ A document with one mutation, or one that selects a mutation field this build did not
1601
+ generate, still runs unwrapped — there is nothing to keep atomic.
1602
+
1603
+ ## Write hooks
1604
+
1605
+ `onWrite` runs a hook **inside the mutation's transaction**, so its writes commit or roll back
1606
+ with the mutation itself. That is the position an audit row, an outbox row or a denormalized
1607
+ counter needs, and the one a `graphql-middleware` wrapper cannot occupy — a wrapper sits
1608
+ outside the resolver, so its after-position is after the commit.
1609
+
1610
+ ```Typescript
1611
+ const { schema } = buildSchema(db, {
1612
+ onWrite: {
1613
+ posts: async ({ table, operation, rows, tx }) => {
1614
+ await tx.insert(auditLog).values(rows.map((row) => ({ table, operation, rowId: row.id })))
1615
+ },
1616
+ },
1617
+ })
1618
+ ```
1619
+
1620
+ A bare function is the **`after`** hook — the position that has rows. `{ before, after }` names
1621
+ them explicitly:
1622
+
1623
+ ```Typescript
1624
+ onWrite: {
1625
+ posts: {
1626
+ // Reads inside the same snapshot the write will use.
1627
+ before: async ({ args, tx }) => {
1628
+ const [parent] = await tx.select().from(orgs).where(eq(orgs.id, args.values[0].orgId))
1629
+ if (!parent) throw new Error('org went away')
1630
+ },
1631
+ after: async ({ rows, tx }) => { /* … */ },
1632
+ },
1633
+ }
1634
+ ```
1635
+
1636
+ Registering a bare function (or a positions object) at the top level applies it to every table;
1637
+ anything else is read as a table map keyed by the **Drizzle schema key**, and a name that
1638
+ matches nothing throws at build time.
1639
+
1640
+ The payload:
1641
+
1642
+ | Field | |
1643
+ | --- | --- |
1644
+ | `table` | Drizzle schema key of the table being written |
1645
+ | `operation` | `'insert' \| 'update' \| 'updateMany' \| 'upsert' \| 'delete' \| 'restore'` |
1646
+ | `position` | `'before'` or `'after'` |
1647
+ | `single` | whether the field writes one row or a list |
1648
+ | `args` | the field's GraphQL arguments, as the resolver received them |
1649
+ | `rows` | the post-write rows **as the database returned them**, before the output mapper |
1650
+ | `context`, `info` | the GraphQL context value and resolve info |
1651
+ | `tx` | the executor the mutation ran on |
1652
+
1653
+ - **`tx` is the executor the mutation itself used** — one you supplied under
1654
+ `drizzleExecutorKey`, the request's auto-transaction, or, for a field that would otherwise
1655
+ have run unwrapped, a transaction opened for it *because* the hook is there. The hook does
1656
+ not have to know which.
1657
+ - **Throwing rolls the mutation back.** A hook that can only log is what a logging plugin
1658
+ already does.
1659
+ - **`rows` is empty** at the `before` position, and on MySQL, whose mutations return no rows.
1660
+ The `before` position and `args` still work there.
1661
+ - The hook fires for the **mutation field's own table**. Rows a nested `create` writes into
1662
+ another table do not fire that table's hook.
1663
+
1664
+ A build with no `onWrite` opens exactly the transactions it did before.
1665
+
1666
+
1483
1667
  ## Relations & N+1 handling
1484
1668
 
1485
1669
  Generated schemas resolve nested relations without N+1 query explosions:
@@ -1637,7 +1821,8 @@ const meta = drizzleExtension(field)
1637
1821
  // table: 'Users', // the Drizzle schema key
1638
1822
  // kind: 'mutation', // 'query' | 'mutation' | 'relation' | 'aggregate' | 'type'
1639
1823
  // operation: 'update', // 'select' | 'insert' | 'update' | 'updateMany' | 'upsert'
1640
- // // | 'delete' | 'aggregate' | 'groupBy' | 'relation'
1824
+ // // | 'delete' | 'restore' | 'aggregate' | 'groupBy'
1825
+ // // | 'relation'
1641
1826
  // // | 'relationAggregate'
1642
1827
  // single: true, // one row, or a list
1643
1828
  // targetArg: 'where', // 'where' | 'values' | 'updates' — where the rows live
package/dist/README.md CHANGED
@@ -362,6 +362,81 @@ const { schema } = buildSchema(db, {
362
362
 
363
363
  Return `undefined` for any table that should keep its default naming.
364
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
+
365
440
  ## Scalars
366
441
 
367
442
  Columns whose values don't fit a built-in GraphQL scalar get a named custom scalar, so the
@@ -1055,12 +1130,46 @@ mutation {
1055
1130
  `author: { create: … }` available, `authorId` is one of two ways to supply it
1056
1131
  - Nesting is one level deep: the row a nested `create` inserts takes columns only, and the
1057
1132
  join column is left off, since the write it is part of sets it
1058
- - Many-to-many (`.through()`) relations are not written through
1133
+ - Many-to-many (`.through()`) relations are written through their junction table — see
1134
+ below
1059
1135
  - The whole tree runs in one transaction — a savepoint when the request already carries one
1060
1136
  — so a failure anywhere leaves nothing behind
1061
1137
  - A `set` that carries only relation fields is a valid update: it changes what the row is
1062
1138
  attached to without rewriting the row
1063
1139
 
1140
+ ### Many-to-many relations
1141
+
1142
+ A relation declared with `.through()` is written by inserting and deleting rows of its
1143
+ junction table, so nothing about the two rows being linked changes:
1144
+
1145
+ ```graphql
1146
+ mutation {
1147
+ updatePostsSingle(
1148
+ where: { id: { eq: 1 } }
1149
+ set: { tags: { set: [{ name: { inArray: ["news", "release"] } }] } }
1150
+ ) {
1151
+ id
1152
+ }
1153
+ }
1154
+ ```
1155
+
1156
+ - It offers `connect` on the create input, and `connect`, `disconnect` and `set` on the
1157
+ update input. All three take the target's `Filters`, exactly as on a foreign-key relation
1158
+ - `disconnect` and `set` are offered unconditionally here: unlinking deletes a junction row
1159
+ rather than writing `NULL` into a column that might be `NOT NULL`
1160
+ - `connect` links every row its filters match and leaves already-linked rows alone, so
1161
+ re-connecting is a no-op rather than a unique-constraint error
1162
+ - `disconnect` deletes only the links, never the rows they pointed at
1163
+ - `set: []` unlinks everything and links nothing back, which is how a many-to-many relation
1164
+ is cleared
1165
+ - There is no `create`: inserting a row *and* linking it is a write to two tables, and the
1166
+ link is what the relation field is for. Create the row with its own mutation, then
1167
+ `connect` it
1168
+ - A relation is left out when its junction carries a further `NOT NULL` column with no
1169
+ default — a filter alone could not supply a value for it — and when it is declared as a
1170
+ to-one, whose "exactly one" guarantee lives in the junction's constraints rather than in
1171
+ the relation
1172
+
1064
1173
  Dialect support: **PostgreSQL**, and **SQLite** on an asynchronous driver (libsql, D1). A
1065
1174
  nested write reads back the key of the row it just wrote, which MySQL has no `RETURNING` for,
1066
1175
  and interleaves awaited statements inside a transaction, which a synchronous SQLite driver
@@ -1480,6 +1589,81 @@ example a pooled connection bound to a tenant, or a logging proxy around `db`. B
1480
1589
  key is created with `Symbol.for`, the ESM and CJS builds of this package agree on it when
1481
1590
  both end up loaded in one process.
1482
1591
 
1592
+ Or let the library open one per request: `transactions: 'auto'` wraps a document that fires
1593
+ more than one generated mutation in a single transaction, committed when the last one
1594
+ completes and rolled back if any of them fails.
1595
+
1596
+ ```Typescript
1597
+ buildSchema(db, { transactions: 'auto' })
1598
+ ```
1599
+
1600
+ A document with one mutation, or one that selects a mutation field this build did not
1601
+ generate, still runs unwrapped — there is nothing to keep atomic.
1602
+
1603
+ ## Write hooks
1604
+
1605
+ `onWrite` runs a hook **inside the mutation's transaction**, so its writes commit or roll back
1606
+ with the mutation itself. That is the position an audit row, an outbox row or a denormalized
1607
+ counter needs, and the one a `graphql-middleware` wrapper cannot occupy — a wrapper sits
1608
+ outside the resolver, so its after-position is after the commit.
1609
+
1610
+ ```Typescript
1611
+ const { schema } = buildSchema(db, {
1612
+ onWrite: {
1613
+ posts: async ({ table, operation, rows, tx }) => {
1614
+ await tx.insert(auditLog).values(rows.map((row) => ({ table, operation, rowId: row.id })))
1615
+ },
1616
+ },
1617
+ })
1618
+ ```
1619
+
1620
+ A bare function is the **`after`** hook — the position that has rows. `{ before, after }` names
1621
+ them explicitly:
1622
+
1623
+ ```Typescript
1624
+ onWrite: {
1625
+ posts: {
1626
+ // Reads inside the same snapshot the write will use.
1627
+ before: async ({ args, tx }) => {
1628
+ const [parent] = await tx.select().from(orgs).where(eq(orgs.id, args.values[0].orgId))
1629
+ if (!parent) throw new Error('org went away')
1630
+ },
1631
+ after: async ({ rows, tx }) => { /* … */ },
1632
+ },
1633
+ }
1634
+ ```
1635
+
1636
+ Registering a bare function (or a positions object) at the top level applies it to every table;
1637
+ anything else is read as a table map keyed by the **Drizzle schema key**, and a name that
1638
+ matches nothing throws at build time.
1639
+
1640
+ The payload:
1641
+
1642
+ | Field | |
1643
+ | --- | --- |
1644
+ | `table` | Drizzle schema key of the table being written |
1645
+ | `operation` | `'insert' \| 'update' \| 'updateMany' \| 'upsert' \| 'delete' \| 'restore'` |
1646
+ | `position` | `'before'` or `'after'` |
1647
+ | `single` | whether the field writes one row or a list |
1648
+ | `args` | the field's GraphQL arguments, as the resolver received them |
1649
+ | `rows` | the post-write rows **as the database returned them**, before the output mapper |
1650
+ | `context`, `info` | the GraphQL context value and resolve info |
1651
+ | `tx` | the executor the mutation ran on |
1652
+
1653
+ - **`tx` is the executor the mutation itself used** — one you supplied under
1654
+ `drizzleExecutorKey`, the request's auto-transaction, or, for a field that would otherwise
1655
+ have run unwrapped, a transaction opened for it *because* the hook is there. The hook does
1656
+ not have to know which.
1657
+ - **Throwing rolls the mutation back.** A hook that can only log is what a logging plugin
1658
+ already does.
1659
+ - **`rows` is empty** at the `before` position, and on MySQL, whose mutations return no rows.
1660
+ The `before` position and `args` still work there.
1661
+ - The hook fires for the **mutation field's own table**. Rows a nested `create` writes into
1662
+ another table do not fire that table's hook.
1663
+
1664
+ A build with no `onWrite` opens exactly the transactions it did before.
1665
+
1666
+
1483
1667
  ## Relations & N+1 handling
1484
1668
 
1485
1669
  Generated schemas resolve nested relations without N+1 query explosions:
@@ -1637,7 +1821,8 @@ const meta = drizzleExtension(field)
1637
1821
  // table: 'Users', // the Drizzle schema key
1638
1822
  // kind: 'mutation', // 'query' | 'mutation' | 'relation' | 'aggregate' | 'type'
1639
1823
  // operation: 'update', // 'select' | 'insert' | 'update' | 'updateMany' | 'upsert'
1640
- // // | 'delete' | 'aggregate' | 'groupBy' | 'relation'
1824
+ // // | 'delete' | 'restore' | 'aggregate' | 'groupBy'
1825
+ // // | 'relation'
1641
1826
  // // | 'relationAggregate'
1642
1827
  // single: true, // one row, or a list
1643
1828
  // targetArg: 'where', // 'where' | 'values' | 'updates' — where the rows live