@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 +187 -2
- package/dist/README.md +187 -2
- package/dist/index.cjs +1892 -934
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +204 -5
- package/dist/index.d.ts +204 -5
- package/dist/index.js +1893 -935
- package/dist/index.js.map +1 -1
- package/dist/package.json +1 -1
- package/package.json +1 -1
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
|
|
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' | '
|
|
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
|
|
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' | '
|
|
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
|