@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.cjs CHANGED
@@ -3748,6 +3748,145 @@ const runMutation = async (db, context, info, txCtx, body, forceTx) => {
3748
3748
  return runOnSharedTx(state, body);
3749
3749
  };
3750
3750
  //#endregion
3751
+ //#region src/util/builders/common/write-hooks.ts
3752
+ /**
3753
+ * Normalizes the two registration shapes: a bare function is the `after` hook — the position
3754
+ * that has rows, and the one the common audit/outbox case wants — while `{ before, after }`
3755
+ * names them explicitly.
3756
+ */
3757
+ const normalizeWriteHooks = (hooks, tableColumns, where) => {
3758
+ if (!hooks) return;
3759
+ if (typeof hooks === "function") return { after: hooks };
3760
+ const resolved = {
3761
+ before: hooks.before,
3762
+ after: hooks.after
3763
+ };
3764
+ if (hooks.columns !== void 0) resolved.columns = resolveHookColumns(hooks.columns, !!hooks.after, tableColumns, where);
3765
+ return resolved.before || resolved.after ? resolved : void 0;
3766
+ };
3767
+ /**
3768
+ * Resolves a hook's `columns` against the table it runs on. `where` names the registration
3769
+ * (`config.onWrite.posts`) and, for a top-level one, the table, so the error says which.
3770
+ */
3771
+ const resolveHookColumns = (spec, hasAfter, tableColumns, where) => {
3772
+ if (!hasAfter) throw new Error(`Drizzle-GraphQL Error: ${where}.columns only applies to an 'after' hook, and none is registered.`);
3773
+ if (spec === "all") return { ...tableColumns };
3774
+ if (!Array.isArray(spec)) throw new Error(`Drizzle-GraphQL Error: ${where}.columns must be an array of column names or 'all'.`);
3775
+ const resolved = {};
3776
+ for (const name of spec) {
3777
+ const column = typeof name === "string" && Object.keys(tableColumns).includes(name) ? tableColumns[name] : void 0;
3778
+ if (!column) throw new Error(`Drizzle-GraphQL Error: ${where}.columns names '${String(name)}', which is not a column of that table.`);
3779
+ resolved[name] = column;
3780
+ }
3781
+ return resolved;
3782
+ };
3783
+ /**
3784
+ * Adds a hook's `columns` to a RETURNING column map, and reports which keys it added — the
3785
+ * ones the client never asked for, which have to come back off before the rows are mapped.
3786
+ * Returns the map untouched, and no keys, when the hook named none or the selection already
3787
+ * covers them.
3788
+ */
3789
+ const withHookColumns = (hooks, columns) => {
3790
+ const hookColumns = hooks?.columns;
3791
+ if (!hookColumns) return {
3792
+ returning: columns,
3793
+ added: void 0
3794
+ };
3795
+ const selected = new Set(Object.keys(columns));
3796
+ const added = Object.keys(hookColumns).filter((key) => !selected.has(key));
3797
+ if (!added.length) return {
3798
+ returning: columns,
3799
+ added: void 0
3800
+ };
3801
+ const returning = { ...columns };
3802
+ for (const key of added) returning[key] = hookColumns[key];
3803
+ return {
3804
+ returning,
3805
+ added
3806
+ };
3807
+ };
3808
+ /**
3809
+ * The rows with the hook-only keys taken off, as fresh objects: the hook was handed the
3810
+ * originals and may still hold them, so they are not mutated under it.
3811
+ */
3812
+ const omitHookColumns = (rows, added) => {
3813
+ if (!added) return rows;
3814
+ return rows.map((row) => {
3815
+ const copy = { ...row };
3816
+ for (const key of added) delete copy[key];
3817
+ return copy;
3818
+ });
3819
+ };
3820
+ /**
3821
+ * Invokes one position of a field's hooks. Returns `undefined` rather than a resolved promise
3822
+ * when nothing is registered, so the awaiting call site costs nothing on an unhooked build.
3823
+ */
3824
+ const runWriteHook = (hooks, position, payload) => {
3825
+ const hook = position === "before" ? hooks?.before : hooks?.after;
3826
+ if (!hook) return;
3827
+ return Promise.resolve(hook({
3828
+ ...payload,
3829
+ position,
3830
+ rows: payload.rows ?? []
3831
+ }));
3832
+ };
3833
+ //#endregion
3834
+ //#region src/util/builders/common/write-scaffold.ts
3835
+ /**
3836
+ * Wraps one mutation body in the scaffolding above and returns the field it becomes.
3837
+ *
3838
+ * A field carrying a hook is forced into a transaction even when it would have run straight
3839
+ * on `db`: a hook exists to write atomically with the mutation, and one that ran outside the
3840
+ * transaction could not roll anything back.
3841
+ */
3842
+ const writeResolver = ({ db, tableName, operation, single, fieldName, args: queryArgs, txCtx, policies, run }) => {
3843
+ const hooks = policies?.onWrite?.(tableName, operation);
3844
+ const errorCtx = {
3845
+ table: tableName,
3846
+ operation,
3847
+ field: fieldName
3848
+ };
3849
+ return {
3850
+ name: fieldName,
3851
+ resolver: async (_source, args, context, info) => {
3852
+ try {
3853
+ return await runMutation(db, context, info, txCtx, (executor) => {
3854
+ const payload = {
3855
+ table: tableName,
3856
+ operation,
3857
+ single,
3858
+ args,
3859
+ context,
3860
+ info,
3861
+ tx: executor
3862
+ };
3863
+ let added;
3864
+ return run({
3865
+ executor,
3866
+ args,
3867
+ context,
3868
+ info,
3869
+ before: () => runWriteHook(hooks, "before", payload),
3870
+ after: (rows) => runWriteHook(hooks, "after", {
3871
+ ...payload,
3872
+ rows: rows ?? []
3873
+ }),
3874
+ returning: (columns) => {
3875
+ const withHook = withHookColumns(hooks, columns);
3876
+ added = withHook.added;
3877
+ return withHook.returning;
3878
+ },
3879
+ output: (rows) => omitHookColumns(rows, added)
3880
+ });
3881
+ }, !!hooks);
3882
+ } catch (e) {
3883
+ throw withErrorContext(toGraphQLError(e), errorCtx);
3884
+ }
3885
+ },
3886
+ args: queryArgs
3887
+ };
3888
+ };
3889
+ //#endregion
3751
3890
  //#region src/util/builders/common/mutation-helpers.ts
3752
3891
  /**
3753
3892
  * The `hard` argument on the delete mutations of a soft-deleting table that opted into
@@ -3891,54 +4030,79 @@ const assertSingleMatch = async (executor, table, filters) => {
3891
4030
  * The plural `update` and `delete` always `RETURNING *`, which is the wrong shape for
3892
4031
  * "archive everything older than a year": the caller pays to ship rows it does not want, and
3893
4032
  * on a large match that cost is the whole mutation. These return `Int!` instead — how many
3894
- * rows the write touched, as the driver reported it, with no RETURNING clause at all.
4033
+ * rows the write touched.
3895
4034
  *
3896
- * Dialect-independent: drizzle's update/delete builders take the same calls everywhere, and
3897
- * the drivers' disagreement about how to spell the row count is handled by
3898
- * {@link rowsAffected}.
3899
- */
3900
- const generateWriteCount = ({ db, tableName, table, kind, setArgs, filterArgs, fieldName, requireWhere, filterCtx, txCtx, nested }) => {
4035
+ * Leaving the payload off changes nothing else: the write obeys every rule of the plural
4036
+ * mutation it mirrors, through the same {@link writeResolver} scaffolding.
4037
+ *
4038
+ * - `scope` (and the soft-delete marker) is ANDed into the `where`, so only rows the plural
4039
+ * write could reach are counted and written.
4040
+ * - On a soft-deleting table the delete is the marking `UPDATE`, not a `DELETE`, and rows that
4041
+ * are already marked are not matched; the update skips marked rows too. `hard: true` exists
4042
+ * exactly where the plural delete takes it.
4043
+ * - Context-derived columns are stripped from the update's `set`, as the plural update does.
4044
+ * - `onWrite` hooks run `before` and `after`, inside a transaction. The `after` hook is handed
4045
+ * rows, so a table with one pays for a `RETURNING` — of the columns the hook names in
4046
+ * `columns`, or of the primary key when it names none (the whole row on a table without a
4047
+ * key) — and the count is the number of rows returned. Every other write, and every write on
4048
+ * MySQL (no `RETURNING`; its hooks see `rows: []` as on every MySQL mutation), skips the
4049
+ * `RETURNING` and reads the count off the driver's result via {@link rowsAffected}.
4050
+ */
4051
+ const generateWriteCount = ({ db, tableName, table, kind, setArgs, filterArgs, fieldName, requireWhere, filterCtx, txCtx, nested, policies, pkNames = [], returnsRows }) => {
4052
+ const softDelete = kind === "delete" ? policies?.softDelete?.(tableName) : void 0;
4053
+ const canHardDelete = softDelete?.hardDelete === true;
3901
4054
  const queryArgs = {
3902
4055
  ...setArgs ? { set: { type: new graphql.GraphQLNonNull(setArgs) } } : {},
3903
- where: { type: requireWhere ? new graphql.GraphQLNonNull(filterArgs) : filterArgs }
4056
+ where: { type: requireWhere ? new graphql.GraphQLNonNull(filterArgs) : filterArgs },
4057
+ ...canHardDelete ? { hard: hardDeleteArg } : {}
3904
4058
  };
3905
- const errorCtx = {
3906
- table: tableName,
4059
+ const readsRows = returnsRows && !!policies?.onWrite?.(tableName, kind)?.after;
4060
+ const allColumns = (0, drizzle_orm.getColumns)(table);
4061
+ const keyColumns = pkNames.length ? Object.fromEntries(pkNames.map((name) => [name, allColumns[name]])) : allColumns;
4062
+ return writeResolver({
4063
+ db,
4064
+ tableName,
3907
4065
  operation: kind,
3908
- field: fieldName
3909
- };
3910
- return {
3911
- name: fieldName,
3912
- resolver: async (_source, args, context, info) => {
3913
- try {
3914
- return await runMutation(db, context, info, txCtx, async (executor) => {
3915
- const { where, set } = args;
3916
- const filters = scopedWhere({
3917
- scope: void 0,
3918
- tableName,
3919
- table,
3920
- where,
3921
- relationCtx: relationFilterCtx(filterCtx, tableName),
3922
- required: requireWhere
3923
- });
3924
- let query;
3925
- if (kind === "delete") query = executor.delete(table);
3926
- else {
3927
- const entry = nested?.enabled(tableName) ? nested.split(tableName, set) : void 0;
3928
- 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" });
3929
- const values = remapUpdateInput(entry ? entry.columns : set, table, tableName);
3930
- if (!Object.keys(values).length) throw drizzleError("Unable to update with no values specified!", { code: "DRIZZLE_NO_VALUES" });
3931
- query = executor.update(table).set(values);
3932
- }
3933
- if (filters) query = query.where(filters);
3934
- return rowsAffected(await query);
3935
- });
3936
- } catch (e) {
3937
- throw withErrorContext(toGraphQLError(e), errorCtx);
4066
+ single: false,
4067
+ fieldName,
4068
+ txCtx,
4069
+ policies,
4070
+ args: queryArgs,
4071
+ run: async ({ executor, args, context, before, after, returning }) => {
4072
+ const { where, set } = args;
4073
+ const hard = canHardDelete && args.hard === true;
4074
+ const scope = policies?.scope?.(context);
4075
+ await before();
4076
+ let query;
4077
+ if (kind === "delete") query = softDelete && !hard ? executor.update(table).set({ [softDelete.columnName]: softDelete.writeDeleted() }) : executor.delete(table);
4078
+ else {
4079
+ const entry = nested?.enabled(tableName) ? nested.split(tableName, set) : void 0;
4080
+ 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" });
4081
+ const values = stripContextValues(remapUpdateInput(entry ? entry.columns : set, table, tableName), policies?.contextValues?.(tableName));
4082
+ if (!Object.keys(values).length) throw drizzleError("Unable to update with no values specified!", { code: "DRIZZLE_NO_VALUES" });
4083
+ query = executor.update(table).set(values);
3938
4084
  }
3939
- },
3940
- args: queryArgs
3941
- };
4085
+ const filters = scopedWhere({
4086
+ scope,
4087
+ tableName,
4088
+ table,
4089
+ where,
4090
+ relationCtx: relationFilterCtx(filterCtx, tableName),
4091
+ required: requireWhere,
4092
+ deleted: hard ? "INCLUDE" : void 0
4093
+ });
4094
+ if (filters) query = query.where(filters);
4095
+ if (!readsRows) {
4096
+ const result = await query;
4097
+ await after();
4098
+ return rowsAffected(result);
4099
+ }
4100
+ const named = returning({});
4101
+ const rows = await query.returning(Object.keys(named).length ? named : keyColumns);
4102
+ await after(rows);
4103
+ return rows.length;
4104
+ }
4105
+ });
3942
4106
  };
3943
4107
  //#endregion
3944
4108
  //#region src/util/batch-loader/index.ts
@@ -4876,145 +5040,6 @@ const generateTableTypes = (tableName, tables, relationMap, withReturning, relat
4876
5040
  };
4877
5041
  };
4878
5042
  //#endregion
4879
- //#region src/util/builders/common/write-hooks.ts
4880
- /**
4881
- * Normalizes the two registration shapes: a bare function is the `after` hook — the position
4882
- * that has rows, and the one the common audit/outbox case wants — while `{ before, after }`
4883
- * names them explicitly.
4884
- */
4885
- const normalizeWriteHooks = (hooks, tableColumns, where) => {
4886
- if (!hooks) return;
4887
- if (typeof hooks === "function") return { after: hooks };
4888
- const resolved = {
4889
- before: hooks.before,
4890
- after: hooks.after
4891
- };
4892
- if (hooks.columns !== void 0) resolved.columns = resolveHookColumns(hooks.columns, !!hooks.after, tableColumns, where);
4893
- return resolved.before || resolved.after ? resolved : void 0;
4894
- };
4895
- /**
4896
- * Resolves a hook's `columns` against the table it runs on. `where` names the registration
4897
- * (`config.onWrite.posts`) and, for a top-level one, the table, so the error says which.
4898
- */
4899
- const resolveHookColumns = (spec, hasAfter, tableColumns, where) => {
4900
- if (!hasAfter) throw new Error(`Drizzle-GraphQL Error: ${where}.columns only applies to an 'after' hook, and none is registered.`);
4901
- if (spec === "all") return { ...tableColumns };
4902
- if (!Array.isArray(spec)) throw new Error(`Drizzle-GraphQL Error: ${where}.columns must be an array of column names or 'all'.`);
4903
- const resolved = {};
4904
- for (const name of spec) {
4905
- const column = typeof name === "string" && Object.keys(tableColumns).includes(name) ? tableColumns[name] : void 0;
4906
- if (!column) throw new Error(`Drizzle-GraphQL Error: ${where}.columns names '${String(name)}', which is not a column of that table.`);
4907
- resolved[name] = column;
4908
- }
4909
- return resolved;
4910
- };
4911
- /**
4912
- * Adds a hook's `columns` to a RETURNING column map, and reports which keys it added — the
4913
- * ones the client never asked for, which have to come back off before the rows are mapped.
4914
- * Returns the map untouched, and no keys, when the hook named none or the selection already
4915
- * covers them.
4916
- */
4917
- const withHookColumns = (hooks, columns) => {
4918
- const hookColumns = hooks?.columns;
4919
- if (!hookColumns) return {
4920
- returning: columns,
4921
- added: void 0
4922
- };
4923
- const selected = new Set(Object.keys(columns));
4924
- const added = Object.keys(hookColumns).filter((key) => !selected.has(key));
4925
- if (!added.length) return {
4926
- returning: columns,
4927
- added: void 0
4928
- };
4929
- const returning = { ...columns };
4930
- for (const key of added) returning[key] = hookColumns[key];
4931
- return {
4932
- returning,
4933
- added
4934
- };
4935
- };
4936
- /**
4937
- * The rows with the hook-only keys taken off, as fresh objects: the hook was handed the
4938
- * originals and may still hold them, so they are not mutated under it.
4939
- */
4940
- const omitHookColumns = (rows, added) => {
4941
- if (!added) return rows;
4942
- return rows.map((row) => {
4943
- const copy = { ...row };
4944
- for (const key of added) delete copy[key];
4945
- return copy;
4946
- });
4947
- };
4948
- /**
4949
- * Invokes one position of a field's hooks. Returns `undefined` rather than a resolved promise
4950
- * when nothing is registered, so the awaiting call site costs nothing on an unhooked build.
4951
- */
4952
- const runWriteHook = (hooks, position, payload) => {
4953
- const hook = position === "before" ? hooks?.before : hooks?.after;
4954
- if (!hook) return;
4955
- return Promise.resolve(hook({
4956
- ...payload,
4957
- position,
4958
- rows: payload.rows ?? []
4959
- }));
4960
- };
4961
- //#endregion
4962
- //#region src/util/builders/common/write-scaffold.ts
4963
- /**
4964
- * Wraps one mutation body in the scaffolding above and returns the field it becomes.
4965
- *
4966
- * A field carrying a hook is forced into a transaction even when it would have run straight
4967
- * on `db`: a hook exists to write atomically with the mutation, and one that ran outside the
4968
- * transaction could not roll anything back.
4969
- */
4970
- const writeResolver = ({ db, tableName, operation, single, fieldName, args: queryArgs, txCtx, policies, run }) => {
4971
- const hooks = policies?.onWrite?.(tableName, operation);
4972
- const errorCtx = {
4973
- table: tableName,
4974
- operation,
4975
- field: fieldName
4976
- };
4977
- return {
4978
- name: fieldName,
4979
- resolver: async (_source, args, context, info) => {
4980
- try {
4981
- return await runMutation(db, context, info, txCtx, (executor) => {
4982
- const payload = {
4983
- table: tableName,
4984
- operation,
4985
- single,
4986
- args,
4987
- context,
4988
- info,
4989
- tx: executor
4990
- };
4991
- let added;
4992
- return run({
4993
- executor,
4994
- args,
4995
- context,
4996
- info,
4997
- before: () => runWriteHook(hooks, "before", payload),
4998
- after: (rows) => runWriteHook(hooks, "after", {
4999
- ...payload,
5000
- rows: rows ?? []
5001
- }),
5002
- returning: (columns) => {
5003
- const withHook = withHookColumns(hooks, columns);
5004
- added = withHook.added;
5005
- return withHook.returning;
5006
- },
5007
- output: (rows) => omitHookColumns(rows, added)
5008
- });
5009
- }, !!hooks);
5010
- } catch (e) {
5011
- throw withErrorContext(toGraphQLError(e), errorCtx);
5012
- }
5013
- },
5014
- args: queryArgs
5015
- };
5016
- };
5017
- //#endregion
5018
5043
  //#region src/util/features.ts
5019
5044
  /**
5020
5045
  * What a table generates when the config says nothing. Everything is on except the opt-in
@@ -5226,7 +5251,6 @@ const resolveBuildConfig = (config, schema) => {
5226
5251
  const missing = [!tableFeatures.insert ? "insert" : void 0, !tableFeatures.update ? "update" : void 0].filter(Boolean).join(" and ");
5227
5252
  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);
5228
5253
  }
5229
- 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);
5230
5254
  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);
5231
5255
  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);
5232
5256
  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);
@@ -5832,6 +5856,12 @@ const generateGroupBy = (db, tableName, table, typeName, fieldName, filterArgs,
5832
5856
  };
5833
5857
  //#endregion
5834
5858
  //#region src/util/builders/nested-writes.ts
5859
+ const ALL_OPS = [
5860
+ "create",
5861
+ "connect",
5862
+ "disconnect",
5863
+ "set"
5864
+ ];
5835
5865
  /**
5836
5866
  * Whether a column can be left out of an INSERT — either the database fills it in or it
5837
5867
  * accepts NULL.
@@ -5860,7 +5890,7 @@ const throughColumn = (entry) => entry && (0, drizzle_orm.is)(entry, drizzle_orm
5860
5890
  * joining on more than one column per side; and a junction carrying a further NOT NULL column
5861
5891
  * with no default, which no `connect` operand could supply a value for.
5862
5892
  */
5863
- const buildThroughPlan = (relationName, relation, table, targetTableName, targetTable) => {
5893
+ const buildThroughPlan = (relationName, relation, table, targetTableName, targetTable, tableNameOf) => {
5864
5894
  if ((0, drizzle_orm.is)(relation, drizzle_orm.One)) return;
5865
5895
  const junction = relation.throughTable;
5866
5896
  const sourceEntries = relation.through?.source;
@@ -5891,11 +5921,18 @@ const buildThroughPlan = (relationName, relation, table, targetTableName, target
5891
5921
  canDetach: true,
5892
5922
  through: {
5893
5923
  table: junction,
5924
+ tableName: tableNameOf(junction),
5894
5925
  sourceColPropName,
5895
5926
  targetColPropName,
5896
5927
  sourceCol,
5897
5928
  targetCol
5898
- }
5929
+ },
5930
+ targetKeyColumns: { [foreignColPropName]: foreignCol },
5931
+ allowed: /* @__PURE__ */ new Set([
5932
+ "connect",
5933
+ "disconnect",
5934
+ "set"
5935
+ ])
5899
5936
  };
5900
5937
  };
5901
5938
  /**
@@ -5908,7 +5945,7 @@ const buildThroughPlan = (relationName, relation, table, targetTableName, target
5908
5945
  * relations whose join columns cannot be read off the drizzle relation; and relations where
5909
5946
  * neither join column is unique, which leaves no way to tell which side stores the key.
5910
5947
  */
5911
- const buildNestedWritePlans = (tables, relationMap, resolveUniqueSets, resolvePrimaryKeys, extractJoinColumns) => {
5948
+ const buildNestedWritePlans = (tables, relationMap, resolveUniqueSets, resolvePrimaryKeys, extractJoinColumns, tableNameOf = () => void 0) => {
5912
5949
  const uniqueCache = /* @__PURE__ */ new Map();
5913
5950
  const singleColumnUniques = (tableName, table) => {
5914
5951
  let cached = uniqueCache.get(tableName);
@@ -5937,7 +5974,7 @@ const buildNestedWritePlans = (tables, relationMap, resolveUniqueSets, resolvePr
5937
5974
  const targetTable = tables[targetTableName];
5938
5975
  if (!targetTable) continue;
5939
5976
  if (relation.through) {
5940
- const throughPlan = buildThroughPlan(relationName, relation, table, targetTableName, targetTable);
5977
+ const throughPlan = buildThroughPlan(relationName, relation, table, targetTableName, targetTable, tableNameOf);
5941
5978
  if (throughPlan) tablePlans[relationName] = throughPlan;
5942
5979
  continue;
5943
5980
  }
@@ -5958,6 +5995,10 @@ const buildNestedWritePlans = (tables, relationMap, resolveUniqueSets, resolvePr
5958
5995
  else if (foreignIsUnique) fkSide = "parent";
5959
5996
  else if (localIsUnique) fkSide = "child";
5960
5997
  else continue;
5998
+ const fkColumn = fkSide === "child" ? foreignCol : localCol;
5999
+ const targetColumns = (0, drizzle_orm.getColumns)(targetTable);
6000
+ const targetKeyColumns = Object.fromEntries(resolvePrimaryKeys(targetTable).filter((name) => targetColumns[name]).map((name) => [name, targetColumns[name]]));
6001
+ targetKeyColumns[foreignColPropName] = foreignCol;
5961
6002
  tablePlans[relationName] = {
5962
6003
  relationName,
5963
6004
  targetTableName,
@@ -5968,13 +6009,87 @@ const buildNestedWritePlans = (tables, relationMap, resolveUniqueSets, resolvePr
5968
6009
  localCol,
5969
6010
  isOne,
5970
6011
  fkSide,
5971
- canDetach: !(fkSide === "child" ? foreignCol : localCol).notNull
6012
+ canDetach: !fkColumn.notNull,
6013
+ targetKeyColumns,
6014
+ allowed: new Set(!fkColumn.notNull ? isOne ? [
6015
+ "create",
6016
+ "connect",
6017
+ "disconnect"
6018
+ ] : ALL_OPS : ["create", "connect"])
5972
6019
  };
5973
6020
  }
5974
6021
  if (Object.keys(tablePlans).length) plans[tableName] = tablePlans;
5975
6022
  }
5976
6023
  return plans;
5977
6024
  };
6025
+ /**
6026
+ * Which of the target's (or junction's) write features each input of a relation needs. A
6027
+ * parent-side `connect` / `disconnect` only rewrites the parent's own column, so it needs
6028
+ * nothing of the target; a child-side one moves the target's foreign key, which is an update
6029
+ * of the target. A to-one child-side `create` also detaches the row attached before it, when
6030
+ * the key is nullable.
6031
+ */
6032
+ const requiredWrites = (plan, op) => {
6033
+ if (plan.through) {
6034
+ const junction = plan.through.tableName;
6035
+ if (op === "connect") return [{
6036
+ tableName: junction,
6037
+ feature: "insert"
6038
+ }];
6039
+ if (op === "disconnect") return [{
6040
+ tableName: junction,
6041
+ feature: "delete"
6042
+ }];
6043
+ if (op === "set") return [{
6044
+ tableName: junction,
6045
+ feature: "delete"
6046
+ }, {
6047
+ tableName: junction,
6048
+ feature: "insert"
6049
+ }];
6050
+ return [];
6051
+ }
6052
+ const target = plan.targetTableName;
6053
+ if (op === "create") return plan.fkSide === "child" && plan.isOne && plan.canDetach ? [{
6054
+ tableName: target,
6055
+ feature: "insert"
6056
+ }, {
6057
+ tableName: target,
6058
+ feature: "update"
6059
+ }] : [{
6060
+ tableName: target,
6061
+ feature: "insert"
6062
+ }];
6063
+ return plan.fkSide === "child" ? [{
6064
+ tableName: target,
6065
+ feature: "update"
6066
+ }] : [];
6067
+ };
6068
+ /**
6069
+ * Narrows every plan's `allowed` inputs to the ones whose writes the table they land on
6070
+ * permits: its `features.insert` / `update` / `delete`, and for a junction table that it is
6071
+ * not excluded. A plan left with nothing is dropped, so its relation field is not generated.
6072
+ *
6073
+ * Returns one line per input taken out, for the build note.
6074
+ */
6075
+ const gateNestedWritePlans = (plans, featureOf, isExcluded) => {
6076
+ const omitted = [];
6077
+ for (const [tableName, tablePlans] of Object.entries(plans)) {
6078
+ for (const [relationName, plan] of Object.entries(tablePlans)) {
6079
+ for (const op of [...plan.allowed]) {
6080
+ const blocked = requiredWrites(plan, op).filter(({ tableName: target, feature }) => target !== void 0 && (isExcluded(target) || !featureOf(target)[feature]));
6081
+ if (blocked.length) {
6082
+ plan.allowed.delete(op);
6083
+ const reasons = blocked.map(({ tableName: target, feature }) => isExcluded(target) ? `${target} is excluded` : `${target}.${feature} is off`);
6084
+ omitted.push(`${tableName}.${relationName}.${op} (${[...new Set(reasons)].join(", ")})`);
6085
+ }
6086
+ }
6087
+ if (!plan.allowed.size) delete tablePlans[relationName];
6088
+ }
6089
+ if (!Object.keys(tablePlans).length) delete plans[tableName];
6090
+ }
6091
+ return omitted;
6092
+ };
5978
6093
  const NO_COLUMNS = /* @__PURE__ */ new Set();
5979
6094
  /**
5980
6095
  * Builds the nested-write input types for a schema.
@@ -6029,8 +6144,11 @@ const createNestedWriteTypes = (params) => {
6029
6144
  *
6030
6145
  * A many-to-many relation offers no `create`: inserting a row *and* linking it is two
6031
6146
  * writes to two tables, and the link half is what the relation field is for. Its
6032
- * `disconnect` and `set` are offered unconditionally, since breaking a link deletes a
6147
+ * `disconnect` and `set` do not depend on nullability, since breaking a link deletes a
6033
6148
  * junction row rather than nulling a column that might be NOT NULL.
6149
+ *
6150
+ * Each input is also subject to `plan.allowed` — the table it writes has to have that
6151
+ * write turned on (see {@link gateNestedWritePlans}).
6034
6152
  */
6035
6153
  const wrapperType = (tableName, typeName, plan, forUpdate) => {
6036
6154
  const key = `${tableName}.${plan.relationName}.${forUpdate ? "update" : "create"}`;
@@ -6039,57 +6157,52 @@ const createNestedWriteTypes = (params) => {
6039
6157
  const filters = targetFilters(plan);
6040
6158
  const targetTypeName = resolveTypeName(plan.targetTableName, typeNameMapper);
6041
6159
  const fields = {};
6160
+ const offers = (op) => plan.allowed.has(op);
6042
6161
  if (plan.through) {
6043
- if (!filters) return;
6044
- fields["connect"] = {
6162
+ if (filters && offers("connect")) fields["connect"] = {
6045
6163
  type: listOf(filters),
6046
6164
  description: `Link every existing ${targetTypeName} row these match. Already-linked rows are left alone.`
6047
6165
  };
6048
- if (forUpdate) {
6049
- fields["disconnect"] = {
6050
- type: listOf(filters),
6051
- description: `Unlink every linked ${targetTypeName} row these match, leaving the rows in place`
6052
- };
6053
- fields["set"] = {
6054
- type: listOf(filters),
6055
- description: `Replace the whole set: unlink everything linked, then link every ${targetTypeName} row these match. \`set: []\` clears the relation. Applied before \`disconnect\` and \`connect\`.`
6056
- };
6057
- }
6166
+ if (filters && forUpdate && offers("disconnect")) fields["disconnect"] = {
6167
+ type: listOf(filters),
6168
+ description: `Unlink every linked ${targetTypeName} row these match, leaving the rows in place`
6169
+ };
6170
+ if (filters && forUpdate && offers("set")) fields["set"] = {
6171
+ type: listOf(filters),
6172
+ description: `Replace the whole set: unlink everything linked, then link every ${targetTypeName} row these match. \`set: []\` clears the relation. Applied before \`disconnect\` and \`connect\`.`
6173
+ };
6058
6174
  } else if (plan.isOne) {
6059
- fields["create"] = {
6175
+ if (offers("create")) fields["create"] = {
6060
6176
  type: payloadType(tableName, typeName, plan),
6061
6177
  description: `Insert a ${targetTypeName} row and attach it`
6062
6178
  };
6063
- if (filters) fields["connect"] = {
6179
+ if (filters && offers("connect")) fields["connect"] = {
6064
6180
  type: filters,
6065
6181
  description: `Attach the existing ${targetTypeName} row this matches. Must match exactly one row.`
6066
6182
  };
6067
- if (forUpdate && plan.canDetach) fields["disconnect"] = {
6183
+ if (forUpdate && offers("disconnect")) fields["disconnect"] = {
6068
6184
  type: graphql.GraphQLBoolean,
6069
6185
  description: `Detach the currently attached ${targetTypeName} row, leaving it in place`
6070
6186
  };
6071
6187
  } else {
6072
- fields["create"] = {
6188
+ if (offers("create")) fields["create"] = {
6073
6189
  type: listOf(payloadType(tableName, typeName, plan)),
6074
6190
  description: `Insert these ${targetTypeName} rows and attach them`
6075
6191
  };
6076
- if (filters) {
6077
- fields["connect"] = {
6078
- type: listOf(filters),
6079
- description: `Attach every existing ${targetTypeName} row these match`
6080
- };
6081
- if (forUpdate && plan.canDetach) {
6082
- fields["disconnect"] = {
6083
- type: listOf(filters),
6084
- description: `Detach every attached ${targetTypeName} row these match, leaving the rows in place`
6085
- };
6086
- fields["set"] = {
6087
- type: listOf(filters),
6088
- description: `Replace the whole set: detach everything attached, then attach every ${targetTypeName} row these match. Applied before \`disconnect\`, \`connect\` and \`create\`.`
6089
- };
6090
- }
6091
- }
6192
+ if (filters && offers("connect")) fields["connect"] = {
6193
+ type: listOf(filters),
6194
+ description: `Attach every existing ${targetTypeName} row these match`
6195
+ };
6196
+ if (filters && forUpdate && offers("disconnect")) fields["disconnect"] = {
6197
+ type: listOf(filters),
6198
+ description: `Detach every attached ${targetTypeName} row these match, leaving the rows in place`
6199
+ };
6200
+ if (filters && forUpdate && offers("set")) fields["set"] = {
6201
+ type: listOf(filters),
6202
+ description: `Replace the whole set: detach everything attached, then attach every ${targetTypeName} row these match. Applied before \`disconnect\`, \`connect\` and \`create\`.`
6203
+ };
6092
6204
  }
6205
+ if (!Object.keys(fields).length) return;
6093
6206
  const type = new graphql.GraphQLInputObjectType({
6094
6207
  name: cacheCtx.typeName({
6095
6208
  kind: "nestedWriteInput",
@@ -6121,7 +6234,7 @@ const createNestedWriteTypes = (params) => {
6121
6234
  const tablePlans = plans[tableName];
6122
6235
  if (!tablePlans) return NO_COLUMNS;
6123
6236
  const relaxed = /* @__PURE__ */ new Set();
6124
- for (const plan of Object.values(tablePlans)) if (plan.fkSide === "parent" && !isOptionalOnInsert(plan.localCol)) relaxed.add(plan.localColPropName);
6237
+ 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);
6125
6238
  return relaxed.size ? relaxed : NO_COLUMNS;
6126
6239
  }
6127
6240
  };
@@ -6132,8 +6245,99 @@ const createNestedWriteTypes = (params) => {
6132
6245
  */
6133
6246
  const mergedOps = (entries) => Object.assign({}, ...entries.map((entry) => entry.ops));
6134
6247
  const asArray = (value) => Array.isArray(value) ? value : value === void 0 ? [] : [value];
6248
+ const supplied = (value) => value !== void 0 && value !== null;
6249
+ /**
6250
+ * The hook calls one relation field makes. Every step is registered before the field's first
6251
+ * statement, so each `before` runs ahead of all of them and each `after` once the last has
6252
+ * returned. A table without hooks registers nothing, and its statements run exactly as they
6253
+ * did before hooks reached nested writes — no RETURNING clause is added.
6254
+ */
6255
+ const createHookSteps = (onWrite, executor, request, parentTable, plan) => {
6256
+ const steps = /* @__PURE__ */ new Map();
6257
+ const keyOf = (op, operation) => `${op}:${operation}`;
6258
+ const payload = (step) => ({
6259
+ table: step.tableName,
6260
+ operation: step.operation,
6261
+ single: plan.isOne,
6262
+ args: request?.args,
6263
+ context: request?.context,
6264
+ info: request?.info,
6265
+ tx: executor,
6266
+ nested: {
6267
+ parentTable,
6268
+ relation: plan.relationName,
6269
+ op: step.op
6270
+ }
6271
+ });
6272
+ return {
6273
+ /** Registers the hook call for `op`'s `operation` statements, if that table has hooks. */
6274
+ add: (op, operation) => {
6275
+ const through = plan.through;
6276
+ const tableName = through ? through.tableName : plan.targetTableName;
6277
+ const hooks = tableName ? onWrite?.(tableName, operation) : void 0;
6278
+ if (!tableName || !hooks || steps.has(keyOf(op, operation))) return;
6279
+ const keyColumns = through ? {
6280
+ [through.sourceColPropName]: through.sourceCol,
6281
+ [through.targetColPropName]: through.targetCol
6282
+ } : { ...plan.targetKeyColumns };
6283
+ steps.set(keyOf(op, operation), {
6284
+ tableName,
6285
+ operation,
6286
+ op,
6287
+ hooks,
6288
+ returning: withHookColumns(hooks, keyColumns).returning,
6289
+ rows: []
6290
+ });
6291
+ },
6292
+ get: (op, operation) => steps.get(keyOf(op, operation)),
6293
+ /** Runs one statement, returning its rows to the step it belongs to when there is one. */
6294
+ run: async (query, op, operation) => {
6295
+ const step = steps.get(keyOf(op, operation));
6296
+ if (!step) {
6297
+ await query;
6298
+ return;
6299
+ }
6300
+ step.rows.push(...await query.returning(step.returning));
6301
+ },
6302
+ before: async () => {
6303
+ for (const step of steps.values()) await runWriteHook(step.hooks, "before", payload(step));
6304
+ },
6305
+ after: async () => {
6306
+ for (const step of steps.values()) await runWriteHook(step.hooks, "after", {
6307
+ ...payload(step),
6308
+ rows: step.rows
6309
+ });
6310
+ }
6311
+ };
6312
+ };
6313
+ /**
6314
+ * Registers the hook calls a child-side relation field will make, in the order its
6315
+ * statements run, so the `before` hooks fire in that order too. Mirrors the branches of
6316
+ * `applyChildSide` and `applyThroughOps` exactly: a step registered for an input that runs no
6317
+ * statement would fire with no rows, and one missing would miss them.
6318
+ */
6319
+ const registerChildSteps = (steps, plan, op) => {
6320
+ if (plan.through) {
6321
+ if (supplied(op["set"])) {
6322
+ steps.add("set", "delete");
6323
+ steps.add("set", "insert");
6324
+ }
6325
+ if (supplied(op["disconnect"])) steps.add("disconnect", "delete");
6326
+ if (supplied(op["connect"])) steps.add("connect", "insert");
6327
+ return;
6328
+ }
6329
+ if (op["set"] !== void 0) steps.add("set", "update");
6330
+ if (op["disconnect"] !== void 0 && (!plan.isOne || op["disconnect"] === true)) steps.add("disconnect", "update");
6331
+ const attachedBy = attachingOp(op);
6332
+ if (plan.isOne && attachedBy && plan.canDetach && op["set"] === void 0) steps.add(attachedBy, "update");
6333
+ if (op["connect"] !== void 0) steps.add("connect", "update");
6334
+ if (op["create"] !== void 0) steps.add("create", "insert");
6335
+ };
6336
+ /** The input that attaches a row to a to-one, which detaches whatever was attached before. */
6337
+ const attachingOp = (op) => op["connect"] !== void 0 ? "connect" : op["create"] !== void 0 ? "create" : void 0;
6135
6338
  const createNestedWriteRuntime = (params) => {
6136
6339
  const { plans, filterCtx, policies, contextValues } = params;
6340
+ const onWrite = policies?.onWrite;
6137
6341
  /**
6138
6342
  * A `connect` / `disconnect` / `set` operand compiled to SQL. An operand that compiles to
6139
6343
  * nothing is rejected: `connect: {}` would otherwise attach every row in the table.
@@ -6144,12 +6348,12 @@ const createNestedWriteRuntime = (params) => {
6144
6348
  return withScope(scope, plan.targetTableName, plan.targetTable, condition);
6145
6349
  };
6146
6350
  const assertSingleOperation = (plan, op) => {
6147
- const supplied = [
6351
+ const given = [
6148
6352
  "create",
6149
6353
  "connect",
6150
6354
  "disconnect"
6151
- ].filter((key) => op[key] !== void 0 && op[key] !== null);
6152
- 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" });
6355
+ ].filter((key) => supplied(op[key]));
6356
+ 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" });
6153
6357
  };
6154
6358
  /** The values the junction stores for the target rows one operand selects. */
6155
6359
  const selectedTargetKeys = async (executor, plan, filter, operation, scope) => {
@@ -6164,9 +6368,8 @@ const createNestedWriteRuntime = (params) => {
6164
6368
  * error on some of them — the read and the write share the surrounding transaction, so no
6165
6369
  * other writer can slip a duplicate in between them.
6166
6370
  */
6167
- const applyThroughOps = async (executor, plan, key, op, scope) => {
6371
+ const applyThroughOps = async (executor, plan, key, op, scope, steps) => {
6168
6372
  const through = plan.through;
6169
- const unlinkAll = () => executor.delete(through.table).where((0, drizzle_orm.eq)(through.sourceCol, key));
6170
6373
  const link = async (operands, operation) => {
6171
6374
  const existing = new Set((await executor.select({ [through.targetColPropName]: through.targetCol }).from(through.table).where((0, drizzle_orm.eq)(through.sourceCol, key))).map((row) => row[through.targetColPropName]));
6172
6375
  const rows = [];
@@ -6177,19 +6380,19 @@ const createNestedWriteRuntime = (params) => {
6177
6380
  [through.targetColPropName]: targetKey
6178
6381
  });
6179
6382
  }
6180
- if (rows.length) await executor.insert(through.table).values(rows);
6383
+ if (rows.length) await steps.run(executor.insert(through.table).values(rows), operation, "insert");
6181
6384
  };
6182
- if (op["set"] !== void 0 && op["set"] !== null) {
6183
- await unlinkAll();
6385
+ if (supplied(op["set"])) {
6386
+ await steps.run(executor.delete(through.table).where((0, drizzle_orm.eq)(through.sourceCol, key)), "set", "delete");
6184
6387
  await link(asArray(op["set"]), "set");
6185
6388
  }
6186
- if (op["disconnect"] !== void 0 && op["disconnect"] !== null) for (const filter of asArray(op["disconnect"])) {
6389
+ if (supplied(op["disconnect"])) for (const filter of asArray(op["disconnect"])) {
6187
6390
  const targetKeys = await selectedTargetKeys(executor, plan, filter, "disconnect", scope);
6188
- if (targetKeys.length) await executor.delete(through.table).where((0, drizzle_orm.and)((0, drizzle_orm.eq)(through.sourceCol, key), (0, drizzle_orm.inArray)(through.targetCol, targetKeys)));
6391
+ if (targetKeys.length) await steps.run(executor.delete(through.table).where((0, drizzle_orm.and)((0, drizzle_orm.eq)(through.sourceCol, key), (0, drizzle_orm.inArray)(through.targetCol, targetKeys))), "disconnect", "delete");
6189
6392
  }
6190
- if (op["connect"] !== void 0 && op["connect"] !== null) await link(asArray(op["connect"]), "connect");
6393
+ if (supplied(op["connect"])) await link(asArray(op["connect"]), "connect");
6191
6394
  };
6192
- const detachAll = async (executor, plan, key, scope) => executor.update(plan.targetTable).set({ [plan.foreignColPropName]: null }).where(withScope(scope, plan.targetTableName, plan.targetTable, (0, drizzle_orm.eq)(plan.foreignCol, key)));
6395
+ 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, (0, drizzle_orm.eq)(plan.foreignCol, key))), op, "update");
6193
6396
  return {
6194
6397
  enabled: (tableName) => !!plans[tableName],
6195
6398
  split: (tableName, values) => {
@@ -6218,19 +6421,27 @@ const createNestedWriteRuntime = (params) => {
6218
6421
  }
6219
6422
  return columns;
6220
6423
  },
6221
- applyParentSide: async (executor, tableName, ops, context) => {
6424
+ applyParentSide: async (executor, tableName, ops, request) => {
6222
6425
  const tablePlans = plans[tableName];
6223
6426
  const patch = {};
6224
6427
  if (!tablePlans) return patch;
6428
+ const context = request?.context;
6225
6429
  const scope = resolveScope(policies, context, filterCtx);
6226
6430
  for (const [relationName, op] of Object.entries(ops)) {
6227
6431
  const plan = tablePlans[relationName];
6228
6432
  if (plan?.fkSide !== "parent") continue;
6229
6433
  assertSingleOperation(plan, op);
6230
6434
  if (op["create"] !== void 0) {
6435
+ const steps = createHookSteps(onWrite, executor, request, tableName, plan);
6436
+ steps.add("create", "insert");
6437
+ await steps.before();
6231
6438
  const values = applyContextValues(remapFromGraphQLSingleInput({ ...op["create"] }, plan.targetTable), contextValues?.(plan.targetTableName), context);
6232
- const created = (await executor.insert(plan.targetTable).values(values).returning({ [plan.foreignColPropName]: plan.foreignCol }))[0];
6439
+ const step = steps.get("create", "insert");
6440
+ const inserted = await executor.insert(plan.targetTable).values(values).returning(step ? step.returning : { [plan.foreignColPropName]: plan.foreignCol });
6441
+ step?.rows.push(...inserted);
6442
+ const created = inserted[0];
6233
6443
  if (!created) throw drizzleError(`Drizzle-GraphQL Error: 'create' on '${relationName}' inserted no row, so there is nothing to attach.`, { code: "DRIZZLE_NESTED_WRITE_INVALID" });
6444
+ await steps.after();
6234
6445
  patch[plan.localColPropName] = created[plan.foreignColPropName];
6235
6446
  } else if (op["connect"] !== void 0) {
6236
6447
  const rows = await executor.select({ [plan.foreignColPropName]: plan.foreignCol }).from(plan.targetTable).where(conditionOf(plan, op["connect"], "connect", scope));
@@ -6240,41 +6451,46 @@ const createNestedWriteRuntime = (params) => {
6240
6451
  }
6241
6452
  return patch;
6242
6453
  },
6243
- applyChildSide: async (executor, tableName, ops, parentRows, context) => {
6454
+ applyChildSide: async (executor, tableName, ops, parentRows, request) => {
6244
6455
  const tablePlans = plans[tableName];
6245
6456
  if (!tablePlans) return;
6457
+ const context = request?.context;
6246
6458
  const scope = resolveScope(policies, context, filterCtx);
6247
6459
  for (const [relationName, op] of Object.entries(ops)) {
6248
6460
  const plan = tablePlans[relationName];
6249
6461
  if (plan?.fkSide !== "child") continue;
6250
6462
  if (plan.isOne) assertSingleOperation(plan, op);
6463
+ const steps = createHookSteps(onWrite, executor, request, tableName, plan);
6464
+ registerChildSteps(steps, plan, op);
6465
+ await steps.before();
6251
6466
  for (const parentRow of parentRows) {
6252
6467
  const key = parentRow?.[plan.localColPropName];
6253
6468
  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" });
6254
6469
  if (plan.through) {
6255
- await applyThroughOps(executor, plan, key, op, scope);
6470
+ await applyThroughOps(executor, plan, key, op, scope, steps);
6256
6471
  continue;
6257
6472
  }
6258
6473
  if (op["set"] !== void 0) {
6259
- await detachAll(executor, plan, key, scope);
6260
- for (const filter of asArray(op["set"])) await executor.update(plan.targetTable).set({ [plan.foreignColPropName]: key }).where(conditionOf(plan, filter, "set", scope));
6474
+ await detachAll(executor, plan, key, scope, steps, "set");
6475
+ 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");
6261
6476
  }
6262
6477
  if (op["disconnect"] !== void 0) {
6263
6478
  if (plan.isOne) {
6264
- if (op["disconnect"] === true) await detachAll(executor, plan, key, scope);
6265
- } else for (const filter of asArray(op["disconnect"])) await executor.update(plan.targetTable).set({ [plan.foreignColPropName]: null }).where((0, drizzle_orm.and)((0, drizzle_orm.eq)(plan.foreignCol, key), conditionOf(plan, filter, "disconnect", scope)));
6479
+ if (op["disconnect"] === true) await detachAll(executor, plan, key, scope, steps, "disconnect");
6480
+ } else for (const filter of asArray(op["disconnect"])) await steps.run(executor.update(plan.targetTable).set({ [plan.foreignColPropName]: null }).where((0, drizzle_orm.and)((0, drizzle_orm.eq)(plan.foreignCol, key), conditionOf(plan, filter, "disconnect", scope))), "disconnect", "update");
6266
6481
  }
6267
- const attaches = op["connect"] !== void 0 || op["create"] !== void 0;
6268
- if (plan.isOne && attaches && plan.canDetach && op["set"] === void 0) await detachAll(executor, plan, key, scope);
6269
- 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));
6482
+ const attachedBy = attachingOp(op);
6483
+ if (plan.isOne && attachedBy && plan.canDetach && op["set"] === void 0) await detachAll(executor, plan, key, scope, steps, attachedBy);
6484
+ 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");
6270
6485
  if (op["create"] !== void 0) {
6271
6486
  const rows = asArray(op["create"]).map((row) => ({
6272
6487
  ...applyContextValues(remapFromGraphQLSingleInput({ ...row }, plan.targetTable), contextValues?.(plan.targetTableName), context),
6273
6488
  [plan.foreignColPropName]: key
6274
6489
  }));
6275
- if (rows.length) await executor.insert(plan.targetTable).values(rows);
6490
+ if (rows.length) await steps.run(executor.insert(plan.targetTable).values(rows), "create", "insert");
6276
6491
  }
6277
6492
  }
6493
+ await steps.after();
6278
6494
  }
6279
6495
  }
6280
6496
  };
@@ -6284,21 +6500,25 @@ const createNestedWriteRuntime = (params) => {
6284
6500
  * inside a single transaction — a savepoint when the caller already runs on one, so a failing
6285
6501
  * entry rolls the whole batch back.
6286
6502
  *
6503
+ * That transaction is also the `tx` the target tables' `onWrite` hooks run on. Every nested
6504
+ * write opens it, so a hook on a nested target has the whole tree to roll back even when the
6505
+ * root field carries no hook of its own and was not forced into a transaction for one.
6506
+ *
6287
6507
  * Entries are written one at a time rather than as a single multi-row statement: a nested
6288
6508
  * operation attaches to the row its own entry produced, so the two have to stay correlated.
6289
6509
  * The batch path is untouched when no entry carries a nested operation.
6290
6510
  */
6291
6511
  const writeWithNestedOps = async (params) => {
6292
- const { executor, runtime, tableName, entries, remapValues, write, context } = params;
6512
+ const { executor, runtime, tableName, entries, remapValues, write, request } = params;
6293
6513
  return executor.transaction(async (tx) => {
6294
6514
  const rows = [];
6295
6515
  for (const entry of entries) {
6296
- const patch = await runtime.applyParentSide(tx, tableName, entry.ops, context);
6516
+ const patch = await runtime.applyParentSide(tx, tableName, entry.ops, request);
6297
6517
  const written = await write(tx, {
6298
6518
  ...remapValues(entry.columns),
6299
6519
  ...patch
6300
6520
  });
6301
- await runtime.applyChildSide(tx, tableName, entry.ops, written, context);
6521
+ await runtime.applyChildSide(tx, tableName, entry.ops, written, request);
6302
6522
  rows.push(...written);
6303
6523
  }
6304
6524
  return rows;
@@ -6310,14 +6530,14 @@ const writeWithNestedOps = async (params) => {
6310
6530
  * attaches to each row that came back.
6311
6531
  */
6312
6532
  const updateWithNestedOps = async (params) => {
6313
- const { executor, runtime, tableName, columns, ops, remapValues, write, context } = params;
6533
+ const { executor, runtime, tableName, columns, ops, remapValues, write, request } = params;
6314
6534
  return executor.transaction(async (tx) => {
6315
- const patch = await runtime.applyParentSide(tx, tableName, ops, context);
6535
+ const patch = await runtime.applyParentSide(tx, tableName, ops, request);
6316
6536
  const rows = await write(tx, {
6317
6537
  ...remapValues(columns),
6318
6538
  ...patch
6319
6539
  });
6320
- await runtime.applyChildSide(tx, tableName, ops, rows, context);
6540
+ await runtime.applyChildSide(tx, tableName, ops, rows, request);
6321
6541
  return rows;
6322
6542
  });
6323
6543
  };
@@ -6518,7 +6738,14 @@ const createSchemaBuilder = (adapter) => {
6518
6738
  uniqueKeysOf: (tableName) => uniqueKeys[tableName]
6519
6739
  };
6520
6740
  adapter.preflight?.(db, options);
6521
- const nestedPlans = features.nestedWrites ? buildNestedWritePlans(tables, namedRelations, (target) => uniqueColumnSets(target), (target) => primaryKeyPropNames(target), extractRelationJoinColumns) : void 0;
6741
+ const nestedPlans = features.nestedWrites ? buildNestedWritePlans(tables, namedRelations, (target) => uniqueColumnSets(target), (target) => primaryKeyPropNames(target), extractRelationJoinColumns, (junction) => schemaEntries.find(([, value]) => value === junction)?.[0]) : void 0;
6742
+ if (nestedPlans) {
6743
+ const omitted = gateNestedWritePlans(nestedPlans, featureOf, (tableName) => excludedTables.has(tableName));
6744
+ if (omitted.length) {
6745
+ const listed = omitted.length > 5 ? `${omitted.slice(0, 5).join(", ")} and ${omitted.length - 5} more` : omitted.join(", ");
6746
+ console.warn(`Drizzle-GraphQL Warning: config.features — nestedWrites leaves out the nested inputs whose table has that write turned off: ${listed}.`);
6747
+ }
6748
+ }
6522
6749
  const nestedTypes = nestedPlans ? createNestedWriteTypes({
6523
6750
  plans: nestedPlans,
6524
6751
  cacheCtx,
@@ -7016,7 +7243,9 @@ const generateSchemaData$2 = (db, schema, relations, options) => {
7016
7243
  fieldName: updateCountFieldName,
7017
7244
  requireWhere: tableFeatures.requireWhere,
7018
7245
  filterCtx,
7019
- txCtx: mutationTxCtx
7246
+ txCtx: mutationTxCtx,
7247
+ policies,
7248
+ returnsRows: false
7020
7249
  }) : void 0;
7021
7250
  const deleteCountGenerated = tableFeatures.delete && tableFeatures.countMutations ? generateWriteCount({
7022
7251
  db,
@@ -7027,7 +7256,9 @@ const generateSchemaData$2 = (db, schema, relations, options) => {
7027
7256
  fieldName: deleteCountFieldName,
7028
7257
  requireWhere: tableFeatures.requireWhere,
7029
7258
  filterCtx,
7030
- txCtx: mutationTxCtx
7259
+ txCtx: mutationTxCtx,
7260
+ policies,
7261
+ returnsRows: false
7031
7262
  }) : void 0;
7032
7263
  const hardDeleteMeta = softDeleteInfo?.hardDelete ? { hardDelete: true } : {};
7033
7264
  const withMeta = (generated, meta) => ({
@@ -7097,12 +7328,25 @@ const generateSchemaData$2 = (db, schema, relations, options) => {
7097
7328
  {
7098
7329
  generated: updateCountGenerated,
7099
7330
  type: new graphql.GraphQLNonNull(graphql.GraphQLInt),
7331
+ meta: {
7332
+ operation: "update",
7333
+ single: false,
7334
+ targetArg: "where",
7335
+ count: true
7336
+ },
7100
7337
  description: "How many rows the update touched. The rows themselves are not read back, which is the point of this mutation."
7101
7338
  },
7102
7339
  {
7103
7340
  generated: deleteCountGenerated,
7104
7341
  type: new graphql.GraphQLNonNull(graphql.GraphQLInt),
7105
- description: "How many rows the delete removed. The rows themselves are not read back, which is the point of this mutation."
7342
+ meta: {
7343
+ operation: "delete",
7344
+ single: false,
7345
+ targetArg: "where",
7346
+ count: true,
7347
+ ...hardDeleteMeta
7348
+ },
7349
+ 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."
7106
7350
  }
7107
7351
  ],
7108
7352
  onConflictInput,
@@ -7174,7 +7418,11 @@ const buildWriteResolvers = (primaryKeyPropNames) => {
7174
7418
  entries: nestedEntries,
7175
7419
  remapValues: (values) => applyContextValues(remapFromGraphQLSingleInput(values, table), contextColumns, context),
7176
7420
  write: (tx, values) => runInsert(tx, [values]),
7177
- context
7421
+ request: {
7422
+ context,
7423
+ args,
7424
+ info
7425
+ }
7178
7426
  }) : await runInsert(executor, input);
7179
7427
  await after(written);
7180
7428
  const result = output(written);
@@ -7263,7 +7511,11 @@ const buildWriteResolvers = (primaryKeyPropNames) => {
7263
7511
  entries: nestedEntries,
7264
7512
  remapValues: (values) => applyContextValues(remapFromGraphQLSingleInput(values, table), contextColumns, context),
7265
7513
  write: (tx, values) => runUpsert(tx, [values]),
7266
- context
7514
+ request: {
7515
+ context,
7516
+ args,
7517
+ info
7518
+ }
7267
7519
  }) : await runUpsert(executor, input);
7268
7520
  await after(written);
7269
7521
  const result = output(written);
@@ -7336,7 +7588,11 @@ const buildWriteResolvers = (primaryKeyPropNames) => {
7336
7588
  ops: nestedOps,
7337
7589
  remapValues: (values) => values,
7338
7590
  write: runUpdate,
7339
- context
7591
+ request: {
7592
+ context,
7593
+ args,
7594
+ info
7595
+ }
7340
7596
  }) : await runUpdate(executor, input);
7341
7597
  await after(written);
7342
7598
  const result = output(written);
@@ -7603,7 +7859,10 @@ const createSchemaDataGenerator = (adapter) => {
7603
7859
  requireWhere: tableFeatures.requireWhere,
7604
7860
  filterCtx,
7605
7861
  txCtx: mutationTxCtx,
7606
- nested: nestedRuntime
7862
+ nested: nestedRuntime,
7863
+ policies,
7864
+ pkNames: primaryKeyPropNames(schema[tableName]),
7865
+ returnsRows: true
7607
7866
  }) : void 0;
7608
7867
  const deleteCountGenerated = tableFeatures.delete && tableFeatures.countMutations ? generateWriteCount({
7609
7868
  db,
@@ -7614,7 +7873,10 @@ const createSchemaDataGenerator = (adapter) => {
7614
7873
  fieldName: deleteCountFieldName,
7615
7874
  requireWhere: tableFeatures.requireWhere,
7616
7875
  filterCtx,
7617
- txCtx: mutationTxCtx
7876
+ txCtx: mutationTxCtx,
7877
+ policies,
7878
+ pkNames: primaryKeyPropNames(schema[tableName]),
7879
+ returnsRows: true
7618
7880
  }) : void 0;
7619
7881
  const hardDeleteMeta = softDeleteInfo?.hardDelete ? { hardDelete: true } : {};
7620
7882
  return {
@@ -7724,12 +7986,25 @@ const createSchemaDataGenerator = (adapter) => {
7724
7986
  {
7725
7987
  generated: updateCountGenerated,
7726
7988
  type: new graphql.GraphQLNonNull(graphql.GraphQLInt),
7989
+ meta: {
7990
+ operation: "update",
7991
+ single: false,
7992
+ targetArg: "where",
7993
+ count: true
7994
+ },
7727
7995
  description: "How many rows the update touched. The rows themselves are not read back, which is the point of this mutation."
7728
7996
  },
7729
7997
  {
7730
7998
  generated: deleteCountGenerated,
7731
7999
  type: new graphql.GraphQLNonNull(graphql.GraphQLInt),
7732
- description: "How many rows the delete removed. The rows themselves are not read back, which is the point of this mutation."
8000
+ meta: {
8001
+ operation: "delete",
8002
+ single: false,
8003
+ targetArg: "where",
8004
+ count: true,
8005
+ ...hardDeleteMeta
8006
+ },
8007
+ 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."
7733
8008
  }
7734
8009
  ],
7735
8010
  onConflictInput,
@@ -7747,18 +8022,18 @@ const createSchemaDataGenerator = (adapter) => {
7747
8022
  * the shared multi-mutation transaction opened by `runMutation` — this opens a savepoint, so
7748
8023
  * the batch stays atomic without breaking the outer transaction.
7749
8024
  */
7750
- const runUpdateManyBatch = ({ executor, entries, table, returning, nested, tableName, context }) => executor.transaction(async (tx) => {
8025
+ const runUpdateManyBatch = ({ executor, entries, table, returning, nested, tableName, request }) => executor.transaction(async (tx) => {
7751
8026
  const results = [];
7752
8027
  for (const entry of entries) {
7753
8028
  const values = entry.ops ? {
7754
8029
  ...entry.set,
7755
- ...await nested.applyParentSide(tx, tableName, entry.ops, context)
8030
+ ...await nested.applyParentSide(tx, tableName, entry.ops, request)
7756
8031
  } : entry.set;
7757
8032
  const writes = Object.keys(values).length > 0;
7758
8033
  let query = writes ? tx.update(table).set(values) : tx.select(returning).from(table);
7759
8034
  if (entry.filters) query = query.where(entry.filters);
7760
8035
  const rows = await (writes ? query.returning(returning) : query);
7761
- if (entry.ops) await nested.applyChildSide(tx, tableName, entry.ops, rows, context);
8036
+ if (entry.ops) await nested.applyChildSide(tx, tableName, entry.ops, rows, request);
7762
8037
  results.push(rows);
7763
8038
  }
7764
8039
  return results;
@@ -7831,7 +8106,11 @@ const createUpdateManyGenerator = (primaryKeyPropNames, runBatch = runUpdateMany
7831
8106
  anyNested,
7832
8107
  nested,
7833
8108
  tableName,
7834
- context
8109
+ request: {
8110
+ context,
8111
+ args,
8112
+ info
8113
+ }
7835
8114
  });
7836
8115
  const written = perEntry.flat();
7837
8116
  await after(written);
@@ -7880,7 +8159,7 @@ const sqlitePrimaryKeyPropNames = (table) => getPrimaryKeyPropNamesFromConfig(ta
7880
8159
  * would run after the COMMIT. The driver kind is probed from the first statement's result
7881
8160
  * rather than declared, so the same resolver serves both driver families.
7882
8161
  */
7883
- const runSqliteUpdateManyBatch = ({ executor, entries, table, returning, anyNested, nested, tableName, context }) => {
8162
+ const runSqliteUpdateManyBatch = ({ executor, entries, table, returning, anyNested, nested, tableName, request }) => {
7884
8163
  const runEntry = (tx, entry) => {
7885
8164
  let query = tx.update(table).set(entry.set);
7886
8165
  if (entry.filters) query = query.where(entry.filters);
@@ -7889,13 +8168,13 @@ const runSqliteUpdateManyBatch = ({ executor, entries, table, returning, anyNest
7889
8168
  const runNestedEntry = async (tx, entry) => {
7890
8169
  const values = entry.ops ? {
7891
8170
  ...entry.set,
7892
- ...await nested.applyParentSide(tx, tableName, entry.ops, context)
8171
+ ...await nested.applyParentSide(tx, tableName, entry.ops, request)
7893
8172
  } : entry.set;
7894
8173
  const writes = Object.keys(values).length > 0;
7895
8174
  let query = writes ? tx.update(table).set(values) : tx.select(returning).from(table);
7896
8175
  if (entry.filters) query = query.where(entry.filters);
7897
8176
  const rows = await (writes ? query.returning(returning) : query);
7898
- if (entry.ops) await nested.applyChildSide(tx, tableName, entry.ops, rows, context);
8177
+ if (entry.ops) await nested.applyChildSide(tx, tableName, entry.ops, rows, request);
7899
8178
  return rows;
7900
8179
  };
7901
8180
  return executor.transaction((tx) => {