@vantreeseba/drizzle-graphql 13.1.0 → 14.0.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/dist/index.js CHANGED
@@ -3724,6 +3724,145 @@ const runMutation = async (db, context, info, txCtx, body, forceTx) => {
3724
3724
  return runOnSharedTx(state, body);
3725
3725
  };
3726
3726
  //#endregion
3727
+ //#region src/util/builders/common/write-hooks.ts
3728
+ /**
3729
+ * Normalizes the two registration shapes: a bare function is the `after` hook — the position
3730
+ * that has rows, and the one the common audit/outbox case wants — while `{ before, after }`
3731
+ * names them explicitly.
3732
+ */
3733
+ const normalizeWriteHooks = (hooks, tableColumns, where) => {
3734
+ if (!hooks) return;
3735
+ if (typeof hooks === "function") return { after: hooks };
3736
+ const resolved = {
3737
+ before: hooks.before,
3738
+ after: hooks.after
3739
+ };
3740
+ if (hooks.columns !== void 0) resolved.columns = resolveHookColumns(hooks.columns, !!hooks.after, tableColumns, where);
3741
+ return resolved.before || resolved.after ? resolved : void 0;
3742
+ };
3743
+ /**
3744
+ * Resolves a hook's `columns` against the table it runs on. `where` names the registration
3745
+ * (`config.onWrite.posts`) and, for a top-level one, the table, so the error says which.
3746
+ */
3747
+ const resolveHookColumns = (spec, hasAfter, tableColumns, where) => {
3748
+ if (!hasAfter) throw new Error(`Drizzle-GraphQL Error: ${where}.columns only applies to an 'after' hook, and none is registered.`);
3749
+ if (spec === "all") return { ...tableColumns };
3750
+ if (!Array.isArray(spec)) throw new Error(`Drizzle-GraphQL Error: ${where}.columns must be an array of column names or 'all'.`);
3751
+ const resolved = {};
3752
+ for (const name of spec) {
3753
+ const column = typeof name === "string" && Object.keys(tableColumns).includes(name) ? tableColumns[name] : void 0;
3754
+ if (!column) throw new Error(`Drizzle-GraphQL Error: ${where}.columns names '${String(name)}', which is not a column of that table.`);
3755
+ resolved[name] = column;
3756
+ }
3757
+ return resolved;
3758
+ };
3759
+ /**
3760
+ * Adds a hook's `columns` to a RETURNING column map, and reports which keys it added — the
3761
+ * ones the client never asked for, which have to come back off before the rows are mapped.
3762
+ * Returns the map untouched, and no keys, when the hook named none or the selection already
3763
+ * covers them.
3764
+ */
3765
+ const withHookColumns = (hooks, columns) => {
3766
+ const hookColumns = hooks?.columns;
3767
+ if (!hookColumns) return {
3768
+ returning: columns,
3769
+ added: void 0
3770
+ };
3771
+ const selected = new Set(Object.keys(columns));
3772
+ const added = Object.keys(hookColumns).filter((key) => !selected.has(key));
3773
+ if (!added.length) return {
3774
+ returning: columns,
3775
+ added: void 0
3776
+ };
3777
+ const returning = { ...columns };
3778
+ for (const key of added) returning[key] = hookColumns[key];
3779
+ return {
3780
+ returning,
3781
+ added
3782
+ };
3783
+ };
3784
+ /**
3785
+ * The rows with the hook-only keys taken off, as fresh objects: the hook was handed the
3786
+ * originals and may still hold them, so they are not mutated under it.
3787
+ */
3788
+ const omitHookColumns = (rows, added) => {
3789
+ if (!added) return rows;
3790
+ return rows.map((row) => {
3791
+ const copy = { ...row };
3792
+ for (const key of added) delete copy[key];
3793
+ return copy;
3794
+ });
3795
+ };
3796
+ /**
3797
+ * Invokes one position of a field's hooks. Returns `undefined` rather than a resolved promise
3798
+ * when nothing is registered, so the awaiting call site costs nothing on an unhooked build.
3799
+ */
3800
+ const runWriteHook = (hooks, position, payload) => {
3801
+ const hook = position === "before" ? hooks?.before : hooks?.after;
3802
+ if (!hook) return;
3803
+ return Promise.resolve(hook({
3804
+ ...payload,
3805
+ position,
3806
+ rows: payload.rows ?? []
3807
+ }));
3808
+ };
3809
+ //#endregion
3810
+ //#region src/util/builders/common/write-scaffold.ts
3811
+ /**
3812
+ * Wraps one mutation body in the scaffolding above and returns the field it becomes.
3813
+ *
3814
+ * A field carrying a hook is forced into a transaction even when it would have run straight
3815
+ * on `db`: a hook exists to write atomically with the mutation, and one that ran outside the
3816
+ * transaction could not roll anything back.
3817
+ */
3818
+ const writeResolver = ({ db, tableName, operation, single, fieldName, args: queryArgs, txCtx, policies, run }) => {
3819
+ const hooks = policies?.onWrite?.(tableName, operation);
3820
+ const errorCtx = {
3821
+ table: tableName,
3822
+ operation,
3823
+ field: fieldName
3824
+ };
3825
+ return {
3826
+ name: fieldName,
3827
+ resolver: async (_source, args, context, info) => {
3828
+ try {
3829
+ return await runMutation(db, context, info, txCtx, (executor) => {
3830
+ const payload = {
3831
+ table: tableName,
3832
+ operation,
3833
+ single,
3834
+ args,
3835
+ context,
3836
+ info,
3837
+ tx: executor
3838
+ };
3839
+ let added;
3840
+ return run({
3841
+ executor,
3842
+ args,
3843
+ context,
3844
+ info,
3845
+ before: () => runWriteHook(hooks, "before", payload),
3846
+ after: (rows) => runWriteHook(hooks, "after", {
3847
+ ...payload,
3848
+ rows: rows ?? []
3849
+ }),
3850
+ returning: (columns) => {
3851
+ const withHook = withHookColumns(hooks, columns);
3852
+ added = withHook.added;
3853
+ return withHook.returning;
3854
+ },
3855
+ output: (rows) => omitHookColumns(rows, added)
3856
+ });
3857
+ }, !!hooks);
3858
+ } catch (e) {
3859
+ throw withErrorContext(toGraphQLError(e), errorCtx);
3860
+ }
3861
+ },
3862
+ args: queryArgs
3863
+ };
3864
+ };
3865
+ //#endregion
3727
3866
  //#region src/util/builders/common/mutation-helpers.ts
3728
3867
  /**
3729
3868
  * The `hard` argument on the delete mutations of a soft-deleting table that opted into
@@ -3867,54 +4006,79 @@ const assertSingleMatch = async (executor, table, filters) => {
3867
4006
  * The plural `update` and `delete` always `RETURNING *`, which is the wrong shape for
3868
4007
  * "archive everything older than a year": the caller pays to ship rows it does not want, and
3869
4008
  * on a large match that cost is the whole mutation. These return `Int!` instead — how many
3870
- * rows the write touched, as the driver reported it, with no RETURNING clause at all.
4009
+ * rows the write touched.
3871
4010
  *
3872
- * Dialect-independent: drizzle's update/delete builders take the same calls everywhere, and
3873
- * the drivers' disagreement about how to spell the row count is handled by
3874
- * {@link rowsAffected}.
3875
- */
3876
- const generateWriteCount = ({ db, tableName, table, kind, setArgs, filterArgs, fieldName, requireWhere, filterCtx, txCtx, nested }) => {
4011
+ * Leaving the payload off changes nothing else: the write obeys every rule of the plural
4012
+ * mutation it mirrors, through the same {@link writeResolver} scaffolding.
4013
+ *
4014
+ * - `scope` (and the soft-delete marker) is ANDed into the `where`, so only rows the plural
4015
+ * write could reach are counted and written.
4016
+ * - On a soft-deleting table the delete is the marking `UPDATE`, not a `DELETE`, and rows that
4017
+ * are already marked are not matched; the update skips marked rows too. `hard: true` exists
4018
+ * exactly where the plural delete takes it.
4019
+ * - Context-derived columns are stripped from the update's `set`, as the plural update does.
4020
+ * - `onWrite` hooks run `before` and `after`, inside a transaction. The `after` hook is handed
4021
+ * rows, so a table with one pays for a `RETURNING` — of the columns the hook names in
4022
+ * `columns`, or of the primary key when it names none (the whole row on a table without a
4023
+ * key) — and the count is the number of rows returned. Every other write, and every write on
4024
+ * MySQL (no `RETURNING`; its hooks see `rows: []` as on every MySQL mutation), skips the
4025
+ * `RETURNING` and reads the count off the driver's result via {@link rowsAffected}.
4026
+ */
4027
+ const generateWriteCount = ({ db, tableName, table, kind, setArgs, filterArgs, fieldName, requireWhere, filterCtx, txCtx, nested, policies, pkNames = [], returnsRows }) => {
4028
+ const softDelete = kind === "delete" ? policies?.softDelete?.(tableName) : void 0;
4029
+ const canHardDelete = softDelete?.hardDelete === true;
3877
4030
  const queryArgs = {
3878
4031
  ...setArgs ? { set: { type: new GraphQLNonNull(setArgs) } } : {},
3879
- where: { type: requireWhere ? new GraphQLNonNull(filterArgs) : filterArgs }
4032
+ where: { type: requireWhere ? new GraphQLNonNull(filterArgs) : filterArgs },
4033
+ ...canHardDelete ? { hard: hardDeleteArg } : {}
3880
4034
  };
3881
- const errorCtx = {
3882
- table: tableName,
4035
+ const readsRows = returnsRows && !!policies?.onWrite?.(tableName, kind)?.after;
4036
+ const allColumns = getColumns(table);
4037
+ const keyColumns = pkNames.length ? Object.fromEntries(pkNames.map((name) => [name, allColumns[name]])) : allColumns;
4038
+ return writeResolver({
4039
+ db,
4040
+ tableName,
3883
4041
  operation: kind,
3884
- field: fieldName
3885
- };
3886
- return {
3887
- name: fieldName,
3888
- resolver: async (_source, args, context, info) => {
3889
- try {
3890
- return await runMutation(db, context, info, txCtx, async (executor) => {
3891
- const { where, set } = args;
3892
- const filters = scopedWhere({
3893
- scope: void 0,
3894
- tableName,
3895
- table,
3896
- where,
3897
- relationCtx: relationFilterCtx(filterCtx, tableName),
3898
- required: requireWhere
3899
- });
3900
- let query;
3901
- if (kind === "delete") query = executor.delete(table);
3902
- else {
3903
- const entry = nested?.enabled(tableName) ? nested.split(tableName, set) : void 0;
3904
- if (entry && nested.hasOps(entry.ops)) throw drizzleError("This mutation does not support nested writes — use the one that returns the rows instead.", { code: "DRIZZLE_NESTED_WRITES_UNSUPPORTED" });
3905
- const values = remapUpdateInput(entry ? entry.columns : set, table, tableName);
3906
- if (!Object.keys(values).length) throw drizzleError("Unable to update with no values specified!", { code: "DRIZZLE_NO_VALUES" });
3907
- query = executor.update(table).set(values);
3908
- }
3909
- if (filters) query = query.where(filters);
3910
- return rowsAffected(await query);
3911
- });
3912
- } catch (e) {
3913
- throw withErrorContext(toGraphQLError(e), errorCtx);
4042
+ single: false,
4043
+ fieldName,
4044
+ txCtx,
4045
+ policies,
4046
+ args: queryArgs,
4047
+ run: async ({ executor, args, context, before, after, returning }) => {
4048
+ const { where, set } = args;
4049
+ const hard = canHardDelete && args.hard === true;
4050
+ const scope = policies?.scope?.(context);
4051
+ await before();
4052
+ let query;
4053
+ if (kind === "delete") query = softDelete && !hard ? executor.update(table).set({ [softDelete.columnName]: softDelete.writeDeleted() }) : executor.delete(table);
4054
+ else {
4055
+ const entry = nested?.enabled(tableName) ? nested.split(tableName, set) : void 0;
4056
+ if (entry && nested.hasOps(entry.ops)) throw drizzleError("This mutation does not support nested writes — use the one that returns the rows instead.", { code: "DRIZZLE_NESTED_WRITES_UNSUPPORTED" });
4057
+ const values = stripContextValues(remapUpdateInput(entry ? entry.columns : set, table, tableName), policies?.contextValues?.(tableName));
4058
+ if (!Object.keys(values).length) throw drizzleError("Unable to update with no values specified!", { code: "DRIZZLE_NO_VALUES" });
4059
+ query = executor.update(table).set(values);
3914
4060
  }
3915
- },
3916
- args: queryArgs
3917
- };
4061
+ const filters = scopedWhere({
4062
+ scope,
4063
+ tableName,
4064
+ table,
4065
+ where,
4066
+ relationCtx: relationFilterCtx(filterCtx, tableName),
4067
+ required: requireWhere,
4068
+ deleted: hard ? "INCLUDE" : void 0
4069
+ });
4070
+ if (filters) query = query.where(filters);
4071
+ if (!readsRows) {
4072
+ const result = await query;
4073
+ await after();
4074
+ return rowsAffected(result);
4075
+ }
4076
+ const named = returning({});
4077
+ const rows = await query.returning(Object.keys(named).length ? named : keyColumns);
4078
+ await after(rows);
4079
+ return rows.length;
4080
+ }
4081
+ });
3918
4082
  };
3919
4083
  //#endregion
3920
4084
  //#region src/util/batch-loader/index.ts
@@ -4852,145 +5016,6 @@ const generateTableTypes = (tableName, tables, relationMap, withReturning, relat
4852
5016
  };
4853
5017
  };
4854
5018
  //#endregion
4855
- //#region src/util/builders/common/write-hooks.ts
4856
- /**
4857
- * Normalizes the two registration shapes: a bare function is the `after` hook — the position
4858
- * that has rows, and the one the common audit/outbox case wants — while `{ before, after }`
4859
- * names them explicitly.
4860
- */
4861
- const normalizeWriteHooks = (hooks, tableColumns, where) => {
4862
- if (!hooks) return;
4863
- if (typeof hooks === "function") return { after: hooks };
4864
- const resolved = {
4865
- before: hooks.before,
4866
- after: hooks.after
4867
- };
4868
- if (hooks.columns !== void 0) resolved.columns = resolveHookColumns(hooks.columns, !!hooks.after, tableColumns, where);
4869
- return resolved.before || resolved.after ? resolved : void 0;
4870
- };
4871
- /**
4872
- * Resolves a hook's `columns` against the table it runs on. `where` names the registration
4873
- * (`config.onWrite.posts`) and, for a top-level one, the table, so the error says which.
4874
- */
4875
- const resolveHookColumns = (spec, hasAfter, tableColumns, where) => {
4876
- if (!hasAfter) throw new Error(`Drizzle-GraphQL Error: ${where}.columns only applies to an 'after' hook, and none is registered.`);
4877
- if (spec === "all") return { ...tableColumns };
4878
- if (!Array.isArray(spec)) throw new Error(`Drizzle-GraphQL Error: ${where}.columns must be an array of column names or 'all'.`);
4879
- const resolved = {};
4880
- for (const name of spec) {
4881
- const column = typeof name === "string" && Object.keys(tableColumns).includes(name) ? tableColumns[name] : void 0;
4882
- if (!column) throw new Error(`Drizzle-GraphQL Error: ${where}.columns names '${String(name)}', which is not a column of that table.`);
4883
- resolved[name] = column;
4884
- }
4885
- return resolved;
4886
- };
4887
- /**
4888
- * Adds a hook's `columns` to a RETURNING column map, and reports which keys it added — the
4889
- * ones the client never asked for, which have to come back off before the rows are mapped.
4890
- * Returns the map untouched, and no keys, when the hook named none or the selection already
4891
- * covers them.
4892
- */
4893
- const withHookColumns = (hooks, columns) => {
4894
- const hookColumns = hooks?.columns;
4895
- if (!hookColumns) return {
4896
- returning: columns,
4897
- added: void 0
4898
- };
4899
- const selected = new Set(Object.keys(columns));
4900
- const added = Object.keys(hookColumns).filter((key) => !selected.has(key));
4901
- if (!added.length) return {
4902
- returning: columns,
4903
- added: void 0
4904
- };
4905
- const returning = { ...columns };
4906
- for (const key of added) returning[key] = hookColumns[key];
4907
- return {
4908
- returning,
4909
- added
4910
- };
4911
- };
4912
- /**
4913
- * The rows with the hook-only keys taken off, as fresh objects: the hook was handed the
4914
- * originals and may still hold them, so they are not mutated under it.
4915
- */
4916
- const omitHookColumns = (rows, added) => {
4917
- if (!added) return rows;
4918
- return rows.map((row) => {
4919
- const copy = { ...row };
4920
- for (const key of added) delete copy[key];
4921
- return copy;
4922
- });
4923
- };
4924
- /**
4925
- * Invokes one position of a field's hooks. Returns `undefined` rather than a resolved promise
4926
- * when nothing is registered, so the awaiting call site costs nothing on an unhooked build.
4927
- */
4928
- const runWriteHook = (hooks, position, payload) => {
4929
- const hook = position === "before" ? hooks?.before : hooks?.after;
4930
- if (!hook) return;
4931
- return Promise.resolve(hook({
4932
- ...payload,
4933
- position,
4934
- rows: payload.rows ?? []
4935
- }));
4936
- };
4937
- //#endregion
4938
- //#region src/util/builders/common/write-scaffold.ts
4939
- /**
4940
- * Wraps one mutation body in the scaffolding above and returns the field it becomes.
4941
- *
4942
- * A field carrying a hook is forced into a transaction even when it would have run straight
4943
- * on `db`: a hook exists to write atomically with the mutation, and one that ran outside the
4944
- * transaction could not roll anything back.
4945
- */
4946
- const writeResolver = ({ db, tableName, operation, single, fieldName, args: queryArgs, txCtx, policies, run }) => {
4947
- const hooks = policies?.onWrite?.(tableName, operation);
4948
- const errorCtx = {
4949
- table: tableName,
4950
- operation,
4951
- field: fieldName
4952
- };
4953
- return {
4954
- name: fieldName,
4955
- resolver: async (_source, args, context, info) => {
4956
- try {
4957
- return await runMutation(db, context, info, txCtx, (executor) => {
4958
- const payload = {
4959
- table: tableName,
4960
- operation,
4961
- single,
4962
- args,
4963
- context,
4964
- info,
4965
- tx: executor
4966
- };
4967
- let added;
4968
- return run({
4969
- executor,
4970
- args,
4971
- context,
4972
- info,
4973
- before: () => runWriteHook(hooks, "before", payload),
4974
- after: (rows) => runWriteHook(hooks, "after", {
4975
- ...payload,
4976
- rows: rows ?? []
4977
- }),
4978
- returning: (columns) => {
4979
- const withHook = withHookColumns(hooks, columns);
4980
- added = withHook.added;
4981
- return withHook.returning;
4982
- },
4983
- output: (rows) => omitHookColumns(rows, added)
4984
- });
4985
- }, !!hooks);
4986
- } catch (e) {
4987
- throw withErrorContext(toGraphQLError(e), errorCtx);
4988
- }
4989
- },
4990
- args: queryArgs
4991
- };
4992
- };
4993
- //#endregion
4994
5019
  //#region src/util/features.ts
4995
5020
  /**
4996
5021
  * What a table generates when the config says nothing. Everything is on except the opt-in
@@ -5202,7 +5227,6 @@ const resolveBuildConfig = (config, schema) => {
5202
5227
  const missing = [!tableFeatures.insert ? "insert" : void 0, !tableFeatures.update ? "update" : void 0].filter(Boolean).join(" and ");
5203
5228
  if (missing) noteConflict(`upsert is on while ${missing} is off, so the upsert mutations are a second write path past the operation you turned off`, tableName);
5204
5229
  }
5205
- if (config?.features?.nestedWrites && (!tableFeatures.insert || !tableFeatures.update)) noteConflict("nestedWrites is on while this table's own insert or update is off, so a nested `create` or `connect` under another table's mutation can still write it", tableName);
5206
5230
  if (config?.features?.updateMany !== void 0 && tableFeatures.updateMany && !tableFeatures.update) noteConflict("updateMany is on while update is off, so no batch update is generated", tableName);
5207
5231
  if (config?.features?.groupBy !== void 0 && tableFeatures.groupBy && !tableFeatures.aggregates) noteConflict("groupBy is on while aggregates is off, so no grouped query is generated", tableName);
5208
5232
  if (tableFeatures.countMutations && !tableFeatures.update && !tableFeatures.delete) noteConflict("countMutations is on while update and delete are both off, so no count mutation is generated", tableName);
@@ -5808,6 +5832,12 @@ const generateGroupBy = (db, tableName, table, typeName, fieldName, filterArgs,
5808
5832
  };
5809
5833
  //#endregion
5810
5834
  //#region src/util/builders/nested-writes.ts
5835
+ const ALL_OPS = [
5836
+ "create",
5837
+ "connect",
5838
+ "disconnect",
5839
+ "set"
5840
+ ];
5811
5841
  /**
5812
5842
  * Whether a column can be left out of an INSERT — either the database fills it in or it
5813
5843
  * accepts NULL.
@@ -5836,7 +5866,7 @@ const throughColumn = (entry) => entry && is(entry, Column) ? entry : entry?._?.
5836
5866
  * joining on more than one column per side; and a junction carrying a further NOT NULL column
5837
5867
  * with no default, which no `connect` operand could supply a value for.
5838
5868
  */
5839
- const buildThroughPlan = (relationName, relation, table, targetTableName, targetTable) => {
5869
+ const buildThroughPlan = (relationName, relation, table, targetTableName, targetTable, tableNameOf) => {
5840
5870
  if (is(relation, One)) return;
5841
5871
  const junction = relation.throughTable;
5842
5872
  const sourceEntries = relation.through?.source;
@@ -5867,11 +5897,18 @@ const buildThroughPlan = (relationName, relation, table, targetTableName, target
5867
5897
  canDetach: true,
5868
5898
  through: {
5869
5899
  table: junction,
5900
+ tableName: tableNameOf(junction),
5870
5901
  sourceColPropName,
5871
5902
  targetColPropName,
5872
5903
  sourceCol,
5873
5904
  targetCol
5874
- }
5905
+ },
5906
+ targetKeyColumns: { [foreignColPropName]: foreignCol },
5907
+ allowed: /* @__PURE__ */ new Set([
5908
+ "connect",
5909
+ "disconnect",
5910
+ "set"
5911
+ ])
5875
5912
  };
5876
5913
  };
5877
5914
  /**
@@ -5884,7 +5921,7 @@ const buildThroughPlan = (relationName, relation, table, targetTableName, target
5884
5921
  * relations whose join columns cannot be read off the drizzle relation; and relations where
5885
5922
  * neither join column is unique, which leaves no way to tell which side stores the key.
5886
5923
  */
5887
- const buildNestedWritePlans = (tables, relationMap, resolveUniqueSets, resolvePrimaryKeys, extractJoinColumns) => {
5924
+ const buildNestedWritePlans = (tables, relationMap, resolveUniqueSets, resolvePrimaryKeys, extractJoinColumns, tableNameOf = () => void 0) => {
5888
5925
  const uniqueCache = /* @__PURE__ */ new Map();
5889
5926
  const singleColumnUniques = (tableName, table) => {
5890
5927
  let cached = uniqueCache.get(tableName);
@@ -5913,7 +5950,7 @@ const buildNestedWritePlans = (tables, relationMap, resolveUniqueSets, resolvePr
5913
5950
  const targetTable = tables[targetTableName];
5914
5951
  if (!targetTable) continue;
5915
5952
  if (relation.through) {
5916
- const throughPlan = buildThroughPlan(relationName, relation, table, targetTableName, targetTable);
5953
+ const throughPlan = buildThroughPlan(relationName, relation, table, targetTableName, targetTable, tableNameOf);
5917
5954
  if (throughPlan) tablePlans[relationName] = throughPlan;
5918
5955
  continue;
5919
5956
  }
@@ -5934,6 +5971,10 @@ const buildNestedWritePlans = (tables, relationMap, resolveUniqueSets, resolvePr
5934
5971
  else if (foreignIsUnique) fkSide = "parent";
5935
5972
  else if (localIsUnique) fkSide = "child";
5936
5973
  else continue;
5974
+ const fkColumn = fkSide === "child" ? foreignCol : localCol;
5975
+ const targetColumns = getColumns(targetTable);
5976
+ const targetKeyColumns = Object.fromEntries(resolvePrimaryKeys(targetTable).filter((name) => targetColumns[name]).map((name) => [name, targetColumns[name]]));
5977
+ targetKeyColumns[foreignColPropName] = foreignCol;
5937
5978
  tablePlans[relationName] = {
5938
5979
  relationName,
5939
5980
  targetTableName,
@@ -5944,13 +5985,87 @@ const buildNestedWritePlans = (tables, relationMap, resolveUniqueSets, resolvePr
5944
5985
  localCol,
5945
5986
  isOne,
5946
5987
  fkSide,
5947
- canDetach: !(fkSide === "child" ? foreignCol : localCol).notNull
5988
+ canDetach: !fkColumn.notNull,
5989
+ targetKeyColumns,
5990
+ allowed: new Set(!fkColumn.notNull ? isOne ? [
5991
+ "create",
5992
+ "connect",
5993
+ "disconnect"
5994
+ ] : ALL_OPS : ["create", "connect"])
5948
5995
  };
5949
5996
  }
5950
5997
  if (Object.keys(tablePlans).length) plans[tableName] = tablePlans;
5951
5998
  }
5952
5999
  return plans;
5953
6000
  };
6001
+ /**
6002
+ * Which of the target's (or junction's) write features each input of a relation needs. A
6003
+ * parent-side `connect` / `disconnect` only rewrites the parent's own column, so it needs
6004
+ * nothing of the target; a child-side one moves the target's foreign key, which is an update
6005
+ * of the target. A to-one child-side `create` also detaches the row attached before it, when
6006
+ * the key is nullable.
6007
+ */
6008
+ const requiredWrites = (plan, op) => {
6009
+ if (plan.through) {
6010
+ const junction = plan.through.tableName;
6011
+ if (op === "connect") return [{
6012
+ tableName: junction,
6013
+ feature: "insert"
6014
+ }];
6015
+ if (op === "disconnect") return [{
6016
+ tableName: junction,
6017
+ feature: "delete"
6018
+ }];
6019
+ if (op === "set") return [{
6020
+ tableName: junction,
6021
+ feature: "delete"
6022
+ }, {
6023
+ tableName: junction,
6024
+ feature: "insert"
6025
+ }];
6026
+ return [];
6027
+ }
6028
+ const target = plan.targetTableName;
6029
+ if (op === "create") return plan.fkSide === "child" && plan.isOne && plan.canDetach ? [{
6030
+ tableName: target,
6031
+ feature: "insert"
6032
+ }, {
6033
+ tableName: target,
6034
+ feature: "update"
6035
+ }] : [{
6036
+ tableName: target,
6037
+ feature: "insert"
6038
+ }];
6039
+ return plan.fkSide === "child" ? [{
6040
+ tableName: target,
6041
+ feature: "update"
6042
+ }] : [];
6043
+ };
6044
+ /**
6045
+ * Narrows every plan's `allowed` inputs to the ones whose writes the table they land on
6046
+ * permits: its `features.insert` / `update` / `delete`, and for a junction table that it is
6047
+ * not excluded. A plan left with nothing is dropped, so its relation field is not generated.
6048
+ *
6049
+ * Returns one line per input taken out, for the build note.
6050
+ */
6051
+ const gateNestedWritePlans = (plans, featureOf, isExcluded) => {
6052
+ const omitted = [];
6053
+ for (const [tableName, tablePlans] of Object.entries(plans)) {
6054
+ for (const [relationName, plan] of Object.entries(tablePlans)) {
6055
+ for (const op of [...plan.allowed]) {
6056
+ const blocked = requiredWrites(plan, op).filter(({ tableName: target, feature }) => target !== void 0 && (isExcluded(target) || !featureOf(target)[feature]));
6057
+ if (blocked.length) {
6058
+ plan.allowed.delete(op);
6059
+ const reasons = blocked.map(({ tableName: target, feature }) => isExcluded(target) ? `${target} is excluded` : `${target}.${feature} is off`);
6060
+ omitted.push(`${tableName}.${relationName}.${op} (${[...new Set(reasons)].join(", ")})`);
6061
+ }
6062
+ }
6063
+ if (!plan.allowed.size) delete tablePlans[relationName];
6064
+ }
6065
+ if (!Object.keys(tablePlans).length) delete plans[tableName];
6066
+ }
6067
+ return omitted;
6068
+ };
5954
6069
  const NO_COLUMNS = /* @__PURE__ */ new Set();
5955
6070
  /**
5956
6071
  * Builds the nested-write input types for a schema.
@@ -6005,8 +6120,11 @@ const createNestedWriteTypes = (params) => {
6005
6120
  *
6006
6121
  * A many-to-many relation offers no `create`: inserting a row *and* linking it is two
6007
6122
  * writes to two tables, and the link half is what the relation field is for. Its
6008
- * `disconnect` and `set` are offered unconditionally, since breaking a link deletes a
6123
+ * `disconnect` and `set` do not depend on nullability, since breaking a link deletes a
6009
6124
  * junction row rather than nulling a column that might be NOT NULL.
6125
+ *
6126
+ * Each input is also subject to `plan.allowed` — the table it writes has to have that
6127
+ * write turned on (see {@link gateNestedWritePlans}).
6010
6128
  */
6011
6129
  const wrapperType = (tableName, typeName, plan, forUpdate) => {
6012
6130
  const key = `${tableName}.${plan.relationName}.${forUpdate ? "update" : "create"}`;
@@ -6015,57 +6133,52 @@ const createNestedWriteTypes = (params) => {
6015
6133
  const filters = targetFilters(plan);
6016
6134
  const targetTypeName = resolveTypeName(plan.targetTableName, typeNameMapper);
6017
6135
  const fields = {};
6136
+ const offers = (op) => plan.allowed.has(op);
6018
6137
  if (plan.through) {
6019
- if (!filters) return;
6020
- fields["connect"] = {
6138
+ if (filters && offers("connect")) fields["connect"] = {
6021
6139
  type: listOf(filters),
6022
6140
  description: `Link every existing ${targetTypeName} row these match. Already-linked rows are left alone.`
6023
6141
  };
6024
- if (forUpdate) {
6025
- fields["disconnect"] = {
6026
- type: listOf(filters),
6027
- description: `Unlink every linked ${targetTypeName} row these match, leaving the rows in place`
6028
- };
6029
- fields["set"] = {
6030
- type: listOf(filters),
6031
- description: `Replace the whole set: unlink everything linked, then link every ${targetTypeName} row these match. \`set: []\` clears the relation. Applied before \`disconnect\` and \`connect\`.`
6032
- };
6033
- }
6142
+ if (filters && forUpdate && offers("disconnect")) fields["disconnect"] = {
6143
+ type: listOf(filters),
6144
+ description: `Unlink every linked ${targetTypeName} row these match, leaving the rows in place`
6145
+ };
6146
+ if (filters && forUpdate && offers("set")) fields["set"] = {
6147
+ type: listOf(filters),
6148
+ description: `Replace the whole set: unlink everything linked, then link every ${targetTypeName} row these match. \`set: []\` clears the relation. Applied before \`disconnect\` and \`connect\`.`
6149
+ };
6034
6150
  } else if (plan.isOne) {
6035
- fields["create"] = {
6151
+ if (offers("create")) fields["create"] = {
6036
6152
  type: payloadType(tableName, typeName, plan),
6037
6153
  description: `Insert a ${targetTypeName} row and attach it`
6038
6154
  };
6039
- if (filters) fields["connect"] = {
6155
+ if (filters && offers("connect")) fields["connect"] = {
6040
6156
  type: filters,
6041
6157
  description: `Attach the existing ${targetTypeName} row this matches. Must match exactly one row.`
6042
6158
  };
6043
- if (forUpdate && plan.canDetach) fields["disconnect"] = {
6159
+ if (forUpdate && offers("disconnect")) fields["disconnect"] = {
6044
6160
  type: GraphQLBoolean,
6045
6161
  description: `Detach the currently attached ${targetTypeName} row, leaving it in place`
6046
6162
  };
6047
6163
  } else {
6048
- fields["create"] = {
6164
+ if (offers("create")) fields["create"] = {
6049
6165
  type: listOf(payloadType(tableName, typeName, plan)),
6050
6166
  description: `Insert these ${targetTypeName} rows and attach them`
6051
6167
  };
6052
- if (filters) {
6053
- fields["connect"] = {
6054
- type: listOf(filters),
6055
- description: `Attach every existing ${targetTypeName} row these match`
6056
- };
6057
- if (forUpdate && plan.canDetach) {
6058
- fields["disconnect"] = {
6059
- type: listOf(filters),
6060
- description: `Detach every attached ${targetTypeName} row these match, leaving the rows in place`
6061
- };
6062
- fields["set"] = {
6063
- type: listOf(filters),
6064
- description: `Replace the whole set: detach everything attached, then attach every ${targetTypeName} row these match. Applied before \`disconnect\`, \`connect\` and \`create\`.`
6065
- };
6066
- }
6067
- }
6168
+ if (filters && offers("connect")) fields["connect"] = {
6169
+ type: listOf(filters),
6170
+ description: `Attach every existing ${targetTypeName} row these match`
6171
+ };
6172
+ if (filters && forUpdate && offers("disconnect")) fields["disconnect"] = {
6173
+ type: listOf(filters),
6174
+ description: `Detach every attached ${targetTypeName} row these match, leaving the rows in place`
6175
+ };
6176
+ if (filters && forUpdate && offers("set")) fields["set"] = {
6177
+ type: listOf(filters),
6178
+ description: `Replace the whole set: detach everything attached, then attach every ${targetTypeName} row these match. Applied before \`disconnect\`, \`connect\` and \`create\`.`
6179
+ };
6068
6180
  }
6181
+ if (!Object.keys(fields).length) return;
6069
6182
  const type = new GraphQLInputObjectType({
6070
6183
  name: cacheCtx.typeName({
6071
6184
  kind: "nestedWriteInput",
@@ -6097,7 +6210,7 @@ const createNestedWriteTypes = (params) => {
6097
6210
  const tablePlans = plans[tableName];
6098
6211
  if (!tablePlans) return NO_COLUMNS;
6099
6212
  const relaxed = /* @__PURE__ */ new Set();
6100
- for (const plan of Object.values(tablePlans)) if (plan.fkSide === "parent" && !isOptionalOnInsert(plan.localCol)) relaxed.add(plan.localColPropName);
6213
+ for (const plan of Object.values(tablePlans)) if (plan.fkSide === "parent" && (plan.allowed.has("create") || plan.allowed.has("connect")) && !isOptionalOnInsert(plan.localCol)) relaxed.add(plan.localColPropName);
6101
6214
  return relaxed.size ? relaxed : NO_COLUMNS;
6102
6215
  }
6103
6216
  };
@@ -6108,8 +6221,99 @@ const createNestedWriteTypes = (params) => {
6108
6221
  */
6109
6222
  const mergedOps = (entries) => Object.assign({}, ...entries.map((entry) => entry.ops));
6110
6223
  const asArray = (value) => Array.isArray(value) ? value : value === void 0 ? [] : [value];
6224
+ const supplied = (value) => value !== void 0 && value !== null;
6225
+ /**
6226
+ * The hook calls one relation field makes. Every step is registered before the field's first
6227
+ * statement, so each `before` runs ahead of all of them and each `after` once the last has
6228
+ * returned. A table without hooks registers nothing, and its statements run exactly as they
6229
+ * did before hooks reached nested writes — no RETURNING clause is added.
6230
+ */
6231
+ const createHookSteps = (onWrite, executor, request, parentTable, plan) => {
6232
+ const steps = /* @__PURE__ */ new Map();
6233
+ const keyOf = (op, operation) => `${op}:${operation}`;
6234
+ const payload = (step) => ({
6235
+ table: step.tableName,
6236
+ operation: step.operation,
6237
+ single: plan.isOne,
6238
+ args: request?.args,
6239
+ context: request?.context,
6240
+ info: request?.info,
6241
+ tx: executor,
6242
+ nested: {
6243
+ parentTable,
6244
+ relation: plan.relationName,
6245
+ op: step.op
6246
+ }
6247
+ });
6248
+ return {
6249
+ /** Registers the hook call for `op`'s `operation` statements, if that table has hooks. */
6250
+ add: (op, operation) => {
6251
+ const through = plan.through;
6252
+ const tableName = through ? through.tableName : plan.targetTableName;
6253
+ const hooks = tableName ? onWrite?.(tableName, operation) : void 0;
6254
+ if (!tableName || !hooks || steps.has(keyOf(op, operation))) return;
6255
+ const keyColumns = through ? {
6256
+ [through.sourceColPropName]: through.sourceCol,
6257
+ [through.targetColPropName]: through.targetCol
6258
+ } : { ...plan.targetKeyColumns };
6259
+ steps.set(keyOf(op, operation), {
6260
+ tableName,
6261
+ operation,
6262
+ op,
6263
+ hooks,
6264
+ returning: withHookColumns(hooks, keyColumns).returning,
6265
+ rows: []
6266
+ });
6267
+ },
6268
+ get: (op, operation) => steps.get(keyOf(op, operation)),
6269
+ /** Runs one statement, returning its rows to the step it belongs to when there is one. */
6270
+ run: async (query, op, operation) => {
6271
+ const step = steps.get(keyOf(op, operation));
6272
+ if (!step) {
6273
+ await query;
6274
+ return;
6275
+ }
6276
+ step.rows.push(...await query.returning(step.returning));
6277
+ },
6278
+ before: async () => {
6279
+ for (const step of steps.values()) await runWriteHook(step.hooks, "before", payload(step));
6280
+ },
6281
+ after: async () => {
6282
+ for (const step of steps.values()) await runWriteHook(step.hooks, "after", {
6283
+ ...payload(step),
6284
+ rows: step.rows
6285
+ });
6286
+ }
6287
+ };
6288
+ };
6289
+ /**
6290
+ * Registers the hook calls a child-side relation field will make, in the order its
6291
+ * statements run, so the `before` hooks fire in that order too. Mirrors the branches of
6292
+ * `applyChildSide` and `applyThroughOps` exactly: a step registered for an input that runs no
6293
+ * statement would fire with no rows, and one missing would miss them.
6294
+ */
6295
+ const registerChildSteps = (steps, plan, op) => {
6296
+ if (plan.through) {
6297
+ if (supplied(op["set"])) {
6298
+ steps.add("set", "delete");
6299
+ steps.add("set", "insert");
6300
+ }
6301
+ if (supplied(op["disconnect"])) steps.add("disconnect", "delete");
6302
+ if (supplied(op["connect"])) steps.add("connect", "insert");
6303
+ return;
6304
+ }
6305
+ if (op["set"] !== void 0) steps.add("set", "update");
6306
+ if (op["disconnect"] !== void 0 && (!plan.isOne || op["disconnect"] === true)) steps.add("disconnect", "update");
6307
+ const attachedBy = attachingOp(op);
6308
+ if (plan.isOne && attachedBy && plan.canDetach && op["set"] === void 0) steps.add(attachedBy, "update");
6309
+ if (op["connect"] !== void 0) steps.add("connect", "update");
6310
+ if (op["create"] !== void 0) steps.add("create", "insert");
6311
+ };
6312
+ /** The input that attaches a row to a to-one, which detaches whatever was attached before. */
6313
+ const attachingOp = (op) => op["connect"] !== void 0 ? "connect" : op["create"] !== void 0 ? "create" : void 0;
6111
6314
  const createNestedWriteRuntime = (params) => {
6112
6315
  const { plans, filterCtx, policies, contextValues } = params;
6316
+ const onWrite = policies?.onWrite;
6113
6317
  /**
6114
6318
  * A `connect` / `disconnect` / `set` operand compiled to SQL. An operand that compiles to
6115
6319
  * nothing is rejected: `connect: {}` would otherwise attach every row in the table.
@@ -6120,12 +6324,12 @@ const createNestedWriteRuntime = (params) => {
6120
6324
  return withScope(scope, plan.targetTableName, plan.targetTable, condition);
6121
6325
  };
6122
6326
  const assertSingleOperation = (plan, op) => {
6123
- const supplied = [
6327
+ const given = [
6124
6328
  "create",
6125
6329
  "connect",
6126
6330
  "disconnect"
6127
- ].filter((key) => op[key] !== void 0 && op[key] !== null);
6128
- if (supplied.length > 1) throw drizzleError(`Drizzle-GraphQL Error: '${plan.relationName}' takes one of ${supplied.join(", ")} at a time — it holds a single row.`, { code: "DRIZZLE_NESTED_WRITE_INVALID" });
6331
+ ].filter((key) => supplied(op[key]));
6332
+ if (given.length > 1) throw drizzleError(`Drizzle-GraphQL Error: '${plan.relationName}' takes one of ${given.join(", ")} at a time — it holds a single row.`, { code: "DRIZZLE_NESTED_WRITE_INVALID" });
6129
6333
  };
6130
6334
  /** The values the junction stores for the target rows one operand selects. */
6131
6335
  const selectedTargetKeys = async (executor, plan, filter, operation, scope) => {
@@ -6140,9 +6344,8 @@ const createNestedWriteRuntime = (params) => {
6140
6344
  * error on some of them — the read and the write share the surrounding transaction, so no
6141
6345
  * other writer can slip a duplicate in between them.
6142
6346
  */
6143
- const applyThroughOps = async (executor, plan, key, op, scope) => {
6347
+ const applyThroughOps = async (executor, plan, key, op, scope, steps) => {
6144
6348
  const through = plan.through;
6145
- const unlinkAll = () => executor.delete(through.table).where(eq(through.sourceCol, key));
6146
6349
  const link = async (operands, operation) => {
6147
6350
  const existing = new Set((await executor.select({ [through.targetColPropName]: through.targetCol }).from(through.table).where(eq(through.sourceCol, key))).map((row) => row[through.targetColPropName]));
6148
6351
  const rows = [];
@@ -6153,19 +6356,19 @@ const createNestedWriteRuntime = (params) => {
6153
6356
  [through.targetColPropName]: targetKey
6154
6357
  });
6155
6358
  }
6156
- if (rows.length) await executor.insert(through.table).values(rows);
6359
+ if (rows.length) await steps.run(executor.insert(through.table).values(rows), operation, "insert");
6157
6360
  };
6158
- if (op["set"] !== void 0 && op["set"] !== null) {
6159
- await unlinkAll();
6361
+ if (supplied(op["set"])) {
6362
+ await steps.run(executor.delete(through.table).where(eq(through.sourceCol, key)), "set", "delete");
6160
6363
  await link(asArray(op["set"]), "set");
6161
6364
  }
6162
- if (op["disconnect"] !== void 0 && op["disconnect"] !== null) for (const filter of asArray(op["disconnect"])) {
6365
+ if (supplied(op["disconnect"])) for (const filter of asArray(op["disconnect"])) {
6163
6366
  const targetKeys = await selectedTargetKeys(executor, plan, filter, "disconnect", scope);
6164
- if (targetKeys.length) await executor.delete(through.table).where(and(eq(through.sourceCol, key), inArray(through.targetCol, targetKeys)));
6367
+ if (targetKeys.length) await steps.run(executor.delete(through.table).where(and(eq(through.sourceCol, key), inArray(through.targetCol, targetKeys))), "disconnect", "delete");
6165
6368
  }
6166
- if (op["connect"] !== void 0 && op["connect"] !== null) await link(asArray(op["connect"]), "connect");
6369
+ if (supplied(op["connect"])) await link(asArray(op["connect"]), "connect");
6167
6370
  };
6168
- const detachAll = async (executor, plan, key, scope) => executor.update(plan.targetTable).set({ [plan.foreignColPropName]: null }).where(withScope(scope, plan.targetTableName, plan.targetTable, eq(plan.foreignCol, key)));
6371
+ const detachAll = (executor, plan, key, scope, steps, op) => steps.run(executor.update(plan.targetTable).set({ [plan.foreignColPropName]: null }).where(withScope(scope, plan.targetTableName, plan.targetTable, eq(plan.foreignCol, key))), op, "update");
6169
6372
  return {
6170
6373
  enabled: (tableName) => !!plans[tableName],
6171
6374
  split: (tableName, values) => {
@@ -6194,19 +6397,27 @@ const createNestedWriteRuntime = (params) => {
6194
6397
  }
6195
6398
  return columns;
6196
6399
  },
6197
- applyParentSide: async (executor, tableName, ops, context) => {
6400
+ applyParentSide: async (executor, tableName, ops, request) => {
6198
6401
  const tablePlans = plans[tableName];
6199
6402
  const patch = {};
6200
6403
  if (!tablePlans) return patch;
6404
+ const context = request?.context;
6201
6405
  const scope = resolveScope(policies, context, filterCtx);
6202
6406
  for (const [relationName, op] of Object.entries(ops)) {
6203
6407
  const plan = tablePlans[relationName];
6204
6408
  if (plan?.fkSide !== "parent") continue;
6205
6409
  assertSingleOperation(plan, op);
6206
6410
  if (op["create"] !== void 0) {
6411
+ const steps = createHookSteps(onWrite, executor, request, tableName, plan);
6412
+ steps.add("create", "insert");
6413
+ await steps.before();
6207
6414
  const values = applyContextValues(remapFromGraphQLSingleInput({ ...op["create"] }, plan.targetTable), contextValues?.(plan.targetTableName), context);
6208
- const created = (await executor.insert(plan.targetTable).values(values).returning({ [plan.foreignColPropName]: plan.foreignCol }))[0];
6415
+ const step = steps.get("create", "insert");
6416
+ const inserted = await executor.insert(plan.targetTable).values(values).returning(step ? step.returning : { [plan.foreignColPropName]: plan.foreignCol });
6417
+ step?.rows.push(...inserted);
6418
+ const created = inserted[0];
6209
6419
  if (!created) throw drizzleError(`Drizzle-GraphQL Error: 'create' on '${relationName}' inserted no row, so there is nothing to attach.`, { code: "DRIZZLE_NESTED_WRITE_INVALID" });
6420
+ await steps.after();
6210
6421
  patch[plan.localColPropName] = created[plan.foreignColPropName];
6211
6422
  } else if (op["connect"] !== void 0) {
6212
6423
  const rows = await executor.select({ [plan.foreignColPropName]: plan.foreignCol }).from(plan.targetTable).where(conditionOf(plan, op["connect"], "connect", scope));
@@ -6216,41 +6427,46 @@ const createNestedWriteRuntime = (params) => {
6216
6427
  }
6217
6428
  return patch;
6218
6429
  },
6219
- applyChildSide: async (executor, tableName, ops, parentRows, context) => {
6430
+ applyChildSide: async (executor, tableName, ops, parentRows, request) => {
6220
6431
  const tablePlans = plans[tableName];
6221
6432
  if (!tablePlans) return;
6433
+ const context = request?.context;
6222
6434
  const scope = resolveScope(policies, context, filterCtx);
6223
6435
  for (const [relationName, op] of Object.entries(ops)) {
6224
6436
  const plan = tablePlans[relationName];
6225
6437
  if (plan?.fkSide !== "child") continue;
6226
6438
  if (plan.isOne) assertSingleOperation(plan, op);
6439
+ const steps = createHookSteps(onWrite, executor, request, tableName, plan);
6440
+ registerChildSteps(steps, plan, op);
6441
+ await steps.before();
6227
6442
  for (const parentRow of parentRows) {
6228
6443
  const key = parentRow?.[plan.localColPropName];
6229
6444
  if (key === void 0 || key === null) throw drizzleError(`Drizzle-GraphQL Error: cannot write through '${relationName}': the ${tableName} row has no '${plan.localColPropName}' value to attach to.`, { code: "DRIZZLE_NESTED_WRITE_INVALID" });
6230
6445
  if (plan.through) {
6231
- await applyThroughOps(executor, plan, key, op, scope);
6446
+ await applyThroughOps(executor, plan, key, op, scope, steps);
6232
6447
  continue;
6233
6448
  }
6234
6449
  if (op["set"] !== void 0) {
6235
- await detachAll(executor, plan, key, scope);
6236
- for (const filter of asArray(op["set"])) await executor.update(plan.targetTable).set({ [plan.foreignColPropName]: key }).where(conditionOf(plan, filter, "set", scope));
6450
+ await detachAll(executor, plan, key, scope, steps, "set");
6451
+ for (const filter of asArray(op["set"])) await steps.run(executor.update(plan.targetTable).set({ [plan.foreignColPropName]: key }).where(conditionOf(plan, filter, "set", scope)), "set", "update");
6237
6452
  }
6238
6453
  if (op["disconnect"] !== void 0) {
6239
6454
  if (plan.isOne) {
6240
- if (op["disconnect"] === true) await detachAll(executor, plan, key, scope);
6241
- } else for (const filter of asArray(op["disconnect"])) await executor.update(plan.targetTable).set({ [plan.foreignColPropName]: null }).where(and(eq(plan.foreignCol, key), conditionOf(plan, filter, "disconnect", scope)));
6455
+ if (op["disconnect"] === true) await detachAll(executor, plan, key, scope, steps, "disconnect");
6456
+ } else for (const filter of asArray(op["disconnect"])) await steps.run(executor.update(plan.targetTable).set({ [plan.foreignColPropName]: null }).where(and(eq(plan.foreignCol, key), conditionOf(plan, filter, "disconnect", scope))), "disconnect", "update");
6242
6457
  }
6243
- const attaches = op["connect"] !== void 0 || op["create"] !== void 0;
6244
- if (plan.isOne && attaches && plan.canDetach && op["set"] === void 0) await detachAll(executor, plan, key, scope);
6245
- if (op["connect"] !== void 0) for (const filter of asArray(op["connect"])) await executor.update(plan.targetTable).set({ [plan.foreignColPropName]: key }).where(conditionOf(plan, filter, "connect", scope));
6458
+ const attachedBy = attachingOp(op);
6459
+ if (plan.isOne && attachedBy && plan.canDetach && op["set"] === void 0) await detachAll(executor, plan, key, scope, steps, attachedBy);
6460
+ if (op["connect"] !== void 0) for (const filter of asArray(op["connect"])) await steps.run(executor.update(plan.targetTable).set({ [plan.foreignColPropName]: key }).where(conditionOf(plan, filter, "connect", scope)), "connect", "update");
6246
6461
  if (op["create"] !== void 0) {
6247
6462
  const rows = asArray(op["create"]).map((row) => ({
6248
6463
  ...applyContextValues(remapFromGraphQLSingleInput({ ...row }, plan.targetTable), contextValues?.(plan.targetTableName), context),
6249
6464
  [plan.foreignColPropName]: key
6250
6465
  }));
6251
- if (rows.length) await executor.insert(plan.targetTable).values(rows);
6466
+ if (rows.length) await steps.run(executor.insert(plan.targetTable).values(rows), "create", "insert");
6252
6467
  }
6253
6468
  }
6469
+ await steps.after();
6254
6470
  }
6255
6471
  }
6256
6472
  };
@@ -6260,21 +6476,25 @@ const createNestedWriteRuntime = (params) => {
6260
6476
  * inside a single transaction — a savepoint when the caller already runs on one, so a failing
6261
6477
  * entry rolls the whole batch back.
6262
6478
  *
6479
+ * That transaction is also the `tx` the target tables' `onWrite` hooks run on. Every nested
6480
+ * write opens it, so a hook on a nested target has the whole tree to roll back even when the
6481
+ * root field carries no hook of its own and was not forced into a transaction for one.
6482
+ *
6263
6483
  * Entries are written one at a time rather than as a single multi-row statement: a nested
6264
6484
  * operation attaches to the row its own entry produced, so the two have to stay correlated.
6265
6485
  * The batch path is untouched when no entry carries a nested operation.
6266
6486
  */
6267
6487
  const writeWithNestedOps = async (params) => {
6268
- const { executor, runtime, tableName, entries, remapValues, write, context } = params;
6488
+ const { executor, runtime, tableName, entries, remapValues, write, request } = params;
6269
6489
  return executor.transaction(async (tx) => {
6270
6490
  const rows = [];
6271
6491
  for (const entry of entries) {
6272
- const patch = await runtime.applyParentSide(tx, tableName, entry.ops, context);
6492
+ const patch = await runtime.applyParentSide(tx, tableName, entry.ops, request);
6273
6493
  const written = await write(tx, {
6274
6494
  ...remapValues(entry.columns),
6275
6495
  ...patch
6276
6496
  });
6277
- await runtime.applyChildSide(tx, tableName, entry.ops, written, context);
6497
+ await runtime.applyChildSide(tx, tableName, entry.ops, written, request);
6278
6498
  rows.push(...written);
6279
6499
  }
6280
6500
  return rows;
@@ -6286,14 +6506,14 @@ const writeWithNestedOps = async (params) => {
6286
6506
  * attaches to each row that came back.
6287
6507
  */
6288
6508
  const updateWithNestedOps = async (params) => {
6289
- const { executor, runtime, tableName, columns, ops, remapValues, write, context } = params;
6509
+ const { executor, runtime, tableName, columns, ops, remapValues, write, request } = params;
6290
6510
  return executor.transaction(async (tx) => {
6291
- const patch = await runtime.applyParentSide(tx, tableName, ops, context);
6511
+ const patch = await runtime.applyParentSide(tx, tableName, ops, request);
6292
6512
  const rows = await write(tx, {
6293
6513
  ...remapValues(columns),
6294
6514
  ...patch
6295
6515
  });
6296
- await runtime.applyChildSide(tx, tableName, ops, rows, context);
6516
+ await runtime.applyChildSide(tx, tableName, ops, rows, request);
6297
6517
  return rows;
6298
6518
  });
6299
6519
  };
@@ -6494,7 +6714,14 @@ const createSchemaBuilder = (adapter) => {
6494
6714
  uniqueKeysOf: (tableName) => uniqueKeys[tableName]
6495
6715
  };
6496
6716
  adapter.preflight?.(db, options);
6497
- const nestedPlans = features.nestedWrites ? buildNestedWritePlans(tables, namedRelations, (target) => uniqueColumnSets(target), (target) => primaryKeyPropNames(target), extractRelationJoinColumns) : void 0;
6717
+ const nestedPlans = features.nestedWrites ? buildNestedWritePlans(tables, namedRelations, (target) => uniqueColumnSets(target), (target) => primaryKeyPropNames(target), extractRelationJoinColumns, (junction) => schemaEntries.find(([, value]) => value === junction)?.[0]) : void 0;
6718
+ if (nestedPlans) {
6719
+ const omitted = gateNestedWritePlans(nestedPlans, featureOf, (tableName) => excludedTables.has(tableName));
6720
+ if (omitted.length) {
6721
+ const listed = omitted.length > 5 ? `${omitted.slice(0, 5).join(", ")} and ${omitted.length - 5} more` : omitted.join(", ");
6722
+ console.warn(`Drizzle-GraphQL Warning: config.features — nestedWrites leaves out the nested inputs whose table has that write turned off: ${listed}.`);
6723
+ }
6724
+ }
6498
6725
  const nestedTypes = nestedPlans ? createNestedWriteTypes({
6499
6726
  plans: nestedPlans,
6500
6727
  cacheCtx,
@@ -6992,7 +7219,9 @@ const generateSchemaData$2 = (db, schema, relations, options) => {
6992
7219
  fieldName: updateCountFieldName,
6993
7220
  requireWhere: tableFeatures.requireWhere,
6994
7221
  filterCtx,
6995
- txCtx: mutationTxCtx
7222
+ txCtx: mutationTxCtx,
7223
+ policies,
7224
+ returnsRows: false
6996
7225
  }) : void 0;
6997
7226
  const deleteCountGenerated = tableFeatures.delete && tableFeatures.countMutations ? generateWriteCount({
6998
7227
  db,
@@ -7003,7 +7232,9 @@ const generateSchemaData$2 = (db, schema, relations, options) => {
7003
7232
  fieldName: deleteCountFieldName,
7004
7233
  requireWhere: tableFeatures.requireWhere,
7005
7234
  filterCtx,
7006
- txCtx: mutationTxCtx
7235
+ txCtx: mutationTxCtx,
7236
+ policies,
7237
+ returnsRows: false
7007
7238
  }) : void 0;
7008
7239
  const hardDeleteMeta = softDeleteInfo?.hardDelete ? { hardDelete: true } : {};
7009
7240
  const withMeta = (generated, meta) => ({
@@ -7073,12 +7304,25 @@ const generateSchemaData$2 = (db, schema, relations, options) => {
7073
7304
  {
7074
7305
  generated: updateCountGenerated,
7075
7306
  type: new GraphQLNonNull(GraphQLInt),
7307
+ meta: {
7308
+ operation: "update",
7309
+ single: false,
7310
+ targetArg: "where",
7311
+ count: true
7312
+ },
7076
7313
  description: "How many rows the update touched. The rows themselves are not read back, which is the point of this mutation."
7077
7314
  },
7078
7315
  {
7079
7316
  generated: deleteCountGenerated,
7080
7317
  type: new GraphQLNonNull(GraphQLInt),
7081
- description: "How many rows the delete removed. The rows themselves are not read back, which is the point of this mutation."
7318
+ meta: {
7319
+ operation: "delete",
7320
+ single: false,
7321
+ targetArg: "where",
7322
+ count: true,
7323
+ ...hardDeleteMeta
7324
+ },
7325
+ description: "How many rows the delete removed — or, on a soft-deleting table, marked deleted. The rows themselves are not read back, which is the point of this mutation."
7082
7326
  }
7083
7327
  ],
7084
7328
  onConflictInput,
@@ -7150,7 +7394,11 @@ const buildWriteResolvers = (primaryKeyPropNames) => {
7150
7394
  entries: nestedEntries,
7151
7395
  remapValues: (values) => applyContextValues(remapFromGraphQLSingleInput(values, table), contextColumns, context),
7152
7396
  write: (tx, values) => runInsert(tx, [values]),
7153
- context
7397
+ request: {
7398
+ context,
7399
+ args,
7400
+ info
7401
+ }
7154
7402
  }) : await runInsert(executor, input);
7155
7403
  await after(written);
7156
7404
  const result = output(written);
@@ -7239,7 +7487,11 @@ const buildWriteResolvers = (primaryKeyPropNames) => {
7239
7487
  entries: nestedEntries,
7240
7488
  remapValues: (values) => applyContextValues(remapFromGraphQLSingleInput(values, table), contextColumns, context),
7241
7489
  write: (tx, values) => runUpsert(tx, [values]),
7242
- context
7490
+ request: {
7491
+ context,
7492
+ args,
7493
+ info
7494
+ }
7243
7495
  }) : await runUpsert(executor, input);
7244
7496
  await after(written);
7245
7497
  const result = output(written);
@@ -7312,7 +7564,11 @@ const buildWriteResolvers = (primaryKeyPropNames) => {
7312
7564
  ops: nestedOps,
7313
7565
  remapValues: (values) => values,
7314
7566
  write: runUpdate,
7315
- context
7567
+ request: {
7568
+ context,
7569
+ args,
7570
+ info
7571
+ }
7316
7572
  }) : await runUpdate(executor, input);
7317
7573
  await after(written);
7318
7574
  const result = output(written);
@@ -7579,7 +7835,10 @@ const createSchemaDataGenerator = (adapter) => {
7579
7835
  requireWhere: tableFeatures.requireWhere,
7580
7836
  filterCtx,
7581
7837
  txCtx: mutationTxCtx,
7582
- nested: nestedRuntime
7838
+ nested: nestedRuntime,
7839
+ policies,
7840
+ pkNames: primaryKeyPropNames(schema[tableName]),
7841
+ returnsRows: true
7583
7842
  }) : void 0;
7584
7843
  const deleteCountGenerated = tableFeatures.delete && tableFeatures.countMutations ? generateWriteCount({
7585
7844
  db,
@@ -7590,7 +7849,10 @@ const createSchemaDataGenerator = (adapter) => {
7590
7849
  fieldName: deleteCountFieldName,
7591
7850
  requireWhere: tableFeatures.requireWhere,
7592
7851
  filterCtx,
7593
- txCtx: mutationTxCtx
7852
+ txCtx: mutationTxCtx,
7853
+ policies,
7854
+ pkNames: primaryKeyPropNames(schema[tableName]),
7855
+ returnsRows: true
7594
7856
  }) : void 0;
7595
7857
  const hardDeleteMeta = softDeleteInfo?.hardDelete ? { hardDelete: true } : {};
7596
7858
  return {
@@ -7700,12 +7962,25 @@ const createSchemaDataGenerator = (adapter) => {
7700
7962
  {
7701
7963
  generated: updateCountGenerated,
7702
7964
  type: new GraphQLNonNull(GraphQLInt),
7965
+ meta: {
7966
+ operation: "update",
7967
+ single: false,
7968
+ targetArg: "where",
7969
+ count: true
7970
+ },
7703
7971
  description: "How many rows the update touched. The rows themselves are not read back, which is the point of this mutation."
7704
7972
  },
7705
7973
  {
7706
7974
  generated: deleteCountGenerated,
7707
7975
  type: new GraphQLNonNull(GraphQLInt),
7708
- description: "How many rows the delete removed. The rows themselves are not read back, which is the point of this mutation."
7976
+ meta: {
7977
+ operation: "delete",
7978
+ single: false,
7979
+ targetArg: "where",
7980
+ count: true,
7981
+ ...hardDeleteMeta
7982
+ },
7983
+ description: "How many rows the delete removed — or, on a soft-deleting table, marked deleted. The rows themselves are not read back, which is the point of this mutation."
7709
7984
  }
7710
7985
  ],
7711
7986
  onConflictInput,
@@ -7723,18 +7998,18 @@ const createSchemaDataGenerator = (adapter) => {
7723
7998
  * the shared multi-mutation transaction opened by `runMutation` — this opens a savepoint, so
7724
7999
  * the batch stays atomic without breaking the outer transaction.
7725
8000
  */
7726
- const runUpdateManyBatch = ({ executor, entries, table, returning, nested, tableName, context }) => executor.transaction(async (tx) => {
8001
+ const runUpdateManyBatch = ({ executor, entries, table, returning, nested, tableName, request }) => executor.transaction(async (tx) => {
7727
8002
  const results = [];
7728
8003
  for (const entry of entries) {
7729
8004
  const values = entry.ops ? {
7730
8005
  ...entry.set,
7731
- ...await nested.applyParentSide(tx, tableName, entry.ops, context)
8006
+ ...await nested.applyParentSide(tx, tableName, entry.ops, request)
7732
8007
  } : entry.set;
7733
8008
  const writes = Object.keys(values).length > 0;
7734
8009
  let query = writes ? tx.update(table).set(values) : tx.select(returning).from(table);
7735
8010
  if (entry.filters) query = query.where(entry.filters);
7736
8011
  const rows = await (writes ? query.returning(returning) : query);
7737
- if (entry.ops) await nested.applyChildSide(tx, tableName, entry.ops, rows, context);
8012
+ if (entry.ops) await nested.applyChildSide(tx, tableName, entry.ops, rows, request);
7738
8013
  results.push(rows);
7739
8014
  }
7740
8015
  return results;
@@ -7807,7 +8082,11 @@ const createUpdateManyGenerator = (primaryKeyPropNames, runBatch = runUpdateMany
7807
8082
  anyNested,
7808
8083
  nested,
7809
8084
  tableName,
7810
- context
8085
+ request: {
8086
+ context,
8087
+ args,
8088
+ info
8089
+ }
7811
8090
  });
7812
8091
  const written = perEntry.flat();
7813
8092
  await after(written);
@@ -7856,7 +8135,7 @@ const sqlitePrimaryKeyPropNames = (table) => getPrimaryKeyPropNamesFromConfig(ta
7856
8135
  * would run after the COMMIT. The driver kind is probed from the first statement's result
7857
8136
  * rather than declared, so the same resolver serves both driver families.
7858
8137
  */
7859
- const runSqliteUpdateManyBatch = ({ executor, entries, table, returning, anyNested, nested, tableName, context }) => {
8138
+ const runSqliteUpdateManyBatch = ({ executor, entries, table, returning, anyNested, nested, tableName, request }) => {
7860
8139
  const runEntry = (tx, entry) => {
7861
8140
  let query = tx.update(table).set(entry.set);
7862
8141
  if (entry.filters) query = query.where(entry.filters);
@@ -7865,13 +8144,13 @@ const runSqliteUpdateManyBatch = ({ executor, entries, table, returning, anyNest
7865
8144
  const runNestedEntry = async (tx, entry) => {
7866
8145
  const values = entry.ops ? {
7867
8146
  ...entry.set,
7868
- ...await nested.applyParentSide(tx, tableName, entry.ops, context)
8147
+ ...await nested.applyParentSide(tx, tableName, entry.ops, request)
7869
8148
  } : entry.set;
7870
8149
  const writes = Object.keys(values).length > 0;
7871
8150
  let query = writes ? tx.update(table).set(values) : tx.select(returning).from(table);
7872
8151
  if (entry.filters) query = query.where(entry.filters);
7873
8152
  const rows = await (writes ? query.returning(returning) : query);
7874
- if (entry.ops) await nested.applyChildSide(tx, tableName, entry.ops, rows, context);
8153
+ if (entry.ops) await nested.applyChildSide(tx, tableName, entry.ops, rows, request);
7875
8154
  return rows;
7876
8155
  };
7877
8156
  return executor.transaction((tx) => {